如何让「跑完就关掉」的 codex exec 支撑起「一直在聊」的对话
对话是一份不断加页的文档,不是一个一直运行的程序。
最近在干一件事:把 Codex、Claude Code 这类 agent 搬上云端,让用户在网页里直接聊。做着做着,撞上一个挺拧巴的问题。
我们跑 agent,用的是它们的命令行本体,比如 codex exec。这家伙性格很干脆:给个任务,跑完,退出。一次性的,跑完即死。
可我要做的,是一个能一直聊的对话:用户来回问,agent 来回答,直到一起把任务干完。一个「跑完就死」的进程,怎么撑得起一段「一直在聊」的对话?
这个问题我卡了挺久。后来想通的答案有点反直觉:要让对话一直活着,就得让进程随时能死。
别把「一段对话」想成「一个进程」
我们太容易把连续的东西,想成一个一直开着的东西。一段对话,总觉得该有个「它」在那儿待命——你说一句,它听着;你想两分钟,它等着。
但这是个错觉。
对话真正的本体,是那份不断增加的 transcript。它是数据,不是进程。你上一句说了什么、agent 调了哪些工具、改了哪些文件,这些是状态,可以老老实实躺在硬盘里。而跑一个回合需要的算力,只在你按下发送、到 agent 吐完这一轮的那几秒里存在。这之后,它没有任何理由继续占着一个进程。
所以我把这件事拆成两半:
- 对话 = 状态。 一份持久化的 session,躺在存储里。
- 回合 = 算力。 每次用户发消息,起一个用完即退的进程去算这一轮。
codex exec 刚好递上了那块拼图:codex exec resume <session_id>。把上一回合拿到的 session id 存下来,下一回合拿它 resume,Codex 会把前面所有轮次的上下文、计划、审批记录都 load 回来,接着往下跑。跑完,照旧退出。
于是一段对话,在后台其实长这样:
- 用户发消息 → 后端把这条消息入队;
- 一个 worker 取到任务,从存储里 load 出这个对话的 session id 和工作目录;
- 起一个容器,
codex exec resume <sid>,带着全部历史接着聊; - agent 一边跑,事件一边通过 SSE 流式推回浏览器;
- 这一轮完了,把改动过的工作目录存回去,更新 session id;
- 容器销毁,进程退出——直到用户发下一句。
用户看到的,是一条从头到尾连续的聊天线程。后台跑的,是一次次针对同一份 session 的短命调用。
这套东西,一点都不新
说到这儿你可能已经笑了——这不就是 Web 干了二十年的事吗?
HTTP 本身就是无状态的。服务器从来不会为每个登录用户挂个进程在那儿等他点下一下。一个 cookie,加上服务端一个 session store,就把一个个孤立的请求,拼成了「连续会话」的错觉。你刷一整天某个网站,感觉它一直「记得」你,其实它每次都是把你从存储里捞出来,处理完,再放回去。
我们只是把这套老办法原封不动搬到了 agent 上:对话是 session,回合是请求,resume 就是那个 cookie。
(说实话,我越来越觉得,系统设计里的很多「顿悟」,本质上都是想起了某个早就存在的老办法。)
为什么「能死」这么重要
如果反过来做呢?为了让对话「跟手」,干脆让容器一直活着,用户不发消息就空转等着。
会死得很惨。用户想一句话,可能几秒,也可能去泡了杯咖啡、开了个会,半小时后才回来。你要是给每段对话钉住一个容器,就是在养成千上万个几乎全程空转、只在偶尔那几秒干活的容器。这才真的扛不住规模。
「用完即退」不是妥协,反而是它能规模化的原因:进程只在真正算的那几秒存在;用户发呆、思考、离开的所有时间里,你占用的只是一份躺着的 session,几乎零成本。而且天然容错——某个容器挂了,对话在存储里好好的,随便换个 worker,一 resume 就回来了。
很多人同时用,怎么办?
一旦接受「一个回合 = 一个短命进程」,并发就变成一个特别标准、特别舒服的问题。
一个用户的一个回合,就是一个容器里的一次 codex exec。一人一容器,互相看不见彼此的代码和文件。那很多人同时来呢?前面架一个任务队列,后面一个 worker 池:
- 请求进来,先入队,不直接开工;
- 一组 worker 从队列里取任务,取到一个就拉个容器去跑,跑完取下一个;
- worker 池按队列深度自动扩缩——忙了加机器,闲了缩回去。
并发的天花板,说到底就两条:你能同时开多少容器(算力),以及模型 API 的 rate limit(额度)。队列的意义,就是请求超过这两条线时让它排队,而不是让系统崩掉。你甚至可以很坦诚地告诉用户「前面还有几位,预计 X 秒」——这比甩一个 500 体面多了。
还有个小细节值得早点想清楚:agent 要用的那些工具,别每个会话都在容器里单独拉一个进程。并发一高,就是成百上千个子进程。做成一个共享的 HTTP 服务,让所有会话去连它,干净很多。
写在最后
这套东西拆开看,没有一块是新的:无状态执行、持久化状态、队列、worker 池,每一样都在别的系统里躺了很多年。新的只是把它们拼起来,套在 agent 上。
我一直觉得,好的架构往往不是发明出来的,是想起来的——想起某个早被验证过的朴素老办法,然后别去为难它。就像你不需要一堆花哨的框架才能做出一个可靠的 agent,你也不需要让一个进程一直活着,才能让用户一直聊下去。