能跑 10 分钟的 Agent,和能上线 10 个月的 Agent,差在哪
系列导读:本系列尝试回答一个具体问题——当 Agent 不再是一次请求,而是一个可能持续几十分钟、会中断、会失败、要花钱、要被人看懂的有状态长事务时,生产系统该一条一条补上什么。
- 第一篇(本文):能跑 10 分钟和能上线 10 个月,差在 Agent 是一笔有状态的长事务,上线就是把这笔事务该有的东西补齐。
- 第二篇:向人提问时,这一轮怎样提交成等待,刷新和隔天再答都还能接上。
- 第三篇:子 Agent 删了三次,因为它是另一笔事务,多数能力只是当前这一轮里的过程。
- 第四篇:接入和执行拆开,开单、回放和把这一轮跑完,不再绑在同一个进程上。
- 第五篇:事实、传输、恢复点、执行现场分开存,Checkpoint 不能当作数据库。
- 第六篇:成功、中断、取消、异常收成同一个提交点,执行不能靠重试再做一遍。
- 第七篇:254KB 的记忆怎样烧掉十几万 token,这笔预算取决于注入多少、何时注入、谁来写。
- 第八篇:轮次账本、租约、公平队列、能力评测、文档五处还没补上,现状离那句话还有多远。
内测刚开始那几天,有个用户刷新了一下页面。
半小时的对话,只剩一半。前端贴了张截图:刷新前四个工具卡片,刷新后只剩一个。用户的原话是:“你们这系统,一刷新就没了。”
数据库一条没少,日志没有报错。实时流按 chunk 推,历史回放按消息给,前端只认前者。数据都在,同一次执行没有同一份可读的形状。
刷新只是这种系统的第一次体检。执行还在,观看断了,两边各交了一份对不上的记录。缺的是长事务里的一条:中断之后,过程还要能被人看懂。
一、先看 demo 里怎么写
任何人第一版都长这样:
@app.post("/chat")asyncdefchat(req):forchunkinagent.stream(req.message):# 一轮 = 一次请求yieldsse(chunk)# 直接推给这条连接状态在进程里,进度在这条连接上,执行在这个函数里。三轮对话、一次工具调用、一张流程图,跑得很好——demo 的成功标准里没有"浏览器关了怎么办"。
对外测试第一周,这段代码遇到四件事:
- 刷新。连接断了,后半段输出再也拿不回来。开头那张截图,是修完"能补"之后暴露的第二层:补回来的格式和实时推的不一样。
- 重启。发布滚动更新,正在跑的那一轮连人带状态一起没了,会话永远停在"运行中"。
- 扩容。想加第二个副本,发现这条连接绑在这个进程上,加了只会让一半请求找不到自己的流。
- 等待。它问了一句"选哪个",用户去开会,两小时后回来。进程不可能挂两小时。
四件事,没有一件能靠"代码写得更小心"解决。它们各自要求系统补上一个东西。
拿掉模型、提示词和框架,剩下的是:
Agent 不是一次请求,是一个可能持续几十分钟、会中断、会失败、要花钱、要被人看懂的有状态长事务。生产化,就是一条一条把这种长事务该有的东西补齐。
这句话拆开是五件事:它比进程活得长,所以有状态——状态得放在进程外面;连接会断,所以会中断——中断是正常路径;执行会出错,所以会失败——失败要落成终态;上下文有硬上限,所以要花钱——每一轮都要算预算;过程要给人看,所以要被人看懂——事件就是契约。
"长事务"我们只取两样:状态在进程之外,提交点要明确。回滚和隔离级别留在数据库里。Agent 的失败不靠撤销,靠终态写清楚;Agent 的隔离不靠两个事务怎么读同一行,靠执行现场分开。
后面每一篇,都是在补上面的某一项。这些项不成立,提示词只优化那条没断过的路径。
二、五件事,逐项拆开
上面四件事,归起来是两条:状态不能只活在进程里,连接不能持有状态。另外三条,是跑起来之后才疼的:失败要收得住,上下文有预算,过程要能被看懂。
五项缺了任何一项,都会得到一种 demo 里不存在的故障。
有状态:进程会死,状态不能只活在进程里,也不能只存一份。
一轮执行横跨多次发布。状态必须外置。外置到哪里,由这份数据负责什么决定。我们拆成四种。介质可以重叠,职责不能重叠:
| 职责 | 放哪 | 合错了会怎样 |
|---|---|---|
| 可查询的事实:消息、轮次、费用、审计 | 数据库 | 没有账,就不能计费,也不能对用户解释这一轮 |
| 可丢失的传输:队列、扇出、在途事件 | Redis | 把它当事实,重启就丢对话 |
| 框架恢复点:图要接着跑的那份快照 | checkpoint | 把它当账本,不可查询,而且每次都整份读写 |
| 执行现场:代码真正读写的文件 | 沙箱 | 把它当共享磁盘,租户互相看得见 |
Checkpoint 经常就落在数据库里。这不使它变成账本。它是框架私有格式的整图快照,不能按用户、轮次、金额去查,长对话下每次触碰都是全量读写。巨型轮次收尾时的同步序列化,我们测到能把新任务排队拖到 P90 55 秒。恢复点负责接着跑,账本负责说得清。用同一份 blob 干两件事,两边一起坏。
会中断:连接会断,连接就不能持有状态。
一条 SSE 推到底,等于宣布:连接还在,这一轮才看得见。网络抖一下,计算可能还在跑,观众已经丢了后半段。
开单和观看要分开。Submit 只受理这一轮:落库、排队、返回。内容一律从 resume 读。在途事件可以走队列,已经结束的轮次走已经落库的事实;两边必须交出同一种事件。来源可以有两个,形状只能有一个。
客户端因此只有一种读法。刷新、断线重连、多端同时看,都是换一个游标,把这份契约再读一遍。文章开头那张截图——四个卡片变成一个——就是两种形状各说各话。
会失败:终态要写清楚,执行现场要被挡住。
失败先要有终态。一轮是完成、中断、取消还是失败,必须写进账里。连接断了不等于这一轮没有结果。终态写清之后,还要挡住失败的半径。
这一轮的代码是模型现写的。它可能删文件,可能读到别人的数据,可能把密钥打进日志。Demo 里操作者和租户是同一个人。多租户里,同一条删除命令就是事故。
要隔离的不只是进程,还有路径。容器里的真实路径一旦出现在提示词里,模型会照着写,把部署细节带进用户看得见的地方。
要花钱:工作记忆是预算,不是缓冲区。
传统后端内存不够,就加机器。上下文加不了。硬上限到了,调用失败;没到,费用已经按 token 计上了。有一次一张 317KB 的照片进了上下文,单次调用到约 30 万 token,吃掉窗口的六成。预算从"什么允许进入这一轮"开始管。等溢出再截断,钱已经花了,窗口也已经碎了。
要被人看懂:过程一旦被读走,就是契约。
用户要知道卡在哪一步,前端要决定渲染哪一种卡片,审计要追到谁授权了哪一次副作用。三方读的是同一份过程。事件一旦被前端读取,就从内部跟踪变成契约。改一个字段,就是改协议。
图 1:长事务要补齐的五项。"持续几十分钟"是它们共同的寿命:比进程长,也比连接长。
和 demo 的默认假设放在一起,差在各自优化的目标:
| Demo 在优化什么 | 生产要优化什么 |
|---|---|
| 一条连接跑到结束 | 连接随时断,计算继续,观看可以重来 |
| 状态留在框架的 checkpointer 里 | 事实可查询、可审计、可计费;恢复点只负责续跑 |
| 沙箱等于本机进程 | 执行的是不可信代码,要隔离,要留痕 |
| 上下文装得下这次演示 | 上下文是预算,超了会失败,没超也在花钱 |
| 输出是一段文本 | 输出是前端、审计和用户共用的事件契约 |
三、为什么在 DeerFlow 上打补丁会越打越别扭
起步时我们 clone 了 DeerFlow。它是一个好的研究型 Agent 项目,图、人机中断、技能、子代理,我们都是从那里跑通的。要改的是它的目标函数。
DeerFlow 优化的是:单租户、单进程、操作者可信,尽快证明"它能做"。
我们要优化的是:多租户、多进程、执行不可信,失败之后仍能说清"它做了什么"。
这两组前提的梯度是反的。前一组上的局部最优,会系统性破坏后一组。
对外测试之后,三个假设都是从主路径上爆的。
一次会话 = 进程内的一条 SSE。一个人、一个浏览器、不刷新,这是最短路径。网络一抖,事件就丢;worker 一重启,这一轮的可见进度就没了;gateway 要加副本,会话还粘在原来的进程上。
状态 = checkpointer。图自己续跑,这份快照够用。它不可查询、不可审计、不可计费,还随消息线性增长。上面那个 P90 55 秒,就是把恢复点当成账本之后付的钱。
沙箱 = 本机进程,用户可信。作者自己的电脑上成立。多租户下,删除命令、漏进提示词的容器路径、同一台机器上互相可见的用户文件,都从这里来。
Demo 要证明能力存在。生产要证明:能力用错时,爆炸半径有限,过程还查得到。两套目标的工程重点几乎不重叠。
补丁因此会打在框架的反方向上。在"连接即状态"的主路径上补重连,在私有快照上补审计,在本机进程上补租户。每个补丁都在对抗这条路径当初被优化的方向。后来改掉的是这三句假设。
四、四幕换掉的是"什么可以当真"
下面四幕,就是"一条一条补齐"的实际顺序。先补了人怎么用,后补了事务怎么活下来。
从仓库第一个提交算起,现在有三千多个提交,不少是跟上游同步。把它变成产品的,是今年 6 月以后的一千四百多个。
四幕里,每一幕作废一批此前为真的假设。
第一幕,跟上游。头一年在同步 DeerFlow:人机中断、搜索、Web UI、技能、子代理。学会了图怎么跑,也原样收下了单进程、可信执行。
第二幕,平台化。今年上半年,同一套运行时撑起十来个业务 Agent:文档、数据、流程、分析。技能包加命令行,确实能操作已有的内部系统。同一套进程假设也被复制了十来份,每个业务都在自己的循环里持有状态。
第三幕,产品化。6 月到 7 月,新一代产品立项,两条原本独立的业务线收进一个 Agent,再补上产品才要的交互:向人提问、先出计划再执行、危险操作二次确认、任务堆积时提醒。两个月,六百多个提交,几乎全在交互上。人进入了回路,等待和恢复仍绑在框架那一条执行循环上。
第四幕,工程化。8 月对外测试。故障从"模型答错"变成刷新、发布、排队和账单。换运行时,拆开接入和执行,修收尾,压性能,重做记忆,补 trace。最近两个多月六百多个提交,一百三十多个是 fix。
图 2:产品化在前,工程化在后。债是这个顺序欠下的,也只有这个顺序才看得清。
我们是先让人用起来,才知道地基错在哪。没有刷新丢卡片、排队 P90 55 秒、一张图片打满窗口这些现场,第一幕结束时,那套单进程结构看起来就是完整的。
五、三次走错,是同一种混淆
子 Agent,删了三次。5 月在一个业务 Agent 上删了一次,7 月在它的流程能力上删了一次,9 月把业务子 Agent 收成技能包,又删了一次。跨两个产品线,同一个判断犯了三遍:把一次能力调用做成了一个新的执行域。子 Agent 自带状态、权限和账单。技能包是当前这一轮里的一段过程。要的是过程,付的是一个新事务的成本。
记忆,换了四代。自己写的分桶 JSON,然后是框架自带的文件方案,然后是独立记忆服务,最后把写权限从模型收回到轮次收尾。中间一次事故把方向钉死:单个用户的记忆文件涨到 254KB,每轮全量注入,约 14 万 input token,首个响应 chunk 延迟 25 到 40 秒。那是把记忆当成了提示词前缀。它应该按本轮问题检索,写不写由系统在收尾时决定。
工具契约,两天改了四次。一个文件展示工具的入参,四十八小时里变了四版。模型侧这是提示词调整。前端侧这是协议破坏:卡片怎么渲染、历史怎么回放,都绑在这组字段上。事件被 UI 读取之后,schema 和对外 API 同一级别。
三件事的共同错误:把"模型多做一步"当成了局部改动。这一步落在状态、权限、费用或协议上,就是长事务的边界。补的时候补错了位置。
六、这个系列写什么
不写框架教程,也不写一份最佳实践清单。系统还在内测,眼下的结构还不是标准答案。
写每种故障的现场、当时的判断、后来的代价,以及长事务的哪一项今天还没补完。这些故障不依赖具体业务。换一个框架,该补的还是这几项。
七、接下来七篇
按踩坑的先后,不按重要性:
- 《Agent 怎么向人提问》——提问、表单、计划确认、危险操作确认。等人的那段时间仍是一轮执行,一样会断,一样要能重放。
- 《我们删了三次子 Agent》——能力是当前轮次里的过程,还是一个新的执行域。
- 《我们把 Agent 从框架的运行时里抠了出来》——接入层开单、回放;执行层把这一轮跑完。粘在一起就无法扩,也无法在发布时活下来。
- 《Checkpoint 不是数据库》——恢复点、账本、在途传输为什么不能合成一份。断线重连和假死自愈从这里来。
- 《一百三十个 bug 之后:Agent 轮次的四种死法》——收尾是提交点。不收敛成一个函数,每种死法都会把账做脏。
- 《254KB 的记忆,烧掉了 14 万 token》——上下文是预算。全量注入是把档案当寄存器。
- 《我们还没解决的 5 个问题》——轮次账本、租约、公平队列、能力评测、文档漂移。
八、可以带走的一句
正在把 Agent demo 推向生产,不必照搬我们的存储划分或进程划分。只要它不再是一次请求,这几件事会自己出现:刷新对不齐,重启丢掉半轮,工具副作用无法撤销,上下文突然变贵,前端跟不上字段。
先让这句话成立,再写 prompt:
Agent 不是一次请求,是一个可能持续几十分钟、会中断、会失败、要花钱、要被人看懂的有状态长事务。生产化,就是一条一条把这种长事务该有的东西补齐。
下一篇从最容易被当成小工具的地方开始:Agent 停下来,向人问一句话。6 月到 7 月,和"等人回答"有关的提交有几十个。到上周,这块还在改。
为保护项目与客户信息,文中系统名、工具名、业务场景和具体数值均做了抽象或区间化处理;技术结论不受影响。这套系统还在内测,判断若有漏洞,欢迎直接指出。