Skip to content

第30章 白纸与边界——Scope ​

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

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

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

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

“那你这半年都搭不完。”老z敲了敲桌子,“我们换个方式:**先决定不做什么。**实现篇从一个具体场景开始——在受限工作区内,让编码助手读取、写入或执行命令,并把每一步留为可检查消息。小a和老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按住他的手:“先别急着写文件。看看这张目录图——目录就是架构图:”

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盯着 AsyncIterable<ModelEvent> 问:“为什么是异步迭代器,不是 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盯着 rounds.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 不是越智能越好,它的价值是确定性——每一轮吐什么,由测试显式声明。”

“那 FakeClient 和真实 AnthropicModelClient 用同一个接口,会不会让我误以为真实行为也和 fake 一样简单?”

“这就是为什么要强调边界。”老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 收到 done 之后还要做什么?”

“还要看累计。”老z说,“你看 agent.ts 里这一段:流式循环把 text 累加进 text 变量,把 tool_call 推进 calls 数组。流结束后,若 text === "" && calls.length === 0,直接抛 model completed without text or tool calls。所以 done 的真实含义是‘流结束了’,不是‘有答案了’。**这条校验是契约的牙齿——它逼着 loop 不把空响应当成功。**测试里专门有一个用例喂了一轮只有 [{ type: "done" }] 的 fake,就是为了让这条抛错路径被覆盖。”

小a盯着 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() 的契约衔接,谁都不用为谁让步。”

“那如果我以后要支持 system 的历史呢?”小a追问,“比如多轮里换 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 一家真实实现,所以测试只能覆盖协议映射,不能覆盖跨厂商一致性。”

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

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

小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" }];
  }
}

“rounds 是确定的事件队列;每次 stream() 消耗一轮。它不会创建 SDK、读取密钥或打开网络。”老z说,“因而测试能证明本地控制流,不会证明 API key、网络、服务端模型或账单正确。记住这条边界——它决定了我们能承诺什么。”

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

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

“那 FakeClient 的 shift() ?? [{ type: "done" }] 这个默认值呢?”小a指那一行,“为什么 rounds 用空了就给个 done?”

“因为这是‘模型没话说’的最简情形。”老z说,“测试里如果你想验证空响应抛错,就传 [[{ type: "done" }]]——一轮,里面只有一个 done。loop 收到它,发现 text 和 calls 都空,就抛 model completed without text or tool calls。这个默认值让‘忘记填 rounds’也不会让测试卡住,但它本身不是测试重点——测试重点是用你显式构造的事件序列去触发特定的控制流分支。”

“那这些分支测试合起来,覆盖的边界有多宽?”小a问。

“你数一数 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。这些命令不需要密钥。