Skip to content

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 的类型数量在初读时是有压迫感的(都是源码事实,本章后面用源码引用块逐条核对):

  • AssistantMessageEvent12 种变体;
  • AgentEvent10 种;
  • Harness 层在 AgentEvent 之外还自带 22 种事件;
  • 内置 API 协议 10 种,内置 Provider(模型服务提供方)38 个。

直接看这些数字,结论只会是「好复杂」。但如果你自己从 1 种事件开始写,每加一种都是因为上一版真的不够用,这些设计就会从「要背下来的清单」变成「本来就该这样」。这就是祛魅:框架不是魔法,是一堆各自有理由的普通决定叠在一起。

🌱 初学者提示这个项目的节奏
每一步都遵循同一个节奏:**先让上一步不够用,再补上一层**。所以请不要跳着做——如果你没先经历过「step02 的模型憋完整句才返回、终端上半天没动静」,step03 的流式输出(Streaming)就只是一个技巧,而不是一个解法。

理由三:建立「该找什么」的直觉

做完某一步再打开 Pi 对应的源文件,你会发现自己已经知道该找什么了。你不是在陌生代码里漫游,而是在核对:「我用一个 while 解决的问题,它用了什么?多出来的那些是为了应对什么?」本章末尾的对照表就是按这个用法设计的。

⚠️ 常见误解把 Mini Harness 当成 Pi 的精简发行版
它不是。精简发行版意味着「功能少一点,但可以拿去用」;Mini Harness 是**教学模型**,它刻意在很多地方选了「更好讲」而不是「更好用」的做法——串行执行工具、复制八份代码、手写一个 30 行的校验函数而不用成熟的库。拿它去做真实产品,你会在并发、错误恢复、上下文长度这三件事上立刻撞墙。它的价值在于:跑完之后你再看 Pi 的对应实现,能说清楚 Pi 多出来的那些代码在防什么。

设计目标与非目标

四条硬约束

约束具体含义为什么这么定
零运行依赖只用 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
  • 不做会话树。我们的会话是一条直线:条目带 idparentId,但永远只有一条链。Pi 的会话是真正的树,支持分支、压缩点、模型切换等 9 种条目类型,见 6.5
  • 不做终端界面(TUI,Terminal User Interface)。我们只有 readlineconsole.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-extensionsCtrl+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.tssrc/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 新增的全部逻辑。

🌱 初学者提示图 8.0-2 里的 types.ts 去哪了
它不在图里,因为它不参与调用关系——`types.ts` 只被 `import type`,运行时没有任何代码。但它是全项目最该先读的文件:下一节那五个类型全在里面。

五个贯穿始终的抽象

这五个类型是整个项目的骨架,后面每一步都是在给它们加内容,而不是推翻重来。

类型首次出现作用
Messagestep02role 为判别字段的可辨识联合:user / assistant / toolResult(第三种 role 在 step04 加入)
Contextstep02一次模型调用的全部输入:systemPrompt + messages + toolstools 在 step05 加入)
StreamEventstep03模型吐出的事件。step03 是 4 种:start / text_delta / done / error;step04 加入 toolcall,此后固定为 5 种
StreamFnstep03模型调用抽象:(context, options) => AsyncIterable<StreamEvent>
HarnessEventstep04Agent 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.tsrender.tssession.tsextensions.tstools/types.ts 全部只依赖 StreamFn 这个类型,不依赖任何一个具体实现。做到 step08 时你可以亲手验证这句话——在 labs/mini-agent-harness/ 下跑一次目录级 diff:

bash
diff -rq step07-abort-extensions/src step08-real-provider/src

写作时的真实输出只有三行:demo.tsmain.ts 两个入口文件不同,外加 step08 独有的 providers 目录。也就是说,八个源文件里只有两个入口变了,agent-loop.tsrender.tssession.tsextensions.tstools/types.tsfake-model.tsrules.ts 一个字符都没动。

两个入口的改动性质还不一样:demo.ts 变化较大,因为它要多演示「请求体构造」和「SSE 解析」两个新场景(这些是 step08 的教学内容,与换模型无关);而 main.ts 里真正与换模型有关的,只有下面这一行:

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 / addedToolNamespackages/ai/src/types.ts
上下文{ systemPrompt?, messages, tools? }形状一致packages/ai/src/types.ts
流式事件5 种12 种,含 text_start / text_end / thinking_delta / toolcall_startpackages/ai/src/types.ts
模型调用抽象StreamFn,1 个可选项StreamFunction 多收一个 model 参数,StreamOptions 含 reasoning、thinking 预算等;agent 层另有自己的 StreamFn 类型,并提供 setDefaultStreamFn / getDefaultStreamFn 让宿主装一个默认实现。契约同样是「不 throw,错误编码进流」packages/ai/src/types.tspackages/agent/src/types.tspackages/agent/src/stream-fn.ts
假模型按子串匹配的规则表faux provider:响应可以是消息,也可以是一个根据上下文动态生成消息的工厂函数packages/ai/src/providers/faux.ts
真实 Provider一个手写的 Anthropic 适配器10 种 API 协议、38 个内置 Provider、模型目录持久化packages/ai/src/types.tspackages/ai/src/api/anthropic-messages.tspackages/ai/src/models-store.ts
循环事件HarnessEvent 共 10 种AgentEvent 10 种;Harness 层另有 22 种自有事件(队列、存档点、压缩、重试、模型切换等)packages/agent/src/types.tspackages/agent/src/harness/types.ts
循环本身一个异步生成器 + maxTurnsrunLoop:外层处理排队进来的后续消息,内层处理「模型请求 → 工具执行 → 回填 → 再请求」;外面再包一层有状态的 Agentpackages/agent/src/agent-loop.tspackages/agent/src/agent.ts
工具执行串行执行,保证输出可预测默认并行,并有 beforeToolCall / afterToolCall 钩子可以阻断执行或按字段覆盖结果packages/agent/src/types.tspackages/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.tspackages/storage/sqlite-node/
上下文压缩没有压缩条目记录保留边界,可回溯packages/agent/src/harness/types.ts
取消AbortSignal + 3 个取消点同样基于 AbortSignal,另有 abort 事件与 settled 相位packages/agent/src/harness/types.ts
扩展2 个钩子:onEvent / registerToolExtensionAPI 远不止两个入口:注册命令、挂各种生命周期钩子、操作界面、跨扩展通信packages/coding-agent/src/core/extensions/types.ts

这张表怎么用:做完某一步之后,去看对应那一行的右边,再打开 Pi 的源文件。你会发现自己已经知道该找什么了——这正是做这个项目的目的。

核对表里的几个数字

earendil-works/pi@c13ffe1第 487–491 行在 GitHub 查看 ↗
Pi 的 `Context`:三个字段 `systemPrompt?` / `messages` / `tools?`,和我们 step02 起用的 `Context` 形状完全一致。这是全表里唯一一处「没有简化」的对应。
packages/ai/src/types.ts · StreamFunction
earendil-works/pi@c13ffe1第 312–324 行在 GitHub 查看 ↗
`StreamFunction` 的类型定义与它上方的契约注释:一旦被调用,请求、模型、运行时的失败都应当编码进返回的流,而不是抛出;错误终止必须产出 `stopReason` 为 `error` 或 `aborted` 的助手消息。我们 `StreamFn` 的契约是从这里抄来的。
packages/ai/src/types.ts · AssistantMessageEvent
earendil-works/pi@c13ffe1第 501–513 行在 GitHub 查看 ↗
`AssistantMessageEvent` 的完整联合,共 12 个变体。我们的 `StreamEvent` 只保留其中 5 种;被砍掉的主要是 `text_start` / `text_end` 这类边界事件和整组 `thinking_*` 事件。
earendil-works/pi@c13ffe1第 422–437 行在 GitHub 查看 ↗
`AgentEvent` 的完整联合,共 10 个变体,按注释分成四组:agent 生命周期、turn 生命周期、消息生命周期、工具执行生命周期。我们的 `HarnessEvent` 恰好也是 10 种,但两者的构成不同:我们把模型的流式事件直接并进了同一个联合,Pi 则用 `message_update` 把它们包了一层。
packages/agent/src/harness/types.ts · AgentHarnessOwnEvent
earendil-works/pi@c13ffe1第 715–740 行在 GitHub 查看 ↗
Harness 层在 `AgentEvent` 之外自带的 22 种事件:队列变化、存档点、取消、settled、模型切换、思考等级切换、工具集变化、三个重试相关事件、四个会话压缩与会话树相关事件等。这一组正好对应本章「非目标」里被我们砍掉的那几件事。
earendil-works/pi@c13ffe1第 16–26 行在 GitHub 查看 ↗
内置 API 协议共 10 种。我们 step08 只实现其中的 `anthropic-messages` 一种。
packages/ai/src/types.ts · KnownProvider
earendil-works/pi@c13ffe1第 34–72 行在 GitHub 查看 ↗
内置 Provider 共 38 个。多个 Provider 可以共用同一种 API 协议,这也是 Pi 把「协议」和「服务商」拆成两个概念的原因。
earendil-works/pi@c13ffe1第 225–231 行在 GitHub 查看 ↗
`Agent` 构造函数的最后几行,逐个给运行时选项落默认值。最后一行就是工具执行模式:调用方不指定时默认 `"parallel"`。这一行是本章「不做并行工具执行」这条非目标的对照物——`ToolExecutionMode` 只有 `"sequential"` 与 `"parallel"` 两个取值(`packages/agent/src/types.ts:42`),我们固定选了前者。

为什么这些简化是安全的

从源码结构看,我们做的简化可以归成三类,而且三类的风险完全不同:

  1. 减少覆盖面,不改结构StreamEvent 从 12 种减到 5 种、Provider 从 38 个减到 1 个,属于这一类。结构没变,你在 Mini Harness 里学到的「事件如何被消费」原样适用于 Pi。
  2. 换掉策略,保留契约。工具执行从并行改成串行、校验从 TypeBox 换成手写 30 行,属于这一类。契约(「执行前必须校验」「工具结果按 toolCallId 配回去」)没变,变的只是实现策略。这类简化会让你低估 Pi 代码量,但不会让你误解它。
  3. 整块砍掉。上下文压缩、会话树、重试,属于这一类。这一类才是需要警惕的——因为你在 Mini Harness 里根本不会遇到它们要解决的问题,很容易读 Pi 时觉得「这段代码是多余的」。所以这三块在书里都有独立章节:6.66.55.6

环境准备

你需要什么

  • Node.js 20 或更高。本书网站工程的 package.jsonengines 字段要求 >=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/ 目录(可加载的扩展示例)。

📘 概念每一步都有 demo 的原因(Non-interactive demo)
交互式命令行界面(CLI,Command-Line Interface)没法自动验证——它要等你敲键盘。所以每步都额外提供一个 src/demo.ts,用一段固定脚本代替人工输入,把同一套核心代码非交互地跑一遍,输出存进 expected-output.txt。你的 npm run demo 输出与它逐字一致,就说明这一步做对了。注意关键词是「同一套核心代码」:demo 与交互入口共享全部逻辑,只是输入来源不同,所以 demo 跑通确实能证明核心逻辑正确。

跑通 step01

bash
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
再见。

然后跑交互模式:

bash
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 监听器只注册一次,不会随循环次数堆积。

⚠️ 常见误解以为「Promise 不 settle」会像异常一样报出来
不会。抛异常至少有堆栈可看、有 `catch` 可接,而一个永远不 settle 的 Promise 在语言层面什么都不发生:没有错误对象,没有堆栈,等它的那段代码就停在那儿。

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 版本。

实践任务

🛠 实践任务跑通 step01,并亲手拆掉那场 Promise 赛跑labs/mini-agent-harness/step01-hello-cli

目标:把第一步跑起来,确认环境没问题;然后故意破坏 ask(),亲眼看看「Promise 永远不 settle」长什么样,以及 Node 会不会告诉你。

步骤一 · 跑通

bash
cd labs/mini-agent-harness/step01-hello-cli
npm install
npm run demo

把输出和同目录下的 expected-output.txt 逐字比对。再跑一次 npm start,输入两句话,用 /exit 退出。

步骤二 · 观察正常的收尾:不改任何代码,先跑一次管道输入,看清「输入读完」这条路径本来长什么样:

bash
echo 你好 | npm start

真实输出(写作时实测):

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

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

你> 再见。

管道里的输入读完之后 readline 触发 closeask() 返回 undefined,循环按「用户不聊了」收尾,打印「再见。」,退出码 0。

步骤三 · 拆掉赛跑:打开 src/main.ts,只把 ask() 里的 return 那一行改掉,去掉那场 Promise.race

ts
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 里的 inputClosedask,以及它们上方那段注释;该目录 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.tsdemo.ts 两个入口变了,agent-loop.ts 一个字符都不用改——用 diff -rq step07-abort-extensions/src step08-real-provider/src 可以自己验证。
  • 简化分三类:减覆盖面不改结构(安全)、换策略保契约(安全,但会让你低估 Pi 的代码量)、整块砍掉(需要警惕,因为你不会遇到它们要解决的问题)。
  • 关键术语:Agent Harness(Agent 运行框架)Agent Loop(Agent 循环)可辨识联合(Discriminated Union)StreamFn(模型调用抽象)HarnessEventFakeModel(假模型)JSONLServer-Sent Events(SSE,服务器发送事件)
  • 关键源码索引:packages/ai/src/types.ts:487-491Context,形状与我们一致)、packages/ai/src/types.ts:312-324StreamFunction 与「不 throw」契约)、packages/ai/src/types.ts:501-513AssistantMessageEvent,12 种)、packages/agent/src/types.ts:422-437AgentEvent,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(工具执行默认并行)。
  • 自测问题:
    1. 「零 API Key 可跑」这条约束除了方便,还带来了一个技术上必需的好处,是什么?(提示:想想 expected-output.txt 凭什么能存在。)
    2. 为什么本项目宁可让八个目录重复大量代码,也不抽一个公共包?请说出这个取舍在教学场景下成立、在产品场景下不成立的原因。
    3. 如果把 StreamFn 的契约改成「出错时直接 throw」,agent-loop.tsrender.ts 分别要多写什么?
    4. 三类简化里,为什么说「整块砍掉」那一类最危险?举出本章提到的其中一项,说明你读 Pi 源码时可能产生什么误解。
  • 下一章:8.1 最小 CLI 与 Fake Model——把 step01 的三个文件逐行读完,然后进入 step02:用可辨识联合定义 Message,把「回复」从一次字符串拼接变成一条真正的助手消息,并让 Context 开始累积对话。

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