Claude Code 源码解剖

Claude Code 的 Agent Loop

从 query()、queryLoop()、State、Continue/Terminal reason、上下文预处理、模型调用和工具回写,拆解 Claude Code 的任务状态机。

第 2 章 15 分钟

Claude Code 的 Agent Loop

第一章建立了整体运行时地图。这一章进入核心:queryLoop()

不要把它理解成“while 里反复问模型”。从源码看,Claude Code 的 Agent Loop 更像一个自修改任务状态机:每一轮都会重建上下文、调用模型、处理流式响应、执行工具、回写结果,然后根据 Continue.reasonTerminal.reason 决定下一步。

核心直觉

Agent Loop 的难点不是“循环”,而是每一轮循环都会改变下一轮的世界:消息变了、工具结果变了、上下文预算变了、权限和 hook 也可能改变。

先看源码入口

这一章先抓这些源码点:

源码定位点关键符号负责什么
restored-src/src/query.ts:219-238query()外层包装,调用 queryLoop() 并收尾命令生命周期
restored-src/src/query.ts:241queryLoop()任务状态机主体,内部是 while (true)
restored-src/src/query.ts:204-217State每轮之间携带的可变任务现场
restored-src/src/query/transitions.tsContinueTerminal继续和终止原因枚举
restored-src/src/query.ts:379-468applyToolResultBudget()snipCompactIfNeeded()microcompact()contextCollapse()autocompact()每轮模型调用前的上下文预处理
restored-src/src/utils/api.ts:437-474appendSystemContext()prependUserContext()system/user context 注入
restored-src/src/services/api/claude.ts:1259-1314normalizeMessagesForAPI()内部消息转 API 消息
restored-src/src/query.ts:650-953attemptWithFallbackcallModel()模型调用、流式响应、降级重试
restored-src/src/query.ts:1363-1408StreamingToolExecutorrunTools()工具执行与结果收集
restored-src/src/query.ts:1267-1355handleStopHooks()、token budget check没有 tool_use 时的恢复和终止判断

这几个点足够支撑一条完整阅读路线:入口包装、状态结构、预处理、API 请求、工具分支、恢复/终止、下一轮状态重建

总流程图

flowchart TD A["query(params)"] --> B["yield* queryLoop(params, consumedCommandUuids)"] B --> C["初始化 State<br/>messages / toolUseContext / counters / transition"] C --> D["while (true)<br/>解构 state"] D --> E["上下文预处理<br/>tool budget -> snip -> microcompact -> collapse -> autocompact"] E --> F{"blocking limit?"} F -->|是| T1["Terminal.reason = blocking_limit"] F -->|否| G["拼装请求<br/>appendSystemContext()<br/>prependUserContext()<br/>normalizeMessagesForAPI()"] G --> H["callModel()<br/>attemptWithFallback 流式读取"] H --> I{"aborted?"} I -->|是| T2["Terminal.reason = aborted_streaming / aborted_tools"] I -->|否| J{"存在 tool_use?"} J -->|是| K["StreamingToolExecutor / runTools"] K --> L["tool_result + attachments<br/>写回 messages"] L --> M{"turnCount >= maxTurns?"} M -->|是| T3["Terminal.reason = max_turns"] M -->|否| N["state = {..., transition: next_turn}<br/>continue"] N --> D J -->|否| O["恢复与终止判定<br/>max_output / prompt-too-long / stop hooks / token budget"] O -->|可恢复| P["state = {..., transition: recovery reason}<br/>continue"] P --> D O -->|不可恢复或完成| T4["Terminal.reason = completed / prompt_too_long / model_error / stop_hook_prevented"]

这张图里要注意两个分叉:

  • tool_use:进入工具执行,结果回写,通常以 next_turn 继续。
  • 没有 tool_use:不一定完成,还要走恢复、stop hook、token budget continuation。

query() 只是外层包装,queryLoop() 才是状态机

源码里的入口很短:

export async function* query(params: QueryParams) {
  const consumedCommandUuids: string[] = [];
  const terminal = yield* queryLoop(params, consumedCommandUuids);

  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed');
  }

  return terminal;
}

这段代码证明了边界:

函数负责什么不负责什么
query()建立命令生命周期数组,委托 queryLoop(),结束后通知 consumed commands 完成不决定上下文怎么压缩,不执行工具,不判断恢复路径
queryLoop()初始化和重建 State,推进模型调用、工具执行、恢复、终止不负责外层命令生命周期收尾

原文指出 queryLoop() 内部是 while (true),每个继续点用 state = next; continue 进入下一轮,每个终止点用 return { reason: &#39;...&#39; } 退出。也就是说,它不是“自然跑完”的函数,而是一张显式状态转移图。

State:跨轮携带的任务现场

State 是读 queryLoop() 的第一把钥匙。原文定位在 restored-src/src/query.ts:204-217,字段包括:

字段含义会影响什么
messages当前对话消息数组,包含用户输入、assistant 响应、工具结果、恢复提示下一轮模型能看见什么
toolUseContext工具执行上下文,包含工具列表、权限模式、abort signal、模型信息等工具能不能执行、如何执行、能否被中断
autoCompactTracking自动压缩追踪状态避免压缩反复失败后继续撞墙
maxOutputTokensRecoveryCount输出截断恢复次数限制 max output recovery 最多重试 3 次
hasAttemptedReactiveCompact是否已经尝试 reactive compact防止 prompt-too-long 恢复死循环
maxOutputTokensOverride覆盖默认输出 token 上限支撑 8k 到 64k 的升级恢复
pendingToolUseSummary上一轮工具摘要的 Promise利用下一轮流式等待窗口并行生成摘要
stopHookActivestop hook 是否处于活跃状态防止 hook 阻塞路径重复触发
turnCount当前轮次支撑 maxTurns 硬上限
transition上一轮为什么继续让测试和调试能看出当前轮来自正常工具回写还是恢复重试

源码注释里有一个非常关键的约束:Continue sites 写完整 state = { ... },而不是 9 个字段分别赋值。这个设计不是风格问题,而是 bug 防线。

伪源码可以写成:

state = {
  messages: nextMessages,
  toolUseContext: nextToolUseContext,
  autoCompactTracking,
  maxOutputTokensRecoveryCount,
  hasAttemptedReactiveCompact,
  maxOutputTokensOverride,
  pendingToolUseSummary,
  stopHookActive,
  turnCount: turnCount + 1,
  transition: { reason: 'next_turn' },
};
continue;

这段结构证明:每个继续点都必须声明“下一轮继承哪些状态、重置哪些状态、为什么继续”。在一个有多种恢复路径的 while (true) 里,这比 state.maxOutputTokensRecoveryCount++ 这类增量修改安全得多。

ContinueTerminal:循环为什么继续、为什么停止

Agent Loop 必须把“继续”和“停止”都变成可观察状态,否则出了问题只能看到“模型没反应了”。

Continue.reason 的主要分支:

reason触发条件下一轮会发生什么
next_turn模型返回 tool_use,工具结果已回写带着 tool_result 再问模型
max_output_tokens_escalate输出被截断,尚未升级输出上限设置 maxOutputTokensOverride,原样重试
max_output_tokens_recovery升级后仍截断,恢复次数未到上限注入恢复提示,让模型接着输出
reactive_compact_retryprompt 太长或媒体过大压缩上下文后重试
collapse_drain_retryprompt 太长且有待提交 context collapse先提交 collapse,再重试
stop_hook_blockingstop hook 返回 blocking errors把阻塞信息写回,让模型修正
token_budget_continuation模型提前收尾但预算允许继续注入 nudge,让模型继续工作

Terminal.reason 则说明最终停在哪里:

reason含义
completed模型没有继续动作,或可恢复错误已处理完并正常收束
blocking_limittoken 硬限制触达,不能继续
prompt_too_longcollapse 和 reactive compact 都没救回来
image_error图片尺寸或格式错误
model_error模型调用出现非预期异常
aborted_streaming用户在流式响应期间中断
aborted_tools用户在工具执行期间中断
stop_hook_preventedstop hook 阻止继续
hook_stopped工具执行 hook 停止流程
max_turns达到最大轮次

容易误解

“没有 tool_use”不等于“任务完成”。它只是进入无工具分支,后面还可能触发 max_output_tokens_recoveryreactive_compact_retrystop_hook_blockingtoken_budget_continuation

每轮开始:上下文先过一条从轻到重的预处理管线

模型调用前,messages 不会原样送进 API。原文把 query.ts:379-468 的管线拆成五级:

applyToolResultBudget()
-> snipCompactIfNeeded()
-> microcompact()
-> contextCollapse()
-> autocompact()

它们的顺序很重要:

阶段读取什么做什么判断产出
applyToolResultBudget()工具结果 blocks单条/聚合工具输出是否过大裁剪或替换过大的工具结果
snipCompactIfNeeded()历史消息和 token 预算旧历史是否可以轻量截断新 messages 和 tokensFreed
microcompact()局部可压缩内容是否能用更细粒度方式释放空间局部压缩后的 messages
contextCollapse()可折叠历史和 collapse store是否有可读时投影的折叠结果投影后的 messages
autocompact()当前 token 压力和 tracking前面几步是否还不够压缩摘要和更新后的 tracking

源码注释里最值得保留的是 context collapse 的读时投影思想。原文引用的大意是:collapse 不向 REPL 历史 yield 新消息,summary 存在 collapse store,折叠视图是在读取时投影出来的。

这证明 contextCollapse() 不是简单“把数组里一段消息替换成摘要”,而是在模型读取路径上生成视图。好处是 UI 的完整历史和模型看到的压缩视图可以分离。

核心直觉

上下文预处理不是为了“压到越短越好”,而是用最少信息损失换可继续工作的模型现场。所以顺序必须从轻到重:先裁工具输出,再 snip,再 microcompact,再 collapse,最后才 autocompact。

请求拼装:system context 和 user context 分层进入 API

上下文预处理后,还要注入运行时上下文。源码里两个函数边界很清楚:

export function appendSystemContext(
  systemPrompt: SystemPrompt,
  context: { [k: string]: string },
): string[] {
  return [
    ...systemPrompt,
    Object.entries(context)
      .map(([key, value]) => `${key}: ${value}`)
      .join('\n'),
  ].filter(Boolean);
}

这段代码证明 system context 是追加到 system prompt 末尾的。稳定、全局、适合缓存的内容更适合放这里。

另一个函数:

export function prependUserContext(
  messages: Message[],
  context: { [k: string]: string },
): Message[] {
  return [
    createUserMessage({
      content: `<system-reminder>\n...\n</system-reminder>\n`,
      isMeta: true,
    }),
    ...messages,
  ];
}

这段代码证明 user context 被包装成 @@INLINE_0@@,作为 meta user message 插到消息数组最前面。

调用点的形状是:

messages: prependUserContext(messagesForQuery, userContext),
systemPrompt: fullSystemPrompt,

这里有一个设计判断:prependUserContext() 在 API 调用时执行,而不是在前面的压缩管线里执行。也就是说,它不是历史消息的一部分,不参与同样的压缩和 token 管线;它更像每次请求前临时贴上的运行时提醒。

消息标准化:内部消息不能直接发给模型

Claude Code 内部消息比 Anthropic API 要丰富:有虚拟消息、进度消息、附件、工具输入的 UI backfill、媒体错误等。发 API 前必须标准化。

原文给出的顺序是:

步骤关键函数证明了什么
1normalizeMessagesForAPI()附件重排序、虚拟消息过滤、进度消息剥离、工具输入标准化、相邻同角色消息合并
2ensureToolResultPairing()修复孤立 tool_use / tool_result,必要时插入合成错误结果
3stripAdvisorBlocks()没有对应 beta header 时剥离 advisor blocks
4stripExcessMediaItems()API 限制最多 100 个媒体项,旧媒体会被静默移除

其中 ensureToolResultPairing() 很关键。Agent 的上下文里,tool_usetool_result 必须配对;远程会话恢复、降级重试、会话裁剪都可能造成孤立块。如果不修复,下一次 API 调用可能直接失败。

读源码抓手

看到消息被过滤或合并时,不要只问“内容有没有丢”。要问:API 对消息格式有什么硬约束?哪些内部 UI 消息本来就不应该进入模型?

模型调用:流式响应里也有恢复分支

API 调用阶段被 attemptWithFallback 包住。原文给出的片段:

let attemptWithFallback = true;
while (attemptWithFallback) {
  attemptWithFallback = false;
  try {
    for await (const message of deps.callModel({
      messages: prependUserContext(messagesForQuery, userContext),
      systemPrompt: fullSystemPrompt,
      // ...
    })) {
      // 处理流式响应消息
    }
  } catch (innerError) {
    if (innerError instanceof FallbackTriggeredError && fallbackModel) {
      currentModel = fallbackModel;
      attemptWithFallback = true;
      // 清理孤立消息, 重置 executor
      continue;
    }
    throw innerError;
  }
}

这段代码证明:模型降级不是外层 queryLoop() 的下一轮,而是同一轮 API 调用内部的重试。这样可以保留当前预处理结果,同时清理已经产生但不能继续使用的流式中间状态。

原文还提到三个流式阶段的源码判断:

机制触发场景为什么需要
消息克隆yield 给 SDK 前克隆 message避免修改回传给 API 的原始消息,破坏 prompt cache 字节一致性
错误扣留prompt-too-long、max-output-tokens、media-size 等可恢复错误不让 SDK 消费者过早终止会话,先尝试恢复
tombstonestreaming fallback 已经 yield 过部分消息通知删除旧流式片段,避免 thinking signature 等模型绑定内容造成后续 400

这里的取舍是:流式输出对用户是实时的,但对状态机不能天真地“发出去就算数”。 可恢复错误要扣留,降级后旧片段要 tombstone,缓存相关消息不能被随意 mutate。

工具分支:tool_use 变成现实反馈

模型响应结束后,Loop 判断有没有 tool_use blocks。有的话进入工具执行阶段:

tool_use blocks
-> StreamingToolExecutor.getRemainingResults()
   或 runTools()
-> toolResults[]
-> normalizeMessagesForAPI()
-> 追加到下一轮 messages
-> transition.reason = 'next_turn'

原文指出 Claude Code 有两种工具执行模式:

模式入口行为
流式并行执行StreamingToolExecutortool_use block 到达时就 addTool(),模型还在流式输出时工具已经开始跑
批量执行runTools()收集完所有 tool_use blocks 后统一执行

流式并行执行说明 Claude Code 没有把“模型完整说完”作为工具执行的唯一起点。只要某个 tool_use block 参数完整,就可以提前开始,把工具耗时和模型流式输出重叠起来。

工具完成后,Loop 还会注入附件:

附件来源注入时机作用
queued commands工具执行后、下一轮前把发往当前 agent 地址的命令变成附件消息
memory prefetch后台预取完成且本轮未消费把相关记忆放入下一轮上下文
skill discovery技能发现完成把可用技能信息接入下一轮

这些后台任务利用的是模型流式响应和工具执行期间的等待窗口。它们不挡在 API 调用前面,而是在下一轮准备阶段消费。

无工具分支:恢复、hook 和终止

没有 tool_use 时,Loop 不是直接 completed。它先检查多条恢复和拦截路径。

输出截断恢复

当模型输出触达 max_output_tokens,Claude Code 先尝试升级输出上限:

默认 max output
-> 输出被截断
-> maxOutputTokensOverride = ESCALATED_MAX_TOKENS
-> transition.reason = 'max_output_tokens_escalate'
-> 原样重试

如果升级后仍截断,再走多轮恢复。原文给出的恢复提示强调:

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.

这条提示证明恢复不是简单“请继续”。它明确禁止道歉和回顾,要求直接接上,并拆小剩余工作。原因很工程化:道歉和 recap 都会浪费刚刚变稀缺的输出 token。

提示过长恢复

prompt 太长时,恢复顺序是:

prompt-too-long
-> collapse_drain_retry
-> reactive_compact_retry
-> 仍失败则 Terminal.reason = prompt_too_long

源码注释里有一个重要判断:prompt-too-long 时不要 fall through 到 stop hooks。因为模型没有产生有效响应,hook 没有可评估对象;如果强行跑 hook,可能形成 error -&gt; hook blocking -&gt; retry -&gt; error 的 death spiral。

这说明恢复路径必须有边界:不是所有错误都能进入所有后处理系统。

停止钩子与 token budget

当模型没有工具调用且响应有效时,Loop 才有条件运行 stop hooks:

const stopHookResult = yield* handleStopHooks(
  messagesForQuery,
  assistantMessages,
  systemPrompt,
  userContext,
  systemContext,
  toolUseContext,
  querySource,
  stopHookActive,
);

如果 stop hook 返回 blockingErrors,Loop 不会直接结束,而是把阻塞错误注入消息流,并用 transition.reason = &#39;stop_hook_blocking&#39; 继续,让模型有机会修正。

token budget 则处理另一类情况:模型以为完成了,但预算还允许继续,系统可以注入 nudge,以 token_budget_continuation 再跑一轮。这里还有 diminishing returns 判断,防止“预算没用完所以硬续”。

状态和数据结构

这一章最值得记住的结构不是某个类,而是三组 reason 和 counters。

结构字段控制什么
StatemessagestoolUseContextturnCount正常任务推进
StateautoCompactTrackinghasAttemptedReactiveCompact上下文超限恢复
StatemaxOutputTokensRecoveryCountmaxOutputTokensOverride输出截断恢复
StatependingToolUseSummary工具摘要异步化
StatestopHookActivehook 阻塞路径
Statetransition当前轮为什么发生
Continuenext_turnreactive_compact_retrytoken_budget_continuation继续下一轮的原因
Terminalcompletedprompt_too_longmax_turns最终停止的原因

这些字段共同防止三类问题:

  • 无限循环:用 hasAttemptedReactiveCompactmaxOutputTokensRecoveryCountturnCount 限制恢复和轮次。
  • 不可解释失败:用 Terminal.reason 说明停止原因。
  • 调试不可见:用 transition 让测试和日志知道上一轮为什么继续。

设计取舍

取舍点源码体现工程判断
完整状态重建每个 continue 点写完整 state = { ... }牺牲一点啰嗦,换来恢复路径可审计,避免忘记重置字段
从轻到重压缩tool budget -&gt; snip -&gt; microcompact -&gt; collapse -&gt; autocompact先保信息,再换空间;不要一上来全量摘要
可恢复错误扣留prompt-too-long、max-output-tokens 等先不 yield上层消费者看到错误可能终止会话,所以先尝试内部恢复
降级在同轮完成attemptWithFallback 内部重试保留本轮上下文预处理结果,同时清理不兼容的流式中间状态
工具执行可流式并行StreamingToolExecutor.addTool()把工具耗时和模型输出重叠,降低总等待
hook 不滥用prompt-too-long 不进入 stop hooks没有有效模型响应时跑 hook 会制造 death spiral
reason 显式化Continue.reasonTerminal.reasonAgent 最怕不是失败,而是不知道为什么失败

真实任务怎么流动

假设用户说:“npm run build 挂了,帮我修一下。”

轮次模型输出Loop 做什么写回什么
第 1 轮tool_use: Bash(npm run build)工具执行,收集 stdout/stderrbuild 错误作为 tool_result 进入 messages
第 2 轮tool_use: Read(...)Grep(...)读取相关文件或搜索错误符号文件片段/搜索结果回写
第 3 轮tool_use: Edit(...)修改最小代码编辑结果回写
第 4 轮tool_use: Bash(npm run build)再验证成功输出回写
收尾tool_use跑 stop hooks / token budget 判断Terminal.reason = completed

如果第 1 轮构建日志过长,会先被 applyToolResultBudget() 裁剪;如果多轮后上下文过大,会走 snip / microcompact / collapse / autocompact;如果模型回答被截断,会走 max output recovery;如果用户中断,会返回 abort 类 terminal reason。

这就是 Agent Loop 的本质:每一轮不是照脚本执行下一步,而是根据新证据重建下一轮决策现场。

读源码抓手

queryLoop() 这种长函数,不建议从第一行追到最后一行。按下面顺序更稳:

  1. 先看 State 类型,标出哪些字段是计数器、哪些字段是上下文、哪些字段是恢复守卫。
  2. transition: { reason:,把所有 continue 点画出来。
  3. return { reason:,把所有 terminal 点画出来。
  4. 再看每轮开头的预处理管线,确认 messagesForQuery 怎么来。
  5. 进入 API 调用,看 attemptWithFallback 如何处理降级和流式消息。
  6. toolUseBlocksStreamingToolExecutorrunTools,追工具结果如何变成下一轮消息。
  7. 最后看无工具分支:max output recovery、reactive compact、stop hooks、token budget。

小结

- query() 是生命周期包装,queryLoop() 才是任务状态机。 - State 保存跨轮任务现场,不只是聊天记录。 - 每轮模型调用前,上下文会按从轻到重的管线处理。 - tool_use 分支把模型意图变成现实反馈,再写回下一轮。 - 无工具分支仍可能恢复、被 hook 拦截或被预算策略继续。 - Continue.reasonTerminal.reason 是读懂 Loop 行为的主索引。