阶段二第三章验证清单 — 源码逐条验证报告
验证范围:source-code-notes/3.2 ~ 3.10 全部 9 篇精读笔记验证方法:以源码笔记中的逐行代码引用为证据,对照原书描述进行判定
验证项 [1]:QueryEngine 是否真的是 while(true) + switch/case 结构
结论:✅ 基本准确,但有两个重要修正
原书描述"QueryEngine 是 while(true) + switch/case 结构",源码验证了这个判断的精神,但在两个关键细节上需要修正。
修正一:while(true) 不在 QueryEngine 中,而在 query.ts 的 queryLoop() 中
原书将 while(true) 循环归功于 QueryEngine,但二元分离架构告诉我们:
QueryEngine.submitMessage() ← for-await 消费循环(switch/case 消息分流)└── query() ← 薄包装器└── queryLoop() ← while(true) 真正所在(query.ts:307-1728)
证据(来自 3.2 笔记):
QueryEngine 的类注释(QueryEngine.ts:176-183)明确写道:
/*** QueryEngine owns the query lifecycle and session state for a conversation.* One QueryEngine per conversation. Each submitMessage() call starts a new* turn within the same conversation. State persists across turns.*/
而 queryLoop 函数(query.ts:241)才是 while(true) 的真正宿主:
// query.ts:307// eslint-disable-next-line no-constant-conditionwhile (true) {// ... 1421 行循环体} // while (true) ← query.ts:1728
QueryEngine.submitMessage() 内部使用的是 for await...of 消费 query() 的 AsyncGenerator 输出——这是一个switch/case 消息分流循环(QueryEngine.ts:675-1048),但不是 while(true)。
总结:while(true) 在 query() 层(轮次级),switch/case 在 QueryEngine 层(会话级消费),两者通过 AsyncGenerator 接口连接。
修正二:不是传统 switch/case 状态机,而是 if/else + transition 字段的隐式状态机
原书说"switch/case 结构",但 queryLoop 内部没有 switch 语句。状态分支通过 if/else 链 + state.transition 字段实现:
// 隐式状态机的核心:transition 字段记录”为什么回到循环顶部”type State = {// ...transition: Continue | undefined // ← 隐式状态机的核心字段}type Continue = {reason:| 'next_turn' // 正常工具调用后继续| 'collapse_drain_retry' // Context Collapse 后重试| 'reactive_compact_retry' // 响应式压缩后重试| 'stop_hook_blocking' // Stop Hook 阻止后重试| 'token_budget_continuation' // Token 预算延续| 'max_output_tokens_escalate' // Max Output Tokens 升级| 'max_output_tokens_recovery' // Max Output Tokens 恢复}
循环体的分支逻辑用 if/else 链实现,而非 switch:
while (true) {// 阶段 B:压缩管线// ...// 阶段 C:API 调用for await (const message of deps.callModel({...})) { ... }// 阶段 E:终止判断(if/else 链,不是 switch)if (!needsFollowUp) {if (isWithheld413) {if (state.transition?.reason !== 'collapse_drain_retry') {// Collapse drain 恢复state = { ..., transition: { reason: 'collapse_drain_retry' } }continue}// Reactive Compact 恢复// ...}if (isWithheldMaxOutputTokens) { ... }// ...return { reason: 'completed' }}// 阶段 F:工具执行 + 下一轮准备state = { ..., transition: { reason: 'next_turn' } }// 隐式 continue:循环回到顶部}
真正的 switch/case在 QueryEngine.submitMessage() 的 for-await 消费循环中:
// QueryEngine.ts:675-1048 — 这才是 switch/casefor await (const message of query({...})) {switch (message.type) {case 'tombstone': break // 控制信号,跳过case 'assistant': ... // push + normalize + yieldcase 'progress': ... // push + inline record + yieldcase 'user': ... // push + normalize + yieldcase 'stream_event': ... // 累积 usagecase 'attachment': ... // 结构化输出提取case 'system': ... // compact_boundary / api_errorcase 'tool_use_summary': ... // yield// ... 12 种消息类型分流}}
完整结构总结
所以原书说的"while(true) + switch/case"需要理解为:while(true) 在 query 层,switch/case 在 QueryEngine 层,两层通过 AsyncGenerator 连接。这是二元分离架构的直接产物。
补充:隐式状态机的 17 条路径
原书提到"约 6 种转换路径",实际源码中有17 条路径(来自 3.3 笔记):
原书的"约 6 种"偏少,但核心判断"线性为主"是准确的——7 条 continue 路径中,next_turn 是压倒性的常见路径,其余 6 条都是错误恢复路径。
验证项 [2]:错误恢复的三层策略(重试/降级/崩溃上报)是否如书中描述
结论:✅ 框架准确,但源码实际有更丰富的层级和机制
原书描述的"三层策略"——重试、降级、崩溃上报——在源码中都能找到对应实现,但实际体系远比三层丰富。
第一层:重试(Retry)
来自 3.10 笔记,services/api/withRetry.ts(823 行)实现了 API 调用级别的重试:
// withRetry.ts — 核心签名export async function* withRetry(operation: () => Promise,options: { maxRetries?: number, source?: QuerySource, ... }): AsyncGenerator
重试决策函数shouldRetry()覆盖 13 种场景:
指数退避算法:
function getRetryDelay(attempt: number, response?: Response): number {// 1. 优先使用 retry-after headerconst retryAfter = response?.headers?.get('retry-after')if (retryAfter) { ... }// 2. 指数退避 + 25% jitterconst baseMs = BASE_DELAY_MS * Math.pow(2, attempt - 1) // 500 * 2^(n-1)const jitter = Math.random() * 0.5 - 0.25 // ±25%return Math.min(baseMs + baseMs * jitter, 32_000) // 上限 32s}
退避序列:500ms → 1s → 2s → 4s → 8s → 16s → 32s(上限)
来自 3.3/3.4 笔记,queryLoop 内部有 7 条 continue 路径实现循环级重试。与 API 层重试不同,循环级重试修改 State 后回到循环顶部,而不是简单地重新调用 API:
// 示例:Max Output Tokens 恢复(渐进式升级)// 第一步:升级到 64Kstate = {...state,maxOutputTokensOverride: ESCALATED_MAX_TOKENS, // 8K → 64Ktransition: { reason: 'max_output_tokens_escalate' },}continue // ← 回到循环顶部重试// 第二步~第四步:发送恢复消息(上限 3 次)if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) { // 3state = {...state,messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,transition: { reason: 'max_output_tokens_recovery', attempt: ... },}continue}
第二层:降级(Degradation / Fallback)
来自 3.10 笔记,当 API 连续返回 529(服务器过载)时触发模型降级:
// withRetry.tsconst MAX_529_RETRIES = 3if (consecutive529s >= MAX_529_RETRIES) {throw new FallbackTriggeredError(`Model ${currentModel} overloaded, falling back to ${fallbackModel}`,fallbackModel)}
queryLoop 捕获 FallbackTriggeredError 后切换到备用模型:
// query.ts:893-953} catch (innerError) {if (innerError instanceof FallbackTriggeredError && fallbackModel) {currentModel = fallbackModel // ← 切换模型attemptWithFallback = true// 清理失败的尝试yield* yieldMissingToolResultBlocks(assistantMessages, 'Model fallback triggered')assistantMessages.length = 0yield createSystemMessage(`Switched to ${renderModelName(...)} due to high demand...`, 'warning')continue // ← 用新模型重试}throw innerError // ← 无法处理,抛出}
来自 3.4/3.7 笔记,当上下文过长(413 Prompt Too Long)时,触发递进式上下文降级:
四层压缩管线(先轻后重):1. applyToolResultBudget — 工具结果预算裁剪(最轻)2. Snip 压缩 — 移除僵尸消息(HISTORY_SNIP feature gate)3. microcompact — 微压缩(合并相邻消息)4. Context Collapse — 上下文折叠(CONTEXT_COLLAPSE feature gate)5. autocompact — LLM 驱动摘要(最重)
当 413 错误发生时,还有两步响应式恢复:
// 第一步:Context Collapse Drain(轻量,保留粒度)if (state.transition?.reason !== 'collapse_drain_retry') {const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource)if (drained.committed > 0) {state = { ..., transition: { reason: 'collapse_drain_retry' } }continue}}// 第二步:Reactive Compact(重量,LLM 驱动摘要)if (reactiveCompact) {const compacted = await reactiveCompact.tryReactiveCompact({...})if (compacted) {state = { ..., hasAttemptedReactiveCompact: true,transition: { reason: 'reactive_compact_retry' } }continue}// 恢复失败 → surface 错误yield lastMessagereturn { reason: 'prompt_too_long' }}
来自 3.10 笔记,errors.ts 中的 get3PModelFallbackSuggestion() 提供模型降级路径:
const fallbackMap = {'claude-opus-4-6-20251101': 'claude-opus-4-1-20250805','claude-sonnet-4-6-20251101': 'claude-sonnet-4-5-20250929','claude-sonnet-4-5-20250929': 'claude-sonnet-4-20250514',}
第三层:崩溃上报(Crash Reporting)
来自 3.4/3.7 笔记,当错误无法恢复时,QueryEngine.submitMessage() 构造结构化的错误结果消息:
// QueryEngine.ts:1058-1118 — EDE(error_during_execution)诊断if (!isResultSuccessful(result, lastStopReason)) {yield {type: 'result',subtype: 'error_during_execution',is_error: true,num_turns: turnCount,stop_reason: lastStopReason,total_cost_usd: getTotalCost(),usage: this.totalUsage,errors: [`[ede_diagnostic] result_type=${edeResultType} last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,...all.slice(start).map(_ => _.error), // turn-scoped 错误日志],}return}
EDE 诊断包含三个关键字段,帮助快速定位失败原因:
result_type:最后一条消息的类型(assistant/user/undefined) last_content_type:最后一条 content block 的类型(text/tool_use/none) stop_reason:LLM 的停止原因(end_turn/tool_use/max_tokens/...)
turn-scoped 错误日志使用引用水线(errorLogWatermark)限制 errors[] 只包含当前轮次的错误:
// 注释(QueryEngine.ts:1106-1114)// 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).
来自 3.10 笔记,services/api/errors.ts(1208 行)实现了 20+ 种错误分类:
aborted / api_timeout / repeated_529 / rate_limit / server_overload /prompt_too_long / pdf_too_large / image_too_large / tool_use_mismatch /invalid_model / credit_balance_low / invalid_api_key / token_revoked /oauth_org_not_allowed / auth_error / bedrock_model_access / server_error /client_error / ssl_cert_error / connection_error / unknown
services/api/logging.ts(789 行)记录完整的错误遥测:
logAPIError() — errorType / gateway / clientRequestId / connectionDetails OTLP api_error 事件 — 用于遥测系统 7 种 Gateway 检测 — litellm/helicone/portkey/cloudflare/kong/braintrust/databricks
原书未描述的关键机制
这是源码中一个原书完全未描述的重要机制(来自 3.2/3.3/3.4 笔记)。当 API 返回可恢复错误时,这些错误不会被立即 yield 给消费者,而是被"暂存":
// query.ts:799-825let withheld = falseif (contextCollapse?.isWithheldPromptTooLong(message)) withheld = trueif (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = trueif (mediaRecoveryEnabled && reactiveCompact?.isWithheldMediaSizeError(message)) withheld = trueif (isWithheldMaxOutputTokens(message)) withheld = trueif (!withheld) {yield yieldMessage // 只有非暂存消息才 yield}// 暂存的错误仍推入 assistantMessages(供恢复检查使用)if (message.type === 'assistant') {assistantMessages.push(message)}
设计哲学:如果恢复成功,错误对调用方完全不可见;如果恢复失败,错误才被 yield lastMessage surface。这防止 SDK 消费者(如 claude-desktop)在恢复期间看到中间态错误而误终止会话。
来自 3.2/3.4 笔记,hasAttemptedReactiveCompact 在 stop_hook_blocking 路径中不重置,防止形成死循环:
// query.ts:1282-1306 — 注释解释了为什么不能重置// Preserve the reactive compact guard — if compact already ran and// couldn't recover from prompt-too-long, retrying after a stop-hook// blocking error will produce the same result. Resetting to false// here caused an infinite loop: compact → still too long → error →// stop hook blocking → compact → … burning thousands of API calls.hasAttemptedReactiveCompact, // ← 不重置!
来自 3.10 笔记,CLAUDE_CODE_UNATTENDED_RETRY 环境变量启用无人值守的持久重试:
错误恢复层级全景
综合 9 篇笔记,源码中的错误恢复实际有五个层级,远超原书的"三层":
┌─────────────────────────────────────────────────────────────┐│ 错误恢复五层体系 │├─────────────────────────────────────────────────────────────┤│ ││ Layer 1: API 层重试(withRetry.ts) ││ ├── 指数退避 + jitter(上限 32s) ││ ├── 529 特殊处理(3 次后触发降级) ││ ├── 持久重试模式(6h 上限,30s 心跳) ││ └── Max Tokens 溢出自动修复 ││ ││ Layer 2: 循环级恢复(queryLoop continue 路径) ││ ├── Context Collapse Drain(413 恢复第一步) ││ ├── Reactive Compact(413 恢复第二步 / Media 恢复) ││ ├── Max Output Tokens 升级(8K → 64K) ││ ├── Max Output Tokens 恢复消息(上限 3 次) ││ └── Stop Hook Blocking 重试 ││ ││ Layer 3: 模型降级(FallbackTriggeredError) ││ ├── 529 连续 3 次 → 切换备用模型 ││ └── 第三方模型降级建议链(Opus 4.6→4.1, Sonnet 4.6→4.5→4) ││ ││ Layer 4: 上下文降级(四层压缩管线) ││ ├── applyToolResultBudget(工具结果预算裁剪) ││ ├── Snip 压缩(移除僵尸消息) ││ ├── microcompact(微压缩) ││ ├── Context Collapse(上下文折叠) ││ └── autocompact(LLM 驱动摘要) ││ ││ Layer 5: 崩溃上报(QueryEngine 层) ││ ├── error_during_execution + EDE 诊断 ││ ├── turn-scoped 错误日志(引用水线) ││ ├── API 层 20+ 错误分类 + OTLP 遥测 ││ └── 7 种 Gateway 检测 ││ ││ + Withheld 错误暂存(横跨 Layer 1-3 的保护机制) ││ └── 可恢复错误不立即暴露,恢复成功则不可见 ││ │└─────────────────────────────────────────────────────────────┘
验证项 [3]:依赖注入模式是否用于解耦 API 调用和工具执行
结论:✅ 准确,但需要精确理解"解耦"的边界
原书描述"依赖注入模式用于解耦 API 调用和工具执行",源码验证了这一判断,但需要澄清:DI 只解耦了 API 调用和压缩服务,工具执行目前不在 DI 边界内。
3.1 QueryDeps 的四个依赖
来自 3.6 笔记,query/deps.ts(40 行)定义了精确的依赖注入边界:
export type QueryDeps = {// -- 模型层(网络 I/O)callModel: typeof queryModelWithStreaming // LLM API 流式调用// -- 压缩层(磁盘/缓存/网络 I/O)microcompact: typeof microcompactMessages // 微压缩autocompact: typeof autoCompactIfNeeded // 自动压缩(可能调用 LLM)// -- 平台层(随机数)uuid: () => string // UUID 生成}export function productionDeps(): QueryDeps {return {callModel: queryModelWithStreaming,microcompact: microcompactMessages,autocompact: autoCompactIfNeeded,uuid: randomUUID,}}
消费方式(query.ts:263):
const deps = params.deps ?? productionDeps()-
-
3.2 DI 如何解耦 API 调用
callModel 是 queryModelWithStreaming 的类型别名,这是 services/api/claude.ts(3419 行)中的核心函数。通过 DI,queryLoop 不直接 import queryModelWithStreaming,而是通过 deps.callModel() 调用:
// query.ts:659 — 通过 deps 调用 APIfor await (const message of deps.callModel({messages: prependUserContext(messagesForQuery, userContext),systemPrompt: fullSystemPrompt,thinkingConfig: toolUseContext.options.thinkingConfig,tools: toolUseContext.options.tools,signal: toolUseContext.abortController.signal,options: { model: currentModel, ... },})) {// 处理流式响应}
解耦效果:
测试时可以注入 mock callModel,返回预设的流式响应 不需要 mock 整个 services/api/ 模块(20 个文件、9000 行代码) 不需要处理真实的 HTTP 连接、OAuth 认证、重试逻辑
3.3 工具执行不在DI 边界内
原书说"解耦 API 调用和工具执行",但源码中工具执行(runTools)不在QueryDeps中。deps.ts 的注释明确列出了未来可能的扩展:
// Scope is intentionally narrow (4 deps) to prove the pattern. Followup// PRs can add runTools, handleStopHooks, logEvent, queue ops, etc.
工具执行目前通过直接 import调用:
// query.ts:1380-1408 — 直接调用 runTools,不通过 depsconst toolUpdates = streamingToolExecutor? streamingToolExecutor.getRemainingResults(): runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
为什么 runTools 不在 deps 中?来自 3.6 笔记的分析:
核心原则:只注入改变行为的 I/O 边界,不注入观测性工具。callModel 改变行为(返回什么响应),logEvent 不改变行为(只是记录)。
3.4 DI 如何实现解耦:typeof 类型同步技巧
来自 3.6 笔记,deps.ts 使用 typeof 而非手写接口,确保类型定义自动与真实函数签名同步:
// ✅ 使用 typeof — 类型自动同步callModel: typeof queryModelWithStreaming// ❌ 传统方式 — 手动维护两份签名,容易遗漏interface QueryDeps {callModel: (messages: Message[],config: QueryConfig,signal?: AbortSignal) => AsyncGenerator // ← 当原函数新增参数时,这里容易遗漏}
当 queryModelWithStreaming 的签名变化时(如新增 taskBudget 参数),TypeScript 编译器会自动检测所有 mock 是否兼容——不需要等到运行时。
uuid 的例外:uuid 是唯一不使用 typeof 的依赖,因为 Node.js 的 randomUUID 返回值是过于具体的 UUID 模板字面量类型,mock 时返回 'test-uuid-001' 会被类型检查拒绝。使用 () => string 放宽约束,是务实的 Trade-off。
3.5 DI 的消费点映射
来自 3.6 笔记,deps 在 queryLoop 中的 5 个消费点全部集中在前三个阶段:
后三个阶段(钩子/终止/工具执行)不直接使用 deps。这意味着 mock 只需要覆盖 I/O 操作(压缩 + API)就能控制循环的核心行为。
3.6 DI 的边界只在 query() 层
来自 3.6 笔记的关键发现:QueryEngine 从不传递 deps。搜索 QueryEngine.ts 中的 deps/QueryDeps 关键词,结果为零:
// QueryEngine.ts:675-686 — 注意没有 deps 参数for await (const message of query({messages,systemPrompt,userContext,systemContext,canUseTool: wrappedCanUseTool,toolUseContext: processUserInputContext,fallbackModel,querySource: 'sdk',maxTurns,taskBudget,// ← 没有 deps!使用 productionDeps() 的默认值})) {
这是刻意的设计——DI 的边界只在query()层。QueryEngine 的测试策略是集成测试(使用真实 query() + Mock canUseTool),不需要在更高层级注入 deps。
测试策略自然分层:
3.7 API 调用与工具执行的真正解耦点
虽然 runTools 不在 DI 中,但 API 调用和工具执行确实被架构性解耦了——只是不是通过 DI,而是通过AsyncGenerator 接口 + StreamingToolExecutor:
API 调用(deps.callModel) 工具执行(runTools / StreamingToolExecutor)│ ││ for await (message of │ for await (update of│ deps.callModel({...})) │ toolUpdates)│ ││ ┌─────────────────────┐ │ ┌─────────────────────┐│ │ 流式响应处理 │ │ │ 工具执行结果 ││ │ • assistantMessages │ │ │ • toolResults ││ │ • toolUseBlocks │──────────────▶│ │ • canUseTool 检查 ││ │ • needsFollowUp │ │ │ • 权限决策 ││ └─────────────────────┘ │ └─────────────────────┘│ ││ yield message │ yield update.message│ │└───────────────┬───────────────────────┘│▼QueryEngine.submitMessage()for-await 消费(switch/case 分流)
关键解耦点:
3.8 修正原书描述
原书说"依赖注入模式用于解耦 API 调用和工具执行",更精确的描述应该是:
依赖注入模式用于解耦 query() 循环与核心 I/O 边界(LLM API 调用 + 压缩服务),使其可在测试中替换为 mock 实现。工具执行目前不在 DI 边界内,但通过 AsyncGenerator 接口和 StreamingToolExecutor 实现了与 API 调用的架构性解耦。
总结
总体验证结果:原书的三个核心论断在源码中都得到了验证,细节上的修正和补充不影响原书的整体判断。源码比原书描述更丰富——这符合"书→码"对照阅读的预期:书是架构概览,码是工程实现。
夜雨聆风