Claude Code 的 Agent Loop
第一章建立了整体运行时地图。这一章进入核心:queryLoop()。
不要把它理解成“while 里反复问模型”。从源码看,Claude Code 的 Agent Loop 更像一个自修改任务状态机:每一轮都会重建上下文、调用模型、处理流式响应、执行工具、回写结果,然后根据 Continue.reason 或 Terminal.reason 决定下一步。
核心直觉
Agent Loop 的难点不是“循环”,而是每一轮循环都会改变下一轮的世界:消息变了、工具结果变了、上下文预算变了、权限和 hook 也可能改变。
先看源码入口
这一章先抓这些源码点:
| 源码定位点 | 关键符号 | 负责什么 |
|---|---|---|
restored-src/src/query.ts:219-238 | query() | 外层包装,调用 queryLoop() 并收尾命令生命周期 |
restored-src/src/query.ts:241 | queryLoop() | 任务状态机主体,内部是 while (true) |
restored-src/src/query.ts:204-217 | State | 每轮之间携带的可变任务现场 |
restored-src/src/query/transitions.ts | Continue、Terminal | 继续和终止原因枚举 |
restored-src/src/query.ts:379-468 | applyToolResultBudget()、snipCompactIfNeeded()、microcompact()、contextCollapse()、autocompact() | 每轮模型调用前的上下文预处理 |
restored-src/src/utils/api.ts:437-474 | appendSystemContext()、prependUserContext() | system/user context 注入 |
restored-src/src/services/api/claude.ts:1259-1314 | normalizeMessagesForAPI() 等 | 内部消息转 API 消息 |
restored-src/src/query.ts:650-953 | attemptWithFallback、callModel() | 模型调用、流式响应、降级重试 |
restored-src/src/query.ts:1363-1408 | StreamingToolExecutor、runTools() | 工具执行与结果收集 |
restored-src/src/query.ts:1267-1355 | handleStopHooks()、token budget check | 没有 tool_use 时的恢复和终止判断 |
这几个点足够支撑一条完整阅读路线:入口包装、状态结构、预处理、API 请求、工具分支、恢复/终止、下一轮状态重建。
总流程图
这张图里要注意两个分叉:
- 有
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: '...' } 退出。也就是说,它不是“自然跑完”的函数,而是一张显式状态转移图。
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 | 利用下一轮流式等待窗口并行生成摘要 |
stopHookActive | stop 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++ 这类增量修改安全得多。
Continue 与 Terminal:循环为什么继续、为什么停止
Agent Loop 必须把“继续”和“停止”都变成可观察状态,否则出了问题只能看到“模型没反应了”。
Continue.reason 的主要分支:
| reason | 触发条件 | 下一轮会发生什么 |
|---|---|---|
next_turn | 模型返回 tool_use,工具结果已回写 | 带着 tool_result 再问模型 |
max_output_tokens_escalate | 输出被截断,尚未升级输出上限 | 设置 maxOutputTokensOverride,原样重试 |
max_output_tokens_recovery | 升级后仍截断,恢复次数未到上限 | 注入恢复提示,让模型接着输出 |
reactive_compact_retry | prompt 太长或媒体过大 | 压缩上下文后重试 |
collapse_drain_retry | prompt 太长且有待提交 context collapse | 先提交 collapse,再重试 |
stop_hook_blocking | stop hook 返回 blocking errors | 把阻塞信息写回,让模型修正 |
token_budget_continuation | 模型提前收尾但预算允许继续 | 注入 nudge,让模型继续工作 |
Terminal.reason 则说明最终停在哪里:
| reason | 含义 |
|---|---|
completed | 模型没有继续动作,或可恢复错误已处理完并正常收束 |
blocking_limit | token 硬限制触达,不能继续 |
prompt_too_long | collapse 和 reactive compact 都没救回来 |
image_error | 图片尺寸或格式错误 |
model_error | 模型调用出现非预期异常 |
aborted_streaming | 用户在流式响应期间中断 |
aborted_tools | 用户在工具执行期间中断 |
stop_hook_prevented | stop hook 阻止继续 |
hook_stopped | 工具执行 hook 停止流程 |
max_turns | 达到最大轮次 |
容易误解
“没有 tool_use”不等于“任务完成”。它只是进入无工具分支,后面还可能触发 max_output_tokens_recovery、reactive_compact_retry、stop_hook_blocking 或 token_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 前必须标准化。
原文给出的顺序是:
| 步骤 | 关键函数 | 证明了什么 |
|---|---|---|
| 1 | normalizeMessagesForAPI() | 附件重排序、虚拟消息过滤、进度消息剥离、工具输入标准化、相邻同角色消息合并 |
| 2 | ensureToolResultPairing() | 修复孤立 tool_use / tool_result,必要时插入合成错误结果 |
| 3 | stripAdvisorBlocks() | 没有对应 beta header 时剥离 advisor blocks |
| 4 | stripExcessMediaItems() | API 限制最多 100 个媒体项,旧媒体会被静默移除 |
其中 ensureToolResultPairing() 很关键。Agent 的上下文里,tool_use 和 tool_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 消费者过早终止会话,先尝试恢复 |
| tombstone | streaming 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 有两种工具执行模式:
| 模式 | 入口 | 行为 |
|---|---|---|
| 流式并行执行 | StreamingToolExecutor | tool_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 -> hook blocking -> retry -> 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 = 'stop_hook_blocking' 继续,让模型有机会修正。
token budget 则处理另一类情况:模型以为完成了,但预算还允许继续,系统可以注入 nudge,以 token_budget_continuation 再跑一轮。这里还有 diminishing returns 判断,防止“预算没用完所以硬续”。
状态和数据结构
这一章最值得记住的结构不是某个类,而是三组 reason 和 counters。
| 结构 | 字段 | 控制什么 |
|---|---|---|
State | messages、toolUseContext、turnCount | 正常任务推进 |
State | autoCompactTracking、hasAttemptedReactiveCompact | 上下文超限恢复 |
State | maxOutputTokensRecoveryCount、maxOutputTokensOverride | 输出截断恢复 |
State | pendingToolUseSummary | 工具摘要异步化 |
State | stopHookActive | hook 阻塞路径 |
State | transition | 当前轮为什么发生 |
Continue | next_turn、reactive_compact_retry、token_budget_continuation 等 | 继续下一轮的原因 |
Terminal | completed、prompt_too_long、max_turns 等 | 最终停止的原因 |
这些字段共同防止三类问题:
- 无限循环:用
hasAttemptedReactiveCompact、maxOutputTokensRecoveryCount、turnCount限制恢复和轮次。 - 不可解释失败:用
Terminal.reason说明停止原因。 - 调试不可见:用
transition让测试和日志知道上一轮为什么继续。
设计取舍
| 取舍点 | 源码体现 | 工程判断 |
|---|---|---|
| 完整状态重建 | 每个 continue 点写完整 state = { ... } | 牺牲一点啰嗦,换来恢复路径可审计,避免忘记重置字段 |
| 从轻到重压缩 | tool budget -> snip -> microcompact -> collapse -> autocompact | 先保信息,再换空间;不要一上来全量摘要 |
| 可恢复错误扣留 | prompt-too-long、max-output-tokens 等先不 yield | 上层消费者看到错误可能终止会话,所以先尝试内部恢复 |
| 降级在同轮完成 | attemptWithFallback 内部重试 | 保留本轮上下文预处理结果,同时清理不兼容的流式中间状态 |
| 工具执行可流式并行 | StreamingToolExecutor.addTool() | 把工具耗时和模型输出重叠,降低总等待 |
| hook 不滥用 | prompt-too-long 不进入 stop hooks | 没有有效模型响应时跑 hook 会制造 death spiral |
| reason 显式化 | Continue.reason、Terminal.reason | Agent 最怕不是失败,而是不知道为什么失败 |
真实任务怎么流动
假设用户说:“npm run build 挂了,帮我修一下。”
| 轮次 | 模型输出 | Loop 做什么 | 写回什么 |
|---|---|---|---|
| 第 1 轮 | tool_use: Bash(npm run build) | 工具执行,收集 stdout/stderr | build 错误作为 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() 这种长函数,不建议从第一行追到最后一行。按下面顺序更稳:
- 先看
State类型,标出哪些字段是计数器、哪些字段是上下文、哪些字段是恢复守卫。 - 搜
transition: { reason:,把所有 continue 点画出来。 - 搜
return { reason:,把所有 terminal 点画出来。 - 再看每轮开头的预处理管线,确认
messagesForQuery怎么来。 - 进入 API 调用,看
attemptWithFallback如何处理降级和流式消息。 - 搜
toolUseBlocks、StreamingToolExecutor、runTools,追工具结果如何变成下一轮消息。 - 最后看无工具分支:max output recovery、reactive compact、stop hooks、token budget。
小结
- query() 是生命周期包装,queryLoop() 才是任务状态机。 - State 保存跨轮任务现场,不只是聊天记录。 - 每轮模型调用前,上下文会按从轻到重的管线处理。 - tool_use 分支把模型意图变成现实反馈,再写回下一轮。 - 无工具分支仍可能恢复、被 hook 拦截或被预算策略继续。 - Continue.reason 和 Terminal.reason 是读懂 Loop 行为的主索引。