# 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...) { ... }   ← 循环搬到这里
  }                               }
}
1
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;
}
1
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 ...
}
1
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
1
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 收口。