流式、幂等、安全:QueryEngine 的工程保障三件套
《Claude Code 架构解密》读书笔记 · 第04篇 对应章节:第3章后半(3.6-3.13)— 查询处理的工程保障
导语
上一篇我们拆解了 QueryEngine 的"骨架"——while(true) 状态机、二元分离、AsyncGenerator、配置快照。但骨架只是起点。一个裸的 while(true) 不崩,靠的不是运气,而是一整套工程保障体系:八步管线管住查询生命周期、四层压缩管线管住上下文膨胀、分级错误恢复管住异常路径、窄依赖注入管住测试边界。
本篇从"骨架"走进"器官",看 Claude Code 如何让死循环真正可靠地跑起来。
一、查询生命周期全景:submitMessage 八步管线
从用户输入到结果返回
一次完整的查询从 QueryEngine.submitMessage() 开始,经过八个阶段:
用户输入 "帮我重构 parsePath 函数"
│
▼
① 初始化:清理技能集合、设置 cwd、记录时间戳
│
▼
② 权限包装:将 canUseTool 包装为带拒绝追踪的版本
(每次权限拒绝都被记录,用于后续分析)
│
▼
③ 系统提示构建:fetchSystemPromptParts() → asSystemPrompt()
(动态组装系统提示,详见第7章)
│
▼
④ 用户输入处理:processUserInput()
(解析斜杠命令、处理附件、展开粘贴引用)
│
▼
⑤ 消息持久化:recordTranscript()
(将用户消息写入 JSONL 会话记录)
│
▼
⑥ 查询执行:调用 query() 异步生成器
queryLoop() while(true)
→ 上下文压缩管线(§3.7 详述)
→ API 调用(流式响应)
→ 工具执行(流式或批量)
→ 错误恢复
→ Stop Hooks 执行
→ Token 预算检查
│
▼
⑦ 后处理:更新 totalUsage、技能发现结果等会话级状态
│
▼
⑧ 返回结果:流式产出 SDKMessage 给调用方
装饰器式的权限追踪
第②步的设计细节值得关注。canUseTool 是权限检查函数(详见第5章),但 QueryEngine 不是直接传递,而是用包装器追踪权限拒绝:
const wrappedCanUseTool = async (tool, input) => {
const result = await canUseTool(tool, input)
if (result.denied) {
this.permissionDenials.push({
tool: tool.name,
input,
reason: result.reason,
timestamp: Date.now()
})
}
return result
}
装饰器模式让权限追踪逻辑不侵入 canUseTool 的实现,也不污染 query() 的代码——关注点分离的又一个实例。
State 对象的函数式更新
queryLoop 中的状态更新采用函数式不可变更新:
state = {
…state, // 保留所有旧字段
messages: […state.messages, newMsg], // 追加新消息
turnCount: state.turnCount + 1, // 递增轮次
transition: { reason: 'next_turn' } // 设置转换原因
}
{…state, …updates} 的展开赋值有三个优势:
但注意——这种"函数式"是有限度的。state.messages 数组本身是可变的(push() 操作),因为完全不可变的消息数组在频繁追加时会产生大量 GC 压力。务实 Trade-off:状态顶层保持不可变语义,热点路径上允许可变操作。
二、四层压缩管线:先轻后重的上下文管理
根本矛盾:对话越有用,离上下文极限越近
长会话 Agent 面临一个根本性矛盾:
- 读取 20+ 个文件(每个几百到几千行)
- 执行 10+ 次编辑操作
- 运行 5+ 次测试命令
一个 cat 命令输出 2000 行文件,就可能消耗 8000+ tokens。当消息历史膨胀到接近 200K 上下文窗口时,API 返回 413 错误(Prompt Too Long)。
直觉方案是"压缩"——用 LLM 生成摘要替代历史。但有两个问题:
四层递进策略
Claude Code 的答案:不是所有膨胀都需要 LLM 介入。整个压缩体系由 12 个文件、约 3900 行代码组成,构建了四层递进策略:
第一层 第二层 第三层 第四层
API 原生 → 微压缩 → Session Memory → Full Compact
零成本 零 LLM 成本 零 LLM 成本 LLM 驱动
服务端清理 时间/缓存触发 后台笔记替代摘要 结构化摘要
轻量/快速 ──────────────────────────────────────→ 重量/慢速
信息保留多 ──────────────────────────────────────→ 信息保留少
"先轻后重"确保大多数情况只需轻量压缩,只有当轻量手段不足时才启用更"重"的策略。
第一层:API 原生上下文管理(零成本)
利用 Anthropic API 的原生 context_management 参数,让服务端自动清理工具结果和 thinking 块,客户端零开销:
type ContextEditStrategy = {
type: 'clear_tool_uses_20250919'
trigger: { type: 'input_tokens'; value: 180_000 } // 输入超 180K 时触发
keep: { type: 'tool_uses'; value: number } // 保留最近 N 个
exclude_tools: string[] // 排除写操作工具
}
关键设计:并非所有工具结果都可以安全清理。
| Bash、Glob、Grep、Read、WebFetch、WebSearch | Edit、Write、NotebookEdit |
| 结果是信息性的,清理后可重新执行 | 代表"做过的修改",清理后模型丧失记忆 |
第二层:微压缩(零 LLM 成本)
两条子路径,都不涉及 LLM 调用:
路径 A:时间触发微压缩
核心洞察:Anthropic 的 Prompt Cache TTL 约 1 小时。用户离开超 60 分钟再回来,缓存必然已过期,清理旧工具结果不会造成额外缓存 miss。
// 触发条件:距上次 assistant 消息超过 60 分钟
if (timeSinceLastAssistant > 60 * 60 * 1000) {
const compactableIds = collectCompactableToolIds(messages)
const toClean = compactableIds.slice(0, –5) // 保留最近 5 个
for (const id of toClean) {
replaceToolResult(id, "[old tool result content cleared]")
}
}
路径 B:缓存微压缩(Cached Microcompact)
利用 API 的 cache_edits 能力,在服务端删除工具结果,不修改本地消息。这样既释放上下文空间,又保留本地消息完整性(对会话记录和调试有价值),更不会破坏 cache prefix 的有效性。
工程细节:只有主线程可以注册 cached microcompact 状态。子 Agent 与主线程共享进程和模块级状态,如果子 Agent 错误地修改了主线程的缓存状态,会导致难以追踪的 bug:
if (!isMainThreadSource(querySource)) return { messages }
第三层:Session Memory 压缩(零 LLM 成本,创新性设计)
这是四层管线中最具创新性的设计。
传统方式是"事后摘要"——上下文快满时调用 LLM 生成摘要。Claude Code 引入了"实时笔记"策略:后台有一个独立的 session-memory extraction agent 持续运行,像勤勉的会议记录员,不断将对话中的关键信息提取为结构化笔记。需要压缩时,直接用已有笔记替代 LLM 摘要——零 LLM 成本。
笔记(SessionMemory)被组织为九个章节:
保留多少近期消息? 这是一个双约束优化问题:
// calculateMessagesToKeepIndex 的核心逻辑
// 向前扩展,直到同时满足:
1. 保留的 token 数 ≥ minTokens(10K) // 确保足够的近期上下文
2. 保留的文本块消息数 ≥ minTextBlockMessages(5) // 确保对话连贯性
3. token 数 ≤ maxTokens(40K) // 防止保留太多(硬上限)
→ adjustIndexToPreserveAPIInvariants // 确保不破坏 API 约束
adjustIndexToPreserveAPIInvariants 是整个 Compact 系统中最精细的边界处理:
- Step 1:工具配对完整性——API 要求每个 tool_result 都有对应的 tool_use。分割点恰好在两者之间时,必须向前扩展保留范围
- Step 2:Thinking 块连续性——同一 API 响应的 thinking 块和文本块共享 message.id,分割点不能拆开同一响应
这个算法保证了一个关键不变式:压缩后的消息序列对 API 来说仍然是合法的。tool_use/tool_result 配对、thinking 块连续性、消息角色交替——任何一个被破坏都会导致 API 返回 400 错误。
第四层:Full Compact——LLM 驱动的结构化摘要
当前三层都不够时,最重的手段被启用。采用双路径策略:
优先路径:ForkedAgent 路径(缓存共享)
- 复用主对话的 system prompt → 命中 prompt cache → ~30% 成本折扣
- skipCacheWrite: true → 不覆盖主对话的缓存
- maxTurns: 1 → 只允许一轮,防止压缩 Agent 自己去调用工具
回退路径:独立 API 调用
- 独立的 API 调用
- 消息预处理:剥离图片、移除重注入的附件
- 支持 2 次 PTL 重试
Prompt 设计的防御性工程:压缩 Agent 继承了父进程的完整工具集(为了 cache key 匹配),但它不应该调用任何工具。Prompt 中包含了防线:
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
– Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
– Tool calls will be REJECTED and will waste your only turn – you will fail the task.
即便如此,测试中发现模型仍有约 2.79% 的概率尝试工具调用。maxTurns: 1 是第二道防线——即使模型尝试调用工具,也只有一轮机会。
摘要输出采用 <analysis> + <summary> 双段设计。<analysis> 块在后处理时被剥离——作用是让模型"先思考再总结"(scratchpad thinking),最终只有 <summary> 部分被注入对话历史,避免浪费上下文空间。
压缩触发:自动决策引擎
autoCompact.ts(约 351 行)是整个系统的"大脑":
阈值计算:
effectiveWindow = contextWindowForModel – maxOutputTokensForModel
autoCompactThreshold = effectiveWindow – AUTOCOMPACT_BUFFER_TOKENS(13_000)
示例:200K 上下文窗口,8K 最大输出
effectiveWindow = 200K – 8K = 192K
autoCompactThreshold = 192K – 13K = 179K
→ 当输入 token 超过 179K 时,触发自动压缩
五层防护确保压缩不会导致递归或竞争:
策略优先级:
消息分组算法:API Round 分组
压缩操作中一个棘手的问题:如何分割消息历史?不能在任意位置切割——tool_use 和 tool_result 必须成对,thinking 块必须连续。
旧方案(按"人类输入"分组)的问题:在 SDK/CCR/eVal 等单轮 agentic 场景下,一个"人类轮次"可能包含数百次工具调用,无法有效分割。
新方案(按 API Round 分组):以 assistant.message.id 的变化作为边界:
for (const msg of messages) {
if (msg.type === 'assistant' &&
msg.message.id !== lastAssistantId &&
current.length > 0) {
groups.push(current) // 新 message.id → 新的 API 轮次
current = [msg]
} else {
current.push(msg)
if (msg.type === 'assistant') {
lastAssistantId = msg.message.id
}
}
}
使用 API 返回的 message.id 而非内部生成的 UUID,是因为同一流式响应中的多条消息共享同一个 message.id——这恰好是"API 轮次"的自然边界。
后压缩清理:被忽视的关键环节
压缩完成后,还需清理一系列全局状态——容易被忽视但极其重要:
// 无条件重置
resetMicrocompactState() // 微压缩追踪状态
clearSystemPromptSections() // 系统提示区段缓存
clearClassifierApprovals() // bash 分类器批准记录
clearSpeculativeChecks() // 推测性权限检查
clearBetaTracingState() // 遥测追踪
clearSessionMessagesCache() // session 存储缓存
// 仅主线程
if (isMainThreadCompact) {
getUserContext.cache.clear() // 用户上下文 memoize 缓存
resetGetMemoryFilesCache('compact') // memory 文件 one-shot 标志
// 注意:故意不重置 sentSkillNames
// 原因:skill 内容需在多次压缩间保持,省去每次重注入 ~4K tokens
}
精华所在:主线程 vs 子 Agent 的区分。子 Agent 与主线程共享进程和模块级状态(Node.js 特性),子 Agent 压缩时错误清理主线程状态会导致难以追踪的 bug——比如用户突然被重新要求授权已批准的操作。
sentSkillNames 的"故意不重置"是有意思的反模式——通常我们期望压缩后状态是"干净"的,但为了节省 4K tokens 的重注入成本,特意保留。清理规则不是"全部重置"或"全部保留"的二元选择,需要逐个字段评估保留价值。
三、分级错误恢复:每种错误配独立药方
为什么不能"出错就重试"?
queryLoop 执行中各种错误随时可能发生。不同错误的恢复方向可能完全相反:
| Prompt Too Long (413) | 减少输入——压缩历史 |
| Max Output Tokens | 增加输出——升级 maxOutputTokens |
| Model Unavailable | 换模型——降级到备用模型 |
| Media Size Error | 剥离媒体——移除图片 |
如果对四种错误都执行"减少输入",Max Output Tokens 的恢复反而会让情况更糟。
分级恢复策略
| Prompt Too Long (413) | Context Collapse → Reactive Compact | 各一次 |
| Max Output Tokens | 升级 64K maxOutputTokens → 多轮恢复消息 | 3 次 |
| Model Unavailable | 切换到 Fallback 模型 | 1 次 |
| Media Size Error | Reactive Compact + 图片剥离 | 1 次 |
最复杂的路径——Prompt Too Long 两步恢复:
API 返回 413(Prompt Too Long)
│
├─ 尝试 1: Context Collapse
│ 将历史消息折叠为摘要,保留近期消息
│ transition = 'collapse_drain_retry'
│ ├─ 成功 → continue(回到循环顶部重试 API 调用)
│ └─ 失败(仍然 413)→ 进入尝试 2
│
└─ 尝试 2: Reactive Compact
更激进的压缩——调用 LLM 生成完整摘要
transition = 'reactive_compact_retry'
├─ 成功 → continue
└─ 失败 → 终止循环,返回错误
注意:transition 字段防止重复尝试
如果 transition 已经是 'collapse_drain_retry'
→ 跳过 Context Collapse,直接进入 Reactive Compact
Max Output Tokens 的渐进式升级:
模型输出被截断(达到 maxOutputTokens 限制)
│
├─ 恢复 1:升级 maxOutputTokens 到 64K
│ 设置 maxOutputTokensOverride = 64_000
│ 发送恢复消息:"你的输出被截断了,请继续"
│ continue
│
├─ 恢复 2:再次截断 → 再发恢复消息
│ maxOutputTokensRecoveryCount++
│ continue
│
└─ 恢复 3:第三次截断 → 终止
"已尝试 3 次恢复,无法继续"
错误恢复作为状态转换
将错误恢复与 while(true) 状态机结合,产生了一个简洁的实现模式:
if (error.type === 'prompt_too_long') {
if (!state.hasAttemptedReactiveCompact) {
const compacted = await reactiveCompact(state.messages)
state = {
…state,
messages: compacted,
hasAttemptedReactiveCompact: true,
transition: { reason: 'reactive_compact_retry' }
}
continue // ← 回到循环顶部,用压缩后的消息重试
}
// 已尝试过压缩,无法恢复
return { reason: 'error', error }
}
优雅之处:错误恢复不是特殊的代码路径,而是状态机的一个正常转换。压缩后重试和正常工具调用后继续,在代码结构上完全对称——都是"修改 state → 设置 transition → continue"。
四、依赖注入:四个依赖的精准边界
为什么不用 DI 框架?
query() 需要调用外部服务——LLM API、压缩服务、UUID 生成器。测试时需要 mock。query() 接受可选的 deps 参数:
type QueryDeps = {
callModel: typeof callModel // LLM API 调用
microcompact: typeof microcompact // 微压缩
autocompact: typeof autocompact // 自动压缩
uuid: typeof uuid // UUID 生成
}
// 生产环境使用真实实现
function productionDeps(): QueryDeps {
return { callModel, microcompact, autocompact, uuid }
}
// 测试中可以注入 mock
const testDeps: Partial<QueryDeps> = {
callModel: mockCallModel,
uuid: () => 'test-uuid-001'
}
只为 4 个 mock 点引入 InversifyJS/tsyringe 等 DI 框架——装饰器语法、容器配置、运行时类型检查——是典型的过度工程。
typeof 的类型同步技巧
callModel: typeof callModel // 类型自动与 callModel 函数签名同步
当 callModel 的签名变化时(比如新增参数),QueryDeps 的类型定义会自动更新——不需要手动维护两份签名。如果使用传统接口定义,原函数签名变化时很容易遗漏更新 mock 接口。
渐进式扩展
代码注释中列出了可能在未来添加的依赖:runTools、handleStopHooks、logEvent。但当前没包含,因为测试不需要 mock 它们——对工具执行测试,Claude Code 使用 spyOn 而非依赖注入。
这不是理想做法,但体现了一个务实原则:先解决当前痛点(4 个核心 I/O 依赖),而不是一次性设计"完美" DI 方案。这就是渐进式依赖注入的精髓:从最小 mock 集合开始,按需扩展。
五、子模块的单一职责
query() 的逻辑被拆分到四个子模块,每个维护严格的单一职责:
| 全局状态、环境变量 | config.ts | 配置快照 | QueryConfig(纯数据) | 无 |
| 依赖注入边界 | deps.ts | 依赖定义 | QueryDeps(函数接口) | 无 |
| Token 预算 | tokenBudget.ts | 预算决策 | TokenBudgetDecision + token 计数 | 修改 tracker |
| 状态 + 消息 + 上下文 | stopHooks.ts | Hook 编排 | StopHookResult + yield 消息 | 触发后台任务 |
注意"副作用"列的差异:config.ts 和 deps.ts 是纯函数(无副作用),tokenBudget.ts 和 stopHooks.ts 有副作用。这种分离不是偶然的——将可以纯化的部分拆出来,使其更接近 (state, event, config) → (state, output) 的 reducer 形态,为未来可能的重构(提取 step() 函数使 queryLoop 可被单步测试)做准备。
tokenBudget.ts:预算决策引擎
Token 预算是 Claude Code 控制成本的关键机制,做出三种决策之一:
type TokenBudgetDecision =
| { action: 'continue' } // 预算充足,继续
| { action: 'stop' } // 预算耗尽,终止
| { action: 'warn_and_continue' } // 接近上限,继续但发出警告
stopHooks.ts:用户定义的生命周期拦截
三种 Hook 类型:
- Stop:Agent 完成当前任务时触发
- TaskCompleted:任务明确完成时触发
- TeammateIdle:多 Agent 协作中队友空闲时触发
每种 Hook 都支持两种控制语义:
- blocking error:Hook 返回错误,阻止 Agent 停止,要求处理错误
- preventContinuation:阻止 Agent 自动继续,强制等待用户输入
这个设计让用户可以实现诸如"Agent 完成修改后自动运行测试,测试失败则要求 Agent 修复"的工作流(详见第9章)。
六、横向对比
Claude Code vs LangChain/LangGraph
| 循环模式 | while(true) + 隐式状态机 | 显式 Graph(节点+边) |
| 工具执行 | 流式+批量双模式 | 通常批量执行 |
| 上下文管理 | 四层压缩管线 | 通常依赖外部 Memory |
| 权限控制 | 内建分层权限模型 | 无内建权限 |
| 错误恢复 | 分级恢复策略(每种错误独立路径) | 通常简单重试 |
| 状态更新 | 函数式 {…state, …updates} | Graph 节点间传递状态 |
LangGraph 用显式有向图定义状态转换,可视化和理解上有优势。但 Claude Code 的转换路径以线性为主,不需要图结构的表达能力。
Claude Code vs OpenAI Assistants API
| 运行位置 | 客户端(本地) | 服务端 |
| 状态管理 | 客户端内存+本地持久化 | 服务端 Thread |
| 工具执行 | 本地执行 | Function Calling → 客户端执行 → 提交结果 |
| 上下文压缩 | 客户端主动管理(四层管线) | 服务端透明处理 |
| 取消机制 | AsyncGenerator.return() | Cancel Run API |
两种架构流派——“胖客户端"和"胖服务端”。Claude Code 将查询循环放在客户端,获得完全控制权(流式工具执行、本地权限检查、sandbox 隔离),但代价是客户端需要自行管理所有状态和压缩。OpenAI Assistants 将状态放在服务端,简化了客户端,但牺牲了细粒度控制。
七、六大可复用设计模式
从本章提炼出六个可复用模式:
模式 1:Generator 驱动的查询循环
- 问题:Agent 系统需要流式输出中间结果,同时支持取消和背压
- 方案:AsyncGenerator 驱动循环,yield 输出中间结果,return() 支持取消
- 适用:需要流式输出、消费者需控制速率
- 不适用:所有结果可一次性返回
模式 2:级联压缩管线
- 问题:上下文窗口有限,需在保留信息和释放空间间取得平衡
- 方案:多层递进压缩策略,按"先轻后重"排列,每层失败后优雅降级
- 适用:有多种压缩手段(成本不同)、大多数情况只需轻量压缩
- 不适用:所有情况都需要同等深度的压缩
模式 3:配置快照
- 问题:长时间异步操作中,运行时配置可能变化,导致行为不一致
- 方案:操作开始时一次性快照配置,操作期间使用快照值
- 适用:操作持续秒到分钟级、可复现性比实时性更重要
- 不适用:需要实时响应配置变更
模式 4:窄依赖注入边界
- 问题:核心代码需调用外部服务,测试需 mock,但 DI 框架过重
- 方案:手动定义最小依赖接口 + 工厂函数,只注入核心 I/O 边界
- 适用:mock 点数量少(< 10 个)
- 不适用:需要大规模依赖注入(100+ 注入点)
模式 5:轮次状态机
- 问题:需要状态机但转换路径简单,不值得引入框架
- 方案:while(true) + State 对象 + transition 字段,通过 continue/return 控制流转
- 适用:转换路径线性为主(约 5-10 种 reason)、需与 AsyncGenerator 兼容
- 不适用:状态转换复杂(图结构、条件分支合并)
模式 6:分级错误恢复
- 问题:不同错误类型需不同恢复策略,统一重试效率低下
- 方案:为每种错误类型设计独立恢复路径和次数限制
- 适用:错误类型多且恢复方向不同、每种恢复有明确次数上限
- 不适用:所有错误恢复策略相同
八、实战启示
启示一:压缩先轻后重,恢复逐级升级
四层压缩管线和分级错误恢复遵循同一个元模式——渐进式降级。先尝试零成本的方案,失败后才逐步升级到更重的手段。这个模式适用于任何"多种策略可选但成本不同"的场景——缓存策略、降级策略、重试策略。
启示二:后压缩清理比压缩本身更危险
压缩逻辑容易想到,但压缩后的状态清理常被忽视。Claude Code 的经验表明:清理时最大的陷阱是共享进程状态(主线程 vs 子 Agent)。任何 Node.js/Python 的多协程场景都需考虑"谁有权清理全局状态"。
启示三:函数式更新不是全有或全无
{…state, …updates} 在顶层保持不可变语义,但 messages.push() 在热点路径上允许可变操作。务实的选择比教条的纯粹更有价值。性能关键路径上的可变操作,只要边界清晰(只在特定位置允许),就是合理的工程取舍。
启示四:DI 框架不是唯一答案
4 个 mock 点不需要 InversifyJS。手动定义 QueryDeps + Partial<QueryDeps> 就够了。当依赖注入点 < 10 个时,手动 DI 比框架 DI 更清晰。typeof 技巧确保类型同步,无需额外维护。
下期预告
第05篇:30+ 工具背后的秘密——工具系统注册、调度与沙箱安全
QueryEngine 是 Agent 的大脑,工具系统就是它的"手脚"。一个没有工具的 LLM 只能说话,拥有工具的 Agent 才能行动。下一篇走进第4章,看 Claude Code 如何在赋能与防御之间找到精确的平衡点——30+ 工具的注册调度、BashTool 的七层安全防御、FileEditTool 的原子性写入。
思考题:Session Memory 压缩依赖后台 Agent 持续维护笔记。如果后台 Agent 出现延迟,笔记不够"新鲜"——系统应该降级到 Full Compact,还是使用"过时的笔记 + 近期消息"的折中方案?
📖 本系列基于《Claude Code 架构解密》精读整理,系列共20篇,本文为第04篇。 上一篇:第03篇 死循环里的优雅:QueryEngine 的 while(true) 状态机与原子操作 下一篇:第05篇 30+ 工具背后的秘密:工具系统注册、调度与沙箱安全(待发布)
