第三章:一轮对话到底是怎么跑起来的
把 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.reasonreturn { 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 | 上一轮工具摘要的异步结果 |
stopHookActive | stop hook 是否已经在阻塞路径中触发过 |
5.3 一个很重要的设计味道
作者没有把所有状态都塞进 State。
例如:
taskBudgetRemainingqueryTrackingmessagesForQueryassistantMessagestoolResults
这些有的放在循环局部变量里。
这会让代码写起来更灵活,但迁到 C# 时我不建议继续这么做。更稳妥的方式是:
QueryLoopState:跨迭代状态IterationContext:单次迭代临时状态
这样你后面抽 reducer 会简单很多。
6. 一次迭代的准备阶段
这一阶段大概对应 src/query.ts:365-548。
顺序不是随便排的,顺序本身就是设计。
6.1 先裁历史
第一步先取:
getMessagesAfterCompactBoundary(messages)
也就是只拿压缩边界之后的有效上下文。
6.2 再做工具结果预算裁剪
applyToolResultBudget(...)
这里不是压缩消息,而是控制工具结果的总体大小,避免工具输出把上下文直接撑爆。
6.3 再跑三层压缩
顺序是:
snipCompactIfNeededmicrocompactcontextCollapse.applyCollapsesIfNeededautocompact
源码锚点:
- 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 真正流式调用时,有四件事同时在发生
- assistant 消息在持续产出
- tool_use block 在被捕获
- 某些 recoverable error 会先被 withheld
- 如果启用了 streaming tool execution,工具会边流边启动
7.3 为什么它不用 stop_reason === tool_use
代码里明确写了:
stop_reason === 'tool_use'不可靠
所以真实判断标准不是 API stop reason,而是:
- 在流里有没有出现
tool_useblock
这点非常关键。做 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:561src/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 的恢复链
顺序是:
- 先试
contextCollapse.recoverFromOverflow - 不行再试
reactiveCompact.tryReactiveCompact - 还不行才真正把错误暴露出来
源码锚点:
- collapse drain:
src/query.ts:1087-1115 - reactive compact:
src/query.ts:1117-1173
9.2 max_output_tokens 的恢复链
顺序是:
- 如果满足条件,先把单次输出上限升到
ESCALATED_MAX_TOKENS - 还不行,就加一条 meta user message,要求模型直接续写
- 超过恢复次数上限,才把错误暴露出来
源码锚点:
- 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 可能产生三种结果:
preventContinuation = trueblockingErrors.length > 0- 什么都不拦,正常结束
这意味着 stop hook 在设计上不是“后处理回调”,而是:
有资格改变状态机走向的治理节点。
9.4 token budget 也能强行续一轮
如果启用 token budget,它也可能在无工具分支里塞入一个 meta 消息,再强行继续一轮。
源码锚点:src/query.ts:1308-1339
所以“没有工具”不等于“马上结束”,它还会经过:
- 错误恢复
- hook 治理
- 预算治理
10. “有工具”时,真正难的在拼下一轮上下文
有工具分支大概是 src/query.ts:1360-1727。
这里最关键的不是执行工具,而是:
工具执行完之后,怎么把下一轮 prompt 重新拼起来。
顺序大致是:
- 执行工具,收集
tool_result - 如有需要,异步生成 tool use summary
- 注入 attachment
- 消费 memory prefetch
- 注入 skill discovery attachment
- 从命令队列取 task-notification / prompt attachment
- 刷新工具池,接入新 MCP tools
- 组合成下一轮
messages 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.reason 在 query.ts 里明确出现过的值。
| reason | 含义 |
|---|---|
collapse_drain_retry | 上下文折叠回放后重试 |
reactive_compact_retry | reactive compact 后重试 |
max_output_tokens_escalate | 临时提高单次输出上限后重试 |
max_output_tokens_recovery | 追加恢复提示消息后继续 |
stop_hook_blocking | stop hook 注入阻塞错误后继续 |
token_budget_continuation | token budget 触发续跑 |
next_turn | 工具执行完,进入下一轮正常 agent 循环 |
11.2 Terminal reason
这些是 return { reason: ... } 真正出现过的终止结果。
| reason | 含义 |
|---|---|
blocking_limit | 预估 token 已到硬阻断阈值 |
image_error | 图片或媒体相关错误,且无法恢复 |
model_error | 模型流调用抛异常 |
aborted_streaming | 流式阶段被中断 |
prompt_too_long | prompt too long 恢复失败 |
completed | 正常结束 |
stop_hook_prevented | stop 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 第三层:按阶段拆服务
我建议至少拆成这些阶段服务:
IQueryPreparationStageIModelStreamingStageIToolExecutionStageIStopPolicyStageIAttachmentEnrichmentStageITransitionDecider
然后让 QueryTurnLoop 只负责串这些阶段,不直接塞满所有细节。
14. 我建议你下一步怎么继续
如果沿着“做 C# 版”的目标走,最合理的顺序是:
- 先把
query.ts里的状态和转移做成 C# 草图 - 再拆
Tool.ts/toolExecution.ts的执行协议 - 最后再拆 MCP / 技能 / 插件的扩展总线
原因是:
- 先有 turn loop,才能挂工具
- 先有工具协议,才能挂扩展
- 先有核心,再谈宿主是 CLI、TUI、IDE 还是 Web
15. 一句话总结
query.ts 不是一个“问模型”的函数,而是一个:
能压缩上下文、能流式执行工具、能自恢复错误、能被 hook 和预算系统干预、还能持续拼装下一轮上下文的 agent turn state machine。