Coding Agent的心脏——Agent Loop:为什么要做成显式状态机,错误为什么要从轻到重地恢复
去年年底开始关注 coding agent 这类工具,今年 2 月 Claude Code 出来以后,几乎天天都在用。
用久了发现它很耐操。一个任务跑了几十轮,上下文满了,它会自己压缩一下接着干;输出写到一半被截断了,它会自己续上;碰上模型过载,它还能换个模型重试。整个过程我什么都不用管。
我很好奇这些行为是怎么来的。研究了一段时间它的设计之后发现,这些"打不死"的能力并不是散落在各处的补丁,而是都长在同一个地方——Agent Loop。
一、Agent Loop 是什么
Anthropic 在《Building effective agents》里对 agent 有一个很朴素的定义:
Agent 通常就是 LLM 在一个循环里,根据环境的反馈去使用工具。
简单的说,Agent Loop 就是一个循环:把对话发给模型,模型说要调用工具就执行工具,把结果塞回对话,再问模型,直到模型不再调用工具为止。
最朴素的写法十几行就够了:
async function agentLoop(messages: Message[]) {
while (true) {
// 1. 把完整对话发给模型
const response = await callModel(messages)
messages.push(response)
// 2. 模型没有要调用的工具,说明任务做完了
const toolCalls = response.content.filter((b) => b.type === 'tool_use')
if (toolCalls.length === 0) return response
// 3. 执行工具,把结果作为下一条消息回填
const results = await Promise.all(toolCalls.map(runTool))
messages.push({ role: 'user', content: results })
}
}
可以看到,模型负责"想",循环负责"做"。Claude Code 的核心也就是这样一个循环,工具、提示词、压缩、权限,全都挂在这个循环的某个环节上。
二、朴素循环的问题
上面这段代码能跑,但它有一个隐含的假设:一切顺利。
真实情况下,一个 coding agent 做一次任务要跑几十上百轮,每一轮都可能出事:
- 上下文超长:读过的文件、跑过的命令越堆越多,请求直接被 API 以 prompt too long 拒掉;
- 输出被截断:模型一口气写一个大文件,写到一半碰到了输出 token 上限;
- 模型过载或断流:高峰期返回 529 overloaded,或者流式输出到一半没动静了;
- 工具失败:命令报错、文件不存在、执行超时;
- 用户中断:用户按了 Esc,想换个方向。
朴素循环遇到这些情况,只能把错误抛给上层,整个会话就这么结束了。前面跑了三十轮攒下来的上下文,全部白费。
所以 Claude Code 对 Agent Loop 的设计假设恰恰相反:我随时可能失败。整个循环都是围绕"怎么恢复"来设计的。
那么怎么把一堆恢复逻辑塞进循环里,又不让它变成一团乱麻呢?答案是显式状态机。
三、显式状态机
1. 状态 → 转换原因 → 新状态
Claude Code 的主循环形式上依然是 while (true),但每一轮的结尾只有两种出口:
- 继续:
state = next; continue,并且注明为什么继续; - 终止:
return { reason },并且注明为什么终止。
继续的原因有好几种,比如工具执行完了正常进入下一轮、输出截断后提额重试、上下文超长后压缩重试、stop hook 要求继续等等。终止的原因也分得很细:正常完成、上下文超长且救不回来、模型出错、用户中断、达到最大轮数……
简单的说,循环不再是"while 加一堆 if",而是一张写得清清楚楚的状态转换表:当前状态加上发生了什么,等于下一个状态。每一条路都有自己的名字。
2. 每次继续,都构造一个完整的新 State
循环里需要跨轮传递的东西不少:消息列表、压缩的记录、输出截断恢复了几次、有没有尝试过应急压缩、当前的输出上限、已经跑了几轮……
最直觉的写法,是在需要的地方改一下对应的字段:
// 反例:在各个分支里逐字段修改
if (isOutputTruncated) {
state.maxOutputTokens = 64000
state.recoveryCount++
continue
}
if (isPromptTooLong) {
state.messages = await compact(state.messages)
state.hasAttemptedCompact = true
// 这里忘了把 maxOutputTokens 改回去……
continue
}
问题在于,分支一多,迟早会忘了重置某个字段。上面这段代码里,上一轮为了截断而调大的输出上限,在压缩重试时被原封不动地带了过去。这种 bug 不报错,只是让行为变得莫名其妙,非常难查。
Claude Code 的做法是,在每一个 continue 的地方都构造一个完整的新 State:
// 示意:每个继续点都写出完整的下一个状态
const next: State = {
messages: compactedMessages,
turnCount: state.turnCount,
maxOutputTokensOverride: undefined, // 显式写出来,而不是"沿用"
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: true,
transition: 'reactive_compact_retry', // 记录这次为什么继续
// ……其余字段同样逐一写出
}
state = next
continue
看起来啰嗦,但每个字段在每条路径上取什么值,都摆在了明面上。写代码的时候就被逼着回答"这个字段在这里应该是什么",而不是寄希望于它"应该没变"。
这和我们写 Java 时推崇不可变对象是一个道理:与其到处 set,不如每次 new 一个新的,状态的来龙去脉一目了然。
3. 用 transition 记录走了哪条路
注意上面代码里的 transition 字段,它记录的是"这一轮为什么会继续"。
这个字段对业务逻辑没什么用,但对测试和排障非常有用。恢复逻辑最难测的地方在于,你很难确定某条恢复路径是真的被触发了,还是只是碰巧结果对了。有了 transition,测试可以直接断言"这次走的是压缩重试";线上出了问题,日志里也能看到循环是怎么一步步走过来的。
四、从轻到重的分层恢复
状态机解决的是恢复逻辑怎么组织,接下来看恢复本身怎么做。
1. 扣留-释放
流式输出的时候,模型的响应是一边生成一边往外推的。如果中途发现输出被截断,或者请求因为上下文超长被拒,最直接的做法是马上把错误推给上层。
但这样有个问题:上层(UI 或者调用 SDK 的程序)一看到错误,很可能直接把会话结束了,而这个错误本来是可以恢复的。
所以 Claude Code 对可恢复的错误,是先扣下来,不往外推。循环内部把能用的恢复手段一个个试过去,成功了,上层根本感知不到出过错;所有手段都失败了,才把之前扣下的错误释放出去。
这就是扣留-释放(Withhold-Release)。
2. 例子一:输出被截断
以输出截断为例,恢复链是这样的:
- 第一次截断,先把输出上限调大(比如从默认的 8K 提到 64K),原样重试一次。这一步不需要多轮对话,几乎是免费的;
- 调大以后还是截断,说明这次要输出的东西确实太多了。这时往对话里注入一条提示,让模型接着往下写;
- 续写最多尝试 3 次,还不行,就释放之前扣下的错误,结束这一轮。
第 2 步注入的那条提示很有意思,大意是:
输出达到上限了。直接接着写,不要道歉,不要回顾,从中断的地方继续,把剩下的工作拆成更小的块。
每一句都有讲究:不道歉,是为了省 token;不回顾,是为了避免把写过的内容再写一遍;拆成小块,是从根上降低单次输出量,免得下一次又被截断。
3. 例子二:上下文超长
上下文超长的恢复也是同样的思路,从轻到重:
- 先做便宜的:把一些旧的、细粒度的内容折叠起来,尽量多保留原文;
- 不够再做贵的:调用模型把历史整体压缩成一份摘要,然后重试;
- 还不行,才以"上下文超长"终止。
为什么不一上来就全量压缩?因为压缩是有损的。全量摘要会把很多细节丢掉,能用轻量手段解决的,就没必要动用重型武器。
4. 单次尝试守卫
自动恢复有一个天然的风险:恢复本身也可能失败,失败了又触发恢复,然后无限循环下去。这就是所谓的 death spiral。
所以 Claude Code 给每一种自动恢复都配了一个"试过了"的标记,比如 hasAttemptedReactiveCompact。应急压缩试过一次,标记就置为 true,下次再遇到同样的问题不再尝试,直接终止。续写恢复也一样,计数到 3 就停。
每加一种自动恢复,就要同时加一个守卫,否则就是在给自己埋死循环。
还有一个细节:上下文超长的时候,不会去跑 stop hooks。hook 是用来检查模型这一轮输出的,而这时候模型根本没有产出有效的响应,hook 没东西可查。硬要跑的话,很容易形成"报错 → hook 拦截 → 重试 → 再报错"的螺旋。
五、通信层:失败是常态
上面说的都是循环内部的恢复。循环之外,和模型 API 打交道的那一层,Claude Code 的态度也一样:网络失败是常态,不是例外。
这一层有四个设计,我觉得特别值得借鉴。
一是有上限的重试,加上降级。
重试有一个总预算(大约 10 次),退避时间指数增长,再加一点随机抖动,避免大量客户端在同一时刻一起重试。另外,连续几次碰到 529 过载,就不再死磕,而是换一个模型(比如从 Opus 降到 Sonnet),在同一轮里重试。
降级这个动作是通过抛出一个特殊的错误来实现的。这个错误不代表真的"出错了",而是一个控制流信号,一路穿透调用栈回到 Agent Loop,由循环来完成换模型这件事。
二是前台重试,后台让路。
同样是 529,只有用户正在等结果的请求才会重试。生成标题、做摘要这类后台请求,遇到过载直接放弃。
道理很简单:服务端过载的时候,每一次重试都在给它加压。后台任务本来就不急,这时候还跟着重试,只会让过载更严重。
三是慢不等于死。
流式输出有时会卡住。Claude Code 用了两层看门狗:
- 第一层只记录:两个数据块之间间隔太久(30 秒左右),记一笔"有点慢",但不打断;
- 第二层会中断:完全没有数据太久(90 秒左右),判定这条流已经死了,主动断开,改用非流式请求重试。
检测和处置分开,慢的请求还有机会跑完,真死掉的流也不会让用户无限干等。
四是部分完成加整体重试,等于重复执行。
这是所有流式系统的经典陷阱。流式输出到一半,模型已经发出了工具调用,工具也已经开始执行,这时候流断了。如果简单地整体重试一遍,同一个工具就会执行两次。
读文件执行两次无所谓,但如果这个工具是"提交代码"或者"发一条消息"呢?所以重试之前,必须先搞清楚哪些副作用已经发生了。
总结
- Agent Loop 本质上是一个"调模型 → 执行工具 → 回填结果"的循环,其他能力都挂在这个循环上;
- 生产级的循环要假设"我随时会失败",把恢复当作一等公民来设计;
- 用显式状态机组织循环:每次继续都构造完整的新状态,并记录转换原因;
- 可恢复的错误先扣留,恢复手段从轻到重地试,每一种都配一个单次尝试守卫;
- 通信层的重试要有上限、有降级,前台优先、后台让路,并且时刻提防部分完成后的重复执行。