Book 第三章:一轮对话到底是怎么跑起来的
第二部分:运行时主链路

第三章:一轮对话到底是怎么跑起来的

把 query.ts 当显式状态机来读,看一轮 Claude Code 回合到底怎么推进、怎么自恢复、怎么收口。

1. 为什么先拆它

如果只看一处代码来理解 Claude Code 的内核运行方式,我认为最应该先拆的是 src/query.ts

原因很简单:

  • main.tsx 决定“怎么启动”
  • REPL.tsx 决定“怎么交互”
  • tools.ts 决定“有哪些能力”
  • query.ts 决定“一轮 agent 行为到底怎么跑

也就是说,query.ts 不是一个普通的“调用模型”的函数,而是 Claude Code 的 回合状态机

2. 先说结论

query.ts 干的事情可以概括成一句话:

它不断在“准备上下文 -> 调模型流 -> 处理工具 -> 决定是否继续”这四步之间循环,直到到达某个终止条件。

这个循环不是递归调用,而是一个显式的 while (true) 状态机,状态放在一个 State 对象里。

源码锚点:

  • 主循环:src/query.ts:307
  • 状态定义:src/query.ts:204
  • 无工具时的收尾分支:src/query.ts:1060 左右
  • 有工具时的继续分支:src/query.ts:1360 左右

3. 这份快照里有一个小缺口

src/query.ts 里 import 了:

import type { Terminal, Continue } from './query/transitions.js'

但这份快照里没有 src/query/transitions.ts/js

所以这篇文档里关于:

  • transition.reason
  • return { reason: ... }

的枚举和状态含义,都是根据 src/query.ts 的实际分支反推出来的,不是假设仓库里已经有完整显式定义。

4. 主循环长什么样

4.1 总体图

flowchart TD
    Start["进入 query()"] --> Init["初始化 State / budget / config / prefetch"]
    Init --> Loop["while (true)"]

    Loop --> Prepare["准备 messagesForQuery<br/>budget / snip / microcompact / collapse / autocompact"]
    Prepare --> Stream["调用模型并流式消费响应"]

    Stream --> NeedTools{"本轮是否出现 tool_use"}
    NeedTools -- 否 --> FinishBranch["无工具收尾分支<br/>错误恢复 / stop hooks / token budget"]
    NeedTools -- 是 --> ToolBranch["工具分支<br/>执行工具 / 附件注入 / 下一轮消息组装"]

    FinishBranch --> End{"结束还是继续"}
    ToolBranch --> End

    End -- 继续 --> Loop
    End -- 结束 --> Terminal["返回 Terminal reason"]

4.2 关键理解

这个循环里最重要的不是“有没有调用模型”,而是:

  • 同一轮里,模型可能因为 fallback 重试多次
  • 同一轮里,错误可能被先暂时压住,等恢复失败后再真正对外暴露
  • 同一轮结束后,不一定结束整个 turn,可能会自己塞一个 meta message 再继续
  • 工具结果并不是简单 append,还会混入 attachment、memory、skill discovery、队列消息

所以它本质上是 一个多阶段、带恢复分支的 turn orchestrator

5. State 里到底存了什么

src/query.ts:204 附近的 State 是这个状态机的核心。

我建议把它理解成两类字段。

5.1 主状态

字段作用
messages当前完整消息历史
toolUseContext当前工具运行上下文
turnCount已经跑到第几轮
transition上一次为什么继续

5.2 恢复与治理状态

字段作用
autoCompactTracking自动压缩后的追踪状态
maxOutputTokensRecoveryCount输出 token 超限已经恢复了几次
hasAttemptedReactiveCompact是否已经尝试过 reactive compact
maxOutputTokensOverride本轮是否临时提升输出 token 上限
pendingToolUseSummary上一轮工具摘要的异步结果
stopHookActivestop hook 是否已经在阻塞路径中触发过

5.3 一个很重要的设计味道

作者没有把所有状态都塞进 State

例如:

  • taskBudgetRemaining
  • queryTracking
  • messagesForQuery
  • assistantMessages
  • toolResults

这些有的放在循环局部变量里。

这会让代码写起来更灵活,但迁到 C# 时我不建议继续这么做。更稳妥的方式是:

  • QueryLoopState:跨迭代状态
  • IterationContext:单次迭代临时状态

这样你后面抽 reducer 会简单很多。

6. 一次迭代的准备阶段

这一阶段大概对应 src/query.ts:365-548

顺序不是随便排的,顺序本身就是设计。

6.1 先裁历史

第一步先取:

  • getMessagesAfterCompactBoundary(messages)

也就是只拿压缩边界之后的有效上下文。

6.2 再做工具结果预算裁剪

applyToolResultBudget(...)

这里不是压缩消息,而是控制工具结果的总体大小,避免工具输出把上下文直接撑爆。

6.3 再跑三层压缩

顺序是:

  1. snipCompactIfNeeded
  2. microcompact
  3. contextCollapse.applyCollapsesIfNeeded
  4. autocompact

源码锚点:

  • snip:src/query.ts:396
  • microcompact:src/query.ts:412
  • collapse:src/query.ts:428
  • autocompact:src/query.ts:453

这个顺序很重要,因为它体现了 Claude Code 的压缩策略是分层的:

  • 先做轻量裁剪
  • 再做微压缩
  • 再做上下文折叠
  • 最后才做真正昂贵、语义更重的 summary compact

也就是说,它不是“上下文太长了就总结一下”,而是明显在追求:

能不做重压缩,就尽量不做。

7. 模型流式阶段不是线性的

这一阶段大概是 src/query.ts:650-996

7.1 它先做一个 blocking limit 防线

在真正请求模型前,会先根据 token 估算做一次“硬阻断”判断:

  • 如果上下文已经顶到 blocking limit
  • 并且又不适合自动恢复
  • 就直接报 prompt too long

源码锚点:src/query.ts:628-646

7.2 真正流式调用时,有四件事同时在发生

  1. assistant 消息在持续产出
  2. tool_use block 在被捕获
  3. 某些 recoverable error 会先被 withheld
  4. 如果启用了 streaming tool execution,工具会边流边启动

7.3 为什么它不用 stop_reason === tool_use

代码里明确写了:

  • stop_reason === 'tool_use' 不可靠

所以真实判断标准不是 API stop reason,而是:

  • 在流里有没有出现 tool_use block

这点非常关键。做 C# 版时不要把“是否继续工具阶段”的判断绑定到模型 stop reason 上,而要绑定到 assistant 内容块本身

7.4 它会“暂时压住错误”

这些错误可能会被 withheld:

  • prompt too long
  • media size error
  • max_output_tokens

目的不是隐藏错误,而是:

先看看能不能自动恢复,恢复不了再把错误真正抛给上层。

这是一种很典型的 agent runtime 设计:

  • 用户看到的是最终结果
  • runtime 内部先尝试自愈

8. 工具执行有两条路

8.1 普通路径

如果没开 streaming tool execution,就走:

  • runTools(...)

来自 src/services/tools/toolOrchestration.ts

它会按“是否可并发”分批:

  • 可并发的一批一起跑
  • 不可并发的逐个跑

8.2 流式路径

如果开了 streaming tool execution,就走:

  • StreamingToolExecutor

相关代码:

  • src/query.ts:561
  • src/services/tools/StreamingToolExecutor.ts

它的关键设计点是:

  • 工具可以在 assistant 还没完全说完时就启动
  • progress message 可以先吐出来
  • 完整结果按顺序补齐
  • Bash 错误会中止兄弟工具
  • 用户中断时,未完成工具会得到 synthetic error tool_result

这说明 Claude Code 的工具执行器不只是一个 scheduler,还是一个 流式一致性维护器

9. “没有工具”时,真正难的在恢复逻辑

无工具分支大概是 src/query.ts:1060-1357

它看起来像“收尾”,其实是最复杂的一段。

9.1 prompt too long 的恢复链

顺序是:

  1. 先试 contextCollapse.recoverFromOverflow
  2. 不行再试 reactiveCompact.tryReactiveCompact
  3. 还不行才真正把错误暴露出来

源码锚点:

  • collapse drain:src/query.ts:1087-1115
  • reactive compact:src/query.ts:1117-1173

9.2 max_output_tokens 的恢复链

顺序是:

  1. 如果满足条件,先把单次输出上限升到 ESCALATED_MAX_TOKENS
  2. 还不行,就加一条 meta user message,要求模型直接续写
  3. 超过恢复次数上限,才把错误暴露出来

源码锚点:

  • escalate:src/query.ts:1188-1221
  • recovery message:src/query.ts:1223-1252

9.3 stop hook 不是锦上添花,是正式控制面

如果最后一条 assistant 不是 API error,就会走:

  • handleStopHooks(...)

源码锚点:

  • 调用入口:src/query.ts:1267
  • stop hooks 实现:src/query/stopHooks.ts

stop hook 可能产生三种结果:

  1. preventContinuation = true
  2. blockingErrors.length > 0
  3. 什么都不拦,正常结束

这意味着 stop hook 在设计上不是“后处理回调”,而是:

有资格改变状态机走向的治理节点。

9.4 token budget 也能强行续一轮

如果启用 token budget,它也可能在无工具分支里塞入一个 meta 消息,再强行继续一轮。

源码锚点:src/query.ts:1308-1339

所以“没有工具”不等于“马上结束”,它还会经过:

  • 错误恢复
  • hook 治理
  • 预算治理

10. “有工具”时,真正难的在拼下一轮上下文

有工具分支大概是 src/query.ts:1360-1727

这里最关键的不是执行工具,而是:

工具执行完之后,怎么把下一轮 prompt 重新拼起来。

顺序大致是:

  1. 执行工具,收集 tool_result
  2. 如有需要,异步生成 tool use summary
  3. 注入 attachment
  4. 消费 memory prefetch
  5. 注入 skill discovery attachment
  6. 从命令队列取 task-notification / prompt attachment
  7. 刷新工具池,接入新 MCP tools
  8. 组合成下一轮 messages
  9. transition = { reason: 'next_turn' }

源码锚点:

  • 工具更新循环:src/query.ts:1384
  • attachment 注入:src/query.ts:1580
  • memory prefetch consume:src/query.ts:1599
  • skill discovery 注入:src/query.ts:1620
  • 刷新 tools:src/query.ts:1659
  • 下一轮 state:src/query.ts:1715

这里特别能看出 Claude Code 的一个核心思想:

工具结果不是回合的“附属品”,它们本身就是下一轮上下文构造器。

11. 我反推出的 transition / terminal 表

11.1 Continue reason

这些是 state.transition.reasonquery.ts 里明确出现过的值。

reason含义
collapse_drain_retry上下文折叠回放后重试
reactive_compact_retryreactive compact 后重试
max_output_tokens_escalate临时提高单次输出上限后重试
max_output_tokens_recovery追加恢复提示消息后继续
stop_hook_blockingstop hook 注入阻塞错误后继续
token_budget_continuationtoken budget 触发续跑
next_turn工具执行完,进入下一轮正常 agent 循环

11.2 Terminal reason

这些是 return { reason: ... } 真正出现过的终止结果。

reason含义
blocking_limit预估 token 已到硬阻断阈值
image_error图片或媒体相关错误,且无法恢复
model_error模型流调用抛异常
aborted_streaming流式阶段被中断
prompt_too_longprompt too long 恢复失败
completed正常结束
stop_hook_preventedstop hook 明确阻止继续
aborted_tools工具执行阶段被中断
hook_stopped工具结果里带出 hook-stopped continuation
max_turns达到最大回合数

12. 这个状态机最容易忽略的四个设计点

12.1 错误不是立即对外暴露的

Claude Code 会先判断:

  • 这是不是可恢复错误
  • 有没有恢复手段
  • 恢复是否已经尝试过

只有恢复失败,才真正对外 yield 错误。

12.2 工具执行和消息流是交错的

尤其在 StreamingToolExecutor 模式下:

  • progress 可以先出来
  • tool_result 可以后补
  • 失败时还会补 synthetic error

所以不能把“assistant 阶段”和“tool 阶段”简单理解成两个完全分离的阶段。

12.3 stop hook 是状态机节点,不是 callback

它不只是“通知一下 hook”,而是能决定:

  • 结束
  • 注入 blocking message 后继续
  • 让下游看见 stopped continuation attachment

12.4 下一轮上下文不是简单 append

下一轮消息里混入的东西包括:

  • tool_result
  • file change attachment
  • memory attachment
  • skill discovery attachment
  • 队列里的提示或任务通知

这说明它已经不是纯聊天上下文,而是 runtime event log

13. 如果换成 C#,我会怎么拆

13.1 第一层:显式状态模型

先把现在隐性的状态机显式化:

public sealed record QueryLoopState(
    IReadOnlyList<Message> Messages,
    ToolUseContext ToolContext,
    AutoCompactTracking? AutoCompactTracking,
    int TurnCount,
    int MaxOutputTokensRecoveryCount,
    bool HasAttemptedReactiveCompact,
    int? MaxOutputTokensOverride,
    QueryTransition? LastTransition,
    bool? StopHookActive
);

13.2 第二层:显式枚举

public enum QueryContinueReason
{
    CollapseDrainRetry,
    ReactiveCompactRetry,
    MaxOutputTokensEscalate,
    MaxOutputTokensRecovery,
    StopHookBlocking,
    TokenBudgetContinuation,
    NextTurn
}

public enum QueryTerminalReason
{
    BlockingLimit,
    ImageError,
    ModelError,
    AbortedStreaming,
    PromptTooLong,
    Completed,
    StopHookPrevented,
    AbortedTools,
    HookStopped,
    MaxTurns
}

13.3 第三层:按阶段拆服务

我建议至少拆成这些阶段服务:

  • IQueryPreparationStage
  • IModelStreamingStage
  • IToolExecutionStage
  • IStopPolicyStage
  • IAttachmentEnrichmentStage
  • ITransitionDecider

然后让 QueryTurnLoop 只负责串这些阶段,不直接塞满所有细节。

14. 我建议你下一步怎么继续

如果沿着“做 C# 版”的目标走,最合理的顺序是:

  1. 先把 query.ts 里的状态和转移做成 C# 草图
  2. 再拆 Tool.ts / toolExecution.ts 的执行协议
  3. 最后再拆 MCP / 技能 / 插件的扩展总线

原因是:

  • 先有 turn loop,才能挂工具
  • 先有工具协议,才能挂扩展
  • 先有核心,再谈宿主是 CLI、TUI、IDE 还是 Web

15. 一句话总结

query.ts 不是一个“问模型”的函数,而是一个:

能压缩上下文、能流式执行工具、能自恢复错误、能被 hook 和预算系统干预、还能持续拼装下一轮上下文的 agent turn state machine。