4 · 智能体主循环与错误恢复状态机
4.1 教科书版本的循环,以及它会死在哪
如果你去搜「怎么写一个智能体」,得到的答案基本都是这五行:
(llm 是 large language model 的缩写,指调用大语言模型;resp 是 response 即回复;tool_calls 就是第 1.5 节讲的那些「便条」。)
这五行确实能跑通一个演示。但把它放到真实环境里,它会死在下面每一条上:
| 会遇到的问题 | 为什么这五行处理不了 |
|---|---|
| 上下文超了怎么办? | 第 5 行会无限追加内容,迟早撞上上下文窗口上限,API 返回 413 错误。而且更麻烦的是:压缩之后还超怎么办?压缩这个动作本身失败了怎么办? |
| 模型的输出被截断了怎么办? | 模型单次输出也有上限。写到一半被切断时,如果只是把这个截断的回复原样追加进历史再问一遍,模型通常会从头开始道歉并重述 —— 白花一大笔 token。 |
| 用户中途按了 Ctrl+C 怎么办? | 此时可能有几个工具调用已经发给了模型,但工具还在执行、执行结果还没产生。直接退出的话,历史里就有「孤儿工具调用」—— 有便条没有回复。下一轮 API 调用会因为这个不完整的配对直接报错。 |
| 模型服务过载要换备用模型怎么办? | 不能简单换个模型重发。因为历史里可能有模型的「思考块」(thinking block),而思考块带有和原模型绑定的数字签名,把它重放给另一个模型会被拒绝。 |
| 循环永远不停怎么办? | 这五行里没有任何计数器。谁来数轮次?谁来数钱?而且真正的死循环几乎都不来自主流程,而来自恢复逻辑互相触发 —— 这一点后面会详讲。 |
真实的智能体主循环,90% 的代码在处理这五行之外的东西。这正是两套系统最值得学的地方。
4.2 Claude Code 的做法:写成显式状态机
先解释什么是「状态机」
状态机是一种编程模式,它要求你把程序的运行情况归纳成有限的几个具名状态,并且明确写出「在什么条件下,从哪个状态跳到哪个状态」。
举个日常例子:一个电商订单的状态机是「待付款 → 已付款 → 已发货 → 已完成」,加上「已取消」「已退款」两个分支。每条箭头都有明确的触发条件(付款成功、超时未付、发起退款)。
这样写的好处是:你可以穷举所有可能的路径并逐条测试,而不是祈祷自己没漏掉某个情况。
Claude Code 把跨轮次的状态收进一个结构体
type State = {
messages: Message[] // 当前的完整消息历史
toolUseContext: ToolUseContext // 工具执行需要的上下文对象
// 下面这些字段全部是"恢复用的记账"——
autoCompactTracking: ... | undefined // 自动压缩的追踪状态
maxOutputTokensRecoveryCount: number // 输出被截断后已经重试了几次
hasAttemptedReactiveCompact: boolean // 这一轮是否已经做过反应式压缩(幂等锁)
maxOutputTokensOverride: number | undefined // 是否已经把输出上限升过档
pendingToolUseSummary: Promise<...> | undefined
stopHookActive: boolean | undefined
turnCount: number // 已经进行了几轮
transition: Continue | undefined // ★ 上一轮是"因为什么原因"继续的
}
claude-code/src/query.ts · State 类型定义
(type State = {...} 是 TypeScript 语言定义一个数据结构的写法,可以理解成「这个叫 State 的东西,包含以下这些字段」。number 是数字,boolean 是真/假,undefined 表示「这个字段现在没有值」。)
最后那个 transition 字段是点睛之笔
transition 这个字段完全不参与业务逻辑。它存在的唯一目的,是记录「上一轮循环是因为什么原因决定继续的」。源代码里的注释直接说明了原因:
「Why the previous iteration continued. Undefined on first iteration. Lets tests assert recovery paths fired without inspecting message contents.」
译:上一轮为什么继续。第一轮时是空的。它让测试代码可以直接断言某条恢复路径被触发了,而不必去翻消息内容。
为什么这很重要?举个具体例子。假设你要写一个测试,验证「输出被截断时会正确重试」:
| 没有 transition 字段时 | 有 transition 字段时 |
|---|---|
| 只能去翻消息数组,检查里面有没有出现那句提示文案("Output token limit hit...")。 问题:这个测试和文案强耦合。哪天有人改了一个字,测试就红了 —— 但功能其实没坏。这种测试会被团队慢慢禁用掉。 |
直接断言一行:expect(transition.reason).toBe('max_output_tokens_recovery')好处:测的是「哪条恢复路径被走了」这个事实本身,和任何文案、任何消息格式都无关。 |
在你自己的状态机里,加一个纯粹为了可观测和可测试而存在的字段,记录「这次状态转移的原因」。它不参与任何业务判断,但它让你的错误恢复逻辑第一次变得可测试。
代价是多一个字段;收益是每条恢复路径都能被单独验证。这个交易非常划算。
七条转移边逐条解释
循环里有 7 个 continue 语句(continue 的意思是「跳过本轮剩余部分,直接开始下一轮循环」)。每一个都对应一条具名的恢复路径:
| 路径名称 | 什么情况会触发 | 做什么动作 |
|---|---|---|
next_turn正常推进 |
模型这一轮返回了工具调用,并且工具已经执行完了 | 把模型回复和执行结果追加进历史,重置所有恢复计数器,进入下一轮 |
collapse_drain_retry |
API 返回 413(上下文过长) | 先「排空」暂存的上下文折叠 —— 这是最便宜的恢复手段,而且能保住细粒度信息 |
reactive_compact_retry |
413,而且上一步排空之后仍然超 | 做一次完整的摘要压缩。每轮只允许一次(幂等锁:hasAttemptedReactiveCompact) |
max_output_tokens_escalate |
模型的输出被默认的 8,000 token 上限截断了 | 把同一个请求原封不动重发一次,只是把输出上限提到 64,000。每轮只允许一次 |
max_output_tokens_recovery |
提到 64,000 之后还是被截断 | 注入一条「接着写」的指令(下面会详细看这条指令)。最多 3 次 |
stop_hook_blocking |
用户配置的「结束前检查钩子」判定这个回答不合格 | 把钩子给出的错误信息当成一条用户消息注入,让模型自己修 |
token_budget_continuation |
用户设置了 token 预算,而且还没用完 | 注入一条「继续深入」的提示,让模型把剩余预算用掉 |
那条「接着写」的指令值得单独看
const recoveryMessage = createUserMessage({
content:
`Output token limit hit. Resume directly — no apology, no recap ` +
`of what you were doing. Pick up mid-thought if that is where the ` +
`cut happened. Break remaining work into smaller pieces.`,
isMeta: true,
})
claude-code/src/query.ts · 输出截断恢复
这段英文的意思是:「输出 token 上限到了。直接继续 —— 不要道歉,不要复述你刚才在做什么。如果是在句子中间被切断的,就从那里接上。把剩下的工作拆成更小的块。」
三句话解决三个真实问题:
- 「不要道歉」 —— 模型的默认行为是先说一句「抱歉,我的回复被截断了」。这句话本身就要花钱,而且毫无信息量。
- 「从句子中间接上」 —— 不这样明确许可的话,模型倾向于把刚才那段重新说一遍再往下写,浪费更多 token。
- 「拆成更小的块」 —— 防止下一次又被截断,陷入反复截断的循环。
isMeta: true 这个标记的作用是:这条消息只发给模型,不显示给用户看。用户看到的是连贯的输出,感觉不到中间发生过一次截断恢复。
每条恢复路径都必须有幂等锁
请注意上面表格里每条路径的限次条件:
hasAttemptedReactiveCompact—— 一个真/假开关,做过就锁住maxOutputTokensRecoveryCount < 3—— 一个计数器,超过 3 次就放弃maxOutputTokensOverride === undefined—— 检查「还没升过档」,只允许升一次
源代码里有一段注释,记录了漏掉这种锁的真实后果:
「Resetting to false here caused an infinite loop: compact → still too long → error → stop hook blocking → compact → … burning thousands of API calls.」
译:在这里把标记重置成 false 曾导致一个无限循环:压缩 → 还是太长 → 报错 → 结束钩子判定不合格要求重试 → 又去压缩 → …… 烧掉了几千次 API 调用。
注意这个循环的形状:它不是一条路径自己转圈,而是两条恢复路径互相触发。压缩路径和钩子路径各自看起来都有终止条件,但组合起来就成了死循环。恢复路径没有幂等锁 = 生产事故。
关键细节:锁只在正常推进时重置
看那张状态机图上的绿色边:只有 next_turn(正常推进,也就是模型给了工具调用、工具执行成功)这条路径,才会把所有恢复计数器清零。
为什么?因为「正常推进」意味着系统真的往前走了一步,之前的问题解决了。而走恢复路径时绝不能清零 —— 恢复本身不代表问题解决了,如果清零,就等于允许无限次重试。上面那段注释里的事故,本质就是在恢复路径上清零了。
4.3 错误扣留:可恢复的错误不能立刻往外发
这是 Claude Code 一个很精巧、也很容易被忽略的设计。
先说清楚背景:Claude Code 的主循环是一个「生成器」(generator)—— 它一边处理一边把消息吐给外部调用方(可能是终端界面,也可能是桌面应用、软件开发工具包)。
现在的问题是:当 API 返回一个可以恢复的错误(上下文过长、输出被截断、图片过大)时,该不该把这个错误吐给外部?
Claude Code 的答案是不吐,先扣住:
let withheld = false // withheld = 被扣留的
// 三类可恢复错误,任何一类命中就扣住
if (contextCollapse?.isWithheldPromptTooLong(message, ...)) withheld = true
if (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = true
if (mediaRecoveryEnabled &&
reactiveCompact?.isWithheldMediaSizeError(message)) withheld = true
if (isWithheldMaxOutputTokens(message)) withheld = true
if (!withheld) { yield yieldMessage } // 没被扣住的才吐出去
// 但无论扣不扣,都要放进内部数组,供下面的恢复逻辑找到它
if (message.type === 'assistant') assistantMessages.push(message)
claude-code/src/query.ts · 流式循环内部
只有当所有恢复路径都试过、都失败了,才把这条错误吐出去。
源代码注释:「Yielding early leaks an intermediate error to SDK callers (e.g. cowork/desktop) that terminate the session on any error field — the recovery loop keeps running but nobody is listening.」
译:过早吐出去会把一个中间状态的错误泄露给外部调用方(比如桌面应用),而那些调用方看到任何 error 字段就会终止会话 —— 于是恢复循环还在勤勤恳恳地跑,但已经没有人在听了。
这是「内部可恢复状态不应该泄露到外部协议」的经典案例。任何做流式接口的服务端都会遇到同类问题:你内部正在优雅重试,但你已经把一个 error 字段发出去了,对方的客户端已经按「出错了」的逻辑挂断了连接。
4.4 中断的正确处理姿势
用户按下 Ctrl+C 时,系统可能正处在这样一个状态:
(400 是另一个网络错误码,表示「你发来的请求格式不合法」。和 413「内容太长」是不同的错误。)
Claude Code 的处理分两条路:
if (toolUseContext.abortController.signal.aborted) { // 检测到中止信号
if (streamingToolExecutor) {
// 用了流式执行器的情况:
// 必须消费 getRemainingResults(),它会为"排队中"和"执行中"的工具
// 生成合成的(也就是假造的)tool_result
for await (const update of streamingToolExecutor.getRemainingResults()) {
if (update.message) yield update.message
}
} else {
// 没用流式执行器的情况:
// 为每一个 tool_use 兜底造一条标记为错误的 tool_result
yield* yieldMissingToolResultBlocks(assistantMessages, 'Interrupted by user')
}
return { reason: 'aborted_streaming' }
}
claude-code/src/query.ts · 中断处理
那个兜底函数 yieldMissingToolResultBlocks(意思是「吐出缺失的工具结果块」)实现很简单,但它是整个系统的安全网:
function* yieldMissingToolResultBlocks(assistantMessages, errorMessage) {
for (const assistantMessage of assistantMessages) { // 遍历每条模型回复
const toolUseBlocks = assistantMessage.message.content
.filter(c => c.type === 'tool_use') // 找出所有便条
for (const toolUse of toolUseBlocks) {
yield createUserMessage({ // 为每张便条造一条回复
content: [{ type:'tool_result',
content: errorMessage, // 内容是错误说明
is_error: true, // 标记为错误
tool_use_id: toolUse.id }], // ★ 关键:id 必须对上
...
})
}
}
}
这个函数在四个地方被调用:用户中断时、切换备用模型时、流式请求失败回退时、以及最外层的异常捕获里。
凡是可能在「已经发出工具调用、但还没产生执行结果」这个时间窗口里退出的代码路径,都必须补齐合成的执行结果。
实现上只有一条规则:每一个 tool_use 的 id,必须有一个 tool_result 带着同一个 id 回应它。内容是什么不重要,可以是「被用户中断」,可以标记为错误 —— 但配对必须完整。
这是自建智能体最常踩、也最难排查的一个坑:症状是「用户一按 Ctrl+C,这个会话就再也恢复不了了」,而报错信息通常只说「请求格式不合法」,完全不提是哪里不合法。
4.5 切换备用模型:一个想不到的坑
当主模型过载(服务器返回「容量不足」)时,需要切换到备用模型重试。Claude Code 在这里做了三件事,第三件是大多数人想不到的:
- 把已经产生的模型回复全部打上「墓碑」标记(
tombstone)—— 从界面和对话记录里彻底删除。因为这些回复来自旧模型,混在历史里会造成混乱。 - 丢弃流式执行器里所有待定的结果,重建一个新的执行器 —— 避免带着旧工具调用 id 的孤儿结果泄露到重试后的请求里。
- 剥离所有「思考块」的数字签名。
// Thinking signatures are model-bound: replaying a protected-thinking
// block (e.g. capybara) to an unprotected fallback (e.g. opus) 400s.
// Strip before retry so the fallback model gets clean history.
messagesForQuery = stripSignatureBlocks(messagesForQuery)
译:思考块的签名是和模型绑定的:把一个受保护的思考块(比如来自代号 capybara 的模型)重放给一个不受保护的备用模型(比如 opus),会返回 400 错误。所以重试前先剥掉签名,让备用模型拿到干净的历史。
(什么是「思考块」?较新的模型在正式回答前会先做一段内部推理,这段推理内容可以被返回给调用方,叫做 thinking block。为了防止被篡改,它带有一个加密签名。签名是特定模型生成的,换模型就验证不通过。)
源代码里关于思考块的三条规则,写得像魔法书
- 含有 thinking 或 redacted_thinking 块的消息,必须出现在一个允许思考的请求里(
max_thinking_length > 0)。 - thinking 块不能是内容序列里的最后一个元素。
- thinking 块必须在整条模型轨迹期间完整保留 —— 所谓「一条轨迹」指:一个轮次,如果这个轮次里含有工具调用,那么还要包括其后的工具执行结果以及紧接着的下一条模型回复。
「Heed these rules well, young wizard… If ye does not heed these rules, ye will be punished with an entire day of debugging and hair pulling.」
译:好好遵守这些规则,年轻的巫师……若你不遵守,你将受到整整一天调试与揪头发的惩罚。
玩笑归玩笑,第 3 条是真正的硬约束,而且它直接限制了第 6 章所有上下文压缩的实现:
压缩、截断、重放这三种操作,任何一处如果切在了「思考块轨迹」的中间,API 就会拒绝整个请求。
这意味着:你不能简单地说「保留最后 6 条消息,前面全压缩掉」—— 如果第 7 条消息是一个思考块,而第 6 条是它对应的工具结果,你就把一条完整轨迹劈成了两半。保护窗口的边界必须落在轨迹的缝隙上,不能落在轨迹中间。
4.6 Hermes 的做法:预算驱动的循环
Hermes 的主循环入口条件本身就带了三个约束:
while (api_call_count < agent.max_iterations # 调用次数没超上限
and agent.iteration_budget.remaining > 0) # 迭代预算还有余额
or agent._budget_grace_call: # 或者:还有一次"宽限调用"
hermes-agent/agent/conversation_loop.py 第 2029 行
最后那个 _budget_grace_call(宽限调用)是个很有意思的设计:预算耗尽时不是硬性切断,而是再给模型一次机会,通常用来让它把已有的结果总结一下再退出。这一次调用之后无条件退出:
if agent._budget_grace_call:
agent._budget_grace_call = False # 消费掉宽限标记,下一轮就退出了
elif not agent.iteration_budget.consume(): # 尝试扣一次预算,扣不动了
_turn_exit_reason = "budget_exhausted" # 记录退出原因
break
Hermes 也维护了一个 _turn_exit_reason(本轮退出原因)字符串,作用和 Claude Code 的 transition 字段类似 —— 纯粹为了可观测。已经在代码里见到的取值包括 interrupted_by_user(被用户中断)、review_input_budget_exhausted(审查输入预算耗尽)、budget_exhausted(预算耗尽)。
预算撞线时直接 break,用户看到的是一个半成品加一句「预算用完了」。
给一次宽限调用,用户看到的是「我已经完成了 A 和 B,C 还没做完,当前进度是……」。同样的成本上限,体验差距很大。
4.7 Hermes 独有的能力:轮次中途插话
这是 Claude Code 完全没有的功能:模型正在思考的时候,用户可以插一句话,而且这句话在本轮就生效。Hermes 管这个功能叫 /steer(引导)。
实现这个功能的难点有两个,而且都和前面讲过的概念直接相关:
| 难点 | 为什么难 |
|---|---|
| 不能破坏角色交替 | API 要求消息必须按「用户 → 模型 → 用户 → 模型」交替出现。如果模型刚说完话,你又插一条用户消息,形式上没问题;但如果模型正在等工具结果,你插一条用户消息进去,就打断了「工具调用 → 工具结果」的配对,请求会被拒绝。 |
| 不能破坏提示词缓存 | 见第 1.8 节。往对话中间插入一条新消息,等于改变了上下文的中段 —— 从插入点往后的缓存全部失效。 |
Hermes 的解法很聪明:不新增消息,而是把插话追加到「最新一条工具结果消息」的末尾。
_pre_api_steer = agent._drain_pending_steer() # 取出待处理的插话
if _pre_api_steer:
for _si in range(len(messages) - 1, -1, -1): # 从最后一条消息往前找
_sm = messages[_si]
if _sm.get("role") == "tool": # 找到最近的一条"工具结果"消息
marker = format_steer_marker(_pre_api_steer)
_sm["content"] = existing + marker # ★ 追加到它末尾,不新增消息
_injected = True
break
if not _injected:
# 还没有任何工具结果消息(比如第一轮)——
# 把插话放回队列,等下一批工具结果出现时再注入
agent._pending_steer = _pre_api_steer
hermes-agent/agent/conversation_loop.py · 调用 API 前排空插话队列
往对话里塞一条新消息 = 破坏缓存前缀 + 可能破坏角色交替。
往「最后一条消息的末尾」追加 = 只让最后一小段缓存失效,前面全部命中。
这个「最新工具结果消息的尾部」槽位,在 Hermes 里被复用了至少三次:
· /steer 用户中途插话
· 墙上时钟预算用到 80% 时的「请开始收尾」提醒
· 待办事项列表的提示
在你自己的项目里,可以固定预留这一个注入点,让所有「带外信号」都从这里进。这样你只需要保证一个地方的缓存安全性,而不是每加一个功能就重新思考一遍。
4.8 两家对照与取舍
Claude Code
- 状态机显式:所有跨轮次状态收进一个 State 结构体,加具名的 transition 字段
- 恢复路径分层递进:先试便宜的,不行再试贵的
- 可恢复错误先扣住,不泄露到外部协议
- 预算维度多(轮次 / 美元 / token / 任务预算)但都是硬闸,撞线即停
- 代价:逻辑高度耦合 Anthropic 接口的具体语义(思考块签名、缓存编辑、任务预算),换供应商基本要重写循环层
Hermes
- 状态挂在 agent 对象的属性上(
agent._xxx),不是独立结构体 - 循环入口就是预算闸门,加一次 宽限调用做软着陆
- 支持轮次中途插话(用户引导 / 重定向 / 收尾提醒)
- 失败恢复面更宽:供应商切换、凭据轮转、413 重试
- 代价:主循环单文件 8,676 行,状态散落在几十个对象属性上,可测试性明显弱于 Claude Code
不要只答「设一个最大轮次上限」。那是兜底,不是主要手段。完整回答分三层:
- 硬闸 —— 最大轮次、最大美元花费、最长运行时间。这是最后一道防线,正常情况不该碰到它。
- 每条恢复路径独立限次,而且只在正常推进时重置。这是核心。真正的死循环几乎都不来自主流程,而来自两条恢复路径互相触发:压缩失败 → 报错 → 结束钩子判定不合格要求重试 → 又去压缩 → …… Claude Code 源码里就有一条注释记录了这个事故,说是因为在恢复分支里错误地重置了幂等锁,烧掉了几千次 API 调用。
- 软着陆 —— 预算快耗尽时给一次「宽限调用」让模型收尾,比硬切断的用户体验好得多。这是 Hermes 的做法。
如果想再加一层深度,补一句:失败路径要能识别「这个失败不该触发常规的质量重试机制」。比如上下文超长导致的失败,绝对不能交给「结束前质量检查」钩子处理 —— 因为那个钩子会往上下文里注入更多内容,越注入越超,源码里管这叫「死亡螺旋」。