Claude Code 源码剖析 第2章:ReAct 循环 — `while(true)` 里的五个阶段与七层恢复
一个 AI Agent 的核心竞争力不在于它调用了多好的模型,而在于它的主循环有多健壮。Claude Code 的心脏是
query.ts中一个 1,700 行的while(true)循环——本篇逐阶段拆解它。

问题
当你在 Claude Code 里输入”帮我重构这个函数”,系统不是简单地调一次 API 然后返回结果。它可能需要:
-
1. 先搜索代码找到函数位置 -
2. 读取相关文件理解上下文 -
3. 生成修改方案 -
4. 执行文件编辑 -
5. 运行测试确认没有 break
每一步都是一次”模型思考 → 工具执行”的循环。如果中途上下文溢出了怎么办?输出被截断了怎么办?API 过载了怎么办?网络断了怎么办?
Claude Code 的回答是:一个能自我修复的 ReAct 循环。
在整体架构中的位置
用户输入 → QueryEngine.submitMessage() ↓ ┌──────────────────┐ │ query.ts │ ← 本篇聚焦 │ while(true) { │ │ ...1,700 行... │ │ } │ └──────────────────┘ ↓ Ink 渲染引擎 → 终端
QueryEngine 是入口,但真正的循环逻辑在 query.ts 的 queryLoop() 函数中。QueryEngine.submitMessage() 做的是消息规范化、系统提示组装,然后把控制权交给 query() 异步生成器。
循环状态:一个 Agent 需要记住什么
在进入循环之前,先看循环维护的状态。这个状态对象定义了一个 ReAct Agent 需要跨轮次追踪的所有信息:
type State = { messages: Message[] // 对话历史(持续增长) toolUseContext: ToolUseContext // 工具执行上下文 autoCompactTracking: AutoCompactTrackingState // 压缩状态追踪 maxOutputTokensRecoveryCount: number // 输出截断恢复次数(上限 3) hasAttemptedReactiveCompact: boolean // 是否已尝试紧急压缩 maxOutputTokensOverride: number | undefined // Token 上限覆盖(8K→64K) pendingToolUseSummary: Promise<...> // 上一轮的工具摘要(异步) stopHookActive: boolean // Stop Hook 是否激活 turnCount: number // 当前轮次 transition: Continue | undefined // 上一次继续的原因}
两个关键设计:
-
1. 恢复计数器在每轮重置: maxOutputTokensRecoveryCount在正常轮次结束时归零——每轮都有 3 次恢复机会,而不是全局 3 次。这意味着一个跨 20 轮的长任务,每轮都能独立恢复。 -
2. Transition 追踪: transition.reason记录上一次循环为什么继续,而不是结束。这不是给用户看的——是给调试和测试用的。可能的值包括'next_turn'(正常)、'reactive_compact_retry'(紧急压缩后重试)、'max_output_tokens_escalate'(Token 升级)等。
五个阶段

阶段一:上下文准备 — 在调 API 之前先”瘦身”
每轮 API 调用之前,系统会运行一条压缩管线,确保消息历史不会超出上下文窗口:
Tool Result Budget (单消息上限) ↓ Snip (历史裁剪) ↓ Microcompact (工具结果压缩) ↓ Context Collapse (选择性归档) ↓ Autocompact (AI 全量摘要)
每级压缩逐层递进,前一级减少不够才进下一级:
Tool Result Budget:给单条工具结果设置上限。过长的搜索结果、文件内容会被截断。这一步在 Microcompact 之前运行,因为它的截断对缓存是不可见的。
Snip:轻量裁剪,直接移除旧消息。保留最近的上下文不动。释放的 Token 数 snipTokensFreed 会传给后续阶段,影响 Autocompact 的触发阈值。
Microcompact:按 tool_use_id 压缩工具结果。关键技术:它操作的是缓存索引而非消息内容本身,对 API 的 Prompt Cache 不可见。这意味着压缩不会打破缓存。支持的工具包括 FileRead、Shell、Grep、Glob、WebSearch、WebFetch、FileEdit、FileWrite。
Context Collapse:选择性归档。不是摘要所有内容,而是保留细粒度上下文、只归档确定不再需要的部分。
Autocompact:AI 全量摘要。当 Token 数超过 上下文窗口 - 13,000 时触发。使用专门的 Prompt 让模型生成 9 个分区的结构化摘要(请求意图、技术概念、文件代码、错误修复、问题解决、用户消息、待办任务、当前工作、下一步建议)。
有一个熔断器:如果 Autocompact 连续失败 3 次(MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3),就停止尝试,避免无限循环。
阶段二:模型流式调用
准备好上下文后,调用 Anthropic API:
for await (const message of deps.callModel({ messages: prependUserContext(messagesForQuery, userContext), systemPrompt: fullSystemPrompt, tools: toolUseContext.options.tools, signal: toolUseContext.abortController.signal, options: { model: currentModel, fallbackModel, maxOutputTokensOverride, // 可能已从 8K 升至 64K fastMode: appState.fastMode, taskBudget: { total, remaining }, queryTracking: { chainId, depth }, // ... }})) { // 流式处理每个消息块}
流式处理的核心:模型的输出不是一次性返回的,而是逐块到达——文本块、工具调用块、思考块。系统在流式接收过程中做了三件关键的事:
1. 错误拦截与暂扣
并非所有错误都立即暴露给调用方。三类错误会被暂扣(withheld),等待后续恢复:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
暂扣的消息仍然会推入 assistantMessages,但不会 yield 给调用方。只有恢复失败后才会浮出。
2. 工具调用块收集
if (msgToolUseBlocks.length > 0) { toolUseBlocks.push(...msgToolUseBlocks) needsFollowUp = true // 标记:本轮需要继续循环}
3. 流式工具执行(如果启用)
这是最精巧的部分——下一节详述。
阶段三:工具执行 — 流水线并行

Claude Code 有两种工具执行模式:
模式 A:流式执行器(StreamingToolExecutor)
当特性门控 tengu_streaming_tool_execution2 开启时,工具在模型还在输出时就开始执行。
工作原理:
-
1. 模型流式输出 tool_use块 →streamingToolExecutor.addTool(block)立即入队 -
2. 执行器检查并发安全性 → 满足条件立即开始执行 -
3. 模型继续输出 → 新工具继续入队和执行 -
4. 模型输出完毕 → getRemainingResults()等待剩余工具完成
并发安全规则:
canExecuteTool(isConcurrencySafe: boolean): boolean { const executing = this.tools.filter(t => t.status === 'executing') return ( executing.length === 0 || (isConcurrencySafe && executing.every(t => t.isConcurrencySafe)) )}
翻译成人话:
-
• 如果没有工具在执行 → 直接执行 -
• 如果当前工具和所有正在执行的工具都是并发安全的 → 并行执行 -
• 否则 → 等待
哪些工具是并发安全的? 读操作通常是安全的(FileRead、Grep、Glob)。写操作通常不安全(Bash、FileEdit)。但 Bash 工具有特殊逻辑:某些只读命令(git status、ls)也可以并发。
Bash 错误的传播:如果一个 Bash 工具报错,它会通过 siblingAbortController.abort('sibling_error') 取消所有兄弟工具。这是因为 Bash 错误通常意味着环境出了问题,继续执行其他工具没有意义。
工具状态机:
queued → executing → completed → yielded
模式 B:批量执行器(toolOrchestration.ts)
如果流式执行未启用,工具在模型输出完毕后批量执行:
partitionToolCalls(toolUseBlocks) → 并发安全工具分到同一批(并行,最多 10 个) → 非安全工具各自一批(串行)
两种模式的接口是统一的——都产出 { message, newContext } 的异步迭代器——循环体不需要知道用的是哪种模式。
阶段四:附件收集
工具执行完成后,系统收集一系列”附件”为下一轮做准备:
-
1. 排队命令:用户在工具执行期间输入的命令 -
2. Skill 发现:后台预取的新 Skill -
3. 记忆预取:后台发现的相关长期记忆 -
4. 工具摘要:用 Haiku 模型异步生成的工具使用摘要(fire-and-forget,不阻塞下一轮)
// 工具摘要是异步的——这一轮生成,下一轮才消费nextPendingToolUseSummary = generateToolUseSummary({ tools: toolInfoForSummary, signal: toolUseContext.abortController.signal,}).catch(() => null) // 失败也不阻塞
阶段五:终止或继续?
这是每轮的最终决策点:
模型调用了工具? ├─ 是 → needsFollowUp = true → 继续循环 └─ 否 → 进入终止检查终止检查链: 1. API 错误?→ 执行失败 Hook,返回 'completed' 2. Stop Hook 阻止?→ 返回 'stop_hook_prevented' 3. Stop Hook 报错?→ 注入错误消息,继续循环(reason: 'stop_hook_blocking') 4. Token Budget 未用完?→ 注入 nudge 消息,继续循环 5. 超过 maxTurns?→ 返回 'max_turns' 6. 以上都不是 → 返回 'completed'
Stop Hook 是一个有趣的机制:用户可以在设置中定义 Shell 命令,在模型每次完成回复后执行。Hook 可以检查模型的输出,决定是否允许继续。如果 Hook 返回错误,错误会被注入到消息历史中,模型会在下一轮看到这些错误并尝试修正。
Token Budget 是另一个控制点:当启用时,系统会检查本轮已消耗的 Token 是否达到预算的 90%。如果没有,注入一条 nudge 消息(”已用 X%,继续工作,不要总结”)让模型继续工作。还有衰减检测:如果连续 3+ 轮且每轮新增 < 500 Token,判定为”衰减”并停止——模型可能在兜圈子。
七层恢复机制

恢复机制是这个循环最精妙的部分。不是简单的 try-catch 重试,而是分层的、有针对性的自我修复。
前三层:预防性(在 API 调用前)
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这三层在 API 调用前运行,目的是把上下文控制在安全范围内,避免触发 413 错误。
第四层:Context Collapse(413 第一道防线)
当 API 返回 413 错误后,系统首先尝试 Context Collapse——选择性归档消息。
if (state.transition?.reason !== 'collapse_drain_retry') { // 只尝试一次,避免双重 drain const drained = contextCollapse.recoverFromOverflow(messagesForQuery) if (drained.committed > 0) { continue // reason: 'collapse_drain_retry' }}
关键细节:通过 transition.reason 检查避免重复 drain——如果上一轮已经是 collapse_drain_retry,就不再尝试,直接进入下一层。
第五层:Reactive Compact(413 最后手段)
如果 Context Collapse 也不够,触发紧急全量压缩:
if (!hasAttemptedReactiveCompact) { const result = reactiveCompact.tryReactiveCompact({...}) if (result) { continue // reason: 'reactive_compact_retry' }}
Reactive Compact 会:
-
1. 计算需要清理的 Token 差距( actualTokens - limitTokens) -
2. 对整个对话生成紧凑摘要 -
3. 智能恢复:压缩后自动恢复最重要的信息(最近读取的文件、当前 PR 文件等,最多 5 个文件,预算 50K Token) -
4. 对于 Media Size Error,可以剥离图片/PDF 后重试
死亡螺旋防护:413 恢复路径显式跳过 Stop Hook——因为模型从未产生有效输出,Hook 没有东西可评估。如果在这里运行 Hook,会产生”错误 → Hook 阻塞 → 重试 → 错误”的死亡螺旋。
// 代码注释原文:// Do NOT fall through to stop hooks: the model never produced a valid response,// so hooks have nothing meaningful to evaluate. Running stop hooks on// prompt-too-long creates a death spiral: error → hook blocking → retry → error → …
第六层:Token 上限升级(8K → 64K)
当模型输出被截断(max_output_tokens 错误),系统的第一反应不是重试,而是升级限制:
if (capEnabled && maxOutputTokensOverride === undefined) { // 从默认的 8K 升级到 64K——单次升级,同一请求内立即重试 maxOutputTokensOverride = 64_000 // ESCALATED_MAX_TOKENS continue // reason: 'max_output_tokens_escalate'}
为什么默认只给 8K? 这是一个成本优化。大多数回复在 8K 以内就能完成。只在真正需要时才升级到 64K,避免不必要的 Token 消耗。
第七层:多轮恢复(最多 3 次)
如果 64K 也不够,系统会注入恢复指令让模型从断点继续:
if (maxOutputTokensRecoveryCount < 3) { // MAX_OUTPUT_TOKENS_RECOVERY_LIMIT const recoveryMessage = createUserMessage({ content: 'Output token limit hit. Resume directly — no apology, no recap...', isMeta: true, // 对 UI 不可见 }) maxOutputTokensRecoveryCount++ continue // reason: 'max_output_tokens_recovery'}
注意恢复指令的措辞:”Resume directly — no apology, no recap”——告诉模型直接从断点继续,不要浪费 Token 道歉或重复已输出的内容。
3 次恢复后如果仍然被截断,才浮出错误给用户。
循环的 10 种终止方式
|
|
|
|---|---|
completed |
|
aborted_streaming |
|
aborted_tools |
|
hook_stopped |
|
stop_hook_prevented |
|
prompt_too_long |
|
image_error |
|
blocking_limit |
|
model_error |
|
max_turns |
|
6 种继续循环的原因:
|
|
|
|---|---|
next_turn |
|
collapse_drain_retry |
|
reactive_compact_retry |
|
max_output_tokens_escalate |
|
max_output_tokens_recovery |
|
stop_hook_blocking |
|
token_budget_continuation |
|
一个完整的循环实例
把所有部分串起来,看一个”重构函数”任务的完整循环轨迹:
Turn 1: 用户输入 "帮我重构 parseConfig 函数" Phase 1: 无需压缩(对话刚开始) Phase 2: 模型调用 GrepTool 搜索 "parseConfig" Phase 3: GrepTool 并发执行(read-only,并发安全) Phase 4: 收集搜索结果 Phase 5: needsFollowUp=true → continue (reason: 'next_turn')Turn 2: Phase 1: 无需压缩 Phase 2: 模型调用 FileReadTool 读取文件 + GlobTool 查找相关文件 Phase 3: 两个工具并行执行(都是只读) Phase 4: 收集文件内容 Phase 5: needsFollowUp=true → continueTurn 3: Phase 1: 无需压缩 Phase 2: 模型调用 FileEditTool 修改代码 Phase 3: FileEditTool 串行执行(写操作,非并发安全) Phase 4: 记录文件变更 Phase 5: needsFollowUp=true → continueTurn 4: Phase 1: 无需压缩 Phase 2: 模型调用 BashTool 运行测试 Phase 3: BashTool 串行执行 Phase 4: 收集测试结果 Phase 5: needsFollowUp=true → continueTurn 5: Phase 1: 无需压缩 Phase 2: 模型输出最终回复(无工具调用) Phase 3: 跳过 Phase 4: 后台生成工具摘要、提取记忆 Phase 5: needsFollowUp=false → Stop Hook 通过 → 返回 'completed'
5 轮循环,4 次工具调用,零错误——最简单的路径。
但如果 Turn 3 的编辑触发了上下文溢出:
Turn 3 (异常路径): Phase 2: API 返回 413 → 暂扣错误 Recovery L4: Context Collapse → 释放了 20K Token → continue (reason: 'collapse_drain_retry')Turn 3 (重试): Phase 1: 上下文已缩小 Phase 2: API 调用成功,模型继续编辑 → 恢复正常流程
用户完全无感——系统自动压缩、重试,编辑继续。
可借鉴的模式
从这个循环中可以提取三个通用模式:
1. 分层恢复,而非统一重试
不同类型的错误需要不同的恢复策略。413 和 max_output_tokens 是完全不同的问题——前者需要压缩输入,后者需要扩展输出。用一个通用的 retry(n) 处理所有错误是不够的。
2. 暂扣错误,给恢复留窗口
不是”收到错误 → 立即报告”,而是”收到错误 → 先暂扣 → 尝试恢复 → 恢复失败才报告”。这个模式适用于所有有恢复能力的系统。
3. 状态重置的粒度
maxOutputTokensRecoveryCount每轮重置,hasAttemptedReactiveCompact每轮重置(除非是 Stop Hook 阻塞的重试)。恢复预算是按轮次而非全局分配的——长任务不会因为早期的错误耗尽后期的恢复能力。
下一篇预告
ReAct 循环的阶段一(上下文准备)提到了四级压缩和 Prompt Cache 分割——这是控制 Token 成本的核心。下一篇,我们将深入拆解:静态/动态 Prompt 分割如何最大化缓存命中率,以及四级压缩体系如何在保留关键信息的同时将 Token 消耗降到最低。
|
|
|
|
|---|---|---|
|
|
|
|
| 02 | ReAct 循环:while(true) 里的五个阶段与七层恢复
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
引用链接
[1] 01: 01-architecture-overview.md
夜雨聆风