# Step 34: 引擎源码对照

一句话导读:Phase 3.5(step30–33)收尾。不写新功能,把我们做出来的引擎和真实源码 claude-code-src/src/QueryEngine.ts(1295 行)摆一起精读——结论是「主干形状抄对了,差距全在生产级关注点」。这一步的价值是把差距列成一张路线图:缺什么,下一阶段就补什么。


# 一、这一步做了什么(What)

对着真实源码逐项比对我们 step30–33 做出的引擎,回答三个问题:

  1. 形状对不对?——一个会话一个引擎、主入口是 async generator、权限走回调、系统提示每轮临时生成,这些主干结构是不是真的和生产源码一致。
  2. 差在哪?——真实 QueryEngineConfig 约 30 个字段,我们只有 8 个;差的 22 个揭示了「生产化」到底关注什么。
  3. 差距指向哪?——每个缺口对应未来某个 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/插件)要做的事
1
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 判断里的斜杠命令收成一个命令注册表框架。