# Step 30: 从脚本到引擎
一句话导读:到 step29 为止,「AI 对话 + 工具调度」的多轮循环一直摊在
index.ts里,和 readline、console.log、权限询问搅在一起。step30 是路线上的第一次质变——不加任何新功能,只把这段循环抽成一个 UI 无关、可复用、可实例化的QueryEngine类。对应真实源码src/QueryEngine.ts(~1296 行,这里是它最小可运行的内核)。
# 一、这一步做了什么(What)
index.ts 删掉约 35 行内联的 for(round) 循环,换成一行 await engine.run(input)。循环搬进新文件 QueryEngine.ts:
step29 index.ts step30
───────────── ──────
while(input) { const engine = new QueryEngine({...})
...slash 命令... while(input) {
for (round<10) { ...slash 命令...(不变)
callWithTools() await engine.run(input) ← 一行
处理 tool_use }
权限询问
并行执行工具 QueryEngine.run(input) {
拼 tool_result for (round...) { ... } ← 循环搬到这里
} }
}
2
3
4
5
6
7
8
9
10
11
12
关键判据:行为必须和 step29 一模一样。这是「正确重构」的定义——只搬家,不改功能。
# 二、面试官视角:为什么要做?(Why)
面试题:功能没变,只是把一段循环搬进一个 class,这种「纯重构」的价值到底在哪?值得单独拉一个 step 吗?
值得,因为它把一段和终端死死耦合的逻辑,变成了一个可复用的内核。耦合的代价在 step29 那种「脚本」形态下看不见,但一旦你想做下面任何一件事就会撞墙:
- 换 UI(终端 → Ink → 网页):循环里到处
console.log,UI 一换全得重抄; - 写单元测试:想测「多轮工具往返」,却绕不开 readline 和真实终端;
- 开子代理(step38):想「再跑一个独立 agent」,但逻辑焊死在
main()里,new不出第二个。
抽成 QueryEngine 类,本质是划出一条**「引擎逻辑」和「I/O」的边界**:引擎只管「对话 + 调度」,怎么显示、从哪读输入、要不要写日志,全推给外部。这条边界的复利要到 step38 才彻底兑现——那时「开一个子代理」只需 new QueryEngine(...) 一行。step30 埋的是地基,不是装修。
# 三、原理:它是怎么工作的(How)
# 机制一:引擎绝不碰 I/O,靠依赖注入拿能力
引擎不 import 任何终端相关的东西——没有 readline,没有 console.log。它需要的外部能力全靠构造时注入:
export interface EngineOptions {
registry: ToolRegistry;
budget: TokenBudget;
cost: CostTracker;
getModel: () => string; // 当前模型 id(可变)
getSystemPrompt: () => string; // 每轮临时生成系统提示词(可变)
canUseTool: (name, input) => Promise<boolean>; // 权限决策,交给外部
hooks?: EngineHooks; // 事件回调,渲染交给外部
maxRounds?: number;
toolTimeout?: number;
}
2
3
4
5
6
7
8
9
10
11
注意 getSystemPrompt / getModel 是函数而不是值。这是个易错点:如果注入的是 model: string,那么用户运行时 /model 换了模型,引擎手里还攥着旧值。传函数 getModel(),引擎每轮临时去取,才能拿到 /style、/model 改后的最新状态。用函数注入可变状态,是「让引擎跟上运行时变化」的标准手法。
# 机制二:引擎只广播,不渲染(事件钩子)
引擎在循环里通过一组可选回调向外「广播」发生了什么,怎么显示由外部定:
export interface EngineHooks {
onStreamStart?: () => void; // 一轮回复开始 → 打印 "AI:"
onText?: (chunk: string) => void; // 流式文本片段 → 逐字写终端
onToolCalls?: (names) => void; // AI 决定调哪些工具
onToolResult?: (r) => void; // 单个工具执行完
onRound?: (r) => void; // 最终文本 → token/花费统计
onDebug?: (e) => void; // 完整请求/响应/工具明细 → 落盘
// onDenied / onBudgetWarn / onBudgetExceeded / onError ...
}
2
3
4
5
6
7
8
9
index.ts 里 onText 逐字打到终端,onDebug 把会刷屏的完整请求/响应写进 log/debug.json。这正好印证设计意图:终端只留干净的中文摘要,冗长内容通过 onDebug 落盘,需要时再看。引擎完全不知道「终端」这回事的存在。
# 数据流
index.ts: new QueryEngine({ getModel, getSystemPrompt, canUseTool, hooks })
↓ 用户输入
engine.run(input):
for (round < maxRounds):
callWithTools(getSystemPrompt(), getModel()) ← 每轮临时取最新
└ 流式文本 → hooks.onText(chunk)
有 tool_use ?
→ hooks.onToolCalls(names)
→ await canUseTool(name, input) ← 权限决策外包给注入的回调
→ 并行执行 → hooks.onToolResult(...)
→ 拼 tool_result,进下一轮
无 tool_use → hooks.onRound(stats),return
2
3
4
5
6
7
8
9
10
11
12
# 四、深入追问(面试常见 follow-up)
Q:为什么 getSystemPrompt / getModel 要传函数,而 registry / budget 直接传对象就行?
A:看这个依赖「值会不会在 run 之间变、且引擎要拿到最新值」。model / systemPrompt 会被 /model、/style 命令随时改,所以必须包成函数、每轮现取。而 registry / budget 是同一个可变对象的引用——引擎读它时天然拿到的就是最新内部状态,不需要再包一层函数。规则:可变的「值」传取值函数,可变的「对象」传引用。
Q:权限判断 canUseTool 为什么不写死在引擎里,而要注入?
A:因为「允不允许调这个工具」是策略,不是机制。终端版要弹 y/n 询问、SDK 版可能自动放行、测试版可能全拒——策略随宿主变。引擎只负责「在执行前 await 一下这个回调」的机制,把决策权完全交给外部。这样换宿主时引擎一行不改。
Q:这一版用「一组回调」暴露事件,有什么弱点?后面为什么要改?
A:回调是「控制反转」——渲染逻辑被拆散在十几个 onXxx 里,读起来跳来跳去;而且没有统一的「事件」概念,难记录、难重放、难测试断言。step32 会把这堆回调升级成一条 async-generator 事件流(真实源码的做法),让调用方在一个 for await + switch 里夺回控制权。这一版胜在「和 step29 的差异看得最清」,是过渡形态。
Q:怎么验证这次重构「行为完全没变」? A:跑同样的输入(「列出目录 / 读 package.json」),逐字对比终端输出应和 step29 完全一致。重构的判据就是外部可观测行为不变——只要有一处输出对不上,就说明搬家时改了逻辑,不是纯重构。
# 五、踩坑 / 设计权衡
- 重构 ≠ 改功能:最容易犯的错是「反正在动这块代码,顺手优化一下逻辑」。纯重构阶段任何行为改动都会污染「是否等价」的判断,出了 bug 都分不清是搬家搬错了还是新逻辑错了。想改功能,另开一个 step。
messages公开可变:引擎的messages: any[]是public的,因为 REPL 要直接操作它做/clear/save/load/compact。这是刻意的——引擎持有状态,但不垄断状态的操作权。(any[]这个类型欠债由 step31 的 ContentBlock 还上。)
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
QueryEngine 类 | src/QueryEngine.ts(~1296 行) |
run() 方法(本版返回 Promise,用 hooks 广播) | 真实版是 async *submitMessage() 生成器,yield 事件流 |
canUseTool 注入回调 | 真实版的 canUseTool: CanUseToolFn |
getSystemPrompt() 每轮临时生成 | 真实版 fetchSystemPromptParts() |
hooks 一组回调 | 真实版直接是事件流 + 中间件(我们 step32 补上) |
# 七、一句话总结
把对话循环从脚本抽成可实例化的 QueryEngine 类:引擎绝不碰 I/O,外部能力全靠依赖注入(可变的 model/prompt 用取值函数注入才跟得上运行时改动),内部事件靠一组回调广播出去让外部渲染。功能一行没变,但从此引擎可换 UI、可测试、可被 new 出多个——这是子代理(step38)的地基。
# 下一节预告
这一版用「一组回调函数」暴露事件,胜在和 step29 差异看得清。但消息历史还是 any[],类型知识散落各处——step31 先把 ContentBlock 收口。