Skip to content

8.1 最小 CLI 与 Fake Model

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:一个 Agent Harness(Agent 运行框架)的最外层究竟长什么样?把「读一行输入 → 产生回复 → 打印」这二十行写对需要注意什么?以及——在还没有任何模型的第一天,为什么就要把「调用模型」定义成一个函数类型? 前置知识8.0 Mini Harness 总览与准备2.3 union、字面量与可辨识联合2.5 异步编程:Promise 与 async/await2.8 Node.js 文件、路径与进程 API3.2 消息、上下文与 token学习目标:读完本章你能——

  1. node:readline/promises 写出一个不会卡死的交互式输入循环,并说清那种卡死是怎么产生的;
  2. 解释每个步骤为什么都配一个非交互的 demo 入口,以及它与 expected-output.txt 的关系;
  3. 写出 TextContent / ContentBlock / Message / Context 四个类型,并说出它们的包含关系;
  4. 说清 FakeModel 的剧本匹配规则,包括「规则顺序是语义的一部分」这件事;
  5. 论证「模型调用是一个函数类型」这条缝为什么值得在第一天就留出来,并在 Pi 源码里找到它的对应物。

本章对应两个实验目录:labs/mini-agent-harness/step01-hello-clilabs/mini-agent-harness/step02-fake-model。两者都是独立可运行的项目,零运行时依赖(只有 tsxtypescript 两个开发依赖),不需要任何 API Key。本章引用的 demo 输出全部来自这两个目录里的 expected-output.txt(写作时本书作者重新跑了一遍两个 npm run demo,与仓库中的文件逐字一致);其余标注「本书作者实测」的输出,是按正文所述改动代码后真实运行所得。

建立直觉:最外层永远是一个 while 循环

前面七个部分讲了很多机制:流式事件、Agent Loop(Agent 循环)、工具调用(Tool Calling)、会话(Session)、扩展(Extension)。但把 Pi 的 packages/coding-agent 一层层剥到最外面,剩下的东西朴素得出奇:

text
准备好输入输出
while (还想聊) {
  line = 读一行
  if (line 是退出指令) break
  reply = 产生回复
  打印 reply
}
收尾

step01 要做的就是把这段伪代码变成真代码,并且一个字的「智能」都不加。这样做的目的不是省事,而是先把「壳」和「芯」分开:后面七步全部是在替换「产生回复」这一行——step02 换成按剧本回复的假模型,step03 换成流式模型,step04 换成完整的 Agent Loop,step08 换成真实的 Provider(模型服务提供方)。壳本身几乎不再变。

🌱 初学者提示为什么这个壳值得单独做一步
因为壳里藏着两个和「智能」无关、但一定会咬人的问题:**输入结束时怎么优雅收场**,以及**交互式程序如何自动验证**。这两件事在 step01 里各花十行解决掉,后面七步就再也不用管了。真实项目里它们同样是独立的关注点:Pi 把「怎么读输入」放在 `packages/coding-agent` 与 `packages/tui`,把「怎么思考」放在 `packages/agent`。

step01:一个只会回声的命令行循环

先跑起来

labs/mini-agent-harness/step01-hello-cli 目录下:

bash
npm install     # 只安装 tsx 与 typescript
npm run demo    # 非交互演示
npm start       # 交互模式,/exit 退出

npm run demo 的完整输出(与该目录 expected-output.txt 逐字一致):

text
Mini Agent Harness · step01 hello-cli(demo)

你> 你好
助手> 你说的是:你好

你> 什么是 Agent Harness
助手> 你说的是:什么是 Agent Harness

你> /exit
再见。

三个文件、不到 60 行代码就产生了这段输出。下面逐个看。

src/main.ts:交互入口

开头三行 import 加一行初始化:

ts
import * as readline from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { respond } from "./respond.ts";

const rl = readline.createInterface({ input: stdin, output: stdout });

三处值得注意:

  • node:readline/promises,不是 node:readline 后者是回调风格(rl.question(prompt, callback)),前者返回 Promise,可以直接 await。本书第 2.5 章讲过这两种风格的区别;Node 的很多内置模块都同时提供两套,选 promises 版能让循环写成同步的样子。
  • stdin / stdout 是显式传进去的,不是 readline 自己找的。这让「输入从哪来」变成一个参数,而参数是可以替换的——这一点在 step08 之前用不上,但它体现了同一种思路:把外部世界做成入参。
  • import 路径带 .ts 后缀。 本项目是 ES Module(package.json"type": "module"),相对导入必须写全后缀,tsconfig.json 里因此开了 allowImportingTsExtensions

接下来是本文件里唯一一段「不直观」的代码:

ts
const inputClosed = new Promise<undefined>((resolve) => rl.once("close", () => resolve(undefined)));
function ask(prompt: string): Promise<string | undefined> {
  // close 之后再调用 question() 会 reject,这条路径也归一成 undefined。
  return Promise.race([rl.question(prompt).catch(() => undefined), inputClosed]);
}

为什么不直接 await rl.question("你> ")?因为已经挂起的 question() 在 readline 触发 close 之后既不 resolve 也不 reject。用户按 Ctrl+D、或者输入是用管道重定向进来并且已经读完时,就会走到这条路径:程序不报错、不退出,静静地卡在那一行。

ask() 的做法是让两个 Promise 赛跑:一个是正常的读行,一个是 close 事件。谁先来算谁的,close 先到就返回 undefined。三个细节:

  • rl.once("close", ...) 而不是 rl.on(...),并且 inputClosed 定义在循环外面——监听器只注册一次,不会随着循环次数堆积。
  • rl.question(...).catch(() => undefined) 处理的是另一条路径:close 之后再调用 question() 会 reject,这里把它也归一成 undefined。于是 ask() 的返回值只有两种含义:字符串(用户说了话)或 undefined(不说了)。
  • Promise.race 不会取消输的那一方:已经挂起的 question() 仍然挂着,只是没人再等它。这不影响退出——本书作者实测 printf '你好\n' | npx tsx src/main.ts 会正常打印「再见。」并以退出码 0 结束。(分析:此时 readline 已关闭、stdin 已到结尾,事件循环里不再有活跃句柄,那个挂起的 Promise 只是随进程一起消失。)
⚠️ 常见误解以为「await 一个永远不 settle 的 Promise」会报错
不会。异常至少还有堆栈,而一个永不 settle 的 `await` 什么都不产生:程序既不报错也不退出,看上去像死循环但 CPU 占用是 0。这是异步代码里最难查的一类故障。

Node 对顶层 await 这一特例给了提示。本书作者把 step01 的 await ask("你> ") 改成 await rl.question("你> "),然后用管道喂一行输入,真实输出是:

text
你> 助手> 你说的是:你好

你> Warning: Detected unsettled top-level await at /path/to/step01-hello-cli/src/main.ts:32
  const line = await rl.question("你> ");
               ^

进程退出码为 13。注意这只是一条 Warning,而且是 Node 针对顶层 await 这一特例给出的诊断(提示文本里就写着 top-level await)。据此推断(尚未逐一验证):同样的挂起发生在一个普通 async 函数内部时,不会有这行提示,只会安静地卡住。本章末尾的第一个实践任务就是亲手复现它。

最后是循环本体:

ts
while (true) {
  const line = await ask("你> ");
  // undefined 表示输入结束(Ctrl+D);/exit 是用户主动退出。两者都收尾走人。
  if (line === undefined || line.trim() === "/exit") {
    break;
  }
  console.log(`助手> ${respond(line)}\n`);
}

rl.close();
console.log("再见。");

while (true)break 是这里最合适的写法:循环的退出条件有两个(输入结束、用户主动退出),而且它们都要在读完一行之后才知道。循环体里没有任何判断输入内容的逻辑——除了 /exit,任何一行都原样交给 respond()这条边界(壳只认识退出指令,其余一律转交)会一直保持到 step08。

模板字符串末尾那个 \n 加上 console.log 自带的换行,就是输出里每轮之间那个空行的来源。

src/respond.ts:预留出来的插槽

ts
export function respond(input: string): string {
  return `你说的是:${input}`;
}

一个函数,一行实现。把它单独放一个文件,是为了让「产生回复」有一个明确的、可替换的位置。从项目结构看,Pi 的分层是同一个思路的放大版:命令行界面所在的 packages/coding-agent 不直接「思考」,它把输入交给 packages/agent

值得先记住它现在的签名:(input: string) => string。step02 会把它换成 (context: Context) => Promise<AssistantMessage>,本章后半段会专门讨论这次签名变化为什么必须发生。

src/demo.ts:让交互式程序变得可验证

交互式 CLI 有一个天然的麻烦:它要等你敲键盘,没法自动跑。所以每个步骤都额外提供一个 demo 入口,用固定脚本代替人工输入:

ts
import { respond } from "./respond.ts";

const script = ["你好", "什么是 Agent Harness", "/exit"];

console.log("Mini Agent Harness · step01 hello-cli(demo)\n");

for (const line of script) {
  console.log(`你> ${line}`);
  if (line.trim() === "/exit") {
    console.log("再见。");
    break;
  }
  console.log(`助手> ${respond(line)}\n`);
}

它把 while + ask() 换成了 for...of + 数组,其余照抄。请注意一个诚实的说明:在 step01 里,demo 与交互入口共享的只有 respond(),循环体是各写各的一份。这是因为 step01 的「核心」实在太小。从 step02 起共享的部分会迅速变大(消息类型、规则表、模型、渲染、Agent Loop、会话),而不共享的恰恰只剩下「输入从哪来」这一件事——那正是无法自动验证的部分。

demo 的输出被原样保存在 expected-output.txt 里。本项目的约定是:这个文件永远是真实跑出来的,不手写。你改了代码导致输出变化,就重新跑一遍把它更新掉,而不是反过来去凑文件。

package.json 与 tsconfig.json:两处关键设置

json
{
  "type": "module",
  "scripts": {
    "start": "tsx src/main.ts",
    "demo": "tsx src/demo.ts"
  },
  "devDependencies": {
    "tsx": "4.22.1",
    "typescript": "5.9.3"
  }
}

tsx 是 TypeScript 运行器:在内存里即时转译再交给 Node 执行,不产出 .js 文件。与之配套的是 tsconfig 里的两个选项:

json
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true
  },
  "include": ["src"]
}

allowImportingTsExtensions 允许 import "./respond.ts" 这种带后缀的写法,noEmit 声明「本项目不编译产出,只做类型检查」。八个步骤的 tsconfig 完全相同,strict: true 从第一天就开着——后面的可辨识联合收窄、类型谓词、AbortSignal 传递,全都依赖严格模式才有意义。

图解:step01 的骨架

图加载中…

图 8.1-1 step01 的循环骨架与两条退出路径
阅读顺序:从上往下,虚线是一条旁路。请重点看两处。其一,菱形里是**两个**退出条件而不是一个:`/exit` 是用户说的,`undefined` 是输入源说的,后者正是 `ask()` 里那场 Promise 赛跑的产物;去掉赛跑,这条虚线就断了,程序会停在 `B` 再也不动。其二,`E` 这个方框是整个项目唯一会被反复替换的节点——step02 换成 FakeModel,step04 换成 Agent Loop,其余方框到 step08 基本原样保留。

step02:消息类型与假模型

step01 的回复是一次字符串拼接,说完就丢。step02 补上两样东西,它们会一直用到最后一步:消息类型(对话被累积在上下文里)和 FakeModel(按剧本回复的假模型)。

先看结果。npm run demo 的完整输出(与该目录 expected-output.txt 逐字一致):

text
Mini Agent Harness · step02 fake-model(demo)

你> 你好
助手> 你好!我是 FakeModel,一个按剧本回复的假模型。

你> 什么是 Agent Harness
助手> Agent Harness 是驱动 Agent 运行的框架:管理消息、调用模型、执行工具。

你> 随便说点什么
助手> 这句话没有命中任何规则,我只能用兜底回复了。

你> /exit
再见。(本次对话共 6 条消息)

最后一行是本步骤的全部要害:3 轮对话 = 3 条用户消息 + 3 条助手消息,全都还留在 context.messages 里。step01 做不到这一点。

src/types.ts:把第 3.2 章的概念写成类型

3.2 消息、上下文与 token 讲过三件事:对话是一个数组、一条消息是一串内容块、上下文是一次请求携带的全部输入。types.ts 把它们逐条翻译成 TypeScript:

ts
/** 一段纯文本内容块。 */
export interface TextContent {
  type: "text";
  text: string;
}

/** 消息内容块。step02 只有 text;后续步骤会扩展这个联合。 */
export type ContentBlock = TextContent;

/** 用户发出的消息。 */
export interface UserMessage {
  role: "user";
  content: TextContent[];
}

/** 模型(助手)发出的消息。 */
export interface AssistantMessage {
  role: "assistant";
  content: ContentBlock[];
}

/** 对话中的一条消息:以 role 为判别字段的可辨识联合。 */
export type Message = UserMessage | AssistantMessage;

四个设计点,每一个都在为后面的步骤让路:

  1. 内容是数组,不是字符串。 现在数组里只可能放一种东西,看起来是多余的包装。但 step04 会往 ContentBlock 里加 toolCall,那时一条助手消息就可能是「一段文本 + 一个 Tool Call(一次工具调用请求)」,甚至多个,顺序还有意义。用数组是为了以后不改类型形状。
  2. UserMessage.content 写的是 TextContent[]AssistantMessage.content 写的是 ContentBlock[] 此刻这两者完全等价(因为 ContentBlock = TextContent),但它们表达的意图不同:用户消息永远只能装文本块,助手消息装的是「当前允许的所有块类型」。step04 给 ContentBlock 加上 toolCall 之后,这行区别会自动生效——用户消息不会因此获得发起工具调用的能力。这是类型别名的一个用法:给「将来会变的那一组」起个名字,给「不变的那一个」直接写具体类型。
  3. 判别字段是 role 拿到一条 Messageswitch (message.role),TypeScript 就能收窄出具体形状。这是第 2.3 章可辨识联合的直接应用,也是本项目里出现次数最多的模式:ContentBlocktype 判别,step03 的 StreamEventtype 判别,step04 的循环事件同样如此。
  4. Message 现在只有两种 role。 step04 会加入第三种 toolResult

上下文与两个便捷函数:

ts
export interface Context {
  systemPrompt?: string;
  messages: Message[];
}

/** 便捷构造:一条纯文本用户消息。 */
export function userText(text: string): UserMessage {
  return { role: "user", content: [{ type: "text", text }] };
}

/** 取出一条消息里所有文本块拼接后的文本。 */
export function messageText(message: Message): string {
  return message.content
    .filter((block): block is TextContent => block.type === "text")
    .map((block) => block.text)
    .join("");
}

Context 只有两个字段,tools 要等 step05 才加。systemPrompt 是可选的,本步骤没人给它赋值——它先占住位置,因为「一次模型调用的输入」在概念上就该包含它。

🌱 初学者提示filter 里那个 block is TextContent 是什么
它叫**类型谓词(Type Predicate)**。`Array.prototype.filter` 的默认签名不改变元素类型:过滤 `ContentBlock[]` 得到的还是 `ContentBlock[]`,于是 `.map((block) => block.text)` 会报错,因为不是每种 `ContentBlock` 都有 `text` 字段(现在有,step04 之后就没有了)。

写成 (block): block is TextContent => block.type === "text" 之后,TypeScript 认可这个函数是一个「类型判定」,filter 的返回类型随之变成 TextContent[]。这就是第 2.4 章「证据五:自定义 type guard」讲的 x is T 语法,写在箭头函数的返回类型位置上而已。代价也一样:正确性由你负责——函数体里返回 true 但类型对不上,编译器照样相信你。

src/fake-model.ts:剧本化的假模型

📘 概念剧本化假模型(Fake / Faux Model)
一个实现了「模型调用」签名、但不联网的对象:给定输入,按预先写好的规则或队列返回固定回复。

为什么需要它:教学与测试都要求「跑得起来、每次结果一样、不花钱」。真实模型三条都不满足——要 Key、有网络延迟、同一个问题两次回答可能不同。

没有它会怎样:本项目 step01 到 step07 全都无法做成可自动比对的实验;Pi 的 agent 层与 coding-agent 层也将无法在 CI 里跑端到端测试。

Pi 如何使用它packages/ai/src/providers/faux.ts 提供了一个可脚本化的 faux provider,本章「回到 Pi 源码」一节会看它的实现,并对比它与我们这个版本在匹配方式上的差别。

规则的形状与模型调用的签名:

ts
export interface FakeRule {
  /** 命中条件:最后一条消息文本包含该子串。缺省表示兜底规则。 */
  match?: string;
  /** 命中后的回复文本。 */
  reply: string;
}

export type CompleteFn = (context: Context) => Promise<AssistantMessage>;

CompleteFn 是本步骤最重要的一行代码,下一节整节都在讲它。先看实现:

ts
export function createFakeModel(rules: FakeRule[]): CompleteFn {
  return async (context) => {
    const last = context.messages[context.messages.length - 1];
    const input = last ? messageText(last) : "";
    const rule = rules.find((r) => r.match === undefined || input.includes(r.match));
    const reply = rule?.reply ?? "(FakeModel 没有命中任何规则,也没有兜底规则。)";
    return { role: "assistant", content: [{ type: "text", text: reply }] };
  };
}

五行函数体,四个要点:

  • createFakeModel 是工厂函数,接收规则表、返回一个 CompleteFn。规则被闭包捕获,调用方拿到的就是一个「符合模型调用签名的普通函数」,看不出里面是假的。
  • 匹配只看最后一条消息,而且是子串匹配input.includes(r.match),区分大小写)。这是刻意的偷懒:假模型的规则越简单,实验的输出越可预测。
  • r.match === undefined 的规则命中一切,它就是兜底规则。这意味着规则在数组里的顺序是语义的一部分——find() 返回第一条命中的规则。
  • rule?.reply ?? "..." 还留了一层兜底的兜底:规则表里一条兜底规则都没有时,走这句固定文案。
⚠️ 常见误解把兜底规则放在规则数组的最前面
`find()` 返回第一条命中的规则,而没有 `match` 字段的规则对任何输入都命中。把它放在最前面,后面所有规则就都成了死代码。

本书作者把 rules.ts 里的兜底规则挪到数组第一位,其余不变,npm run demo 的真实输出变成:

text
你> 你好
助手> 这句话没有命中任何规则,我只能用兜底回复了。

你> 什么是 Agent Harness
助手> 这句话没有命中任何规则,我只能用兜底回复了。

编译器不会报错——这是一个纯粹的语义错误。真实系统里同类问题很常见:路由表、中间件链、switchdefault,凡是「先匹配先赢」的结构,兜底项都必须在最后。

src/rules.ts:剧本本体

ts
import type { FakeRule } from "./fake-model.ts";

export const rules: FakeRule[] = [
  { match: "你好", reply: "你好!我是 FakeModel,一个按剧本回复的假模型。" },
  {
    match: "Agent Harness",
    reply: "Agent Harness 是驱动 Agent 运行的框架:管理消息、调用模型、执行工具。",
  },
  { reply: "这句话没有命中任何规则,我只能用兜底回复了。" },
];

对照前面的 demo 输出就能逐条对上:「你好」命中第一条;「什么是 Agent Harness」包含子串 Agent Harness,命中第二条;「随便说点什么」两条都不含,落到兜底。

规则表被单独放一个文件,是因为 main.ts(交互)与 demo.ts(非交互)要用同一份剧本。这是「demo 跑通即可证明交互模式核心逻辑正确」这一约定的具体落实:两个入口共享的代码越多,demo 的证明力越强。

src/main.ts 与 src/demo.ts:追加 → 调用 → 追加

main.ts 的 readline 与 ask() 与 step01 逐字相同,变化集中在循环体:

ts
const model = createFakeModel(rules);
const context: Context = { messages: [] };

// …(省略:与 step01 逐字相同的 readline 与 ask 定义)

while (true) {
  const line = await ask("你> ");
  if (line === undefined || line.trim() === "/exit") {
    break;
  }
  context.messages.push(userText(line));
  const reply = await model(context);
  context.messages.push(reply);
  console.log(`助手> ${messageText(reply)}\n`);
}

rl.close();
console.log(`再见。(本次对话共 ${context.messages.length} 条消息)`);

三步:把用户输入追加进 context.messages → 带着整个 context 调用模型 → 把回复追加回去。这三步就是 Agent Loop 的雏形,step04 会在它外面再套一层循环(因为模型可能要求先执行工具,然后再问一次)。

contextmodel 都在模块顶层创建,生命周期等于进程——这也是 step06 之前「退出即失忆」的原因。

demo.ts 的差别依旧只有输入来源:脚本数组多了一句「随便说点什么」用来触发兜底规则,其余三步完全一样。

图解:一轮对话在 step02 里的数据流

图加载中…

图 8.1-2 step02 的一轮对话:追加、调用、追加
阅读顺序:从上到下。请重点看 FakeModel 这一列上的两条线。第三条消息是**签名**:`main.ts` 交出去的是整个 `context`,不是刚输入的那一行文本。第四条消息是**实现**:FakeModel 转头只读了最后一条消息。二者的不一致是刻意的——签名是对外的契约,必须和真实模型一致;实现可以偷懒,因为它是假的。最下面那条注记解释了 demo 结尾为什么是「6 条消息」:三轮,每轮加二。

为什么第一天就要留出「模型调用」这条缝

回到 CompleteFn 这一行:

ts
export type CompleteFn = (context: Context) => Promise<AssistantMessage>;

它做了三个当下看起来「多余」的决定,逐个说。

其一:参数是 Context 而不是 string FakeModel 只用得上最后一条消息,写成 (input: string) => ... 明显更省事。但真实模型没有记忆,每次调用都必须收下完整的对话历史(这是第 3.2 章的核心结论)。如果第一天把签名定成 string,step08 换真实 Provider 时就得改签名,而改签名意味着所有调用点都要改——那时调用点已经在 Agent Loop、会话恢复、扩展钩子里散布好几处了。签名是契约,内部实现可以偷懒;反过来则不行。

其二:返回值是 Promise,尽管这个实现里没有任何 await 真实调用一定是异步的,先把异步性写进类型,调用方从第一天起就习惯 await model(context)

其三:它是一个类型别名,不是一个 class 或 interface。 「模型」在这个项目里不是一个对象,而是一个函数。要换实现,只需要提供另一个同签名的函数。

这三点合起来就是一条:上层只依赖签名,不依赖谁在背后干活。step03 会把这条缝上的类型从 CompleteFn 换成 StreamFn——签名从「还你一条完整消息」变成「还你一串事件」:

ts
// step03/src/types.ts 中的新抽象(本章只作预告,8.2 详讲)
export type StreamFn = (context: Context, options?: StreamOptions) => AsyncIterable<StreamEvent>;

这次替换的代价可以量化:step02 到 step03,types.ts 新增 StopReason / StreamEvent / StreamOptions / StreamFn 四个类型(AssistantMessage 也多了一个 stopReason 字段),fake-model.ts 的函数体改写成异步生成器(async function*),另外新增一个负责打字机渲染的 render.ts;而调用方的改动只有模型调用点那几行——const reply = await model(context) 变成 const message = await renderStream(model(context))。循环骨架、消息累积、规则表结构都没动。

而这条缝的回报到 step08 才完全兑现。本书作者逐文件比对过 step07 与 step08 的 src/agent-loop.tssession.tstypes.tsrender.tsextensions.ts 五个文件加上整个 tools/ 目录字节完全相同,唯一有实质改动的是 main.ts,而那处改动是把

ts
const streamFn = createFakeModel(rules, { delayMs: 40 });

换成

ts
const { choice, streamFn } = createModel(env.ANTHROPIC_API_KEY);

外加一个新增的 src/providers/ 目录。也就是说:把假模型换成真模型,Agent Loop、工具、会话、取消、扩展一行都没改。 一个抽象值不值,判定标准就是这个——换实现时上层要改多少代码。

⚠️ 常见误解以为「先写死、以后再抽象」总是更省事
「以后再抽象」在很多场合是对的:抽象早了会猜错形状。但这一条缝不同,它有一个明确的信号——**你已经知道将来一定会有第二种实现**。本项目从第一天就确定了「先用假模型,最后接真模型」,Pi 从一开始就要支持几十个 Provider。这种情况下推迟抽象,省下的是几行类型,欠下的是一次跨越多个文件的签名迁移。

一种看法是:值得在第一天引入的抽象,是那些「第二种实现已经在路线图上」的抽象;代价是你必须真的走到那一步,否则它就只是多出来的一层。

图加载中…

图 8.1-3 同一条缝上的三种实现,以及它在 Pi 里的对应物
阅读顺序:从左到右。左边的方框会随着步骤推进不断变厚(step04 加 Agent Loop、step06 加会话、step07 加扩展),但它始终只连到中间那一个节点。右边三个实现互不相识,可以随时替换其中之一。虚线指向的是本章下一节要看的真实源码:Pi 在同一个位置放了一个叫 `StreamFn` 的类型,并且用注释把契约写死了。

回到 Pi 源码

我们这两步里的每一个概念,在 Pi 里都能找到对应物。下面按「壳 → 类型 → 假模型 → 缝」的顺序对照。

壳:Pi 也用 node:readline,但只用在一次性提问上

Pi 的交互模式由自绘的终端界面(TUI,Terminal User Interface)承担,不是 readline 循环。node:readlinepackages/coding-agent 里出现在别处——例如启动前的一次性确认:

earendil-works/pi@c13ffe1第 239–251 行在 GitHub 查看 ↗
一次性的 yes/no 确认:建 interface、问一句、立刻 close。
ts
/** Prompt user for yes/no confirmation */
async function promptConfirm(message: string): Promise<boolean> {
	return new Promise((resolve) => {
		const rl = createInterface({
			input: process.stdin,
			output: process.stdout,
		});
		rl.question(`${message} [y/N] `, (answer) => {
			rl.close();
			resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
		});
	});
}

这是源码事实。三点可以对照:它用的是回调风格的 node:readline(不是 promises 版);每次用完立刻 rl.close(),所以不存在我们那个「挂起的 question 永不 settle」的问题;input / output 同样是显式传进去的。从源码结构看,「一次性提问」和「长期持有一个输入循环」是两类不同的需求,前者用 readline 足够,后者 Pi 交给了 packages/tui(见 6.9 pi-tui:终端界面库)。

双入口:npm startnpm run demo 的原型

我们给每一步准备了交互与非交互两个入口。Pi 做的是同一件事,只是判定自动化了:

earendil-works/pi@c13ffe1第 109–120 行在 GitHub 查看 ↗
根据命令行参数与 stdin/stdout 是否为终端,决定进入哪种运行模式。
ts
function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {
	if (parsed.mode === "rpc") {
		return "rpc";
	}
	if (parsed.mode === "json") {
		return "json";
	}
	if (parsed.print || !stdinIsTTY || !stdoutIsTTY) {
		return "print";
	}
	return "interactive";
}

注意第三个 if:输入或输出只要有一端不是终端(被重定向或用管道接走),Pi 就自动落入 print 模式,不去启动 TUI。这正是我们用两个 npm script 手工区分的那件事。第 5.1 启动:pi 命令如何跑起来6.10 交互模式与 RPC 模式 会展开这四种模式。

类型:Pi 的 Message 与 Context

earendil-works/pi@c13ffe1第 487–491 行在 GitHub 查看 ↗
一次模型调用的全部输入:系统提示词、消息历史、工具列表。
ts
export interface Context {
	systemPrompt?: string;
	messages: Message[];
	tools?: Tool[];
}

和我们的 Context 逐字段对得上,只多一个 tools——而那正是我们 step05 要加的字段。消息本身则复杂得多:

earendil-works/pi@c13ffe1第 393–433 行在 GitHub 查看 ↗
三种 role 的消息定义,以及把它们合成可辨识联合的 `Message`。
ts
export interface UserMessage {
	role: "user";
	content: string | (TextContent | ImageContent)[];
	timestamp: number; // Unix timestamp in milliseconds
}

export interface AssistantMessage {
	role: "assistant";
	content: (TextContent | ThinkingContent | ToolCall)[];
	// …(省略:api / provider / model / usage 等元数据字段)
	stopReason: StopReason;
	// …(省略:errorMessage / rawStopReason)
	timestamp: number; // Unix timestamp in milliseconds
}

export type Message = UserMessage | AssistantMessage | ToolResultMessage;

对照我们的版本,差别可以分成三类,值得分别对待:

  • 形状相同、字段更多role 判别、content 是内容块数组,这两点完全一致;Pi 多出来的是元数据(时间戳、用量、停止原因、Provider 信息)。我们省略它们不影响结构。
  • 能力更强UserMessage.content 允许直接写字符串(便利写法),内容块里还有图片与思考块。
  • 已经在路线图上:第三种 role ToolResultMessage——我们 step04 会加。

也就是说,我们精简掉的都是「量」,保留的是「形」。

假模型:Pi 的 faux provider 用队列,不用规则表

earendil-works/pi@c13ffe1第 96–103 行在 GitHub 查看 ↗
faux 的一步「剧本」:可以是一条写死的助手消息,也可以是一个按上下文现算的工厂函数。
ts
export type FauxResponseFactory = (
	context: Context,
	options: StreamOptions | undefined,
	state: { callCount: number },
	model: Model<string>,
) => AssistantMessage | Promise<AssistantMessage>;

export type FauxResponseStep = AssistantMessage | FauxResponseFactory;

这里出现了我们没有的两样东西:工厂函数(响应可以依据 context 和第几次调用现算)和 state.callCount。再看消费方式:

earendil-works/pi@c13ffe1第 445–464 行在 GitHub 查看 ↗
`createFauxCore`(第 406 行起)内部的 `stream`:从队列头部取一步;队列空了也不抛异常,而是发一个 error 事件。
ts
const stream: StreamFunction<string, StreamOptions> = (requestModel, context, streamOptions) => {
	const outer = createAssistantMessageEventStream();
	const step = pendingResponses.shift();
	state.callCount++;

	queueMicrotask(async () => {
		// …(省略:try/catch 与 onResponse 回调)
		if (!step) {
			// …(省略:把 "No more faux responses queued" 包装成 message)
			outer.push({ type: "error", reason: "error", error: message });
			outer.end(message);
			return;
		}
		// …(省略:解析 step、克隆消息、按 token 切块逐个吐出)
	});

	return outer;
};

两处差别值得记住:

  1. 匹配方式不同。 我们按「最后一条消息的子串」找规则,Pi 按 pendingResponses.shift() 消费一个队列:第 N 次调用返回第 N 条预置响应。一种看法是,队列更适合测试(一个用例要断言「模型第一轮请求工具、第二轮总结」这样的多轮剧本,用队列写最直接),代价是响应与输入之间没有关联,读测试时得靠顺序对应;子串规则则更适合手动把玩,代价是写不出「同一句话两次得到不同回复」的剧本。
  2. 队列耗尽不抛异常,而是发一个 error 事件。 这就是 step03 会正式引入的那条契约——「错误是事件,不是异常」。我们的 step02 还没有事件,所以这一点要到下一章才能完整对上。

缝:Pi 的 StreamFn 与它写死的契约

earendil-works/pi@c13ffe1第 18–32 行在 GitHub 查看 ↗
Agent 循环所依赖的模型调用抽象,注释里把契约写成了三条硬性规定。
ts
/**
 * Stream function used by the agent loop. `Models.streamSimple` satisfies
 * this shape.
 *
 * Contract:
 * - Must not throw or return a rejected promise for request/model/runtime failures.
 * - Must return an AssistantMessageEventStream.
 * - Failures must be encoded in the returned stream via protocol events and a
 *   final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
 */
export type StreamFn = (
	model: Model<Api>,
	context: Context,
	options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;

这就是图 8.1-3 里那条虚线指向的东西:Pi 的 Agent 循环也只依赖一个函数类型。三点对照:

  • 参数里同样有完整的 Context;多出来的 model 参数是因为 Pi 要在多个 Provider、多个模型之间切换,而我们只有一个模型。
  • 返回的是事件流而不是一条消息——这正是我们 step03 要走的那一步。
  • 注释把「不许 throw、错误必须编码进流」写成了契约。同样的契约在 pi-ai 层也写了一遍(packages/ai/src/types.ts 第 312–324 行的 StreamFunction),措辞几乎一致。

从源码结构看,这条缝在 Pi 里被使用得非常彻底:packages/agent 不 import 任何 Provider 实现,谁来提供 StreamFn 由调用方决定(packages/agent/src/stream-fn.ts 还提供了一个模块级默认注册表兜底)。6.3 pi-agent-core:Agent 与循环 会沿着这条缝往下走。

实践任务

🛠 实践任务拆掉那场 Promise 赛跑,看进程怎么卡住labs/mini-agent-harness/step01-hello-cli

目标:亲手复现「已经挂起的 rl.question() 在 close 之后永不 settle」,从而理解 ask() 为什么要存在。

步骤

  1. 进入 labs/mini-agent-harness/step01-hello-cli,先跑通原版:
bash
npm install
npm run demo
printf '你好\n' | npx tsx src/main.ts

第二条命令的输出应与 expected-output.txt 逐字一致;第三条命令(用管道喂一行输入)本书作者实测得到:

text
Mini Agent Harness · step01 hello-cli
输入内容后回车;输入 /exit 退出。

你> 助手> 你说的是:你好

你> 再见。

退出码 0。第二个「你> 」之后直接打印「再见。」,是因为管道里的输入读完触发了 closeask() 返回 undefined,循环按「用户不聊了」收尾。

  1. 复制一份 src/main.ts 备份,然后把循环里的
ts
  const line = await ask("你> ");

改成

ts
  const line = await rl.question("你> ");

其余一律不动(askinputClosed 留在文件里不用即可,tsx 只转译不做类型检查,不影响运行)。

  1. 再跑一次同样的管道命令:printf '你好\n' | npx tsx src/main.ts

预期现象:前半段输出相同,但收不到「再见。」,取而代之的是一条警告,指向你刚改的那一行(路径和行号取决于你的文件):

text
你> Warning: Detected unsettled top-level await at /path/to/step01-hello-cli/src/main.ts:32
  const line = await rl.question("你> ");
               ^

如何判断成功:进程退出码不再是 0。本书作者实测为 13。在 bash/zsh 里用 echo $? 查看,fish 里用 echo $status

进阶(可选):把改动后的程序用 npm start 交互运行,然后按 Ctrl+D。据此推断你会看到同样的卡住(本书作者只在管道场景下实测过),因为「管道读完」和「用户按 Ctrl+D」在 readline 眼里是同一件事:close 事件。跑一遍验证这个推断,本身就是一个好练习。

常见错误

  • 忘了用管道,直接 npm start 然后手动输入:交互模式下不按 Ctrl+D 就不会触发 close,也就看不到现象。
  • 改完忘了改回来:后面的实践任务基于原版代码,做完记得恢复备份。
  • 以为程序「死循环」了:观察 CPU 占用,它是 0。真正的死循环会跑满一个核,永不 settle 的 await 不会。

相关源码labs/mini-agent-harness/step01-hello-cli/src/main.tsask();Pi 的对照实现见 packages/coding-agent/src/main.ts 第 239–251 行(promptConfirm,用完立刻 close,因此不存在这个问题)。

🛠 实践任务改剧本,并让假模型说出它收到了多少条消息labs/mini-agent-harness/step02-fake-model

目标:验证两件事——规则顺序是语义的一部分;以及 CompleteFn 拿到的确实是完整上下文,尽管这个实现只用了最后一条。

步骤

  1. 进入 labs/mini-agent-harness/step02-fake-modelnpm installnpm run demo,确认输出与 expected-output.txt 一致。

  2. 打开 src/rules.ts,把最后那条兜底规则(没有 match 字段的那条)移到数组第一位,重新 npm run demo

预期现象:三轮全部回答「这句话没有命中任何规则,我只能用兜底回复了。」。注意类型检查照样通过——数组元素换个位置不改变任何类型,这是一个纯粹的语义错误。做完把它挪回最后。

  1. 再做一个实验:把第二条规则的 match"Agent Harness" 改成小写的 "agent harness",同时删掉兜底规则,重新跑。

预期现象:第一轮仍然命中「你好」;后两轮打印 (FakeModel 没有命中任何规则,也没有兜底规则。)——这句来自 createFakeModelrule?.reply ?? "..." 那一层。它同时证明了子串匹配是区分大小写的。做完恢复原样。

  1. 最后改一处实现。打开 src/fake-model.ts,把返回语句
ts
    return { role: "assistant", content: [{ type: "text", text: reply }] };

替换成

ts
    const prefix = `[本次收到 ${context.messages.length} 条消息] `;
    return { role: "assistant", content: [{ type: "text", text: prefix + reply }] };

这一步的预期输出(本书作者实际运行所得):

text
Mini Agent Harness · step02 fake-model(demo)

你> 你好
助手> [本次收到 1 条消息] 你好!我是 FakeModel,一个按剧本回复的假模型。

你> 什么是 Agent Harness
助手> [本次收到 3 条消息] Agent Harness 是驱动 Agent 运行的框架:管理消息、调用模型、执行工具。

你> 随便说点什么
助手> [本次收到 5 条消息] 这句话没有命中任何规则,我只能用兜底回复了。

你> /exit
再见。(本次对话共 6 条消息)

如何判断成功:数字是 1、3、5(每轮加 2),且第三轮仍然能看到完整历史。如果你看到的永远是 1,说明 context 被谁重建了。这组数字与第 3.2 章最小示例里的 1、3、5 是同一个现象——只不过这次它发生在一个真正的 CLI 里。

常见错误

  • 改完 rules.ts 就去比对 expected-output.txt,发现不一致以为做错了:不一致是必然的。本项目的约定是 expected-output.txt 永远由真实运行产生,改了剧本就重新跑一遍存回去,而不是反过来改文件。
  • 想让 FakeModel「记住」上一轮:本步骤的规则表没有这个能力。要做到这一点需要状态,而状态属于 step06 的会话与 Pi faux provider 的 state.callCount 那一路思路。
  • context.messages.length 打印在 main.ts 而不是模型里:那证明不了「模型收到了完整上下文」,只证明 main 自己知道。这个实验的关键是在模型内部数消息。

相关源码labs/mini-agent-harness/step02-fake-model/src/fake-model.ts;Pi 的对照实现见 packages/ai/src/providers/faux.ts 第 96–103 行(响应可以是工厂函数,因而能读到 context)与第 445–464 行(队列消费)。

本章小结

  • Harness 的最外层是一个 while 循环:读一行 → 判断是否退出 → 产生回复 → 打印。八个步骤全部在替换「产生回复」这一个节点。
  • 交互式输入有两个退出条件:用户的 /exit 和输入源的结束。后者必须显式处理,否则挂起的 rl.question() 永不 settle,进程静静卡死(顶层 await 时 Node 会给一条 Warning,退出码 13)。
  • 每一步都配一个非交互 demo 入口,与交互入口共享尽可能多的核心代码,输出存进 expected-output.txt。这是交互式程序的可验证性方案,也是本项目「完成标准」的载体。
  • 消息类型的四个设计点:内容是块数组、role 作判别字段、用类型别名给「将来会扩张的那一组」留位置、上下文是「一次调用的全部输入」。它们都不是为 step02 服务的,而是为 step04、step05 让路。
  • 规则表的顺序是语义的一部分find() 先匹配先赢,兜底规则必须在最后。
  • 模型调用是一个函数类型,这条缝值得在第一天就留出来:参数收 Context 而不是 string,返回 Promise,用类型别名而不是 class。判定它值不值的标准是可量化的——step07 到 step08 换成真实模型时,agent-loop.ts / session.ts / types.ts / render.ts / extensions.ts 五个文件与整个 tools/ 目录字节未变。

关键术语:命令行界面(CLI,Command-Line Interface)、Agent Harness(Agent 运行框架)、消息(Message)、内容块(ContentBlock)、上下文(Context)、可辨识联合(Discriminated Union)、类型谓词(Type Predicate)、剧本化假模型(Fake / Faux Model)、Provider(模型服务提供方)、StreamFn。

关键源码索引

  • packages/ai/src/types.ts —— Message 第 393–433 行、Context 第 487–491 行、StreamFunction 及其契约注释第 312–324 行、ProviderStreams 第 236–239 行。
  • packages/agent/src/types.ts —— StreamFn 与契约注释第 18–32 行。
  • packages/ai/src/providers/faux.ts —— FauxResponseFactory / FauxResponseStep 第 96–103 行、队列式 stream 实现第 445–464 行。
  • packages/coding-agent/src/main.ts —— resolveAppMode 第 109–120 行、promptConfirm 第 239–251 行。

自测问题

  1. ask() 里那个 Promise.race 如果去掉,什么情况下程序会卡住?为什么这种卡住比抛异常更难查?
  2. step02 的 UserMessage.content 写成 TextContent[]AssistantMessage.content 写成 ContentBlock[],此刻两者等价。到了 step04 加入 toolCall 之后,这个区别会产生什么效果?
  3. CompleteFn 的参数为什么是 Context 而不是 string?如果第一天写成 string,到 step08 需要付出什么代价?
  4. Pi 的 faux provider 用队列而不是子串规则来产生响应。举一个「队列写得出、子串规则写不出」的测试剧本。

下一章预告8.2 消息历史与流式事件。step02 的模型是「算完一次性返回」,可真实模型要花几十秒才说完一段话。下一步把 CompleteFn 换成 StreamFn:函数体变成异步生成器(async function*),每 yield 一次就是一个流式事件,调用方用 for await ... of 接住并做打字机渲染。同时会正式确立本章末尾已经露头的那条契约——错误是事件,不是异常

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)