# Step 34: 引擎源码对照
一句话导读:Phase 3.5(step30–33)收尾。不写新功能,把我们做出来的引擎和真实源码
claude-code-src/src/QueryEngine.ts(1295 行)摆一起精读——结论是「主干形状抄对了,差距全在生产级关注点」。这一步的价值是把差距列成一张路线图:缺什么,下一阶段就补什么。
# 一、这一步做了什么(What)
对着真实源码逐项比对我们 step30–33 做出的引擎,回答三个问题:
- 形状对不对?——一个会话一个引擎、主入口是 async generator、权限走回调、系统提示每轮临时生成,这些主干结构是不是真的和生产源码一致。
- 差在哪?——真实
QueryEngineConfig约 30 个字段,我们只有 8 个;差的 22 个揭示了「生产化」到底关注什么。 - 差距指向哪?——每个缺口对应未来某个 step,差距即路线图。
# 二、面试官视角:为什么要做?(Why)
面试题:你照着一个生产级 agent 的源码,用几百行实现了一个「最小内核」。那生产版剩下那一千多行到底在做什么?为什么一个「跑得通」的引擎和一个「能上线」的引擎差了 20 倍代码量?
这是一道**区分「demo 思维」和「工程思维」**的题。核心答案是:核心循环很小,生产化很重。
「AI 对话 + 多轮工具往返」这个主干逻辑,几十行就能对——我们 step30–33 已经证明了。但生产版多出来的一千多行,几乎没有一行是在改这个主干,全是在给它套工程化外壳:
- 可取消:用户 Ctrl-C 或超预算,要能中断正在流式的请求(
abortController); - 可观测:每次权限拒绝要记录上报,调试要能投影完整历史;
- 健壮:模型失败要降级到备用模型,三重预算(轮数/美元/任务)任一超限都要收束;
- 可扩展:MCP 外部工具、子代理、结构化输出、文件缓存……
- 状态管理:全局状态集中存取,而不是散在一堆
let变量里。
面试官想听的不是「我实现了个 agent」,而是「我清楚一个玩具和一个产品之间具体差哪些工程能力,且这些能力不在核心循环里、而在循环周围」。能把这张差距清单说清楚,才说明你真读懂了生产代码。
# 三、原理:我们的引擎 vs 真实 QueryEngine.ts(How)
# 一、形状:主干抄对了
真实源码类注释原文(QueryEngine.ts:177):
One QueryEngine per conversation. Each submitMessage() call starts a new turn within the same conversation. State (messages, file cache, usage…) persists across turns.
逐项对照,主干形状完全一致:
| 维度 | 我们 (step30–33) | 真实 QueryEngine.ts |
|---|---|---|
| 一个会话一个引擎实例 | new QueryEngine(opts) | new QueryEngine(config) |
| 主入口是 async generator | async *run() | async *submitMessage()(L210) |
| 产出事件流 | yield EngineEvent | yield SDKMessage(L406…) |
| 权限是注入回调 | canUseTool | canUseTool: CanUseToolFn(L136) |
| 系统提示每轮临时生成 | getSystemPrompt() | fetchSystemPromptParts() |
| 状态跨轮持久 | 公开可变 messages | private mutableMessages |
generator + 事件流 + canUseTool 回调——都是真实源码的真实做法,不是编的。这验证了 step30–33 的方向没错。
# 二、config:8 个 vs ~30 个
我们的 EngineOptions(8 项):registry / budget / cost / getModel / getSystemPrompt / getThinking / canUseTool / maxRounds。真实 QueryEngineConfig(QueryEngine.ts:130-173,~30 项)多出来的关键项,正好是生产化关注点的清单:
| 真实字段 | 作用 | 我们 | 指向 |
|---|---|---|---|
mcpClients | MCP 外部工具连接 | ✗ | Phase 5 |
agents | 子代理定义 | ✗ | step38 起 |
getAppState/setAppState | 全局状态集中存取 | ✗(散在 index.ts 的 let) | step35 |
userSpecifiedModel + fallbackModel | 模型 + 失败降级 | 只有 getModel,无降级 | 未来 |
maxTurns/maxBudgetUsd/taskBudget | 轮数/美元/任务三重上限 | 只有 token 预算 + maxRounds | 未来 |
abortController | 取消正在进行的请求 | ✗ | Phase 4 |
readFileCache | 文件读取缓存 | ✗ | 未来 |
jsonSchema | 结构化输出约束 | ✗ | 未来 |
thinkingConfig | 思考档位(adaptive…) | thinking: boolean(更糙) | — |
snipReplay / snipCompact | 历史截断省内存、UI 仍投影完整 | ✗ | step44 |
handleElicitation | MCP 工具触发的交互 | ✗ | Phase 5 |
# 三、几个值得细看的差距
- 思考:我们
boolean;真实(QueryEngine.ts:278)默认{ type: 'adaptive' }——模型按问题难度自适应决定想多久,而非一刀切开/关。 - 压缩:我们
compact.ts~95 行字符串拼接;真实services/compact/15 个文件,用compact_boundary系统消息标记分界(QueryEngine.ts:597),还有snipCompact在长会话里截断内存但 UI 仍能投影完整历史。我们 step29 踩的「孤儿 tool_result」坑,真实版用边界消息从结构上根除——事后退点是补救,边界消息是预防。 - 取消:真实全程带
abortController(L203),Ctrl-C 或超预算能中断正在流式的请求;我们的for await跑起来只能等它自然结束。 - 权限:真实用
wrappedCanUseTool包一层(L244-270),每次拒绝 push 进permissionDenials供 SDK 上报;我们只用回调返回值,不记录。 - 状态:我们用一堆
let散在index.ts;真实用getAppState/setAppState(L137)集中管理。
# 四、还有一层:ask() 包着 QueryEngine
真实源码 QueryEngine.ts:1186 顶层还有个 export async function* ask({...}):它创建 QueryEngine、注入依赖、处理特性门控,再转发 submitMessage 的事件。相当于我们 index.ts 里「创建 engine + for await 渲染」那段——真实版把这层编排也独立成了可复用函数。
# 结论数据流
形状(generator + 事件流 + canUseTool 回调)→ 抄对了 ✓
差距 → 全在「生产级关注点」:
取消 / 全局状态 / 三重预算 / 结构化压缩 /
MCP / 子代理 / 模型降级 / 拒绝上报 / 生命周期 hooks
这些不是「引擎逻辑」,而是「围绕引擎的工程化外壳」
→ 正好是 Phase 4(命令/hooks/子代理/记忆)和 Phase 5(MCP/插件)要做的事
2
3
4
5
6
# 四、深入追问(面试常见 follow-up)
Q:为什么核心循环几十行就够,生产版却要一千多行?多出来的行数「重」在哪? A:因为核心循环解决的是**「happy path」——一切顺利时的对话+工具往返。生产版的重量全在处理「不顺利」和「规模化」**:请求被中断怎么办、模型挂了怎么降级、上下文爆了怎么压缩还不丢信息、外部工具(MCP)怎么接、子任务怎么隔离、权限拒绝怎么审计。这些都不改主干逻辑,但每一个都是上线的硬门槛。一个系统的成熟度,往往体现在它处理边界情况的代码占比。
Q:我们 step29 用「事后退点」修孤儿 tool_result,真实版用「compact_boundary 边界消息」,两种思路的本质区别是什么? A:退点是补救——先按位置切,切坏了再往回退找合法点;边界消息是预防——压缩时就显式写一条系统消息标记分界,结构上根本不产生孤儿块。前者是「在错误可能发生后纠正」,后者是「让错误无法发生」。生产级设计倾向后者:把不变量编码进数据结构,而不是靠运行时逻辑事后兜。这也是为什么真实压缩要 15 个文件——它把「压缩」当成一个有边界、有投影、可截断的完整子系统在做。
Q:真实版有 abortController 能中断流式请求,我们没有。为什么「可取消」在生产里这么重要,做起来又难在哪?
A:重要是因为——用户按 Ctrl-C 时如果请求还在跑、token 还在烧,体验和成本都不可接受;超预算了却停不下来更是事故。难在于「取消」要穿透整条异步链:正在 await 的 API 流、正在跑的工具、正在 for await 的消费方,都得响应同一个取消信号。abortController 就是那根贯穿始终的信号线。我们的 for await 一旦启动只能等它自然结束,正是缺了这根线。
Q:getAppState/setAppState 的集中状态管理,比我们「一堆 let 变量散在 index.ts」好在哪?
A:好在单一数据源 + 可追踪。权限模式、当前模型、待办、思考档位……这些状态会被命令改、被引擎读、被 UI 显示。散成 let 时,谁改了谁、改的时机对不对,全靠人肉追踪,多个消费者读到不一致状态是迟早的事。集中到 store 后,读写都过一个入口,能加日志、能做门控、能保证一致性——这也是 step35 命令框架要顺带规整的方向。
# 五、踩坑 / 设计权衡
- 对照的意义不是自卑,是定位:主干抄对了说明学习方向没错;差距全在外壳说明「下一步该往哪走」是清晰的。对照让学习不悬空——每个差距都指回真实源码的具体行号和未来某个 step。
- 不要一开始就追生产级完备:如果 step30 就想把 30 个 config 字段全实现,会被工程化细节淹没、看不清核心循环本身。先做对 8 个字段的最小内核,再逐个补外壳——这个「先内核后外壳」的顺序本身就是正确的学习/工程路径。
- 差距即路线图:这张表最大的价值是它同时是 Phase 4/5 的 backlog。缺
abortController→ Phase 4 补取消;缺agents→ step38 做子代理;缺mcpClients→ Phase 5 接 MCP。缺什么,下一阶段就补什么。
# 六、与真实源码的对照
| 维度 | 我们 (step30–33) | 真实 QueryEngine.ts(1295 行) |
|---|---|---|
| 主干形状 | generator + 事件流 + canUseTool 回调 | 完全一致(抄对了 ✓) |
| config 规模 | EngineOptions 8 项 | QueryEngineConfig ~30 项 |
| 思考 | boolean | { type: 'adaptive' } 自适应档位 |
| 压缩 | compact.ts ~95 行退点补救 | services/compact/ 15 文件 + compact_boundary 预防 |
| 取消 | ✗(for await 停不下来) | abortController 全程可中断 |
| 状态 | 一堆 let 散在 index.ts | getAppState/setAppState 集中管理 |
| 权限 | 只用回调返回值 | wrappedCanUseTool + permissionDenials 上报 |
| 编排层 | index.ts 里那段 | 独立成顶层 ask() 生成器 |
# 七、一句话总结
核心循环很小,生产化很重:我们 step30–33 的引擎在「一个会话一个实例、generator 吐事件流、权限走回调」这些主干形状上和真实 QueryEngine.ts 完全一致,差距全在围绕引擎的工程化外壳——取消、集中状态、三重预算、结构化压缩、MCP、子代理、模型降级、拒绝上报。这些不是引擎逻辑,而是「能上线」和「跑得通」之间的距离,也正是 Phase 4/5 的路线图。
# 下一节预告
Phase 4 开始第二次质变(工具调用 → 自主 Agent)。第一步 step35:把散在 if 判断里的斜杠命令收成一个命令注册表框架。