8.0 Mini Harness 总览与准备
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:第四到第七部分把 Pi 的源码从入口读到了扩展机制。读懂和写得出之间还隔着一层,第八部分用一个从零搭起的 Mini Agent Harness 把这层捅破:八个可以单独运行的小项目,零运行依赖、零 API Key,每一步只加一层。本章交代这个项目要做什么、明确不做什么、八步怎么排、和 Pi 是什么关系,并把第一步 step01 真正跑起来。
前置知识:第一部分与第二部分(TypeScript、异步迭代器、AbortController、Node 文件与进程 API)、第三部分(Agent 概念)、第五部分(Pi 的一次完整请求)。第六、七部分不是硬前置,但读过之后本部分的对照表会更有味道。
学习目标:读完本章后你能
- 说出「已经读完源码了,为什么还要自己写一遍」的三条具体理由,而不是「练手」这种空话;
- 复述 Mini Harness 的四条设计约束,以及它明确不做的六件事各自被谁替代;
- 画出 7+1 个步骤的路线图,并说出每一步新增了哪个文件、解决了上一步的什么不足;
- 指出
StreamFn是整个项目最重要的一条缝,并解释为什么 step08 换模型时上层一行都不用改;- 在本地跑通 step01,得到与
expected-output.txt逐字一致的输出。
为什么读完源码还要自己写一遍
理由一:「看懂」的判定标准太松,「写得出」的标准很硬
读源码时,你很容易在一段代码上点头——变量名认得、控制流跟得上、注释也读了。但「点头」和「能重新造出来」之间差距很大。判定标准的差别在于:读的时候,正确答案就摆在眼前,你只需要认同它;写的时候,没有答案,你必须自己决定「这里到底要不要多一层」。
这个项目把判定标准换成后者。八个步骤每一步都要求你能说出一句话:上一步在什么地方不够用。说不出来,就说明上一步其实没读懂。
理由二:祛魅——把「框架」还原成几百行普通 TypeScript
Pi 的类型数量在初读时是有压迫感的(都是源码事实,本章后面用源码引用块逐条核对):
AssistantMessageEvent有 12 种变体;AgentEvent有 10 种;- Harness 层在
AgentEvent之外还自带 22 种事件; - 内置 API 协议 10 种,内置 Provider(模型服务提供方)38 个。
直接看这些数字,结论只会是「好复杂」。但如果你自己从 1 种事件开始写,每加一种都是因为上一版真的不够用,这些设计就会从「要背下来的清单」变成「本来就该这样」。这就是祛魅:框架不是魔法,是一堆各自有理由的普通决定叠在一起。
理由三:建立「该找什么」的直觉
做完某一步再打开 Pi 对应的源文件,你会发现自己已经知道该找什么了。你不是在陌生代码里漫游,而是在核对:「我用一个 while 解决的问题,它用了什么?多出来的那些是为了应对什么?」本章末尾的对照表就是按这个用法设计的。
设计目标与非目标
四条硬约束
| 约束 | 具体含义 | 为什么这么定 |
|---|---|---|
| 零运行依赖 | 只用 TypeScript + Node 内置模块。开发依赖只有 tsx(直接运行 .ts 文件)与 typescript | 每引入一个库,就有一层「这是库干的还是我干的」的模糊。连参数校验库都不用:validate 函数是手写的,正好 30 行(step05 的 src/tools/validate.ts:32-61),足够把「运行时校验」这个教学点讲透 |
| 零 API Key 可跑 | step01–step07 全程离线,靠一个剧本化的 FakeModel 驱动 | 学习不该被账号、额度、网络卡住。而且假模型的输出是可复现的,才可能有 expected-output.txt 这种「对答案」机制 |
| 命名向 Pi 对齐,实现大幅简化 | Message / Context / StreamFn / ToolSpec / HarnessEvent 这些名字在 Pi 里都能找到对应物,但我们只保留骨架 | 名字一致,你从这里跳到 Pi 源码时不需要做术语翻译 |
| 每一步可独立运行 | 八个目录之间不共享代码,靠复制 + 增量修改推进 | 代价是重复,收益是你随时可以 diff 两个步骤,看清这一步到底改了什么。「没改什么」往往比「改了什么」更说明问题 |
非目标:明确不做的六件事
这些不是「来不及做」,是刻意不做。每一条都注明了它在 Pi 里对应什么、在本书哪里讲。
- 不做并行工具执行。我们串行执行工具,保证 demo 输出逐字可复现。Pi 的
Agent默认取ToolExecutionMode的"parallel"(源码事实,本章后面有源码引用块核对),代价是输出顺序不确定——教学场景里这个代价不值得。真实实现见 6.4。 - 不做上下文压缩(Context Compaction)。我们的对话不会长到撑爆上下文;一旦要做,就得引入「用模型总结旧消息」这条完全独立的链路。Pi 的做法见 6.6。
- 不做会话树。我们的会话是一条直线:条目带
id与parentId,但永远只有一条链。Pi 的会话是真正的树,支持分支、压缩点、模型切换等 9 种条目类型,见 6.5。 - 不做终端界面(TUI,Terminal User Interface)。我们只有
readline加console.log。Pi 有自己的 TUI 库,见 6.9。 - 不做多 Provider 与模型目录。step08 只手写一个 Anthropic 适配器。Pi 的模型注册与目录持久化见 6.2。
- 不做重试、限流与错误分类。我们只有「错误变成一个
error事件」这一条路径。Pi 的重试与错误处理见 5.6。
从源码结构看,被砍掉的这六件事有一个共同点:它们都是「规模」带来的问题——对话变长、工具变多、用户变多、模型变杂。而被保留下来的骨架(消息、流、循环、工具、会话、取消、扩展)是「机制」层面的问题,规模再小也躲不掉。这就是为什么砍掉前者不影响你理解后者。
路线图:7 个必做步骤 + 1 个可选步骤
图 8.0-1 7+1 步:架构如何一层层长大
阅读顺序:从上到下。每个方框的第二行是「上一步的哪个不足被解决了」,第三行是「文件层面发生了什么」。
这张图要看的不是箭头,而是每个方框的第三行:文件是一个一个加进来的,而且加进来之后基本不再推翻。step01 的 main.ts 里那个读输入的 ask() 函数,到 step08 还在,一个字符没改。请特别注意两处「不是新增」的变化:step02 里 respond.ts 退场(它的职责被 fake-model.ts 接管),step05 里单文件 tools.ts 长成了 tools/ 目录。这两处是全项目仅有的结构性重构,其余七步都是纯粹的叠加。
下面这张表是同一件事的文字版,多了「新增能力」这一列,方便你做完一步后自查。
| 步骤 | 一句话 | 新增能力 |
|---|---|---|
| step01-hello-cli | 一个只会回声的命令行循环 | readline 读一行 → 产生回复 → 打印,这个骨架一直用到最后 |
| step02-fake-model | 有了消息类型和一个按剧本回复的假模型 | Message 可辨识联合(Discriminated Union)、Context 累积、FakeModel |
| step03-streaming | 模型改成一边生成一边吐 | 流式事件联合、StreamFn 抽象、打字机渲染、「错误是事件不是异常」 |
| step04-agent-loop | 一次输入之后,循环自己决定转几圈 | Tool Call(一次工具调用请求)内容块、第三种 role(工具结果)、Agent Loop(Agent 循环)、maxTurns 安全阀 |
| step05-tools | 工具变成正经抽象,参数执行前必须校验 | ToolSpec / ToolDefinition 切分、手写 validate、错误回填给模型让它自我纠错、路径越界与输出截断 |
| step06-sessions | 对话在进程退出后还活着 | JSONL 会话文件、id + parentId 条目、--continue 恢复 |
| step07-abort-extensions | Ctrl+C 只打断这一轮;外部文件能注册工具 | AbortController 贯穿到最底层的等待、三个取消点、onEvent / registerTool 两个钩子、动态 import() 加载扩展 |
| step08-real-provider(可选) | 把 FakeModel 换成真模型,上层一行不改 | Server-Sent Events(SSE,服务器发送事件)解析、消息格式翻译、分片工具参数的拼装、有 Key 才联网 |
对应到本书章节:step01–02 在 8.1,step03 在 8.2,step04–05 在 8.3,step06 在 8.4,step07 在 8.5,step08 在 8.6。
终点长什么样
图 8.0-2 step08 完成时的全部零件,以及各自是哪一步加进来的
阅读顺序:从上到下。方框第二行是引入它的步骤号;带文字的箭头是真实的调用关系,取自 step08 的 src/main.ts 与 src/agent-loop.ts。
九个方框,这就是全部。请注意三件事。
第一,main.ts 的全部工作是接线:创建工具注册表、加载扩展文件、选一个 streamFn 实现、开一个会话,然后每读到一行输入就启动一次循环、把吐出来的事件交给渲染。它自己不含任何「智能」——从 step01 到 step08,main.ts 里没有一行是在决定「该回什么」。
第二,agent-loop.ts 不知道 session.ts 存在,也不知道终端存在。它总共只 import 两个模块:./types.ts 与 ./tools/index.ts(源码事实,见 step08 的 src/agent-loop.ts 第 19–30 行)。它只吐事件,谁来消费是外面的事——落盘的是 main.ts,画到屏幕上的是 render.ts。
第三,streamFn 下面挂着两个实现,在它们之间做选择是 step08 新增的全部逻辑。
五个贯穿始终的抽象
这五个类型是整个项目的骨架,后面每一步都是在给它们加内容,而不是推翻重来。
| 类型 | 首次出现 | 作用 |
|---|---|---|
Message | step02 | 以 role 为判别字段的可辨识联合:user / assistant / toolResult(第三种 role 在 step04 加入) |
Context | step02 | 一次模型调用的全部输入:systemPrompt + messages + tools(tools 在 step05 加入) |
StreamEvent | step03 | 模型吐出的事件。step03 是 4 种:start / text_delta / done / error;step04 加入 toolcall,此后固定为 5 种 |
StreamFn | step03 | 模型调用抽象:(context, options) => AsyncIterable<StreamEvent> |
HarnessEvent | step04 | Agent Loop 对外吐的事件,是 StreamEvent 的超集:多出 turn_start / tool_call / tool_result / turn_end,step07 再加 abort,最终共 10 种 |
StreamFn 是全项目最重要的一条缝。它的契约只有一句话,而且这句话是从 Pi 抄来的:实现不允许 throw,所有错误必须编码成 error 事件放进流里(源码事实,Pi 的对应契约见本章下一节「核对表里的几个数字」)。
为什么这条契约值得单独强调?因为调用方已经在 for await 里逐个消费事件、并且已经把半截回复打印到屏幕上了。如果实现改成抛异常,调用方就得同时维护两套收尾路径:一套处理「流正常结束」,一套处理「异常从 for await 里蹦出来」,而且后者还得自己想办法知道「已经打印了多少」。统一成事件之后只有一套。
也正因为有这条缝,step08 换模型时才不需要动上层:agent-loop.ts、render.ts、session.ts、extensions.ts、tools/、types.ts 全部只依赖 StreamFn 这个类型,不依赖任何一个具体实现。做到 step08 时你可以亲手验证这句话——在 labs/mini-agent-harness/ 下跑一次目录级 diff:
diff -rq step07-abort-extensions/src step08-real-provider/src写作时的真实输出只有三行:demo.ts 与 main.ts 两个入口文件不同,外加 step08 独有的 providers 目录。也就是说,八个源文件里只有两个入口变了,agent-loop.ts、render.ts、session.ts、extensions.ts、tools/、types.ts、fake-model.ts、rules.ts 一个字符都没动。
两个入口的改动性质还不一样:demo.ts 变化较大,因为它要多演示「请求体构造」和「SSE 解析」两个新场景(这些是 step08 的教学内容,与换模型无关);而 main.ts 里真正与换模型有关的,只有下面这一行:
// step07
const streamFn = createFakeModel(rules, { delayMs: 40 });
// step08
const { choice, streamFn } = createModel(env.ANTHROPIC_API_KEY);与 Pi 的对照:简化了什么,为什么可以简化
图 8.0-3 我们的三组文件与 Pi 的三个 package 的对应关系
阅读顺序:左右对照。虚线表示「职责对应」,不表示代码相似——右边每一格的体量都比左边大一到两个数量级。
这张图给的是粗粒度定位,用来回答「我写的这个文件,去 Pi 的哪个包里找对应物」。有一处对应关系是跨层的,需要单独说明:工具在我们这里是一个 tools/ 目录,在 Pi 里却横跨三层——Tool(在 packages/ai,含 TypeBox schema)→ AgentTool(在 packages/agent,加执行模式等)→ ToolDefinition(在 packages/coding-agent 的扩展层,再加渲染字段)。详见 6.4。
逐项对照
下面是细粒度对照表。左边是我们的简化版,右边是 Pi 的完整实现。表格后面用源码引用块核对了其中几个具体数字。
| 概念 | 我们的简化版 | Pi 的完整实现 | Pi 源文件 |
|---|---|---|---|
| 消息 | 3 种 role,内容块只有 text / toolCall | 同样 3 种 role,内容块还有 thinking(带签名与 redacted 位)、image;工具结果消息带 details / addedToolNames | packages/ai/src/types.ts |
| 上下文 | { systemPrompt?, messages, tools? } | 形状一致 | packages/ai/src/types.ts |
| 流式事件 | 5 种 | 12 种,含 text_start / text_end / thinking_delta / toolcall_start 等 | packages/ai/src/types.ts |
| 模型调用抽象 | StreamFn,1 个可选项 | StreamFunction 多收一个 model 参数,StreamOptions 含 reasoning、thinking 预算等;agent 层另有自己的 StreamFn 类型,并提供 setDefaultStreamFn / getDefaultStreamFn 让宿主装一个默认实现。契约同样是「不 throw,错误编码进流」 | packages/ai/src/types.ts、packages/agent/src/types.ts、packages/agent/src/stream-fn.ts |
| 假模型 | 按子串匹配的规则表 | faux provider:响应可以是消息,也可以是一个根据上下文动态生成消息的工厂函数 | packages/ai/src/providers/faux.ts |
| 真实 Provider | 一个手写的 Anthropic 适配器 | 10 种 API 协议、38 个内置 Provider、模型目录持久化 | packages/ai/src/types.ts、packages/ai/src/api/anthropic-messages.ts、packages/ai/src/models-store.ts |
| 循环事件 | HarnessEvent 共 10 种 | AgentEvent 10 种;Harness 层另有 22 种自有事件(队列、存档点、压缩、重试、模型切换等) | packages/agent/src/types.ts、packages/agent/src/harness/types.ts |
| 循环本身 | 一个异步生成器 + maxTurns | runLoop:外层处理排队进来的后续消息,内层处理「模型请求 → 工具执行 → 回填 → 再请求」;外面再包一层有状态的 Agent 类 | packages/agent/src/agent-loop.ts、packages/agent/src/agent.ts |
| 工具执行 | 串行执行,保证输出可预测 | 默认并行,并有 beforeToolCall / afterToolCall 钩子可以阻断执行或按字段覆盖结果 | packages/agent/src/types.ts、packages/agent/src/agent.ts |
| 参数校验 | 手写 30 行的 validate 函数 | TypeBox schema,并可开启受约束采样让模型只能生成合法参数 | packages/ai/src/types.ts |
| 会话存储 | 一个 JSONL 文件,id + parentId 构成直线 | 9 种条目类型构成真正的树,并抽象出存储接口,另有 SQLite 后端 | packages/coding-agent/src/core/session-manager.ts、packages/storage/sqlite-node/ |
| 上下文压缩 | 没有 | 压缩条目记录保留边界,可回溯 | packages/agent/src/harness/types.ts |
| 取消 | AbortSignal + 3 个取消点 | 同样基于 AbortSignal,另有 abort 事件与 settled 相位 | packages/agent/src/harness/types.ts |
| 扩展 | 2 个钩子:onEvent / registerTool | ExtensionAPI 远不止两个入口:注册命令、挂各种生命周期钩子、操作界面、跨扩展通信 | packages/coding-agent/src/core/extensions/types.ts |
这张表怎么用:做完某一步之后,去看对应那一行的右边,再打开 Pi 的源文件。你会发现自己已经知道该找什么了——这正是做这个项目的目的。
核对表里的几个数字
ContextStreamFunctionAssistantMessageEventAgentEventAgentHarnessOwnEventKnownApiKnownProvidertoolExecution为什么这些简化是安全的
从源码结构看,我们做的简化可以归成三类,而且三类的风险完全不同:
- 减少覆盖面,不改结构。
StreamEvent从 12 种减到 5 种、Provider 从 38 个减到 1 个,属于这一类。结构没变,你在 Mini Harness 里学到的「事件如何被消费」原样适用于 Pi。 - 换掉策略,保留契约。工具执行从并行改成串行、校验从 TypeBox 换成手写 30 行,属于这一类。契约(「执行前必须校验」「工具结果按
toolCallId配回去」)没变,变的只是实现策略。这类简化会让你低估 Pi 代码量,但不会让你误解它。 - 整块砍掉。上下文压缩、会话树、重试,属于这一类。这一类才是需要警惕的——因为你在 Mini Harness 里根本不会遇到它们要解决的问题,很容易读 Pi 时觉得「这段代码是多余的」。所以这三块在书里都有独立章节:6.6、6.5、5.6。
环境准备
你需要什么
- Node.js 20 或更高。本书网站工程的
package.json里engines字段要求>=20.0.0;本章的 step01 输出是在 Node v26.5.0 上实测得到的。用node -v确认你的版本。 - npm(随 Node 一起安装)。
- 一个能编辑
.ts文件的编辑器。装上 TypeScript 插件后,把鼠标停在HarnessEvent上能看到完整的联合类型——这个项目里很多「原来如此」的时刻就发生在这种悬停里。 - 不需要 API Key。step01–step07 全程离线;step08 的
npm run demo也是完全离线的。
目录长什么样
实验代码在 labs/mini-agent-harness/,八个步骤目录的结构完全一致:
labs/mini-agent-harness/
├── README.md # 项目总览、步骤索引、与 Pi 的完整对照表
├── step01-hello-cli/
│ ├── README.md # 学习目标、运行方式、逐文件讲解、常见问题、完成标准
│ ├── expected-output.txt # npm run demo 的真实输出,用来对答案
│ ├── package.json # scripts: start(交互)/ demo(非交互)
│ ├── tsconfig.json # strict: true
│ └── src/
├── step02-fake-model/
└── ...(共八个)从 step05 起会多一个 data/ 目录(放 read_file 工具用来读的示例文件;沙箱根其实是步骤目录本身,data/ 只是里面的一个子目录),从 step07 起会多一个 extensions/ 目录(可加载的扩展示例)。
src/demo.ts,用一段固定脚本代替人工输入,把同一套核心代码非交互地跑一遍,输出存进 expected-output.txt。你的 npm run demo 输出与它逐字一致,就说明这一步做对了。注意关键词是「同一套核心代码」:demo 与交互入口共享全部逻辑,只是输入来源不同,所以 demo 跑通确实能证明核心逻辑正确。 跑通 step01
cd labs/mini-agent-harness/step01-hello-cli
npm install # 只安装 tsx 与 typescript,零运行时依赖
npm run demo # 非交互演示,输出应与 expected-output.txt 一致输出应当逐字是这样(以下内容取自 labs/mini-agent-harness/step01-hello-cli/expected-output.txt,并经写作时真实运行核对一致):
Mini Agent Harness · step01 hello-cli(demo)
你> 你好
助手> 你说的是:你好
你> 什么是 Agent Harness
助手> 你说的是:什么是 Agent Harness
你> /exit
再见。然后跑交互模式:
npm start随便输入几句,每句都会得到 助手> 你说的是:... 的回声;输入 /exit 退出,打印「再见。」。
step01 里有什么
只有三个文件,src/ 下加起来 72 行——其中去掉注释与空行之后,真正的代码只有 33 行。8.1 会逐行讲,这里先建立印象:
src/main.ts——交互入口。readline.createInterface把标准输入输出包装成可以await rl.question("你> ")的对象,while (true)每轮读一行,遇到/exit退出。循环体里没有任何「智能」,它只调用respond()。src/respond.ts——「产生回复」的唯一职责点,现在只有一行回声逻辑。把它拆出来是为了给后续替换留一个明确的插槽:step02 换成 FakeModel,step04 换成完整的 Agent Loop。src/demo.ts——用固定的输入数组代替人工输入,复用同一个respond()。
有一个细节现在就值得看,因为它会原样活到 step08:main.ts 里读输入用的不是 rl.question,而是自己包的 ask()。原因写在源码注释里——用户按 Ctrl+D、或者输入是重定向进来的且已经读完时,readline 会触发 close,而此刻已经挂起的 question() 既不 resolve 也不 reject。直接 await 它,这一轮循环就永远停在那里:没有异常、没有堆栈、也没有下一行输出。所以 ask() 让 question() 和一个 close 事件的 Promise 赛跑,谁先来算谁的——close 先到就返回 undefined,循环据此判断「用户不聊了」。这个 close 监听器只注册一次,不会随循环次数堆积。
Node 对这种情况有一条兜底诊断,但它只覆盖顶层 await:本章实践任务里去掉 ask() 的赛跑之后,Node 会打印 Warning: Detected unsettled top-level await,然后以退出码 13 结束(写作时在 Node v26.5.0 上实测)。注意这条诊断的两个边界:一是它只认顶层 await,同样的问题发生在一个普通 async 函数内部时不会有任何提示;二是它的触发条件是事件循环已经空了——只要还有别的定时器或句柄活着,程序就会真的停在那里。所以不能指望它替你发现问题。
step01 的 ask() 是本项目第一次、也是最简单的一次演示,后面 step07 处理取消时会再遇到同一类问题的另一个变体。
输出对不上怎么办
- 先确认你是在步骤目录下运行的。
npm run demo找的是当前目录(或其上级)里的package.json,不在步骤目录里执行会报「找不到脚本」。- 顺带澄清一个容易搞反的细节:
data/与.sessions/这些路径不受当前工作目录影响。从 step05 起,各步骤的main.ts是这样算出项目根目录的——path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),也就是从脚本文件自己的位置往上退一级(源码事实,见labs/mini-agent-harness/step05-tools/src/main.ts第 22 行)。所以就算你用绝对路径从别处启动脚本,它读写的仍然是步骤目录下的data/与.sessions/。
- 顺带澄清一个容易搞反的细节:
- 逐字比对差异出现的第一行,那通常就是问题所在,后面的差异往往只是它的连锁反应。
.sessions/目录可以随时删除。step06 与 step07 的 demo 每次运行前都会自己清空它。- 所有
expected-output.txt都是真实运行录下来的,不是手写的。如果你没改过代码而输出对不上,先看 Node 版本。
实践任务
labs/mini-agent-harness/step01-hello-cli目标:把第一步跑起来,确认环境没问题;然后故意破坏 ask(),亲眼看看「Promise 永远不 settle」长什么样,以及 Node 会不会告诉你。
步骤一 · 跑通:
cd labs/mini-agent-harness/step01-hello-cli
npm install
npm run demo把输出和同目录下的 expected-output.txt 逐字比对。再跑一次 npm start,输入两句话,用 /exit 退出。
步骤二 · 观察正常的收尾:不改任何代码,先跑一次管道输入,看清「输入读完」这条路径本来长什么样:
echo 你好 | npm start真实输出(写作时实测):
Mini Agent Harness · step01 hello-cli
输入内容后回车;输入 /exit 退出。
你> 助手> 你说的是:你好
你> 再见。管道里的输入读完之后 readline 触发 close,ask() 返回 undefined,循环按「用户不聊了」收尾,打印「再见。」,退出码 0。
步骤三 · 拆掉赛跑:打开 src/main.ts,只把 ask() 里的 return 那一行改掉,去掉那场 Promise.race:
function ask(prompt: string): Promise<string | undefined> {
// close 之后再调用 question() 会 reject,这条路径也归一成 undefined。
return rl.question(prompt); // ← 原本是 Promise.race([...])
}只改这一行、保留上面那行注释,是为了让文件的行数不变,你看到的行号才会和下面一致。改完再跑一次同一条命令。
预期现象(同样是实测结果,Node v26.5.0):前面几行完全一样,但最后一行不再是「再见。」,而是 Node 的兜底诊断,且退出码变成 13:
你> 助手> 你说的是:你好
你> Warning: Detected unsettled top-level await at .../src/main.ts:32
const line = await ask("你> ");
^第 32 行正是 while 循环里那句 const line = await ask("你> ");——Node 直接把卡住的那一行指给你看了。
请注意这里发生了什么、又没发生什么:没有任何异常被抛出,respond() 也没出错,程序只是永远等不到那个 question() 的 Promise。之所以还能看到一行提示,是因为管道输入读完后事件循环空了,Node 才有机会给出这条诊断。
如何判断成功:你能用自己的话说清楚三件事——① 为什么「没有异常、没有堆栈」比「抛异常」更难查;② Promise.race 到底解决了什么,它是不是让 question() 那个 Promise 消失了(答案是「没有」:那个 Promise 依然挂着,只是没人再等它了);③ 如果这段 await 不在模块顶层,而是写在某个普通 async 函数里,Node 那条诊断还会出现吗。
常见错误:① 做完不改回来。八个步骤是各自独立的目录,改坏 step01 不会影响 step02,但下次你回头看 step01 时会以为它本来就是坏的——请手动撤销或用版本控制回滚;② 在别的目录下执行 npm run demo,报找不到脚本;③ Node 版本低于 20,tsx 或顶层 await 报错。
源码位置:labs/mini-agent-harness/step01-hello-cli/src/main.ts 里的 inputClosed 与 ask,以及它们上方那段注释;该目录 README 的「常见问题 Q4」讨论的是同一件事。
更多实践入口见实践任务索引。
本章小结
- 自己写一遍的三条理由:「写得出」的判定标准比「看懂」硬;祛魅,把 12 种事件、10 种事件、22 种事件还原成一次一次的「上一版不够用」;建立「该找什么」的直觉,让你读 Pi 时是在核对而不是漫游。
- 四条设计约束:零运行依赖、零 API Key 可跑、命名向 Pi 对齐而实现大幅简化、每一步可独立运行(代价是八份重复代码,收益是可
diff、可单独跑、可改坏了重来)。 - 六个非目标:并行工具执行、上下文压缩、会话树、TUI、多 Provider、重试与错误分类。它们的共同点是都由「规模」引起,而被保留的骨架是「机制」层面的问题。
- 路线图是 7 个必做 + 1 个可选:step01 骨架 → step02 消息与假模型 → step03 流式 → step04 循环 → step05 工具与校验 → step06 会话 → step07 取消与扩展 →(可选)step08 真实 Provider。全项目仅有两处结构性重构,其余都是纯叠加。
StreamFn是全项目最重要的一条缝,契约是「不允许 throw,错误必须编码成error事件」。有了它,step08 换模型时八个源文件里只有main.ts与demo.ts两个入口变了,agent-loop.ts一个字符都不用改——用diff -rq step07-abort-extensions/src step08-real-provider/src可以自己验证。- 简化分三类:减覆盖面不改结构(安全)、换策略保契约(安全,但会让你低估 Pi 的代码量)、整块砍掉(需要警惕,因为你不会遇到它们要解决的问题)。
- 关键术语:Agent Harness(Agent 运行框架)、Agent Loop(Agent 循环)、可辨识联合(Discriminated Union)、
StreamFn(模型调用抽象)、HarnessEvent、FakeModel(假模型)、JSONL、Server-Sent Events(SSE,服务器发送事件)。 - 关键源码索引:
packages/ai/src/types.ts:487-491(Context,形状与我们一致)、packages/ai/src/types.ts:312-324(StreamFunction与「不 throw」契约)、packages/ai/src/types.ts:501-513(AssistantMessageEvent,12 种)、packages/agent/src/types.ts:422-437(AgentEvent,10 种)、packages/agent/src/harness/types.ts:715-740(Harness 自有事件,22 种)、packages/ai/src/types.ts:16-26(10 种 API 协议)、packages/ai/src/types.ts:34-72(38 个内置 Provider)、packages/agent/src/agent.ts:230(工具执行默认并行)。 - 自测问题:
- 「零 API Key 可跑」这条约束除了方便,还带来了一个技术上必需的好处,是什么?(提示:想想
expected-output.txt凭什么能存在。) - 为什么本项目宁可让八个目录重复大量代码,也不抽一个公共包?请说出这个取舍在教学场景下成立、在产品场景下不成立的原因。
- 如果把
StreamFn的契约改成「出错时直接 throw」,agent-loop.ts和render.ts分别要多写什么? - 三类简化里,为什么说「整块砍掉」那一类最危险?举出本章提到的其中一项,说明你读 Pi 源码时可能产生什么误解。
- 「零 API Key 可跑」这条约束除了方便,还带来了一个技术上必需的好处,是什么?(提示:想想
- 下一章:8.1 最小 CLI 与 Fake Model——把 step01 的三个文件逐行读完,然后进入 step02:用可辨识联合定义
Message,把「回复」从一次字符串拼接变成一条真正的助手消息,并让Context开始累积对话。