乐于分享
好东西不私藏

Claude Code 源码剖析 第2章:ReAct 循环 — `while(true)` 里的五个阶段与七层恢复

Claude Code 源码剖析 第2章:ReAct 循环 — `while(true)` 里的五个阶段与七层恢复

一个 AI Agent 的核心竞争力不在于它调用了多好的模型,而在于它的主循环有多健壮。Claude Code 的心脏是 query.ts 中一个 1,700 行的 while(true) 循环——本篇逐阶段拆解它。


问题

当你在 Claude Code 里输入”帮我重构这个函数”,系统不是简单地调一次 API 然后返回结果。它可能需要:

  1. 1. 先搜索代码找到函数位置
  2. 2. 读取相关文件理解上下文
  3. 3. 生成修改方案
  4. 4. 执行文件编辑
  5. 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. 1. 恢复计数器在每轮重置maxOutputTokensRecoveryCount 在正常轮次结束时归零——每轮都有 3 次恢复机会,而不是全局 3 次。这意味着一个跨 20 轮的长任务,每轮都能独立恢复。
  2. 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),等待后续恢复:

错误类型
暂扣条件
恢复手段
413 Prompt Too Long
Context Collapse 或 Reactive Compact 可用
压缩后重试
Max Output Tokens
总是暂扣
Token 升级或多轮恢复
Media Size Error
Reactive Compact 可用
剥离图片/PDF 后重试

暂扣的消息仍然会推入 assistantMessages,但不会 yield 给调用方。只有恢复失败后才会浮出。

2. 工具调用块收集

if (msgToolUseBlocks.length > 0) {  toolUseBlocks.push(...msgToolUseBlocks)  needsFollowUp = true   // 标记:本轮需要继续循环}

3. 流式工具执行(如果启用)

这是最精巧的部分——下一节详述。


阶段三:工具执行 — 流水线并行

Claude Code 有两种工具执行模式:

模式 A:流式执行器(StreamingToolExecutor)

当特性门控 tengu_streaming_tool_execution2 开启时,工具在模型还在输出时就开始执行

工作原理:

  1. 1. 模型流式输出 tool_use 块 → streamingToolExecutor.addTool(block) 立即入队
  2. 2. 执行器检查并发安全性 → 满足条件立即开始执行
  3. 3. 模型继续输出 → 新工具继续入队和执行
  4. 4. 模型输出完毕 → getRemainingResults() 等待剩余工具完成

并发安全规则

canExecuteTool(isConcurrencySafe: boolean): boolean {  const executing = this.tools.filter(t => t.status === &#x27;executing&#x27;)  return (    executing.length === 0 ||    (isConcurrencySafe && executing.every(t => t.isConcurrencySafe))  )}

翻译成人话:

  • • 如果没有工具在执行 → 直接执行
  • • 如果当前工具和所有正在执行的工具都是并发安全的 → 并行执行
  • • 否则 → 等待

哪些工具是并发安全的? 读操作通常是安全的(FileReadGrepGlob)。写操作通常不安全(BashFileEdit)。但 Bash 工具有特殊逻辑:某些只读命令(git statusls)也可以并发。

Bash 错误的传播:如果一个 Bash 工具报错,它会通过 siblingAbortController.abort('sibling_error') 取消所有兄弟工具。这是因为 Bash 错误通常意味着环境出了问题,继续执行其他工具没有意义。

工具状态机

queued → executing → completed → yielded

模式 B:批量执行器(toolOrchestration.ts)

如果流式执行未启用,工具在模型输出完毕后批量执行

partitionToolCalls(toolUseBlocks)  → 并发安全工具分到同一批(并行,最多 10 个)  → 非安全工具各自一批(串行)

两种模式的接口是统一的——都产出 { message, newContext } 的异步迭代器——循环体不需要知道用的是哪种模式。


阶段四:附件收集

工具执行完成后,系统收集一系列”附件”为下一轮做准备:

  1. 1. 排队命令:用户在工具执行期间输入的命令
  2. 2. Skill 发现:后台预取的新 Skill
  3. 3. 记忆预取:后台发现的相关长期记忆
  4. 4. 工具摘要:用 Haiku 模型异步生成的工具使用摘要(fire-and-forget,不阻塞下一轮)
// 工具摘要是异步的——这一轮生成,下一轮才消费nextPendingToolUseSummary = generateToolUseSummary({  tools: toolInfoForSummary,  signal: toolUseContext.abortController.signal,}).catch(() => null)  // 失败也不阻塞

阶段五:终止或继续?

这是每轮的最终决策点:

模型调用了工具?  ├─ 是 → needsFollowUp = true → 继续循环  └─ 否 → 进入终止检查终止检查链:  1. API 错误?→ 执行失败 Hook,返回 &#x27;completed&#x27;  2. Stop Hook 阻止?→ 返回 &#x27;stop_hook_prevented&#x27;  3. Stop Hook 报错?→ 注入错误消息,继续循环(reason: &#x27;stop_hook_blocking&#x27;)  4. Token Budget 未用完?→ 注入 nudge 消息,继续循环  5. 超过 maxTurns?→ 返回 &#x27;max_turns&#x27;  6. 以上都不是 → 返回 &#x27;completed&#x27;

Stop Hook 是一个有趣的机制:用户可以在设置中定义 Shell 命令,在模型每次完成回复后执行。Hook 可以检查模型的输出,决定是否允许继续。如果 Hook 返回错误,错误会被注入到消息历史中,模型会在下一轮看到这些错误并尝试修正。

Token Budget 是另一个控制点:当启用时,系统会检查本轮已消耗的 Token 是否达到预算的 90%。如果没有,注入一条 nudge 消息(”已用 X%,继续工作,不要总结”)让模型继续工作。还有衰减检测:如果连续 3+ 轮且每轮新增 < 500 Token,判定为”衰减”并停止——模型可能在兜圈子。


七层恢复机制

恢复机制是这个循环最精妙的部分。不是简单的 try-catch 重试,而是分层的、有针对性的自我修复

前三层:预防性(在 API 调用前)

名称
触发
做什么
L1
Autocompact
Token > 上下文窗口 – 13K
AI 生成 9 段结构化摘要
L2
Snip
历史消息过长
裁剪旧消息
L3
Microcompact
工具结果冗余
按 tool_use_id 压缩

这三层在 API 调用前运行,目的是把上下文控制在安全范围内,避免触发 413 错误。

第四层:Context Collapse(413 第一道防线)

当 API 返回 413 错误后,系统首先尝试 Context Collapse——选择性归档消息。

if (state.transition?.reason !== &#x27;collapse_drain_retry&#x27;) {  // 只尝试一次,避免双重 drain  const drained = contextCollapse.recoverFromOverflow(messagesForQuery)  if (drained.committed > 0) {    continue  // reason: &#x27;collapse_drain_retry&#x27;  }}

关键细节:通过 transition.reason 检查避免重复 drain——如果上一轮已经是 collapse_drain_retry,就不再尝试,直接进入下一层。

第五层:Reactive Compact(413 最后手段)

如果 Context Collapse 也不够,触发紧急全量压缩:

if (!hasAttemptedReactiveCompact) {  const result = reactiveCompact.tryReactiveCompact({...})  if (result) {    continue  // reason: &#x27;reactive_compact_retry&#x27;  }}

Reactive Compact 会:

  1. 1. 计算需要清理的 Token 差距(actualTokens - limitTokens
  2. 2. 对整个对话生成紧凑摘要
  3. 3. 智能恢复:压缩后自动恢复最重要的信息(最近读取的文件、当前 PR 文件等,最多 5 个文件,预算 50K Token)
  4. 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: &#x27;max_output_tokens_escalate&#x27;}

为什么默认只给 8K? 这是一个成本优化。大多数回复在 8K 以内就能完成。只在真正需要时才升级到 64K,避免不必要的 Token 消耗。

第七层:多轮恢复(最多 3 次)

如果 64K 也不够,系统会注入恢复指令让模型从断点继续:

if (maxOutputTokensRecoveryCount < 3) {  // MAX_OUTPUT_TOKENS_RECOVERY_LIMIT  const recoveryMessage = createUserMessage({    content: &#x27;Output token limit hit. Resume directly — no apology, no recap...&#x27;,    isMeta: true,  // 对 UI 不可见  })  maxOutputTokensRecoveryCount++  continue  // reason: &#x27;max_output_tokens_recovery&#x27;}

注意恢复指令的措辞:”Resume directly — no apology, no recap”——告诉模型直接从断点继续,不要浪费 Token 道歉或重复已输出的内容。

3 次恢复后如果仍然被截断,才浮出错误给用户。


循环的 10 种终止方式

终止原因
含义
completed
正常完成:模型没有调用工具
aborted_streaming
用户中断(Ctrl+C),模型还在输出
aborted_tools
用户中断,工具还在执行
hook_stopped
Hook 发出了停止信号
stop_hook_prevented
Stop Hook 显式阻止继续
prompt_too_long
413 错误,所有恢复手段用尽
image_error
图片处理错误
blocking_limit
Token 超过手动压缩阈值
model_error
未处理的模型调用异常
max_turns
超过最大轮次限制

6 种继续循环的原因

Transition Reason
含义
next_turn
正常:模型调用了工具,需要继续
collapse_drain_retry
Context Collapse 释放了空间,重试
reactive_compact_retry
紧急压缩成功,重试
max_output_tokens_escalate
Token 上限从 8K 升到 64K
max_output_tokens_recovery
注入恢复指令,继续输出
stop_hook_blocking
Hook 报错,注入错误让模型修正
token_budget_continuation
Token 预算未用完,注入 nudge

一个完整的循环实例

把所有部分串起来,看一个”重构函数”任务的完整循环轨迹:

Turn 1: 用户输入 "帮我重构 parseConfig 函数"  Phase 1: 无需压缩(对话刚开始)  Phase 2: 模型调用 GrepTool 搜索 "parseConfig"  Phase 3: GrepTool 并发执行(read-only,并发安全)  Phase 4: 收集搜索结果  Phase 5: needsFollowUp=true → continue (reason: &#x27;next_turn&#x27;)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 通过 → 返回 &#x27;completed&#x27;

5 轮循环,4 次工具调用,零错误——最简单的路径。

但如果 Turn 3 的编辑触发了上下文溢出:

Turn 3 (异常路径):  Phase 2: API 返回 413 → 暂扣错误  Recovery L4: Context Collapse → 释放了 20K Token → continue (reason: &#x27;collapse_drain_retry&#x27;)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 消耗降到最低


标题
状态
01[1]
512K 行代码,一个终端里的 Agent Runtime
02 ReAct 循环:while(true) 里的五个阶段与七层恢复

(本篇)
03
Prompt 缓存分割与四级上下文压缩
🔄 下一篇
04
50 个工具的统一契约:Tool System 设计
05
五层记忆体系:从短期到持久化
06
纵深防御:23 项安全检查与”不信任任何输入”
07
投机执行与自研状态管理:隐藏延迟的两个利器
08
多 Agent 编排:三种执行模型与 Coordinator 模式
09
在终端里造一个浏览器:自定义 Ink 渲染引擎
10
Bridge 与协议层:让 VS Code、Web、Mobile 共享一个 Claude
11
Skill、Plugin、Hook:三层扩展的设计谱系
12
回顾:从 Claude Code 中提炼的 10 个 Agent 工程模式

引用链接

[1] 01: 01-architecture-overview.md