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/await、2.8 Node.js 文件、路径与进程 API、3.2 消息、上下文与 token。 学习目标:读完本章你能——
- 用
node:readline/promises写出一个不会卡死的交互式输入循环,并说清那种卡死是怎么产生的;- 解释每个步骤为什么都配一个非交互的
demo入口,以及它与expected-output.txt的关系;- 写出
TextContent/ContentBlock/Message/Context四个类型,并说出它们的包含关系;- 说清 FakeModel 的剧本匹配规则,包括「规则顺序是语义的一部分」这件事;
- 论证「模型调用是一个函数类型」这条缝为什么值得在第一天就留出来,并在 Pi 源码里找到它的对应物。
本章对应两个实验目录:labs/mini-agent-harness/step01-hello-cli 与 labs/mini-agent-harness/step02-fake-model。两者都是独立可运行的项目,零运行时依赖(只有 tsx 与 typescript 两个开发依赖),不需要任何 API Key。本章引用的 demo 输出全部来自这两个目录里的 expected-output.txt(写作时本书作者重新跑了一遍两个 npm run demo,与仓库中的文件逐字一致);其余标注「本书作者实测」的输出,是按正文所述改动代码后真实运行所得。
建立直觉:最外层永远是一个 while 循环
前面七个部分讲了很多机制:流式事件、Agent Loop(Agent 循环)、工具调用(Tool Calling)、会话(Session)、扩展(Extension)。但把 Pi 的 packages/coding-agent 一层层剥到最外面,剩下的东西朴素得出奇:
准备好输入输出
while (还想聊) {
line = 读一行
if (line 是退出指令) break
reply = 产生回复
打印 reply
}
收尾step01 要做的就是把这段伪代码变成真代码,并且一个字的「智能」都不加。这样做的目的不是省事,而是先把「壳」和「芯」分开:后面七步全部是在替换「产生回复」这一行——step02 换成按剧本回复的假模型,step03 换成流式模型,step04 换成完整的 Agent Loop,step08 换成真实的 Provider(模型服务提供方)。壳本身几乎不再变。
step01:一个只会回声的命令行循环
先跑起来
在 labs/mini-agent-harness/step01-hello-cli 目录下:
npm install # 只安装 tsx 与 typescript
npm run demo # 非交互演示
npm start # 交互模式,/exit 退出npm run demo 的完整输出(与该目录 expected-output.txt 逐字一致):
Mini Agent Harness · step01 hello-cli(demo)
你> 你好
助手> 你说的是:你好
你> 什么是 Agent Harness
助手> 你说的是:什么是 Agent Harness
你> /exit
再见。三个文件、不到 60 行代码就产生了这段输出。下面逐个看。
src/main.ts:交互入口
开头三行 import 加一行初始化:
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。
接下来是本文件里唯一一段「不直观」的代码:
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 只是随进程一起消失。)
Node 对顶层 await 这一特例给了提示。本书作者把 step01 的 await ask("你> ") 改成 await rl.question("你> "),然后用管道喂一行输入,真实输出是:
你> 助手> 你说的是:你好
你> 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 函数内部时,不会有这行提示,只会安静地卡住。本章末尾的第一个实践任务就是亲手复现它。
最后是循环本体:
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:预留出来的插槽
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 入口,用固定脚本代替人工输入:
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:两处关键设置
{
"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 里的两个选项:
{
"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 逐字一致):
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:
/** 一段纯文本内容块。 */
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;四个设计点,每一个都在为后面的步骤让路:
- 内容是数组,不是字符串。 现在数组里只可能放一种东西,看起来是多余的包装。但 step04 会往
ContentBlock里加toolCall,那时一条助手消息就可能是「一段文本 + 一个 Tool Call(一次工具调用请求)」,甚至多个,顺序还有意义。用数组是为了以后不改类型形状。 UserMessage.content写的是TextContent[],AssistantMessage.content写的是ContentBlock[]。 此刻这两者完全等价(因为ContentBlock = TextContent),但它们表达的意图不同:用户消息永远只能装文本块,助手消息装的是「当前允许的所有块类型」。step04 给ContentBlock加上toolCall之后,这行区别会自动生效——用户消息不会因此获得发起工具调用的能力。这是类型别名的一个用法:给「将来会变的那一组」起个名字,给「不变的那一个」直接写具体类型。- 判别字段是
role。 拿到一条Message先switch (message.role),TypeScript 就能收窄出具体形状。这是第 2.3 章可辨识联合的直接应用,也是本项目里出现次数最多的模式:ContentBlock靠type判别,step03 的StreamEvent靠type判别,step04 的循环事件同样如此。 Message现在只有两种 role。 step04 会加入第三种toolResult。
上下文与两个便捷函数:
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 是可选的,本步骤没人给它赋值——它先占住位置,因为「一次模型调用的输入」在概念上就该包含它。
写成 (block): block is TextContent => block.type === "text" 之后,TypeScript 认可这个函数是一个「类型判定」,filter 的返回类型随之变成 TextContent[]。这就是第 2.4 章「证据五:自定义 type guard」讲的 x is T 语法,写在箭头函数的返回类型位置上而已。代价也一样:正确性由你负责——函数体里返回 true 但类型对不上,编译器照样相信你。
src/fake-model.ts:剧本化的假模型
为什么需要它:教学与测试都要求「跑得起来、每次结果一样、不花钱」。真实模型三条都不满足——要 Key、有网络延迟、同一个问题两次回答可能不同。
没有它会怎样:本项目 step01 到 step07 全都无法做成可自动比对的实验;Pi 的 agent 层与 coding-agent 层也将无法在 CI 里跑端到端测试。
Pi 如何使用它:packages/ai/src/providers/faux.ts 提供了一个可脚本化的 faux provider,本章「回到 Pi 源码」一节会看它的实现,并对比它与我们这个版本在匹配方式上的差别。
规则的形状与模型调用的签名:
export interface FakeRule {
/** 命中条件:最后一条消息文本包含该子串。缺省表示兜底规则。 */
match?: string;
/** 命中后的回复文本。 */
reply: string;
}
export type CompleteFn = (context: Context) => Promise<AssistantMessage>;CompleteFn 是本步骤最重要的一行代码,下一节整节都在讲它。先看实现:
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 ?? "..."还留了一层兜底的兜底:规则表里一条兜底规则都没有时,走这句固定文案。
本书作者把 rules.ts 里的兜底规则挪到数组第一位,其余不变,npm run demo 的真实输出变成:
你> 你好
助手> 这句话没有命中任何规则,我只能用兜底回复了。
你> 什么是 Agent Harness
助手> 这句话没有命中任何规则,我只能用兜底回复了。编译器不会报错——这是一个纯粹的语义错误。真实系统里同类问题很常见:路由表、中间件链、switch 的 default,凡是「先匹配先赢」的结构,兜底项都必须在最后。
src/rules.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 逐字相同,变化集中在循环体:
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 会在它外面再套一层循环(因为模型可能要求先执行工具,然后再问一次)。
context 与 model 都在模块顶层创建,生命周期等于进程——这也是 step06 之前「退出即失忆」的原因。
demo.ts 的差别依旧只有输入来源:脚本数组多了一句「随便说点什么」用来触发兜底规则,其余三步完全一样。
图解:一轮对话在 step02 里的数据流
图 8.1-2 step02 的一轮对话:追加、调用、追加
阅读顺序:从上到下。请重点看 FakeModel 这一列上的两条线。第三条消息是**签名**:`main.ts` 交出去的是整个 `context`,不是刚输入的那一行文本。第四条消息是**实现**:FakeModel 转头只读了最后一条消息。二者的不一致是刻意的——签名是对外的契约,必须和真实模型一致;实现可以偷懒,因为它是假的。最下面那条注记解释了 demo 结尾为什么是「6 条消息」:三轮,每轮加二。
为什么第一天就要留出「模型调用」这条缝
回到 CompleteFn 这一行:
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——签名从「还你一条完整消息」变成「还你一串事件」:
// 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.ts、session.ts、types.ts、render.ts、extensions.ts 五个文件加上整个 tools/ 目录字节完全相同,唯一有实质改动的是 main.ts,而那处改动是把
const streamFn = createFakeModel(rules, { delayMs: 40 });换成
const { choice, streamFn } = createModel(env.ANTHROPIC_API_KEY);外加一个新增的 src/providers/ 目录。也就是说:把假模型换成真模型,Agent Loop、工具、会话、取消、扩展一行都没改。 一个抽象值不值,判定标准就是这个——换实现时上层要改多少代码。
一种看法是:值得在第一天引入的抽象,是那些「第二种实现已经在路线图上」的抽象;代价是你必须真的走到那一步,否则它就只是多出来的一层。
图 8.1-3 同一条缝上的三种实现,以及它在 Pi 里的对应物
阅读顺序:从左到右。左边的方框会随着步骤推进不断变厚(step04 加 Agent Loop、step06 加会话、step07 加扩展),但它始终只连到中间那一个节点。右边三个实现互不相识,可以随时替换其中之一。虚线指向的是本章下一节要看的真实源码:Pi 在同一个位置放了一个叫 `StreamFn` 的类型,并且用注释把契约写死了。
回到 Pi 源码
我们这两步里的每一个概念,在 Pi 里都能找到对应物。下面按「壳 → 类型 → 假模型 → 缝」的顺序对照。
壳:Pi 也用 node:readline,但只用在一次性提问上
Pi 的交互模式由自绘的终端界面(TUI,Terminal User Interface)承担,不是 readline 循环。node:readline 在 packages/coding-agent 里出现在别处——例如启动前的一次性确认:
promptConfirm/** 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 start 与 npm run demo 的原型
我们给每一步准备了交互与非交互两个入口。Pi 做的是同一件事,只是判定自动化了:
resolveAppModefunction 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
Contextexport interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}和我们的 Context 逐字段对得上,只多一个 tools——而那正是我们 step05 要加的字段。消息本身则复杂得多:
Messageexport 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 用队列,不用规则表
FauxResponseStepexport type FauxResponseFactory = (
context: Context,
options: StreamOptions | undefined,
state: { callCount: number },
model: Model<string>,
) => AssistantMessage | Promise<AssistantMessage>;
export type FauxResponseStep = AssistantMessage | FauxResponseFactory;这里出现了我们没有的两样东西:工厂函数(响应可以依据 context 和第几次调用现算)和 state.callCount。再看消费方式:
streamconst 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;
};两处差别值得记住:
- 匹配方式不同。 我们按「最后一条消息的子串」找规则,Pi 按
pendingResponses.shift()消费一个队列:第 N 次调用返回第 N 条预置响应。一种看法是,队列更适合测试(一个用例要断言「模型第一轮请求工具、第二轮总结」这样的多轮剧本,用队列写最直接),代价是响应与输入之间没有关联,读测试时得靠顺序对应;子串规则则更适合手动把玩,代价是写不出「同一句话两次得到不同回复」的剧本。 - 队列耗尽不抛异常,而是发一个
error事件。 这就是 step03 会正式引入的那条契约——「错误是事件,不是异常」。我们的 step02 还没有事件,所以这一点要到下一章才能完整对上。
缝:Pi 的 StreamFn 与它写死的契约
StreamFn/**
* 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 与循环 会沿着这条缝往下走。
实践任务
labs/mini-agent-harness/step01-hello-cli目标:亲手复现「已经挂起的 rl.question() 在 close 之后永不 settle」,从而理解 ask() 为什么要存在。
步骤
- 进入
labs/mini-agent-harness/step01-hello-cli,先跑通原版:
npm install
npm run demo
printf '你好\n' | npx tsx src/main.ts第二条命令的输出应与 expected-output.txt 逐字一致;第三条命令(用管道喂一行输入)本书作者实测得到:
Mini Agent Harness · step01 hello-cli
输入内容后回车;输入 /exit 退出。
你> 助手> 你说的是:你好
你> 再见。退出码 0。第二个「你> 」之后直接打印「再见。」,是因为管道里的输入读完触发了 close,ask() 返回 undefined,循环按「用户不聊了」收尾。
- 复制一份
src/main.ts备份,然后把循环里的
const line = await ask("你> ");改成
const line = await rl.question("你> ");其余一律不动(ask 与 inputClosed 留在文件里不用即可,tsx 只转译不做类型检查,不影响运行)。
- 再跑一次同样的管道命令:
printf '你好\n' | npx tsx src/main.ts。
预期现象:前半段输出相同,但收不到「再见。」,取而代之的是一条警告,指向你刚改的那一行(路径和行号取决于你的文件):
你> 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.ts 的 ask();Pi 的对照实现见 packages/coding-agent/src/main.ts 第 239–251 行(promptConfirm,用完立刻 close,因此不存在这个问题)。
labs/mini-agent-harness/step02-fake-model目标:验证两件事——规则顺序是语义的一部分;以及 CompleteFn 拿到的确实是完整上下文,尽管这个实现只用了最后一条。
步骤
进入
labs/mini-agent-harness/step02-fake-model,npm install后npm run demo,确认输出与expected-output.txt一致。打开
src/rules.ts,把最后那条兜底规则(没有match字段的那条)移到数组第一位,重新npm run demo。
预期现象:三轮全部回答「这句话没有命中任何规则,我只能用兜底回复了。」。注意类型检查照样通过——数组元素换个位置不改变任何类型,这是一个纯粹的语义错误。做完把它挪回最后。
- 再做一个实验:把第二条规则的
match从"Agent Harness"改成小写的"agent harness",同时删掉兜底规则,重新跑。
预期现象:第一轮仍然命中「你好」;后两轮打印 (FakeModel 没有命中任何规则,也没有兜底规则。)——这句来自 createFakeModel 里 rule?.reply ?? "..." 那一层。它同时证明了子串匹配是区分大小写的。做完恢复原样。
- 最后改一处实现。打开
src/fake-model.ts,把返回语句
return { role: "assistant", content: [{ type: "text", text: reply }] };替换成
const prefix = `[本次收到 ${context.messages.length} 条消息] `;
return { role: "assistant", content: [{ type: "text", text: prefix + reply }] };这一步的预期输出(本书作者实际运行所得):
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 行。
自测问题
ask()里那个Promise.race如果去掉,什么情况下程序会卡住?为什么这种卡住比抛异常更难查?- step02 的
UserMessage.content写成TextContent[]、AssistantMessage.content写成ContentBlock[],此刻两者等价。到了 step04 加入toolCall之后,这个区别会产生什么效果? CompleteFn的参数为什么是Context而不是string?如果第一天写成string,到 step08 需要付出什么代价?- Pi 的 faux provider 用队列而不是子串规则来产生响应。举一个「队列写得出、子串规则写不出」的测试剧本。
下一章预告:8.2 消息历史与流式事件。step02 的模型是「算完一次性返回」,可真实模型要花几十秒才说完一段话。下一步把 CompleteFn 换成 StreamFn:函数体变成异步生成器(async function*),每 yield 一次就是一个流式事件,调用方用 for await ... of 接住并做打字机渲染。同时会正式确立本章末尾已经露头的那条契约——错误是事件,不是异常。