
流式、幂等、安全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 不是直接传递而是用包装器追踪权限拒绝constwrappedCanUseToolasync(tool,input){constresultawaitcanUseTool(tool,input)if(result.denied){this.permissionDenials.push({tool:tool.name,input,reason:result.reason,timestamp:Date.now()})}returnresult}装饰器模式让权限追踪逻辑不侵入canUseTool的实现也不污染query()的代码——关注点分离的又一个实例。State 对象的函数式更新queryLoop 中的状态更新采用函数式不可变更新state{...state,// 保留所有旧字段messages:[...state.messages,newMsg],// 追加新消息turnCount:state.turnCount1,// 递增轮次transition:{reason:next_turn}// 设置转换原因}{...state, ...updates}的展开赋值有三个优势可追踪性每次状态变化都是新对象可在循环顶部设断点对比前后状态与 React 哲学一致Claude Code 的 UI 层使用 React (Ink)函数式状态更新是其核心范式可预测性不存在意外修改旧状态的可能但注意——这种函数式是有限度的。state.messages数组本身是可变的push()操作因为完全不可变的消息数组在频繁追加时会产生大量 GC 压力。务实 Trade-off状态顶层保持不可变语义热点路径上允许可变操作。二、四层压缩管线先轻后重的上下文管理根本矛盾对话越有用离上下文极限越近长会话 Agent 面临一个根本性矛盾读取 20 个文件每个几百到几千行执行 10 次编辑操作运行 5 次测试命令一个cat命令输出 2000 行文件就可能消耗 8000 tokens。当消息历史膨胀到接近 200K 上下文窗口时API 返回 413 错误Prompt Too Long。直觉方案是压缩——用 LLM 生成摘要替代历史。但有两个问题成本高压缩一次的成本几乎等于再问一个问题信息损失摘要省略的细节可能导致 Agent 重复读取文件四层递进策略Claude Code 的答案不是所有膨胀都需要 LLM 介入。整个压缩体系由 12 个文件、约 3900 行代码组成构建了四层递进策略第一层 第二层 第三层 第四层 API 原生 → 微压缩 → Session Memory → Full Compact 零成本 零 LLM 成本 零 LLM 成本 LLM 驱动 服务端清理 时间/缓存触发 后台笔记替代摘要 结构化摘要 轻量/快速 ──────────────────────────────────────→ 重量/慢速 信息保留多 ──────────────────────────────────────→ 信息保留少先轻后重确保大多数情况只需轻量压缩只有当轻量手段不足时才启用更重的策略。第一层API 原生上下文管理零成本利用 Anthropic API 的原生context_management参数让服务端自动清理工具结果和 thinking 块客户端零开销typeContextEditStrategy{type:clear_tool_uses_20250919trigger:{type:input_tokens;value:180_000}// 输入超 180K 时触发keep:{type:tool_uses;value:number}// 保留最近 N 个exclude_tools:string[]// 排除写操作工具}关键设计并非所有工具结果都可以安全清理。可清理只读不可清理写操作Bash、Glob、Grep、Read、WebFetch、WebSearchEdit、Write、NotebookEdit结果是信息性的清理后可重新执行代表做过的修改清理后模型丧失记忆第二层微压缩零 LLM 成本两条子路径都不涉及 LLM 调用路径 A时间触发微压缩核心洞察Anthropic 的 Prompt Cache TTL 约 1 小时。用户离开超 60 分钟再回来缓存必然已过期清理旧工具结果不会造成额外缓存 miss。// 触发条件距上次 assistant 消息超过 60 分钟if(timeSinceLastAssistant60*60*1000){constcompactableIdscollectCompactableToolIds(messages)consttoCleancompactableIds.slice(0,-5)// 保留最近 5 个for(constidoftoClean){replaceToolResult(id,[old tool result content cleared])}}路径 B缓存微压缩Cached Microcompact利用 API 的cache_edits能力在服务端删除工具结果不修改本地消息。这样既释放上下文空间又保留本地消息完整性对会话记录和调试有价值更不会破坏 cache prefix 的有效性。工程细节只有主线程可以注册 cached microcompact 状态。子 Agent 与主线程共享进程和模块级状态如果子 Agent 错误地修改了主线程的缓存状态会导致难以追踪的 bugif(!isMainThreadSource(querySource))return{messages}第三层Session Memory 压缩零 LLM 成本创新性设计这是四层管线中最具创新性的设计。传统方式是事后摘要——上下文快满时调用 LLM 生成摘要。Claude Code 引入了实时笔记策略后台有一个独立的 session-memory extraction agent 持续运行像勤勉的会议记录员不断将对话中的关键信息提取为结构化笔记。需要压缩时直接用已有笔记替代 LLM 摘要——零 LLM 成本。笔记SessionMemory被组织为九个章节Primary Request and Intent——用户核心目标Key Technical Concepts——涉及的技术概念Files and Code Sections——操作过的文件和代码段Errors and Fixes——遇到的错误和修复Problem Solving——问题分析过程All User Messages——所有用户消息Pending Tasks——未完成的任务Current Work——当前进行的工作Optional Next Step——建议的下一步保留多少近期消息这是一个双约束优化问题// calculateMessagesToKeepIndex 的核心逻辑// 向前扩展直到同时满足1.保留的 token 数 ≥ minTokens10K// 确保足够的近期上下文2.保留的文本块消息数 ≥ minTextBlockMessages5// 确保对话连贯性3.token 数 ≤ maxTokens40K// 防止保留太多硬上限→ adjustIndexToPreserveAPIInvariants// 确保不破坏 API 约束adjustIndexToPreserveAPIInvariants是整个 Compact 系统中最精细的边界处理Step 1工具配对完整性——API 要求每个tool_result都有对应的tool_use。分割点恰好在两者之间时必须向前扩展保留范围Step 2Thinking 块连续性——同一 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是第二道防线——即使模型尝试调用工具也只有一轮机会。摘要输出采用analysissummary双段设计。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 时触发自动压缩五层防护确保压缩不会导致递归或竞争互斥锁防止压缩操作触发新的压缩的无限循环功能开关支持通过环境变量或用户配置禁用自动压缩Reactive Compact 互斥如果响应式压缩已在进行主动压缩退让Context Collapse 互斥避免两种压缩策略同时操作消息历史熔断器Circuit Breaker连续压缩失败 3 次后静默停止防止无限浪费 API 调用策略优先级Session Memory Compact零 LLM 成本最优选→ 失败 → 进入下一级Full CompactLLM 驱动兜底方案→ 失败 → 递增 consecutiveFailures → 可能触发熔断器消息分组算法API Round 分组压缩操作中一个棘手的问题如何分割消息历史不能在任意位置切割——tool_use和tool_result必须成对thinking 块必须连续。旧方案按人类输入分组的问题在 SDK/CCR/eVal 等单轮 agentic 场景下一个人类轮次可能包含数百次工具调用无法有效分割。新方案按 API Round 分组以assistant.message.id的变化作为边界for(constmsgofmessages){if(msg.typeassistantmsg.message.id!lastAssistantIdcurrent.length0){groups.push(current)// 新 message.id → 新的 API 轮次current[msg]}else{current.push(msg)if(msg.typeassistant){lastAssistantIdmsg.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增加输出——升级 maxOutputTokensModel 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 ErrorReactive Compact 图片剥离1 次最复杂的路径——Prompt Too Long 两步恢复API 返回 413Prompt 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 CompactMax Output Tokens 的渐进式升级模型输出被截断达到 maxOutputTokens 限制 │ ├─ 恢复 1升级 maxOutputTokens 到 64K │ 设置 maxOutputTokensOverride 64_000 │ 发送恢复消息你的输出被截断了请继续 │ continue │ ├─ 恢复 2再次截断 → 再发恢复消息 │ maxOutputTokensRecoveryCount │ continue │ └─ 恢复 3第三次截断 → 终止 已尝试 3 次恢复无法继续错误恢复作为状态转换将错误恢复与 while(true) 状态机结合产生了一个简洁的实现模式if(error.typeprompt_too_long){if(!state.hasAttemptedReactiveCompact){constcompactedawaitreactiveCompact(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参数typeQueryDeps{callModel:typeofcallModel// LLM API 调用microcompact:typeofmicrocompact// 微压缩autocompact:typeofautocompact// 自动压缩uuid:typeofuuid// UUID 生成}// 生产环境使用真实实现functionproductionDeps():QueryDeps{return{callModel,microcompact,autocompact,uuid}}// 测试中可以注入 mockconsttestDeps:PartialQueryDeps{callModel:mockCallModel,uuid:()test-uuid-001}只为 4 个 mock 点引入 InversifyJS/tsyringe 等 DI 框架——装饰器语法、容器配置、运行时类型检查——是典型的过度工程。typeof 的类型同步技巧callModel:typeofcallModel// 类型自动与 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.tsHook 编排StopHookResult yield 消息触发后台任务注意副作用列的差异config.ts 和 deps.ts 是纯函数无副作用tokenBudget.ts 和 stopHooks.ts 有副作用。这种分离不是偶然的——将可以纯化的部分拆出来使其更接近(state, event, config) → (state, output)的 reducer 形态为未来可能的重构提取step()函数使 queryLoop 可被单步测试做准备。tokenBudget.ts预算决策引擎Token 预算是 Claude Code 控制成本的关键机制做出三种决策之一typeTokenBudgetDecision|{action:continue}// 预算充足继续|{action:stop}// 预算耗尽终止|{action:warn_and_continue}// 接近上限继续但发出警告stopHooks.ts用户定义的生命周期拦截三种 Hook 类型StopAgent 完成当前任务时触发TaskCompleted任务明确完成时触发TeammateIdle多 Agent 协作中队友空闲时触发每种 Hook 都支持两种控制语义blocking errorHook 返回错误阻止 Agent 停止要求处理错误preventContinuation阻止 Agent 自动继续强制等待用户输入这个设计让用户可以实现诸如Agent 完成修改后自动运行测试测试失败则要求 Agent 修复的工作流详见第9章。六、横向对比Claude Code vs LangChain/LangGraph维度Claude CodeLangChain/LangGraph循环模式while(true) 隐式状态机显式 Graph节点边工具执行流式批量双模式通常批量执行上下文管理四层压缩管线通常依赖外部 Memory权限控制内建分层权限模型无内建权限错误恢复分级恢复策略每种错误独立路径通常简单重试状态更新函数式{...state, ...updates}Graph 节点间传递状态LangGraph 用显式有向图定义状态转换可视化和理解上有优势。但 Claude Code 的转换路径以线性为主不需要图结构的表达能力。Claude Code vs OpenAI Assistants API维度Claude CodeOpenAI Assistants API运行位置客户端本地服务端状态管理客户端内存本地持久化服务端 Thread工具执行本地执行Function Calling → 客户端执行 → 提交结果上下文压缩客户端主动管理四层管线服务端透明处理取消机制AsyncGenerator.return()Cancel Run API两种架构流派——“胖客户端和胖服务端”。Claude Code 将查询循环放在客户端获得完全控制权流式工具执行、本地权限检查、sandbox 隔离但代价是客户端需要自行管理所有状态和压缩。OpenAI Assistants 将状态放在服务端简化了客户端但牺牲了细粒度控制。七、六大可复用设计模式从本章提炼出六个可复用模式模式 1Generator 驱动的查询循环问题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。手动定义QueryDepsPartialQueryDeps就够了。当依赖注入点 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 工具背后的秘密工具系统注册、调度与沙箱安全待发布