4 · 智能体主循环与错误恢复状态机

4.1 教科书版本的循环,以及它会死在哪

如果你去搜「怎么写一个智能体」,得到的答案基本都是这五行:

while True: # 无限循环 resp = llm(messages) # 把全部历史发给模型 if not resp.tool_calls: break # 没有工具调用 = 任务完成,退出 results = [run(tc) for tc in resp.tool_calls] # 执行所有工具调用 messages += [resp, *results] # 把模型的回复和执行结果追加到历史里

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')

好处:测的是「哪条恢复路径被走了」这个事实本身,和任何文案、任何消息格式都无关。
可以搬走的做法

在你自己的状态机里,加一个纯粹为了可观测和可测试而存在的字段,记录「这次状态转移的原因」。它不参与任何业务判断,但它让你的错误恢复逻辑第一次变得可测试。

代价是多一个字段;收益是每条恢复路径都能被单独验证。这个交易非常划算。

智能体循环错误恢复状态机
智能体循环错误恢复状态机 — 中间发光的 CALL MODEL(调用模型)是唯一的枢纽,所有恢复路径都回到它。六条橙色的恢复边各自带着自己的幂等锁(图上标在 guard 后面);只有绿色的 next_turn(正常推进)会重置全部计数器点击放大

七条转移边逐条解释

循环里有 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 上限到了。直接继续 —— 不要道歉,不要复述你刚才在做什么。如果是在句子中间被切断的,就从那里接上。把剩下的工作拆成更小的块。」

三句话解决三个真实问题:

isMeta: true 这个标记的作用是:这条消息只发给模型,不显示给用户看。用户看到的是连贯的输出,感觉不到中间发生过一次截断恢复。

每条恢复路径都必须有幂等锁

这是全章最重要的一条经验

请注意上面表格里每条路径的限次条件:

源代码里有一段注释,记录了漏掉这种锁的真实后果:

「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 时,系统可能正处在这样一个状态:

已经发生的: · 模型返回了 3 个工具调用(3 张便条),每张都有一个唯一的 id · 工具 1 执行完了 · 工具 2 正在执行中 · 工具 3 还在队列里排队 现在用户按了 Ctrl+C,如果直接退出 —— 历史里有 3 个 tool_use,但只有 1 个 tool_result → 工具 2 和工具 3 成了"孤儿工具调用" → 下一次 API 调用时,服务器发现便条和回复对不上,直接返回 400 错误 → 这场会话彻底废了,用户无法用 --resume 恢复

(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 在这里做了三件事,第三件是大多数人想不到的:

  1. 把已经产生的模型回复全部打上「墓碑」标记tombstone)—— 从界面和对话记录里彻底删除。因为这些回复来自旧模型,混在历史里会造成混乱。
  2. 丢弃流式执行器里所有待定的结果,重建一个新的执行器 —— 避免带着旧工具调用 id 的孤儿结果泄露到重试后的请求里。
  3. 剥离所有「思考块」的数字签名。
// 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。为了防止被篡改,它带有一个加密签名。签名是特定模型生成的,换模型就验证不通过。

源代码里关于思考块的三条规则,写得像魔法书

The Rules of Thinking(思考块三定律)
  1. 含有 thinking 或 redacted_thinking 块的消息,必须出现在一个允许思考的请求里(max_thinking_length > 0)。
  2. thinking 块不能是内容序列里的最后一个元素。
  3. 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
Hermes
面试追问:你的智能体循环怎么防止无限循环?

不要只答「设一个最大轮次上限」。那是兜底,不是主要手段。完整回答分三层:

  1. 硬闸 —— 最大轮次、最大美元花费、最长运行时间。这是最后一道防线,正常情况不该碰到它。
  2. 每条恢复路径独立限次,而且只在正常推进时重置。这是核心。真正的死循环几乎都不来自主流程,而来自两条恢复路径互相触发:压缩失败 → 报错 → 结束钩子判定不合格要求重试 → 又去压缩 → …… Claude Code 源码里就有一条注释记录了这个事故,说是因为在恢复分支里错误地重置了幂等锁,烧掉了几千次 API 调用。
  3. 软着陆 —— 预算快耗尽时给一次「宽限调用」让模型收尾,比硬切断的用户体验好得多。这是 Hermes 的做法。

如果想再加一层深度,补一句:失败路径要能识别「这个失败不该触发常规的质量重试机制」。比如上下文超长导致的失败,绝对不能交给「结束前质量检查」钩子处理 —— 因为那个钩子会往上下文里注入更多内容,越注入越超,源码里管这叫「死亡螺旋」。