Skip to content

Part III · 实现篇 / Ch 01

实现篇第1章 白纸与边界——Scope ​

小A与老Z用橙色细绳围出有限的工作区,将文件夹、终端和模型球留在线内,网络球与额外机器人则置于线外。

本章导读: 这一步不写一行逻辑代码,只做一件事:把“做”和“不做”同时写进清单。你会建立 examples/mini-agent 的骨架、FakeClient 测试替身,以及一份明确的非目标列表(MCP、Skill、多 Agent、retry 都不做)。

实现篇开工那天,小A抱着一个空目录来找老Z,里面只躺着 package.json 和一张白纸。他把 pi-mono 的源码目录截图摆在旁边,一脸豪迈:“我打算照着它写一个自己的 Agent。”

老Z看了一眼,没有接话,先问了个问题:“你打算写多大?”

“多大?”小A愣了,“就……把 pi 的功能都实现一遍?”

“那你这半年都搭不完。”老Z敲了敲桌子,“换个方式:先决定不做什么。实现篇从一个具体场景开始:在受限工作区内,让编码助手读取、写入或执行命令,并把每一步留为可检查消息。咱们结对的第一件事,是把范围写进代码库,而不是先堆功能。”

小A:“那范围到底怎么定?我怕写小了,被人说‘这不算 Agent’。”

“定范围不看‘像不像 Agent’,看每一行承诺能不能验证。”老Z把一张表推到小A面前:

验收项当前证据非目标
模型事件与工具回路test/agent.test.ts多 Provider、自动重试
工作区工具src/tools.tsOS 沙箱、跨文件事务
JSONL 与字符预算src/session.ts、src/context.ts摘要压缩、长期记忆
一次命令行入口src/main.ts、src/cli.ts完整 REPL、TUI、RPC

“你看,每个功能旁边都写着它不做什么。”老Z说,“范围交付的是两样东西:可验证的承诺,和明确的非目标。”

“可验证承诺”这四个字让小A停住了:“‘能验证’怎么算?谁来验证?”

“两条路。”老Z说,“一条是测试文件能证明的:agent.test.ts 覆盖模型事件与工具回路,tools.ts 的行为由工具测试钉住;另一条是命令能复现的:npm test、npm run typecheck、npm start -- "任务",每一个都有确定输出。所谓‘可验证’,是一个刚接手的人照着文档跑一遍,能亲眼看到承诺兑现。反过来,凡是‘只有作者知道跑没跑通’的东西,比如‘多 Provider 支持’,就该待在非目标栏里,不进承诺栏。”

小A把范围文档往下翻,念出其中一条:“模型输出、文件内容和工具参数,都视为不可信……”

“对。路径检查只能形成路径边界,不能阻止 TOCTOU,也不能限制 shell 已拥有的网络、进程或凭据权限。”老Z说,“这一条决定后面每一章怎么写代码。”

工作场景与用户故事 ​

小A:“那我到底在造什么?用户拿它干嘛?”

老Z:“先把用户故事讲清楚:”

用户故事

用户说“把配置文件改掉并跑检查”,这个需求看起来很小,为什么不直接让模型执行 shell?

因为用户真正委托的是一条可检查的工作流:模型提出动作,程序验证边界,执行器返回结果,下一轮再根据事实决定。用户不是把机器的全部权限交给模型,而是要求它在当前工作区协助完成一件事。

小A:“正常流程和失败流程,都得有?”

“都有,失败故事同样是产品。”老Z说,“这个参考实现的正常故事是:用户给出文本任务;runAgent() 将任务写入消息;fake 或生产客户端返回文本或工具请求;工具在工作区内执行;结果再次进入消息。失败故事也一样:缺少模型配置、工具参数不合法、路径逃逸、审批拒绝、空模型响应、预算不足,每一种都必须留下清楚结果或异常,不能伪装成完成。”

小A把“失败故事同样是产品”记了下来,又问:“每个失败都要写成用户故事吗?会不会太啰嗦?”

“不用写成故事,但范围里得给每个失败一个位置。”老Z说,“你数一数刚才那一串:缺少模型配置是启动期失败,由 main 抛错;工具参数不合法和路径逃逸是执行期失败,由工具返回 isError 结果;审批拒绝由 approval hook 决定;空模型响应由 loop 抛异常;预算不足由 trimContext 抛错。每个失败都有归属层,没有一个是‘到时候再说’。范围文档的任务,是确认每一种失败都有人负责:谁抛、抛给谁、记录成什么样。这张归属表在,后面每一章的代码才有验收对象。”

“预算不足为什么抛错?截断一下不行吗?”小A追问。

“trimContext 的裁剪单位是‘最小的自洽消息组’,一组带结果的工具调用。预算小到连一组都装不下,裁剪就无从谈起,这时候静默返回空数组会毒化整个上下文。”老Z说,“context.test.ts 里专门有一条 assert.throws(() => trimContext(messages, 1), /smallest consistent/),钉住的就是这个语义。”

目录是架构图 ​

小A打开编辑器,准备从空目录开始搭。老Z按住他的手:“先别急着写文件。目录先照这个摆:”

下面的目录树是核心文件节选,不是最终完整清单。src 下实际有 9 个 TypeScript 文件,test 下有 8 个测试文件。

text
examples/mini-agent/
  src/types.ts          契约
  src/model-client.ts   Provider 适配器
  src/agent.ts          控制循环
  src/tools.ts          工作区能力
  src/session.ts        JSONL 记录
  src/context.ts        字符预算
  test/agent.test.ts    fake 集成测试

小A:“就这么几个文件?”

“够了。重点是依赖方向:从 types.ts 向外,模型客户端和工具实现契约,Agent 组合它们,测试再替换模型客户端。这样测试不需要网络,也能覆盖从模型‘提出调用’到文件被写入的完整控制流。”

老Z画了一张依赖方向的图:

text
types.ts(契约,不依赖任何人)
    ▲          ▲
model-client.ts   tools.ts(实现契约)
    ▲          ▲
      agent.ts(组合两者)
          ▲
      test/(替换 model-client 为 fake)

“注意,这张图里 agent.ts 是唯一‘知道两边’的文件。”老Z说,“它 import 契约类型,调用 client.stream(),也调用 tool.execute(),两侧依赖全是接口。test/ 站在最顶上,替换的是 model-client.ts 那一侧,tools.ts 这一侧保持真实。为什么不全 fake?工具是副作用边界,测试恰恰要看真实的文件读写有没有被正确约束。‘模型可替、工具真实’,为的就是让每个文件都待在它该被测试的位置。”

“session.ts 和 context.ts 呢?图里没画。”小A指着目录清单。

“它们不在主链路上,是 agent 的附加能力。”老Z说,“agent.ts 在循环外加载会话、在请求前裁剪上下文,都没有动‘模型提出、宿主执行’这个主干,只是改了发给模型的 messages 长什么样。这张目录图的完整读法是:主干三条线,契约、客户端、工具;agent 是组合点;session 和 context 是组合点上的两个可选插件。”

可注入模型是测试边界 ​

小A:“‘模型’这块怎么处理?我可不想测试时真去调 API。”

老Z在 types.ts 里敲出一段:

ts
export interface ModelClient {
  stream(request: { system: string; messages: Message[]; tools: ToolSpec[]; signal?: AbortSignal }): AsyncIterable<ModelEvent>;
}

“stream 的输入同时包含规则、历史和工具说明;输出是异步事件,不是一个字符串。signal 由调用者拥有,所以中止不是 SDK 私有状态。”老Z说,“接口没有写厂商字段,FakeClient 才能按预设事件实现它;这只是依赖倒置,不意味着实现自动支持多个 Provider。”

小A把接口从头读到尾,在返回类型上停住:“为什么是异步迭代器,不是 Promise<string>?一次性返回一个字符串不更简单?”

“因为模型的真实输出本来就是流。”老Z说,“ModelEvent 有三种:text、tool_call、done。接口只返回一个字符串,‘模型边想边说’和‘模型决定调工具’就被压扁成了一个值。更要命的是取消:stream 接受 signal,调用方能在流式过程中 abort;换成 Promise<string>,你要么等到底,要么得另外发明一套取消协议。”

“那 FakeClient 怎么用这个接口?”小A接着问。

“它把‘流’退化成内存数组。”老Z指 test/agent.test.ts 里的那段:

ts
class FakeClient implements ModelClient {
  constructor(private readonly rounds: ModelEvent[][]) {}
  async *stream(): AsyncIterable<ModelEvent> {
    yield* this.rounds.shift() ?? [{ type: "done" }];
  }
}

“rounds 是个数组的数组:外层每一项代表一轮对话,内层是这一轮要吐出的事件。每次 stream() 被调,shift() 弹出第一轮,逐个 yield。它实现的是同一个 ModelClient 接口,loop 一行不改就能换上它。这就是依赖倒置的回报:契约先定,真实和 fake 各自实现,测试不需要网络。”

小A顺着 shift() 那行往下想:“要是测试忘了填够轮数呢?fake 备了两轮数据,loop 跑了三轮。”

“那就走默认值:this.rounds.shift() ?? [{ type: "done" }]。”老Z说,“第 N+1 次调用 stream() 时队列已空,fake 吐一个 done,loop 发现没文本也没调用,抛 model completed without text or tool calls。‘轮数不够’因此变成一次立刻可见的失败,而不是让测试卡在某个永不 resolve 的 await 上。fake 的价值就在确定:每一轮吐什么,由测试显式声明。”

小A把 fake 和接口又对照了一遍:“FakeClient 和真实 AnthropicModelClient 用同一个接口,会不会让人误以为真实行为也这么简单?”

“所以要分清边界。”老Z说,“fake 只复刻了 stream() 的签名,没复刻 SDK 的投递顺序、认证、限流、费用。测试里 FakeClient 一次都不会 new Anthropic,model-client.test.ts 也刻意不实例化 SDK,它用手工构造的 MessageStreamEvent 直接喂 toModelEvents。fake 证明的是‘模型照我说的吐出这些事件时,loop 会怎样’;真实模型会不会照说,是另一张测试网的事。”

契约先于代码:types.ts 是地基 ​

小A正要动手写工具,老Z按住他:“工具先不写。这一篇所有代码都围绕同一套类型转,先把 src/types.ts 里的契约定死,后面的代码才不会互相打架。”

老Z打开 types.ts,把三组核心类型指给小A看:

ts
// src/types.ts,节选
export type Message =
  | { role: "user"; content: string }
  | { role: "assistant"; content: string; toolCalls: ToolCall[] }
  | { role: "tool"; toolCallId: string; content: string; isError: boolean };

export type ToolCall = { id: string; name: string; input: unknown };

export type ModelEvent =
  | { type: "text"; text: string }
  | { type: "tool_call"; call: ToolCall }
  | { type: "done" };

“第一个是 Message,会话里唯一的事实记录。”老Z逐行讲,“三种角色:user 是用户的话;assistant 是模型的话,必须带 toolCalls,可以为空数组;tool 是工具结果,必须带 toolCallId 和 isError。为什么这么设计?循环后面要做的每件事,记录、恢复、审计、裁剪,都依赖这三者的关联。tool 结果要是不带调用 ID,恢复会话时你根本不知道这个结果是谁要的。”

“第二个是 ToolCall,模型提出的请求,还没执行。input 是 unknown,因为模型和网络都能给出任意 JSON,类型在这里不撒谎。”

“第三个是 ModelEvent,模型流的三种事件。done 不携带‘回答一定有效’的结论,循环还要检查是否累计了文本或工具调用。”老Z说,“这三个类型定下来,后面每一章(29 的模型流、30 的循环、31 的工具、32 的会话)都只是它们的展开。”

小A在 done 上停了一下:“done 不保证回答有效,那 loop 收到它之后还要做什么?”

“看累计。”老Z说,“agent.ts 里这一段:流式循环把 text 累加进 text 变量,把 tool_call 推进 calls 数组。流结束后,若 text === "" && calls.length === 0,直接抛 model completed without text or tool calls。所以 done 的真实含义是‘流结束了’,不是‘有答案了’。测试里专门有一个用例,喂了一轮只有 [{ type: "done" }] 的 fake,就是为了让这条抛错路径被覆盖。”

小A往回翻到 agent.ts,果然找到那行判断,又指回 Message:“为什么没有 system 角色?”

“system 是 stream() 请求里的独立字段(system: string),不是会话历史里的一条消息。”老Z说,“角色怎么建模,取决于它参与什么逻辑。system 不参与‘恢复会话’‘裁剪历史’,所以它不在 Message 里。别照抄别人的模型,先想清楚你的代码需要什么。”

“那 toolCalls 为什么必须挂在 assistant 上,不能单独成一个角色?”小A继续问。

“因为工具调用是 assistant 行为的一部分。”老Z说,“模型在一轮里既说了话又请求了工具,这两件事属于同一条 assistant 消息;toAnthropicMessages 也是把它们映射进同一个 content 数组,文本作为 text 块,调用作为 tool_use 块。拆成两个角色,恢复会话时就确认不了‘这条 tool result 对应哪次提出’。Message 的三种角色不是照抄 Anthropic 的 API,是‘记录、恢复、审计、裁剪’这四件事共同需要的形状。”

小A:“ModelEvent 的三种和 Message 的三种为什么对不上?事件里没有 isError 啊。”

“事件描述‘模型正在说什么’,消息描述‘会话记下了什么’。”老Z说,“模型只会给你三段流:吐字、请求工具、结束。isError 是宿主在工具执行后才贴上去的判断,不来自模型流,所以不在 ModelEvent 里;它落在 tool 消息上,因为那是会话必须回放的事实。两套类型各管各的,中间由 stream() 的契约衔接。”

小A又想到一步:“以后要支持 system 的历史呢?比如多轮里换 system。”

“到那时再加。”老Z说,“现在把 system 角色放进 Message,意味着 loadSession 要解析它、trimContext 要考虑裁不裁它、toAnthropicMessages 要把它映射成 provider 的字段。而当前产物里,system 每次调用都由入口重新决定,不进历史。先不做,是避免为一个还没有失败证据的需求预先付复杂度税。等真出现‘system 要随轮次变化’的场景,再加这一层,并配上回归测试。”

依赖与非目标的决策 ​

小A拿起 package.json:“依赖怎么选?”

“生产依赖固定到 @anthropic-ai/sdk@0.91.1,开发工具固定 TypeScript 与 tsx。固定版本让本书所述的类型和事件形状可复查,但它不保证未来 SDK 兼容。”老Z说,“不引入 Agent 框架,为的是让状态变化留在少量文件里。”

老Z列了一张决策表,小A逐行看:

选择收益代价与未覆盖
一个 ModelClient可 fake、可替换没有 Provider 目录或重试
三个工具可演练副作用无 edit、grep、并发队列
JSONL可追加、易检查无索引、加密、fork
字符裁剪行为确定不是 token、没有语义摘要
一次入口退出语义简单不是 REPL/TUI

“每一行都是‘我故意不做什么’。”老Z说,“你把它念完,范围就清楚了。”

小A指着“一个 ModelClient”那行问:“这里写‘没有 Provider 目录或重试’,可接口明明没绑死厂商,我以后加 OpenAI 不行吗?”

“能加,但要付两层代价。”老Z说,“第一层是新的 stream() 实现:你得把另一家的流式协议翻译成 ModelEvent,就像 toModelEvents 之于 Anthropic。第二层是 toAnthropicMessages 这个函数名本身就泄露了厂商,它只负责把内部 Message 翻译成 Anthropic 的 MessageParam。换厂商,要么把这一层抽象成 toProviderMessages,要么每家一个函数。接口可注入,不等于多 Provider 已支持。‘一个 ModelClient’这一行写的就是当前事实:只有 Anthropic 一家真实实现,测试只能覆盖协议映射,覆盖不了跨厂商一致性。”

“这个‘能加但不支持’的边界,会不会让以后接手的人困惑?”

“所以要把话说透。”老Z说,“model-client.test.ts 里的 toModelEvents 被导出,就是为了‘不用构造 SDK client、不用发网络请求就能测’;AnthropicModelClient 的注释也写明‘生产适配器,测试用 fake 且从不实例化它’。每一处‘能加’的边界,代码里都有注释钉住它为什么到此为止。接手的人不用猜‘能不能加 OpenAI’,注释里写明:可以加,要付两层代价,且当前测试不覆盖。”

小A:“那为什么开发依赖也要固定版本?不用最新不行吗?”

“固定到 @anthropic-ai/sdk@0.91.1,是因为 toModelEvents 依赖 SDK 的 MessageStreamEvent 类型形状。”老Z说,“tool_use 块的 input 可能一开始就是完整对象,也可能靠 input_json_delta 增量拼出来,这个行为是 SDK 版本给的。哪天 SDK 改了投递顺序,本书写的‘block index 关联、增量 JSON 拼接、闭合校验’就可能失真。固定版本让书里写的类型和事件形状可复查,代价是未来升级要重跑测试,升级因此变成一次显式动作。”

验收从失败开始设计 ​

小A:“测试怎么写?Agent 又不像函数,一个输入一个输出。”

老Z打开 test/agent.test.ts:

ts
class FakeClient implements ModelClient {
  constructor(private readonly rounds: ModelEvent[][]) {}
  async *stream(): AsyncIterable<ModelEvent> {
    yield* this.rounds.shift() ?? [{ type: "done" }];
  }
}

“还是那个 FakeClient,rounds 是确定的事件队列,每次 stream() 消耗一轮,它不创建 SDK、不读取密钥、也不打开网络。”老Z说,“所以测试能证明的是本地控制流。API key、网络、服务端模型、账单,它一概证明不了。这条边界决定了我们能承诺什么。”

小A:“‘从失败开始设计’具体怎么落地?我总不能凭空想一堆失败吧。”

“凭空想不行,但有路径。”老Z说,“你看那几个失败用例的来源:空模型响应、坏工具参数、未知工具名、审批拒绝、路径逃逸、坏会话记录。它们不是拍脑袋出来的,是顺着‘模型输出不可信、文件内容不可信、工具参数不可信’这条威胁模型反推的。每一条威胁对应一个失败路径,每一个失败路径对应一个测试。先有威胁,再有失败语义,再有测试,最后才是让测试通过的代码。”

小A抢答:“那我反过来,先把主流程跑通,失败用例回头补?”

“顺序一反,测试就只剩‘代码写完,补一个 happy path’。”

“空响应这个用例怎么造?”小A翻到 fake 那段,“传 [[{ type: "done" }]] 就行?”

“对,一轮,里面只有一个 done,这是‘模型没话说’的最简情形。”老Z说,“loop 收到它,发现 text 和 calls 都空,就抛 model completed without text or tool calls。shift() 后面那个 ?? [{ type: "done" }] 只是兜底,免得‘忘记填 rounds’把测试卡住;真正的测试重点,是用你显式构造的事件序列去触发特定的控制流分支。”

“这些分支测试合起来,边界盖到多宽?”

“你数一数 agent.test.ts 和 agent-branches.test.ts 在测什么。”老Z说,“写文件走通工具回路、schema 和 toAnthropicMessages 的参数校验、read 截断与 symlink 逃逸、坏 session 记录、多工具单元的裁剪不拆散、denyBashByDefault 拒绝、空响应抛错、坏工具参数转 tool 错误、未知工具名、审批拒绝的 reason、max turns 停止、pre-abort 不开流。每一条背后都是一个‘如果这里错了会怎样’的问句,答完才写断言。这一章的验收标准,是威胁模型里的每一条都有对应的失败语义;测了多少行,说明不了这个。至于那些没有对应测试的边界(真实网络、费用、模型服从性),本来就在范围线外。”

老Z把范围线的样子画了出来,让小A带回去贴显示器上:

text
  线内:能测的承诺 ── fake client、工具回路、JSONL、裁剪、退出码
  线外:不能离线测的 ── 真实 Provider、网络、费用、模型质量
  虚线:依赖外部环境 ── npm start、VitePress build(有条件才跑)

“线内的东西,npm test 一把梭;虚线的东西,要环境和授权才动;线外的,文档写明‘不在本产物承诺内’。”老Z说,“拿不准一个东西放哪边,就回头问:它现在在线的哪一边,它该在哪一边。”

小结 ​

实现篇的第一步是画一条线:线内是可验证的承诺,线外是明确不做的事。mini-agent 的承诺很克制,“可运行、可测试、能读懂的控制流”,这三句话既是目标也是验收标准。FakeClient 用确定性的事件队列顶替真实模型,让本地测试既不依赖网络也不消耗密钥,从而把“控制流对不对”和“链路通不通”分成两件可以独立验证的事。线外的东西同样重要:MCP、Skill、多 Agent、重试、REPL、流式 UI,全在非目标清单上。每一项不做都有理由:它们要么需要真实 Provider 才能验证,要么会引入尚未被失败证据证明的复杂度。

这条线最大的陷阱是把“跑通了”当成“做完了”。npm test 通过只证明控制流正确,不证明 API key 有效、网络可达、账单可控。范围克制的纪律在于:每次想往线内加东西时,先问“它的失败语义我能测吗”,不能测的,先留在线外。

回头看这一章定下的骨架,会发现三个决定贯穿了后面所有章节:类型先于实现(types.ts 是地基,后面每一章都只是它的展开)、依赖方向单一(契约向外,agent 组合,测试在最顶层替换)、失败语义先行(每一条威胁先有归属,再有测试)。这三个决定,是把范围画线、把承诺分级之后自然长出来的。范围文档是后续每一行代码的验收依据。后面章节里每写一个函数,都可以回头问:它在 agent.test.ts 的那条承诺里,还是在那张非目标表里?

骨架已经立起来,后面章节的每一行代码,都要回到这张图上来对答案:写循环时问“它是否还在只处理 ModelEvent 三态”,写工具时问“它是否仍落在工作区边界内”,写会话时问“它是否还守着 JSONL 的追加语义”。这个参考实现承诺的,始终是一套“可运行、可测试、能读懂”的控制流,而不是一个全能的生产 Agent。记住这句话,后面每一章才不会跑偏。

阶段验收 ​

在 examples/mini-agent 执行 npm ci --ignore-scripts、npm test、npm run typecheck。这些命令不需要密钥。