3.2 二元分离 — QueryEngine vs query.ts
源码文件:
QueryEngine.ts(1296行)、query.ts(1729行)、query/config.ts(46行)、query/deps.ts(40行)、query/tokenBudget.ts(93行)核心概念:二元分离架构、生命周期不对称性、状态所有权矩阵、接口边界设计、状态转换机制、分级错误恢复、Withheld 错误暂存、函数式状态更新
导语:一个架构决策的分量
原书第 3.2 节开宗明义:
“在深入查询循环的细节之前,我们需要先理解 Claude Code 引擎层最重要的一个架构决策:将引擎拆分为两个独立的抽象层级。”
这不是一句客套话。在整个第 3 章中,二元分离是所有后续讨论的基石——while(true) 循环、AsyncGenerator 流式驱动、配置快照模式、分级错误恢复——每一个机制都运行在这两个抽象层级的某一个之上,或者横跨两者之间的接口。理解了二元分离,就拿到了理解整个引擎层的钥匙。
本篇笔记将从源码出发,深入验证原书的描述,并重点关注状态转换和错误恢复这两个最能体现二元分离价值的机制。
一、二元分离的本质:生命周期的不对称性
1.1 原书的论述
原书 3.2.1 节用了一个场景来说明为什么需要两层抽象:
会话开始
用户输入 #1 → query() 循环 #1 开始 → Ctrl+C 中断 → 循环 #1 结束
(消息历史保留,累计用量保留)
用户输入 #2 → query() 循环 #2 开始 → 正常完成 → 循环 #2 结束
(消息历史增长,累计用量增加)
用户输入 #3 → query() 循环 #3 开始 → ...
核心论点是:QueryEngine 的状态贯穿整个会话,而 query() 循环的状态每次用户输入都会重新创建。如果把这两种生命周期不同的状态混在一个对象中,会面临两个问题:
- 重置的范围模糊:中断后重新输入时,哪些状态该重置、哪些该保留?
- 复用的粒度不匹配:query() 需要被多个调用方复用(REPL、子 Agent、SDK),如果绑定到 QueryEngine 实例,复用就需要创建完整实例。
1.2 源码验证
源码完美印证了这一设计。QueryEngine 的类注释(第 176-183 行)写道:
/**
* QueryEngine owns the query lifecycle and session state for a conversation.
* It extracts the core logic from ask() into a standalone class that can be
* used by both the headless/SDK path and (in a future phase) the REPL.
*
* One QueryEngine per conversation. Each submitMessage() call starts a new
* turn within the same conversation. State (messages, file cache, usage, etc.)
* persists across turns.
*/
注意注释中的关键信息:
- “One QueryEngine per conversation” —— 会话级生命周期
- “Each submitMessage() call starts a new turn” —— 每次输入是新一轮
- “State persists across turns” —— 状态跨轮次持久
- “extracts the core logic from ask()” —— 暗示了演进历史(PR#22546 的重构)
而 query() 函数的签名(query.ts:219-228)则体现了轮次级生命周期:
export async function* query(
params: QueryParams,
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
| Message
| TombstoneMessage
| ToolUseSummaryMessage,
Terminal
> {
const consumedCommandUuids: string[] = []
const terminal = yield* queryLoop(params, consumedCommandUuids)
// ...命令生命周期通知
return terminal
}
query() 是一个纯函数式的异步生成器——接收所有上下文作为参数(QueryParams),不持有任何跨调用状态。每次 submitMessage() 调用它时,都会创建全新的 State 对象:
// query.ts:268-279 —— 每次进入 queryLoop 都创建全新 State
let state: State = {
messages: params.messages, // 从 QueryEngine 传入的快照
toolUseContext: params.toolUseContext,
maxOutputTokensOverride: params.maxOutputTokensOverride,
autoCompactTracking: undefined, // ← 每次重置
stopHookActive: undefined, // ← 每次重置
maxOutputTokensRecoveryCount: 0, // ← 每次重置
hasAttemptedReactiveCompact: false, // ← 每次重置
turnCount: 1, // ← 每次重置
pendingToolUseSummary: undefined, // ← 每次重置
transition: undefined, // ← 每次重置
}
1.3 演进历史:ask() 到 QueryEngine + query() 的重构
原书提到了一次关键重构:
“事实上,query() 的提取正是 Claude Code 演进过程中的一次关键重构(PR#22546)——QueryEngine 最初的 ask() 方法包含了整个查询循环,随着系统需要支持 Headless、SDK、REPL 等多种调用模式,将查询循环提取为独立函数成了必然选择。”
源码中 ask() 函数(第 1186-1295 行)正是这个重构的遗留物——它是一个便利包装器,内部创建新的 QueryEngine 实例然后调用 submitMessage():
export async function* ask({...}): AsyncGenerator<SDKMessage, void, unknown> {
const engine = new QueryEngine({
cwd,
tools,
commands,
// ...30+ 个配置字段
initialMessages: mutableMessages,
readFileCache: cloneFileStateCache(getReadFileCache()),
// ...
})
try {
yield* engine.submitMessage(prompt, { uuid: promptUuid, isMeta })
} finally {
setReadFileCache(engine.getReadFileState()) // 读回文件缓存
}
}
ask() 的存在证明了二元分离的第二个理由——复用粒度不匹配。当只需要执行一次性查询时,不必手动管理 QueryEngine 生命周期,ask() 内部自动创建和销毁实例。但 ask() 内部仍然调用了 query()(通过 submitMessage() → for await (const message of query({...}))),这证明了 query() 作为独立函数的可复用性。
二、接口边界设计
2.1 QueryEngine 的对外接口
原书 3.2.2 节描述了 QueryEngine 的两个核心方法。源码中实际暴露的接口更丰富:
export class QueryEngine {
// 核心方法
async *submitMessage(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown>
// 中断当前查询
interrupt(): void
// 状态访问器
getMessages(): readonly Message[]
getReadFileState(): FileStateCache
getSessionId(): string
setModel(model: string): void
}
submitMessage() 的八步管线(原书 3.6.1 节详述,这里从接口边界视角概述):
| 步骤 | 代码位置 | 职责 | 与 query() 的关系 |
|---|---|---|---|
| 1. 初始化 | 第 238-241 行 | 清理技能集合、设置 Cwd、记录时间戳 | 会话级准备 |
| 2. 权限包装 | 第 244-271 行 | 将 canUseTool 包装为带拒绝追踪版本 | 装饰器模式,不侵入 query() |
| 3. 系统提示构建 | 第 284-325 行 | fetchSystemPromptParts() → asSystemPrompt() | 产出 systemPrompt 传给 query() |
| 4. 用户输入处理 | 第 410-428 行 | processUserInput() 解析斜杠命令、附件 | 产出 messages 传给 query() |
| 5. 消息持久化 | 第 450-463 行 | recordTranscript() 写入 JSONL | 在 query() 启动前保证可恢复 |
| 6. 技能/插件加载 | 第 534-538 行 | Promise.all([getSlashCommandToolSkills, loadAllPluginsCacheOnly]) | 并行加载,不阻塞 |
| 7. 查询执行 | 第 675-1048 行 | for await (const message of query({...})) | 核心边界 |
| 8. 结果返回 | 第 1058-1156 行 | 提取文本结果、构建 SDK result 消息 | 后处理 |
第 7 步是最关键的接口边界——QueryEngine 通过 for await...of 消费 query() 的输出,逐条处理消息并 yield 给上层调用方。
2.2 query() 的函数签名
原书给出了简化的签名,实际源码中的 QueryParams 类型更完整:
export type QueryParams = {
messages: Message[] // 历史消息(从 QueryEngine 传入的快照)
systemPrompt: SystemPrompt // 系统提示
userContext: { [k: string]: string } // 用户上下文
systemContext: { [k: string]: string } // 系统上下文
canUseTool: CanUseToolFn // 权限检查函数(已包装)
toolUseContext: ToolUseContext // 工具上下文
fallbackModel?: string // 备用模型
querySource: QuerySource // 调用来源标记
maxOutputTokensOverride?: number // 最大输出 Token 覆盖
maxTurns?: number // 最大轮次限制
skipCacheWrite?: boolean // 跳过缓存写入
taskBudget?: { total: number } // API 任务预算
deps?: QueryDeps // 可选的依赖注入
}
原书评价道:
“注意 query() 的设计哲学:它接收所有必要的上下文作为参数,而不是从全局状态中读取。这使得 query() 成为一个(几乎)纯函数式的异步生成器——给定相同的输入参数,它的行为是确定的。”
源码中的 queryLoop() 函数完美体现了这一点(第 252-279 行):
async function* queryLoop(
params: QueryParams,
consumedCommandUuids: string[],
): AsyncGenerator<...> {
// Immutable params — never reassigned during the query loop.
const {
systemPrompt,
userContext,
systemContext,
canUseTool,
fallbackModel,
querySource,
maxTurns,
skipCacheWrite,
} = params
const deps = params.deps ?? productionDeps() // 依赖注入默认值
// Mutable cross-iteration state. ← 轮次级状态
let state: State = { ... }
const 解构 params 的细节值得注意——params 的内容在循环期间不会被重新赋值,这确保了查询参数的不可变性。真正可变的是 state(轮次级状态)和 toolUseContext(工具上下文),它们在循环迭代中更新。
三、状态所有权矩阵(完整版)
3.1 原书矩阵的扩展
原书 3.2.3 节给出了一个状态所有权矩阵。通过逐行检查源码,我们得到了更完整的版本:
QueryEngine 拥有的状态(会话级)
| 状态字段 | 源码位置 | 类型 | 生命周期说明 |
|---|---|---|---|
mutableMessages | QueryEngine.ts:186 | Message[] | 完整对话历史,跨轮次累积 |
totalUsage | QueryEngine.ts:189 | NonNullableUsage | 累计 token 使用量(输入/输出/缓存) |
permissionDenials | QueryEngine.ts:188 | SDKPermissionDenial[] | 权限拒绝记录,供 SDK 报告 |
readFileState | QueryEngine.ts:191 | FileStateCache | 文件读取缓存(文件内容哈希等) |
discoveredSkillNames | QueryEngine.ts:197 | Set<string> | 已发现的技能名称集合(每次 submitMessage 清空) |
loadedNestedMemoryPaths | QueryEngine.ts:198 | Set<string> | 已加载的嵌套记忆路径 |
hasHandledOrphanedPermission | QueryEngine.ts:190 | boolean | 孤儿权限处理标记(仅处理一次) |
abortController | QueryEngine.ts:187 | AbortController | 中断控制器(会话级,但每次中断后状态保留) |
query() 拥有的状态(轮次级)
| 状态字段 | 源码位置 | 类型 | 生命周期说明 |
|---|---|---|---|
messages (State) | query.ts:205 | Message[] | 当前轮消息快照(从 params 传入,循环中追加) |
turnCount | query.ts:213 | number | 当前轮次计数,从 1 开始递增 |
transition | query.ts:216 | Continue | undefined | "为什么回到循环顶部"的原因标签 |
maxOutputTokensRecoveryCount | query.ts:208 | number | Max Output Tokens 错误恢复计数(上限 3) |
hasAttemptedReactiveCompact | query.ts:209 | boolean | 是否已尝试响应式压缩(防死循环) |
maxOutputTokensOverride | query.ts:210 | number | undefined | 输出 Token 限制覆盖值 |
autoCompactTracking | query.ts:207 | AutoCompactTrackingState | undefined | 自动压缩跟踪状态 |
pendingToolUseSummary | query.ts:211 | Promise<ToolUseSummaryMessage | null> | undefined | 待处理的工具使用摘要 Promise |
stopHookActive | query.ts:212 | boolean | undefined | Stop Hook 是否激活 |
budgetTracker | query.ts:280 | BudgetTracker | null | Token 预算追踪器(循环局部变量) |
taskBudgetRemaining | query.ts:291 | number | undefined | 任务预算剩余(循环局部变量) |
3.2 状态隔离的关键设计
discoveredSkillNames 的特殊处理值得特别关注。它是 QueryEngine 的成员变量,但每次 submitMessage() 调用时会清空:
// QueryEngine.ts:238
this.discoveredSkillNames.clear()
注释解释了原因:
// Turn-scoped skill discovery tracking (feeds was_discovered on
// tengu_skill_tool_invocation). Must persist across the two
// processUserInputContext rebuilds inside submitMessage, but is cleared
// at the start of each submitMessage to avoid unbounded growth across
// many turns in SDK mode.
这是一个跨两个抽象层级的混合生命周期状态——它在单次 submitMessage() 内需要跨 processUserInputContext 重建持久,但在多次 submitMessage() 之间需要重置。这种状态不适合放在 query() 的 State 中(因为 processUserInputContext 在调用 query() 之前就构建了),也不适合纯会话级持久(会导致无限增长)。
四、状态转换机制:二元分离的核心价值
4.1 两层状态流转的全景
二元分离在状态转换方面的核心价值是:QueryEngine 负责状态在轮次间的持久化,query() 负责状态在单轮内的转换。
┌─────────────────────────────────────────────────────────────────┐
│ QueryEngine (会话级) │
│ │
│ mutableMessages ──────┐ │
│ totalUsage │ submitMessage() 八步管线 │
│ permissionDenials │ │
│ readFileState │ ┌─────────────────────────────────┐ │
│ discoveredSkillNames │ │ query() (轮次级) │ │
│ │ │ │ │
│ └──│ State { │ │
│ │ messages: [...snapshot] │ │
│ │ turnCount: 1 │ │
│ │ transition: undefined │ │
│ │ ... │ │
│ │ } │ │
│ │ │ │
│ │ while (true) { │ │
│ │ // 六阶段循环体 │ │
│ │ // 通过 continue/return 转换 │ │
│ │ } │ │
│ │ │ │
│ │ yield: StreamEvent / Message │ │
│ │ return: Terminal │ │
│ └─────────────────────────────────┘ │
│ │ │
│ for await (message of query({...})) │ │
│ ← 消费流式消息 ───────────────────┘ │
│ ← 更新 mutableMessages、totalUsage 等 │
│ ← yield SDKMessage 给上层 │
│ │
│ return: SDK result (success/error) │
└─────────────────────────────────────────────────────────────────┘
4.2 状态从 QueryEngine 到 query() 的传递
submitMessage() 在第 6 步将 QueryEngine 的会话级状态"快照"传给 query():
// QueryEngine.ts:675-686
for await (const message of query({
messages, // ← 从 mutableMessages 复制的快照
systemPrompt, // ← 会话级构建的系统提示
userContext, // ← 会话级用户上下文
systemContext, // ← 会话级系统上下文
canUseTool: wrappedCanUseTool, // ← 已包装的权限函数
toolUseContext: processUserInputContext, // ← 工具上下文
fallbackModel, // ← 备用模型
querySource: 'sdk', // ← 调用来源
maxTurns, // ← 最大轮次限制
taskBudget, // ← 任务预算
})) {
注意 messages 的来源:
// QueryEngine.ts:434
const messages = [...this.mutableMessages] // ← 浅拷贝快照
这是二元分离的精髓——query() 获得的是 mutableMessages 的快照,不是引用。query() 在循环中追加的 assistant/user/progress 消息先存入局部 messages 数组,然后逐条 yield 回 submitMessage(),由后者决定是否 push 到 mutableMessages。
4.3 状态从 query() 回流到 QueryEngine
submitMessage() 的 for await 循环体(第 687-968 行)是状态回流的关键。每种消息类型有不同的回流策略:
for await (const message of query({...})) {
// 1. 持久化:assistant/user/compact_boundary 写入 transcript
if (message.type === 'assistant' || message.type === 'user' ||
(message.type === 'system' && message.subtype === 'compact_boundary')) {
messages.push(message)
if (persistSession) {
if (message.type === 'assistant') {
void recordTranscript(messages) // ← fire-and-forget
} else {
await recordTranscript(messages) // ← await
}
}
}
// 2. 回流到 mutableMessages(按类型区分)
switch (message.type) {
case 'assistant':
this.mutableMessages.push(message) // ← 回流到会话级状态
yield* normalizeMessage(message) // ← 转发给 SDK 调用方
break
case 'user':
this.mutableMessages.push(message) // ← 回流到会话级状态
yield* normalizeMessage(message)
break
case 'progress':
this.mutableMessages.push(message) // ← 进度消息也回流
if (persistSession) {
messages.push(message)
void recordTranscript(messages)
}
yield* normalizeMessage(message)
break
case 'stream_event':
// stream_event 不回流到 mutableMessages
// 但 usage 信息回流到 totalUsage
if (message.event.type === 'message_stop') {
this.totalUsage = accumulateUsage(
this.totalUsage, // ← 回流到会话级状态
currentMessageUsage,
)
}
break
case 'system':
// system 消息选择性回流
if (message.subtype === 'compact_boundary') {
this.mutableMessages.push(message)
// 释放压缩前的消息(GC 优化)
const boundaryIdx = this.mutableMessages.length - 1
if (boundaryIdx > 0) {
this.mutableMessages.splice(0, boundaryIdx)
}
}
break
}
}
关键发现:并非所有 query() 的输出都回流到 mutableMessages。stream_event 只回流 usage 信息到 totalUsage,消息本身不持久化。tombstone 消息被完全跳过(控制信号,不是数据)。这种细粒度的回流策略正是二元分离的价值——QueryEngine 可以根据会话级需求决定保留什么,而不影响 query() 的循环逻辑。
4.4 State 对象的函数式更新
原书 3.6.3 节描述了 queryLoop 中的函数式更新模式:
“状态更新始终是创建新对象,而不是修改旧对象”
源码中每个 continue 点都用 {...state, ...updates} 创建新 State 对象。以 next_turn(最常见的转换)为例:
// query.ts:1715-1727
const next: State = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
toolUseContext: toolUseContextWithQueryTracking,
autoCompactTracking: tracking,
turnCount: nextTurnCount, // ← 递增
maxOutputTokensRecoveryCount: 0, // ← 重置
hasAttemptedReactiveCompact: false, // ← 重置
pendingToolUseSummary: nextPendingToolUseSummary,
maxOutputTokensOverride: undefined, // ← 重置
stopHookActive,
transition: { reason: 'next_turn' }, // ← 设置转换原因
}
state = next
原书也指出了务实的 Trade-off:
“这种’函数式’是有限度的——state.messages 数组本身是可变的(push() 操作),因为完全不可变的消息数组在频繁追加时会产生大量 GC 压力。”
源码验证了这一点。messages 在循环内部使用 push() 和 [...array] 混合策略:
// 不可变:创建新数组
messages: [...messagesForQuery, ...assistantMessages, ...toolResults]
// 可变:在循环体内部
assistantMessages.push(message) // query.ts:827
toolResults.push(...) // query.ts:854
五、分级错误恢复:二元分离的最佳佐证
错误恢复是体现二元分离价值的最有力场景。原书 3.8 节的核心论点是:
“不同错误的恢复方向可能完全相反……如果对这四种错误都执行’减少输入’的操作,Max Output Tokens 的恢复反而会让情况变糟。”
5.1 错误恢复的层级归属
二元分离将错误处理清晰地分成了两层:
| 层级 | 错误类型 | 处理方式 | 源码位置 |
|---|---|---|---|
| query() 层 | Prompt Too Long (413) | Context Collapse → Reactive Compact | query.ts:1085-1183 |
| query() 层 | Max Output Tokens | 升级 64K → 多轮恢复消息(上限 3 次) | query.ts:1188-1256 |
| query() 层 | Model Unavailable | FallbackTriggeredError → 切换备用模型 | query.ts:893-953 |
| query() 层 | Media Size Error | Reactive Compact + 图片剥离 | query.ts:1119-1175 |
| query() 层 | Image Error | 直接返回 { reason: 'image_error' } | query.ts:970-978 |
| query() 层 | Model Error | yield 错误消息 + 返回 { reason: 'model_error' } | query.ts:955-997 |
| query() 层 | Aborted (streaming) | yield 中断消息 + 返回 { reason: 'aborted_streaming' } | query.ts:1015-1052 |
| query() 层 | Aborted (tools) | yield 中断消息 + 返回 { reason: 'aborted_tools' } | query.ts:1485-1516 |
| query() 层 | Blocking Limit | yield PTL 错误 + 返回 { reason: 'blocking_limit' } | query.ts:641-647 |
| QueryEngine 层 | Max Turns | yield max_turns_reached attachment + return | QueryEngine.ts:842-874 |
| QueryEngine 层 | USD Budget Exceeded | yield error_max_budget_usd result + return | QueryEngine.ts:972-1002 |
| QueryEngine 层 | Structured Output Retry Limit | yield error result + return | QueryEngine.ts:1004-1048 |
| QueryEngine 层 | Error During Execution | yield error_during_execution result + return | QueryEngine.ts:1082-1118 |
关键发现:query() 层处理的是可恢复错误——通过修改 State 并 continue 回到循环顶部重试。QueryEngine 层处理的是不可恢复错误——直接终止查询并返回错误结果。这种分层是二元分离的直接产物。
5.2 四种恢复路径的详细分析
路径 1:Prompt Too Long (413) — 两步递进恢复
// query.ts:1085-1183
// 第一步:Context Collapse Drain(轻量,保留粒度)
if (feature('CONTEXT_COLLAPSE') &&
contextCollapse &&
state.transition?.reason !== 'collapse_drain_retry') { // ← 防重复
const drained = contextCollapse.recoverFromOverflow(
messagesForQuery, querySource)
if (drained.committed > 0) {
state = {
messages: drained.messages,
// ...保留其他状态
transition: { reason: 'collapse_drain_retry', committed: drained.committed },
}
continue // ← 回到循环顶部,用压缩后的消息重试
}
}
// 第二步:Reactive Compact(重量,LLM 驱动摘要)
if ((isWithheld413 || isWithheldMedia) && reactiveCompact) {
const compacted = await reactiveCompact.tryReactiveCompact({
hasAttempted: hasAttemptedReactiveCompact, // ← 防重复
// ...
})
if (compacted) {
state = {
messages: postCompactMessages,
hasAttemptedReactiveCompact: true, // ← 标记已尝试
transition: { reason: 'reactive_compact_retry' },
}
continue // ← 回到循环顶部,用摘要后的消息重试
}
// 恢复失败——surface 被暂存的错误
yield lastMessage
return { reason: isWithheldMedia ? 'image_error' : 'prompt_too_long' }
}
二元分离的价值:错误恢复通过修改 State 并 continue 实现,而不是抛出异常。这使得恢复逻辑与正常循环逻辑在代码结构上完全对称——都是"修改 state → 设置 transition → continue"。QueryEngine 完全不需要知道错误发生了,它只看到 query() yield 了压缩边界消息和新的 assistant 消息。
路径 2:Max Output Tokens — 渐进式升级
// query.ts:1188-1256
// 恢复 1:升级 maxOutputTokens 到 64K(单次)
if (capEnabled &&
maxOutputTokensOverride === undefined && // ← 未曾覆盖
!process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
state = {
...state,
maxOutputTokensOverride: ESCALATED_MAX_TOKENS, // ← 升级到 64K
transition: { reason: 'max_output_tokens_escalate' },
}
continue
}
// 恢复 2-3:发送恢复消息(最多 3 次)
if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) { // ← 上限 3
const recoveryMessage = createUserMessage({
content: 'Output token limit hit. Resume directly — no apology, no recap...',
isMeta: true,
})
state = {
...state,
messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1, // ← 递增
transition: { reason: 'max_output_tokens_recovery', attempt: ... },
}
continue
}
// 恢复耗尽——surface 被暂存的错误
yield lastMessage
路径 3:Model Unavailable — 流式回退
// query.ts:893-953
} catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
currentModel = fallbackModel // ← 切换模型
attemptWithFallback = true // ← 重试标志
// 清理失败的尝试
yield* yieldMissingToolResultBlocks(assistantMessages, 'Model fallback triggered')
assistantMessages.length = 0
toolResults.length = 0
toolUseBlocks.length = 0
needsFollowUp = false
// 创建新的流式执行器
if (streamingToolExecutor) {
streamingToolExecutor.discard()
streamingToolExecutor = new StreamingToolExecutor(...)
}
// 更新模型
toolUseContext.options.mainLoopModel = fallbackModel
yield createSystemMessage(
`Switched to ${renderModelName(...)} due to high demand...`, 'warning')
continue // ← 回到 while (attemptWithFallback) 循环
}
throw innerError // ← 无法处理,抛出
}
路径 4:Stop Hook Blocking — 带防死循环保护的恢复
// query.ts:1282-1306
if (stopHookResult.blockingErrors.length > 0) {
const next: State = {
messages: [
...messagesForQuery,
...assistantMessages,
...stopHookResult.blockingErrors, // ← 注入 Hook 错误消息
],
maxOutputTokensRecoveryCount: 0,
// ↓ 关键:保留 hasAttemptedReactiveCompact
// 注释解释:如果 compact 已经运行且无法从 PTL 恢复,
// 在 stop-hook 阻塞错误后重试会产生相同结果。
// 重置为 false 会导致死循环:compact → 仍然太长 → 错误 →
// stop hook blocking → compact → … 燃烧数千次 API 调用。
hasAttemptedReactiveCompact, // ← 不重置!防死循环
stopHookActive: true,
transition: { reason: 'stop_hook_blocking' },
}
state = next
continue
}
5.3 Withheld 错误暂存机制
这是源码中一个原书未描述的重要机制。query() 不会立即将可恢复错误 yield 给调用方,而是先暂存(withhold),等恢复结果确定后再决定是否暴露:
// query.ts:799-825
let withheld = false
// 检查多种可恢复错误
if (feature('CONTEXT_COLLAPSE') &&
contextCollapse?.isWithheldPromptTooLong(message, ...)) {
withheld = true
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) {
withheld = true
}
if (mediaRecoveryEnabled &&
reactiveCompact?.isWithheldMediaSizeError(message)) {
withheld = true
}
if (isWithheldMaxOutputTokens(message)) {
withheld = true
}
// 暂存的错误不 yield 给调用方,但仍推入 assistantMessages
// 以便后续恢复检查能找到它
if (!withheld) {
yield yieldMessage
}
if (message.type === 'assistant') {
assistantMessages.push(message) // ← 无论是否暂存都 push
}
设计哲学:如果恢复成功,错误对调用方不可见——调用方只看到恢复后的正常响应。如果恢复失败,错误才被 yield lastMessage surface 给调用方。这避免了 SDK 消费者(如 desktop/cowork)在恢复期间看到中间态错误而误终止会话。
5.4 错误恢复作为状态转换的对称性
原书 3.8.3 节有一个精辟的总结:
“错误恢复不是一个特殊的代码路径,而是状态机的一个正常转换。压缩后的重试和正常的工具调用后继续,在代码结构上是完全对称的——都是’修改 state → 设置 transition → continue’。”
源码完美验证了这一点。对比两种 continue 路径:
// 正常工具调用后继续(next_turn)
state = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
turnCount: nextTurnCount,
transition: { reason: 'next_turn' },
// ...重置恢复相关状态
}
// 413 错误恢复后继续(reactive_compact_retry)
state = {
messages: postCompactMessages, // ← 压缩后的消息
hasAttemptedReactiveCompact: true, // ← 标记
transition: { reason: 'reactive_compact_retry' },
// ...保留其他状态
}
两者结构完全相同——都是创建新 State 对象、设置 transition reason、然后 continue。循环体不需要区分"正常继续"和"恢复后继续"——它只看 state.transition?.reason 来做分支决策(如跳过已尝试的压缩)。
六、QueryEngine 层的终止保护
除了 query() 层的五层终止保护(maxTurns、Token Budget、错误恢复上限、LLM 自然终止、外部中断),QueryEngine 层还额外实现了两层原书未描述的终止保护:
6.1 USD 预算检查
// QueryEngine.ts:972-1002
// 在 for await 循环体中,每消费一条消息后检查
if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
yield {
type: 'result',
subtype: 'error_max_budget_usd',
is_error: true,
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
total_cost_usd: getTotalCost(),
usage: this.totalUsage,
// ...
}
return // ← 终止 submitMessage()
}
这个检查在 QueryEngine 层而不是 query() 层,因为成本追踪是会话级的——getTotalCost() 读取的是全局累计成本,不是单轮成本。
6.2 结构化输出重试限制
// QueryEngine.ts:1004-1048
if (message.type === 'user' && jsonSchema) {
const currentCalls = countToolCalls(
this.mutableMessages,
SYNTHETIC_OUTPUT_TOOL_NAME,
)
const callsThisQuery = currentCalls - initialStructuredOutputCalls
const maxRetries = parseInt(
process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5', 10)
if (callsThisQuery >= maxRetries) {
yield {
type: 'result',
subtype: 'error_max_structured_output_retries',
errors: [`Failed to provide valid structured output after ${maxRetries} attempts`],
}
return
}
}
这个检查也在 QueryEngine 层,因为它需要访问 this.mutableMessages(会话级消息历史)来计算结构化输出工具的调用次数。
6.3 七层终止保护全景
| 层级 | 保护机制 | 检查位置 | 条件 |
|---|---|---|---|
| query() 层 | maxTurns | query.ts:1705 | nextTurnCount > maxTurns |
| query() 层 | Token Budget | query.ts:1308-1355 | budgetTracker 递减收益检测 |
| query() 层 | Max Output Tokens 恢复上限 | query.ts:1223 | recoveryCount >= 3 |
| query() 层 | LLM 自然终止 | query.ts:1062 | !needsFollowUp(无工具调用) |
| query() 层 | 外部中断 | query.ts:1015, 1485 | abortController.signal.aborted |
| QueryEngine 层 | USD 预算 | QueryEngine.ts:972 | getTotalCost() >= maxBudgetUsd |
| QueryEngine 层 | 结构化输出重试 | QueryEngine.ts:1015 | callsThisQuery >= maxRetries |
七、submitMessage 的消息处理状态机
QueryEngine.submitMessage() 的 for await 循环体本身就是一个消息处理状态机。它维护了多个局部状态变量:
// QueryEngine.ts:657-673
let currentMessageUsage: NonNullableUsage = EMPTY_USAGE // 当前消息 usage
let turnCount = 1 // 轮次计数
let hasAcknowledgedInitialMessages = false // 初始消息确认标记
let structuredOutputFromTool: unknown // 结构化输出
let lastStopReason: string | null = null // 最后的 stop_reason
const errorLogWatermark = getInMemoryErrors().at(-1) // 错误日志水位线
const initialStructuredOutputCalls = jsonSchema // 结构化输出调用基线
? countToolCalls(this.mutableMessages, SYNTHETIC_OUTPUT_TOOL_NAME)
: 0
errorLogWatermark 是一个精妙的设计——它记录了查询开始前最后一条错误日志的引用。查询结束后,如果触发了 error_during_execution,errors[] 数组只包含水位线之后的错误(即本次查询的错误),而不是整个进程的错误历史。注释解释了为什么用引用而不是索引:
// Reference-based watermark so error_during_execution's errors[] is
// turn-scoped. A length-based index breaks when the 100-entry ring buffer
// shift()s during the turn — the index slides. If this entry is rotated
// out, lastIndexOf returns -1 and we include everything (safe fallback).
八、Compact Boundary 的跨层协调
上下文压缩(Compact)是二元分离中最复杂的跨层协调场景。压缩发生时,query() 和 QueryEngine 必须同步更新各自的状态。
8.1 query() 层的压缩触发
query() 在循环体第一阶段执行四层压缩管线:
applyToolResultBudget → Snip 压缩 → 微压缩 → Context Collapse → 自动压缩
压缩成功后,query() yield 一组 postCompactMessages:
// query.ts:528-535
const postCompactMessages = buildPostCompactMessages(compactionResult)
for (const message of postCompactMessages) {
yield message // ← yield 给 QueryEngine
}
messagesForQuery = postCompactMessages // ← 更新本地状态
8.2 QueryEngine 层的压缩响应
QueryEngine 收到 compact_boundary 系统消息后,执行会话级状态清理:
// QueryEngine.ts:897-942
case 'system': {
// Snip 边界处理
const snipResult = this.config.snipReplay?.(message, this.mutableMessages)
if (snipResult !== undefined) {
if (snipResult.executed) {
this.mutableMessages.length = 0 // ← 清空
this.mutableMessages.push(...snipResult.messages) // ← 重建
}
break
}
this.mutableMessages.push(message) // ← 添加 compact_boundary
// Compact Boundary:释放压缩前的消息
if (message.subtype === 'compact_boundary') {
const boundaryIdx = this.mutableMessages.length - 1
if (boundaryIdx > 0) {
this.mutableMessages.splice(0, boundaryIdx) // ← GC 优化
}
// 同步清理局部 messages 数组
const localBoundaryIdx = messages.length - 1
if (localBoundaryIdx > 0) {
messages.splice(0, localBoundaryIdx)
}
yield { // ← 转发给 SDK 调用方
type: 'system',
subtype: 'compact_boundary',
compact_metadata: toSDKCompactMetadata(message.compactMetadata),
}
}
}
关键设计:mutableMessages 在压缩后只保留 compact_boundary 及之后的消息,压缩前的消息被 splice 移除以释放内存。但 query() 内部的 state.messages 仍然保留完整的压缩前历史(因为 query() 获得的是快照副本)。这种不对称是安全的——query() 在下一次 continue 时会用 state.messages(包含压缩后的消息)继续循环,不再引用压缩前的历史。
九、依赖注入:二元分离的测试策略
9.1 QueryDeps 的精准边界
原书 3.9 节讨论了依赖注入。query/deps.ts(40 行)定义了只有 4 个依赖的窄接口:
export type QueryDeps = {
callModel: typeof queryModelWithStreaming // LLM API 调用
microcompact: typeof microcompactMessages // 微压缩
autocompact: typeof autoCompactIfNeeded // 自动压缩
uuid: () => string // UUID 生成
}
export function productionDeps(): QueryDeps {
return {
callModel: queryModelWithStreaming,
microcompact: microcompactMessages,
autocompact: autoCompactIfNeeded,
uuid: randomUUID,
}
}
注释明确解释了设计决策:
// I/O dependencies for query(). Passing a `deps` override into QueryParams
// lets tests inject fakes directly instead of spyOn-per-module — the most
// common mocks (callModel, autocompact) are each spied in 6-8 test files
// today with module-import-and-spy boilerplate.
//
// Using `typeof fn` keeps signatures in sync with the real implementations
// automatically.
//
// Scope is intentionally narrow (4 deps) to prove the pattern. Followup
// PRs can add runTools, handleStopHooks, logEvent, queue ops, etc.
9.2 二元分离对测试的影响
二元分离使测试策略自然分层:
| 测试层级 | 目标 | Mock 策略 | 典型用法 |
|---|---|---|---|
| query() 单元测试 | 验证循环逻辑 | 注入 deps: { callModel: mockFn, ... } | 测试错误恢复、状态转换 |
| QueryEngine 集成测试 | 验证会话级状态 | Mock canUseTool,使用真实 query() | 测试权限追踪、消息持久化 |
| ask() 端到端测试 | 验证完整流程 | Mock API 层 | 测试 SDK 输出格式 |
query() 的纯函数式设计(所有上下文通过参数传入)使得单元测试极其简单——不需要构造 QueryEngine 实例,不需要模拟会话状态,只需构造 QueryParams 和可选的 QueryDeps。
十、子模块的单一职责
原书 3.10 节描述了四个子模块的分工。从二元分离的视角来看,这四个子模块都服务于 query() 层:
| 模块 | 职责 | 副作用 | 与 QueryEngine 的关系 |
|---|---|---|---|
config.ts | 配置快照 | 无(纯函数) | 独立于 QueryEngine |
deps.ts | 依赖注入边界 | 无(纯工厂函数) | 独立于 QueryEngine |
tokenBudget.ts | Token 预算决策 | 修改 tracker(可变) | 独立于 QueryEngine |
stopHooks.ts | Hook 编排 | yield 消息、触发后台任务 | 独立于 QueryEngine |
关键发现:所有四个子模块都不依赖 QueryEngine 的状态。它们要么是纯函数(config、deps),要么只依赖 query() 传入的 toolUseContext(tokenBudget、stopHooks)。这种独立性正是二元分离的产物——query() 不需要访问 QueryEngine 的会话级状态就能完成自己的工作。
十一、与其他框架的对比
原书 3.11 节比较了 Claude Code 与 LangChain/LangGraph 和 OpenAI Assistants API。从二元分离的视角,这些对比有了新的维度:
| 维度 | Claude Code | LangChain/LangGraph | OpenAI Assistants |
|---|---|---|---|
| 状态管理层级 | 两层(QueryEngine + query) | 单层(Graph 节点间传递) | 服务端管理(Thread) |
| 会话级状态 | QueryEngine 类成员 | 无内建会话概念 | 服务端 Thread |
| 轮次级状态 | query() 的 State 对象 | Graph 状态 | 服务端 Run |
| 状态传递方式 | 参数传递 + for await 消费 | Graph 边传递状态 | API 调用 |
| 错误恢复层级 | query() 层可恢复 + QueryEngine 层终止 | 通常简单重试 | 服务端处理 |
| 测试隔离 | query() 可独立测试(注入 deps) | 需要模拟 Graph | 需要 Mock API |
Claude Code 的二元分离在架构上更接近React 的组件分层——QueryEngine 类似 React 的 Fiber 树(持久化状态),query() 类似 React 的 render 函数(每次执行创建新状态)。
十二、设计模式提炼
模式 1:生命周期不对称分离(Lifecycle-Asymmetric Separation)
问题:系统中存在生命周期不同的状态,混在一起会导致重置范围模糊和复用粒度不匹配。
方案:按生命周期分离状态到不同抽象层级——长生命周期的状态放在类中(QueryEngine),短生命周期的状态放在函数中(query() 的 State)。
适用条件:
- 存在明显不同的生命周期(会话级 vs 轮次级)
- 短生命周期逻辑需要被多种调用方复用
- 重置边界需要精确控制
模式 2:状态快照传递(State Snapshot Passing)
问题:长生命周期状态持有者需要将状态传给短生命周期逻辑,但不希望后者直接修改前者。
方案:传入状态的浅拷贝快照,短生命周期逻辑在快照上操作,通过 yield 将结果逐条回流,由持有者决定是否合并。
适用条件:
- 需要隔离状态的读写
- 短生命周期逻辑可能有多种输出(流式消息)
- 持有者需要选择性合并结果
模式 3:Withheld 错误暂存(Withheld Error Staging)
问题:可恢复错误如果立即暴露给调用方,会导致调用方在恢复期间误终止。
方案:将可恢复错误暂存(不 yield),等恢复结果确定后再决定是否暴露——恢复成功则错误不可见,恢复失败才 surface。
适用条件:
- 错误有明确的恢复路径
- 调用方可能对中间态错误过度反应
- 恢复操作在同一循环迭代内完成
模式 4:对称错误恢复(Symmetric Error Recovery)
问题:错误恢复如果用特殊代码路径(如 try/catch + 重试循环),会与正常流程不对称,增加复杂度。
方案:将错误恢复实现为状态机的正常转换——修改 State、设置 transition reason、continue。使恢复路径与正常路径在代码结构上完全对称。
适用条件:
- 使用 while(true) 隐式状态机
- 错误恢复方向可能因错误类型而异
- 恢复操作需要修改多个状态字段
模式 5:装饰器式权限包装(Decorator Permission Wrapping)
问题:需要在权限检查函数上添加追踪逻辑,但不希望侵入原函数实现或污染查询循环代码。
方案:在 QueryEngine 层用包装器装饰 canUseTool,将追踪逻辑(如权限拒绝记录)隔离在包装器中,query() 只看到包装后的函数。
适用条件:
- 需要为已有函数添加横切关注点
- 不希望修改原函数实现
- 追踪状态需要持久化到会话级
十三、原书对照验证
| # | 原书描述 | 源码验证 | 结论 |
|---|---|---|---|
| 1 | “QueryEngine 管理跨轮次状态——消息历史、权限拒绝记录、累计用量、文件缓存” | mutableMessages、permissionDenials、totalUsage、readFileState 均为类成员 | ✅ 准确 |
| 2 | “query 管理单轮查询循环……每次用户输入时被创建,输入处理完毕后结束” | query() 在 submitMessage() 的 for await 中被调用,每次 submitMessage() 创建新 State | ✅ 准确 |
| 3 | “query() 的提取是 PR#22546 的重构” | ask() 函数作为便利包装器验证了这一演进 | ✅ 准确(源码注释提到 “extracts the core logic from ask()”) |
| 4 | “query() 接收所有必要的上下文作为参数” | QueryParams 类型包含 14 个字段,全部通过参数传入 | ✅ 准确 |
| 5 | “query() 是(几乎)纯函数式的异步生成器” | params 用 const 解构不可变,deps 可选注入,但 state.messages 使用 push() | ✅ 基本准确("几乎"修饰很关键) |
| 6 | 状态所有权矩阵包含 5 个会话级 + 5 个轮次级状态 | 源码验证为 8 个会话级 + 11 个轮次级(含循环局部变量) | ⚠️ 原书偏少,实际更丰富 |
| 7 | “submitMessage 返回 AsyncGenerator” | 源码签名:async *submitMessage(...): AsyncGenerator<SDKMessage, void, unknown> | ✅ 准确 |
| 8 | “query() 返回 AsyncGenerator<QueryYield, Terminal>” | 源码签名更复杂:联合类型 yield + Terminal return | ✅ 准确(原书简化了类型) |
| 9 | “权限包装追踪权限拒绝” | wrappedCanUseTool 装饰器,this.permissionDenials.push(...) | ✅ 准确 |
| 10 | “状态更新采用函数式更新 {…state, …updates}” | 每个 continue 点都创建新 State 对象 | ✅ 准确 |
| 11 | “state.messages 数组本身是可变的(push() 操作)” | assistantMessages.push(message) 等 | ✅ 准确 |
| 12 | “递归方案有栈溢出风险” | while(true) 循环体 1421 行,可能涉及数十轮工具调用 | ✅ 准确(while 避免了栈增长) |
| 13 | “五层终止保护确保循环最终终止” | 源码验证为七层(query 层五层 + QueryEngine 层两层) | ⚠️ 原书偏少,实际多了 USD 预算和结构化输出重试 |
十四、思考题延伸
原书在 3.13 节提出了 5 个思考题。从源码阅读的角度,对第 5 题做一些延伸:
思考题 5:“二元分离的极限:QueryEngine 和 query() 的分离基于’生命周期不同’的判断。但随着系统演进,可能出现第三种生命周期的状态(比如’跨会话但非永久’的状态)。你会如何设计一个三层状态管理体系?”
源码中已经出现了第三种生命周期的端倪——discoveredSkillNames。它不是纯会话级(每次 submitMessage 清空),也不是纯轮次级(需要在 submitMessage 内的两次 processUserInputContext 重建之间持久)。如果未来出现更多这种"跨轮次但非跨会话"的状态,可能需要一个 TurnContext 中间层:
QueryEngine (会话级)
└── TurnContext (轮次级,但跨 query() 前后的准备阶段)
└── query() State (循环迭代级)
但这会增加架构复杂度。Claude Code 当前用 submitMessage() 的局部变量(如 turnCount、currentMessageUsage)来承载这种中间生命周期状态,是更务实的方案。
小结
二元分离是 Claude Code 引擎层的基石架构决策。它的核心价值不在于"把代码分成两个文件",而在于:
- 生命周期对齐:变化频率不同的状态被严格分离,各自有合适的重置策略
- 关注点分离:
QueryEngine管持久化和跨轮次协调,query()管单轮循环和错误恢复 - 测试隔离:
query()的纯函数式设计使循环逻辑可独立测试 - 恢复对称性:错误恢复作为状态机的正常转换,与正常流程结构对称
- 回流选择性:
QueryEngine可以选择性合并query()的输出,而非全部接受
原书说"任何长生命周期的交互式系统都能从中受益",这不仅是客套——从编辑器到游戏引擎到交易系统,生命周期不对称分离都是一个值得遵循的原则。
下一篇:按照
SOURCE_CODE_READING_PLAN.md计划,可继续阶段二的其余部分:3.4 查询生命周期全景、3.5 配置快照模式、3.6 依赖注入、3.7 四层压缩管线。如果您准备继续,请告诉我!

270

被折叠的 条评论
为什么被折叠?



