[控场AI]
· 13 分钟阅读· 6,621 字

Claude Code 主循环拆解:QueryEngine 如何用状态机驱动 Agent

Claude Code 主循环拆解:QueryEngine 如何用状态机驱动 Agent

Claude Code 的 Agent 主循环是显式状态机,恢复是同级状态转移而非嵌套重试。

本文是 Claude Code Agent Loop 系列第五篇,基于 `src/query.ts` 约 1700 行 `queryLoop` 的源码梳理,指出主循环并非简单的 `while (has_tool_use)` 伪代码,而是一台显式状态机。`QueryEngine.ts` 只是 SDK 适配层,真正的主循环在 `query.ts`。循环有七个 transition 理由(next_turn、各类重试、stop_hook_blocking、token_budget_continuation)和十多个终态结果。核心设计是:恢复被建模为同级状态转移而非嵌套 try/catch,因此可以链式叠加,也便于测试断言;可恢复错误在恢复耗尽前对 SDK 消费者隐瞒,使循环更像恢复引擎。此外,主 agent 与子 agent 复用同一份 `queryLoop`,通过 `agentId` 区分身份。

Claude Code 的 Agent 主循环常被想象成一段 while (has_tool_use) 的五行伪代码。但拆开源码会发现,它是一个显式的状态机:每次模型调用前要判断走哪条路径,调用后要决定下一次跳到哪里。重试、自动压缩、Stop hook 否决,都是这台机器的正式分支,而非事后补丁。

本文是 Claude Code Agent Loop 系列第五篇,基于对 src/query.ts 约 1700 行 queryLoop 的源码梳理,把前四篇涉及的机制——工具声明、审批、hooks、并行调度、stop_reason 处理与恢复——放进同一个骨架里。

rss source: Claude Code Agent Loop Deep Dive (5)

先纠正一个命名误解

src/QueryEngine.ts 是一个约 45 KB 的文件。光看名字,很容易以为它实现了查询引擎的主循环。

事实并非如此。QueryEngine 是一层 SDK 适配层,为 SDK 和非交互式 CLI 消费者提供稳定接口,其 submitMessage() 是一个异步生成器,逐段向调用方暴露循环事件。真正的主循环是 src/query.ts 中的 queryLoop,一个超过 1700 行的函数。

这个命名大概率是历史遗留:主循环在 QueryEngine 为 SDK 集成而引入之前就已叫 queryLoop。文件体积在这里是个误导性线索——看起来更小的函数反而承载着更复杂的控制流。

状态机的形状:七个 transition 理由

主循环的高层结构极其朴素:

while (true) {
    根据 state.transition.reason 决定这轮要做什么
    执行:调用 LLM、处理流、追加消息、运行工具……
    生成新的 state.transition,决定下一轮
}

关键在于 state.transition.reason 这个联合类型,它包含七个取值:

  • next_turn:正常推进,调用模型并处理输出。
  • collapse_drain_retry:上下文已满,执行激进上下文折叠后重试。
  • reactive_compact_retry:API 返回 prompt_too_long,压缩对话后重试。
  • max_output_tokens_escalate:输出触达 token 上限,提高 max_tokens 后重试。
  • max_output_tokens_recovery:提高后的上限再次触达,注入续写消息后重试。
  • stop_hook_blocking:Stop hook 阻止了完成,强制再走一轮。
  • token_budget_continuation:输出 token 预算模式跨越 500K 后继续。

每轮开始时检查该理由并选择对应路径;每轮结束时,根据当前工作结果生成下一轮的 transition:正常推进、恢复分支或终态结果。所谓状态机,并非 while (has_tool_use),而是只要 transition 不是终态就继续。

单次迭代实际做了什么

一次迭代等于一次 API 调用加一批工具处理,大致顺序为:

  1. 构建请求:组装消息、加载工具、准备系统提示词。
  2. 调用 LLM:消费流式响应。
  3. 判定结果:检查内容中是否有 tool_use,并处理异常的 stop reason。
  4. 运行工具:经过审批、hooks 和并行调度。
  5. 选择下一步 transition:更新 state.transition.reason。

每一轮都走完这整条链路。难点不在任何单一步骤,而在于把正确的状态从一轮带到下一轮。

stop_reason 是 LLM API 在每次响应中返回的字段,用来解释模型为何停止生成。Anthropic Messages API 常见取值包括:end_turn(自然结束)、max_tokens(触达输出上限)、tool_use(请求调用工具)、stop_sequence(命中自定义停止序列)等。主循环对它的处理不是简单转发,而是把它翻译成下一步的动作决策:tool_use 意味着要进入工具执行分支;max_tokens 会触发 max_output_tokens_escalate 或 max_output_tokens_recovery;prompt_too_long 则映射到 reactive_compact_retry。也就是说,stop_reason 是模型侧信号与循环侧 transition 之间的翻译层,理解它是读懂恢复分支的关键。

终态:循环为什么停下

transition 描述循环如何继续,另一组终态结果描述它为什么停止,包括:

  • completed:正常结束,模型不再产生工具调用。
  • max_turns:触达 maxTurns 断路器。
  • aborted_tools / aborted_streaming:用户分别在工具执行或模型流式输出时中断。
  • hook_stopped:工具后 hook 阻止了推进。
  • stop_hook_prevented:Stop hook 阻止完成,直到重试额度耗尽。
  • blocking_limit、image_error / model_error、prompt_too_long 等。

终态变体超过十个,每个都携带具体的终止原因,让 SDK 消费者能给出恰当反馈,而不是把所有结束都当成同一种通用失败。

Stop hook 是 Claude Code hooks 体系中的一类回调,在模型表示「本轮工作已完成」时被触发。它给外部脚本一次否决权:hook 可以返回阻止信号,迫使循环不进入终态,而是再跑一轮,让模型继续修正或补齐。与之相对,PreToolUse / PostToolUse hook 分别在工具执行前、后拦截。这三种 hook 共同构成循环内的策略注入点——审批脚本、审计日志、格式化工具都能借此介入。Stop hook 的否决不是错误,而是一个正式的状态转移理由 stop_hook_blocking,与重试类分支处于同一层级,因此有明确的额度控制,不会无限循环。

恢复是循环内的状态转移,不是嵌套 try/catch

这与系列第三篇的设计原则一脉相承:把问题转成系统可继续处理的数据或状态,而不是让异常拆掉循环。

工具失败会变成给模型的 error tool_result;主循环中可能可恢复的失败,则变成下一轮的 transition。

朴素的恢复设计通常是嵌套的:

try:
    call_llm()
except PromptTooLong:
    compact()
    call_llm()  # 嵌套重试
except MaxTokens:
    escalate()
    call_llm()

Claude Code 不在当前迭代内立即重试。它记录下一步动作,回到循环顶部,让下一轮从该状态分发:当前迭代以可恢复失败结束 → state.transition.reason = 'reactive_compact_retry' → 下一轮从 while 顶部开始 → 看到该理由后执行压缩 → 在该轮内调用 LLM → 再发出下一步 transition。

核心洞察是:恢复是头等的状态转移,而非异常捕获的附属品。

为什么重要:恢复可以链式叠加

reactive compact 成功后,下一次模型调用可能撞上 max_tokens,transition 随之变为 max_output_tokens_escalate;若提高后的上限再次触达,又变成 max_output_tokens_recovery,注入续写消息。这些路径全都是同一个 while 循环内的同级分支。若用嵌套 try/except,每一层恢复都需要再嵌一层分支或复杂的重入规则,而状态机把控制流保持扁平。

上下文压缩(compaction) 是长对话场景下的核心机制。当消息历史逼近模型上下文窗口时,直接调用会因为总 token 数超限被 API 拒绝,返回 prompt_too_long。压缩的做法是用一次额外的模型调用把早期对话摘要成一个较短的消息,替换掉原始片段,从而把总长度压回窗口内。Claude Code 区分了两条压缩路径:collapse_drain_retry 是主动性折叠,在上下文接近满时提前做;reactive_compact_retry 是被动压缩,在 API 明确报错后才做。两者都是状态转移而非异常处理,因此压缩失败本身也可以变成下一轮的 transition,形成可观测、可测试的恢复链。

为什么重要:transition 可被测试断言

源码显式把 state.transition 当作测试的断言面。完成一个工作单元后,测试可以直接断言 state.transition.reason === 'reactive_compact_retry',这比在输出流里 grep 某条错误信息稳定得多。

对 SDK 消费者隐瞒可恢复错误

这一设计还有更深一层意图:循环在尝试恢复期间,对调用方隐瞒中间错误。

一些 SDK 消费者(例如围绕 agent 构建的产品外壳)会把任何带 error 字段的 API 事件当作最终失败并立即展示给用户。Claude Code 则把可恢复错误保持内部:收到 prompt_too_long 时不立即上报,而是先尝试压缩;如果压缩和下一次调用都成功,从 SDK 调用方视角,这个瞬时错误从未发生。只有恢复彻底失败后,循环才发出终态错误。

这就是把循环理解为恢复引擎而非单纯错误处理器的意义:错误是一个可能触发下一次状态转移的内部信号,而未必是外部结果。

主 agent 与子 agent 共用 queryLoop

另一个出人意料的设计是:主 agent 和子 agent 复用同一份 queryLoop 实现。它们各自运行独立的循环实例,不共享同一台活的状态机,身份通过 toolUseContext.agentId 区分。

代价是循环里散落着 if (!toolUseContext.agentId) 之类的检查,用于守护只属于主线程的工作,包括记忆预取、移动端 UI 摘要、MCP 清理和 Stop-hook 加锁。

收益则很可观:修一次循环逻辑,主 agent 和所有子 agent 同时受益。子 agent 的上下文隔离、worktree 与沙箱发生在循环之外的 AgentTool.tsx 里;循环本身是共享代码,只带少量依赖身份的分支。

子 agent(subagent) 指由主 agent 通过 AgentTool 派生出的独立执行单元,用于把一段子任务隔离到干净的上下文里并行推进。它与主 agent 的关键区别不在于循环逻辑,而在于上下文隔离:子 agent 拥有自己的消息历史、工具集合与工作目录(可挂 worktree 或沙箱),因此它的错误、长上下文和工具输出不会污染主对话。循环入口通过 toolUseContext.agentId 判断当前身份——有 agentId 即子 agent,跳过记忆预取、UI 摘要、MCP 清理等只对主线程有意义的工作。这种「共享循环代码 + 身份分支」的设计,把隔离的复杂度留在了循环之外,避免为子 agent 维护第二套生命周期。

主循环全景

把前四篇放进状态机视角,单次迭代可以画成这样:

─── 迭代开始 ───────────────────────────────
从 state.transition.reason 分发:

  next_turn                    → 进入正常流程
  reactive_compact_retry       → 先压缩
  collapse_drain_retry         → 先做上下文折叠
  max_output_tokens_escalate   → 先提高 max_tokens
  max_output_tokens_recovery   → 先注入 [continue] 消息
  stop_hook_blocking           → 因完成被否决而进入正常流程
  token_budget_continuation    → 继续输出预算模式

─── 主流程 ─────────────────────────────────
调用 LLM 并消费流
      ↓
把消息追加到对话历史
      ↓
内容是否包含 tool_use?
      ├─ 否 → 检查 stop_reason 是否需要恢复
      │       ├─ 是 → 为下一轮创建恢复 transition
      │       └─ 否 → 运行 Stop hook
      │               ├─ 被阻止 → transition = stop_hook_blocking;继续
      │               └─ 被允许 → 返回 { reason: 'completed' };退出
      └─ 是 → 执行工具:审批 → PreToolUse hook
              → 按 isConcurrencySafe 分批调度
              → 追加 tool_result 并保持配对不变量
              → PostToolUse hook
              (任何工具失败都变成 is_error 结果,不会外逸)

─── 迭代结束 ───────────────────────────────
用本轮结果更新 state.transition.reason
      ↓
回到 while 顶部

审批、hooks、调度、完成检查与恢复,并不是围绕循环拼接的独立功能,而是同一台状态机骨架中的某个阶段或转移。

要点回顾

  • QueryEngine.ts 是 SDK 适配层;主循环是 src/query.ts 里的 queryLoop。
  • 主循环是状态机,有七个 transition 理由和十多个终态结果。
  • 恢复路径是同级 transition 而非嵌套重试,因此能干净地链式叠加,也便于测试断言。
  • 可恢复失败在恢复耗尽前对 SDK 调用方隐瞒。
  • 循环是恢复引擎:错误可以是下一次状态转移的信号,而非外部可见的失败。
  • 主 agent 与子 agent 复用同一份 queryLoop 代码,独立运行并在需要时按 agentId 分支。

下一篇将聚焦「调用 LLM」这一步:服务端事件如何从 Anthropic API 流出、事件片段如何拼成完整消息,以及 UI 如何增量渲染这条消息。

分享:

相关推荐