Appearance
第41章 把零件拼成机器——Assembly
本章导读: 这一步把实现篇前面章节的六个文件(types、model-client、agent、tools、session、cli)拼成一个能跑的系统,并盘点已知限制清单。读完你会带走一条纪律:已实现和故意不做的同样重要,它们共同定义产物的真实边界。
十二个章节写完,代码、测试、命令都齐了。小a盯着满屏的绿色测试输出,长长地吐了口气,问老z:“所以……这就是一个生产 Agent 了?”
老z没有直接回答,沉默了一会儿,才说:“得到的是一份同一份能测试、能启动、且边界写明的 TypeScript 产物。不是生产保证。这两件事,别混。”
装配路径
小a:“那它到底是怎么拼起来的?”
老z画了一张装配图:
text
main.ts/runCli → runAgent → ModelClient.stream → ModelEvent
↘ Tool.execute → ToolResult → Message
↘ appendSession/trimContext小a盯着箭头看了两遍:“main 和 runCli 都通向 runAgent,但它们前面各自处理入口,后面各自处理出口——那它们是不是共享了最危险的一段?”
“对,这正是取舍的核心。”老z说,“两个入口各自负责:读环境变量、收集参数、决定 system、注入工具和审批;runAgent 只负责中间的循环,最后返回 RunResult。出口也不在 runAgent 里——main 打印最后一条 assistant 文本,runCli 把每条非空 assistant 都写给 write,退出码则统一由 exitCodeForStopReason 这个纯函数映射。你可以把这条链拆成‘入口—循环—出口’三段,任何一段替换都不影响另外两段:想要交互式输入,换入口;想要换 Provider,换 client;想要换退出语义,改 exitCodeForStopReason 的调用位置。装配的价值不是把零件焊死,而是让每个零件都有清晰的接缝。”
“关键在装配点。”老z打开 main.ts:
ts
client: new AnthropicModelClient({ apiKey, model }),
tools: createWorkspaceTools(process.cwd()),
beforeToolCall: denyBashByDefault,“main 选择真实 adapter、当前目录工具和默认审批;runAgent 才拥有消息数组和轮次。完成后 main 只打印最后一条 assistant 文本,并通过和 runCli() 共用的纯映射设置进程退出码:complete 为 0、max turns 为 2、aborted 为 130。将装配放在入口而非 loop 内,使 fake client 和不同工具集可被测试替换。”
小a追着问:“为什么不让 runAgent 自己 new 一个 client?把参数都收进去不是更省事?”
“因为那样 loop 就把‘用什么 Provider’和‘怎么循环’焊死了。”老z说,“你看 runAgent 的签名:它收的是 client: ModelClient,一个接口,不是 AnthropicModelClient。agent.ts 全文没有一处 import SDK——这就是依赖倒置的落点。loop 只认 stream() 这个方法和它吐出的 ModelEvent,至于背后是真实网络还是 fake 的内存队列,loop 根本不关心。测试里 FakeClient 直接替换它,loop 一行不改。”
“那传一个 provider: "anthropic" 字符串不是也一样吗?”小a问,“loop 里 switch 一下,一样能换 Provider。”
“字符串 switch 和接口注入的区别,在于依赖的方向。”老z说,“传字符串,agent.ts 就得 import 每家 SDK,loop 知道全部实现,依赖倒置就还没完成。传接口,agent.ts 只认 stream() 的签名,它 import 的只有 ./types.js、./context.js、./session.js——你打开文件第一行看,没有一行来自 SDK。谁持有接口,谁就掌握替换权:测试持有接口,所以测试能替换;将来真要加别的 Provider,也只改装配处,不改循环。”
“那 system 提示词呢?”小a又问,“我看到 main 传的是 You are a careful coding assistant.,runCli 又另写一句,这算不算各说各话?”
“这正是装配的边界:system 由调用方决定,不由 loop 决定。”老z指 runAgent 的参数列表里 system: string 单独成一项,“loop 把它原样塞进 stream({ system, ... }),不拼接、不覆盖。main 面向生产脚本,措辞中性;runCli 面向交互,额外强调凭据安全。两套 system 共用同一个 loop,因为规则属于入口的决策,不属于循环的逻辑。”
“那我以后能不能让 loop 自动把工作区目录也拼进 system?”小a问,“省得 main 每次都要写。”
“能,但那是循环替调用方做决定。”老z说,“stream({ system, ... }) 里 system 是原样透传,loop 不检查内容、不追加前缀。一旦 loop 开始‘帮忙’,它就必须知道‘该往 system 里塞什么’——工作区路径、用户身份、时间戳,每加一样都是 loop 对应用场景的一个新假设。main 面向脚本可以接受‘当前目录就是工作区’,runCli 却要强调凭据安全——同一个 loop 服务两种场景,恰好说明这个决策确实该留在入口。规则的拥有权,跟着‘谁知道场景’走。”
“那消息记录呢?”小a问。
老z又打开 agent.ts:
ts
const record = async (message: Message) => {
messages.push(message);
if (options.sessionPath) await appendSession(options.sessionPath, message);
};“内存消息是本次运行状态;可选 JSONL 是追加式恢复材料。持久化写入失败会使运行 reject,不应悄悄继续并声称会话已保存。”
“为什么不在最后才一次性写盘?”小a追问,“跑完再 dump 不是更快?”
“因为跑完之前可能就崩了。”老z说,“你看 record 是每条消息都 await——哪怕跑到第三轮工具执行时进程被杀,前两轮的记录已经在磁盘上了。这是追加式写入的回报:断点即终点。代价是每条消息一次 I/O,但这套产物的吞吐瓶颈从来不在这里。把‘写’和‘记’放在同一个 await 里,还有一个好处:若磁盘满或权限错,appendSession 抛错会沿着 record 一路 reject 出去,调用方拿到的就是一个失败,而不是一个‘假装成功但文件是空的’的假象。”
小a把这一段和 agent.ts 的 record 对照着看了一遍,又提出一个疑点:“那 session 文件从一开始就不存在呢?第一次 appendSession 会不会炸?”
“不会,这正是测试覆盖过的事实。”老z说,“appendFile 在文件不存在时会创建它;而恢复侧 loadSession 遇到 ENOENT 返回空数组而不是抛错——‘没有会话记录’是合法的起点,不是错误。你再顺着这条链看一遍:先 loadSession 恢复历史,再 record 新 user 消息,两条路径都碰到同一个磁盘文件。恢复把 ENOENT 当作‘从零开始’,写入用 appendFile 当作‘天然可创建’,这一对语义让 session 文件从第一次运行就能成立。”
“那 messages 数组在内存里和磁盘上是同一份吗?”小a追问。
“是同一份事实的两份视图。”老z说,“record 先 push 进内存数组,再 appendSession 落盘。内存那份是循环马上要用的:模型请求、工具分派、结果回传都读它;磁盘那份是‘进程死后还能再起来’的保障。两份数据由同一个 record 维护,避免出现‘内存说写了、磁盘说没有’的分叉。”
正常和异常控制流
小a:“那整个流程,正常和异常各自怎么走?”
“正常路径是 user message → model text 或 tool call → assistant message → tool result → 下一轮,直到无 tool call。空模型响应抛出,max turns 返回状态,取消在四个 checkpoint 返回 aborted,工具失败作为带 isError 的消息继续交给模型。”老z说,“若取消发生在工具执行期间,循环等待已经启动的工具交还控制权:真实返回会先记录,取消后抛错会形成诊断结果,尚未执行的关联调用会标成 aborted before execution。toAnthropicMessages 负责把三种内部消息转成 Provider 所需角色;它无法证明另一家 Provider 也接受同一形状。”
老z把这条链画成状态图,小a盯着看了半天:
text
[inbox] → stream 中 → 无 tool call → [complete]
│ text/call 累积
▼
assistant commit ── 有 tool call ──▶ 逐个工具
│ approval 拒绝 → 记录 reason,继续
│ 未知工具 → 记录 unknown tool,继续
│ 执行返回 → 记录结果
▼
还有未执行 call?── 否 ──▶ 下一轮
│ 是(但 signal 已取消)
▼
记录 aborted 结果,[aborted]“注意状态图里没有‘回滚’这条边。”老z补了一句,“每次转换只做两件事:要么记录事实,要么返回停止原因。工具一旦启动,它的副作用不在状态图管辖范围内——那张图的边界就是 agent.ts 的边界。”
小a盯着“四个 checkpoint”想了想:“为什么是四个,不是一个?把取消检查放在循环开头不就够了?”
“不够,因为取消是异步的。”老z说,“signal 可能在任何时候被 abort——模型正在流式吐字、approval 正在 await、工具正在执行。如果你只在循环开头看一眼,那 signal 在流式过程中被触发,loop 要等整轮流完才反应,这就不是‘协作式取消’了。你看 agent.ts 里 aborted(options.signal) 出现的地方:进循环前、流式循环里每收到一个事件、流结束之后、approval 前后、工具前后。每一处都是‘控制权交还到 loop 手里’的瞬间,loop 在这些点上才有机会看 signal。这不是为了精密,是为了不漏。”
“那四个 checkpoint 和测试是怎么对上的?”小a问。
“每个 checkpoint 都有至少一个回归测试钉住。”老z说,“cancellation.test.ts 里的八个用例,从‘首次记录前取消不改 session’到‘流完成竞态不提交 assistant’,再到‘审批期间取消后不启动工具’‘工具已返回但 signal 已触发时保留真实结果’,最后是‘一个工具完成后取消,剩余调用全部补上 aborted 结果’——你数一数,正好把四个 checkpoint 各覆盖一轮,外加纯退出码映射。测试不是‘顺便验证取消’,它是先把取消的每种交还瞬间列出来,再一个瞬间配一个用例。”
“那工具失败为什么不直接抛?”小a又问,“前面说持久化失败要 reject,这里又说工具失败转成消息。”
“因为这是两种不同的失败。”老z说,“持久化失败是‘连记忆都坏了’,loop 没法继续;工具失败是‘模型想做的事没做成’,这恰恰是模型应该知道的事实。你看工具返回的是 { content, isError: true },它被 record 成一条 tool message 交给下一轮——模型读到‘命令没找到’或‘路径越界’,自己决定要不要换个方式。把工具失败当异常抛,等于剥夺了模型自我修正的机会;而把持久化失败当消息吞,等于对调用方撒谎。区分两种失败的标准是:调用方需不需要知道,还是模型需要知道。”
“那空响应为什么又是抛?”小a指前面那条 model completed without text or tool calls。
“因为它不是‘模型想做的事没做成’,而是‘模型根本没给出可用的输出’。”老z说,“这条抛错发生在 assistant commit 之前——text === "" && calls.length === 0 时,loop 连一条 assistant 消息都没记录就拒绝继续。如果把它也转成消息,模型会读到一条‘我没说话’的自我对话,越滚越怪。空响应和工具失败的区别在于:工具失败是模型世界的正常反馈,空响应是 loop 世界的契约违约。前者交给模型,后者抛给调用方。”
老z把整条验证链列成一张表:
| 检查 | 覆盖内容 | 不覆盖内容 |
|---|---|---|
npm test | 离线 fake-client 测试,包括工具循环、取消竞态、session、CLI、schema、symlink、trim,以及三种 stop reason 的纯退出码映射和 API key 脱敏格式 | 真实网络、费用、模型质量、真实子进程 Provider |
npm run typecheck | TypeScript 静态一致性 | 运行时 Provider 契约 |
npm start -- "任务" | 真实 key/模型下的一次调用 | 无成本、无副作用或安全隔离 |
| VitePress build | 书稿链接与渲染 | example 真实 API |
fake 测试究竟证明什么
小a:“这些 fake 测试,到底能证明什么、不能证明什么?”
“test/*.test.ts 中的 fake client 以预设内部事件驱动 loop。离线测试覆盖首次记录前取消、流结束竞态、approval race、工具返回竞态、多调用剩余结果、CLI 130,以及 complete/max turns/aborted 的纯退出码映射和 main 的 API key 脱敏错误格式。”老z说,“另一组测试用手工构造的 Anthropic 事件核验 toModelEvents() 的 block index、增量 JSON、闭合和 stop reason;它仍不验证 SDK/服务端实际投递顺序、认证、限流、模型服从工具描述、账单或真实子进程的 Provider 行为。真实 API 冒烟验证只能在明确授权并接受费用的环境另行执行。”
老z把两类测试的边界列成一张表:
| 测试族 | 喂给谁 | 证明了什么 | 证明不了什么 |
|---|---|---|---|
| fake 驱动的 loop 测试 | FakeClient 预设事件序列 | 控制流、数据关联、取消竞态、退出码映射 | Provider 的真实行为 |
| 手工事件的协议测试 | fakeStream 构造的 MessageStreamEvent | block index 关联、增量 JSON 拼接、闭合校验、stop reason 一致性 | SDK 实际投递、网络、认证 |
“两族测试都不触发网络、都不实例化 SDK client,但它们回答的问题不一样。”老z说,“loop 测试回答‘循环会不会把事实记对’,协议测试回答‘Anthropic 的流能不能翻译成 ModelEvent’。toModelEvents 之所以被导出,就是为了让协议映射可以在没有 SDK 实例的情况下被构造和验证。每一族测试都把自己‘不证明什么’写清楚了,这是这套产物敢自称离线可验证的原因。”
小a:“等等,八个测试文件,到底跑了多少个用例?”
“四十个。”老z说,“agent.test.ts 六个,agent-branches.test.ts 五个,cancellation.test.ts 八个,cli-exit.test.ts 两个,context.test.ts 两个,model-client.test.ts 九个,session-tools-cli.test.ts 六个,tool-failure-session.test.ts 两个。这四十个加起来,是这套产物离线能给出的全部证据。”
“那它们合起来证明了什么?”小a追问。
“证明了控制流和数据流的形状对。”老z说,“具体说:user 进得去、assistant 带得上 toolCalls、tool 带得上 toolCallId、取消在每个 checkpoint 都能返回 aborted、退出码和 stop reason 一一对应、API key 不会泄漏进 stderr。这些都不依赖网络。但它们证明不了的是另一组问题:真实模型会不会服从工具描述、限流时 SDK 行为如何、账单多少、子进程在真实 shell 里有没有越权。这两类问题用两套工具回答:离线用 fake,在线只能用真实授权环境,中间没有捷径。”
“举个具体的例子。”老z说,“tool-failure-session.test.ts 只有两个用例,但每一个都覆盖一整条失败链:第一个让第一个工具抛出一个带敏感信息的异常,断言 session 重载后三个 call 都被闭合——失败的那个写 tool execution failed,剩下两个写 not executed after an earlier failure,且磁盘文件里搜不到那个敏感 token;第二个让 approval hook 抛错,断言工具一次都没执行、session 同样闭合、原始异常原样传回调用方。**这两个用例证明的不是‘异常长什么样’,而是‘失败之后,会话记录仍然可以重建,且不会把异常文本泄漏进磁盘’。**这叫失败语义测试——它测的是失败之后世界的形状,不是失败本身。”
故障定位
小a:“那出问题了,我该从哪查起?”
老z给了一张排障表:
| 症状 | 先看 | 常见边界 |
|---|---|---|
| 启动即失败 | main.ts 环境变量 | 未设置 key/model |
| 运行异常 | main stderr 前缀与退出码 | mini-agent: failed to start or run:,退出 1;已配置 key 被替换 |
| 轮次耗尽仍显示成功 | stop reason 映射 | main 和 runCli 都应得到退出码 2 |
| 无最终文本 | agent.ts 事件流 | fake 或 Provider 只产出 done |
| 工具反复调用 | messages 与 tool result | 工具错误描述不足、maxTurns |
| 文件被拒绝 | workspacePath | 工作区外或 symlink |
| bash 未执行 | BeforeToolCall | main 默认拒绝 |
| 会话无法加载 | session.ts 行号错误 | JSONL 记录不符合 Message |
“这张表的顺序也有讲究。”老z说,“你从上往下看:先看入口(环境变量),再看运行期(stderr 和退出码),再看循环内部(事件流),最后才怀疑工具和文件系统。**排障不是乱翻,是沿着‘装配点在哪里,就先查哪里’的顺序推进。**比如‘bash 未执行’,main 默认装配的是 denyBashByDefault,第一步该确认的就不是工具代码,而是 approval.ts 的返回——调用层没放行,工具再对也不会跑。工具代码只在调用层已经放行、仍然出错时才轮到。”
发布前清单和限制
小a:“那我能不能现在就 npm start 跑真实模型?”
“可以,但先看清楚代价。”老z说,“发布前运行安装、测试、typecheck,并审查依赖锁定、环境变量、工作区权限和日志。真实 start 会调用外部 API,必须由操作者提供 ANTHROPIC_API_KEY、ANTHROPIC_MODEL 并承担费用和进程权限风险;它不是本书默认验收命令。”
“那这本书没讲到的,还剩多少?”
老z一条条数:“**已知限制:**取消是协作式的,不能回滚已启动副作用,也不能强制结束不响应 signal 的工具;main 当前没有 signal 装配,所以不能把 exit code 130 解释为已实现 Ctrl-C;main/runCli 尚未装配 sessionPath。此外,无真实 Provider 端到端测试、无重试/限流/并发队列、无 token 级预算或摘要、无 REPL/流式 UI、无 MCP/Skill/多 Agent、无网络控制或 OS sandbox。”
老z把这份清单里最容易被误读的几条挑出来,又补了一张表:
| 已知限制 | 现在的行为 | 用户需要知道 |
|---|---|---|
| 取消是协作式的 | AbortSignal 只请求停止 | 已启动的副作用可能跑完,调用方要自己兜底 |
| main 没有 signal 装配 | exitCodeForStopReason("aborted") 能返回 130 | 130 是纯映射,不是已实现的 Ctrl-C |
| main/runCli 无 sessionPath | 进程退出即丢历史 | 持久化只在显式传 sessionPath 时开启 |
| 无真实 Provider 端到端测试 | 离线测试全部通过 | 在线行为要以授权环境冒烟为准 |
| 无重试/限流/并发队列 | 失败即返回错误 | Provider 抖动要调用方自建策略 |
小a:“这么多‘没有’,会不会让人以为这产物没法用?”
“恰恰相反,把它列清楚才有人敢用。”老z说,“你想想,一份文档说‘支持取消’,但它没说取消能不能回滚已经写出去的文件——用户会以为能。现在这份清单写的是‘取消是协作式的,不回滚副作用’,用户就知道:调用方要自己接受‘工具一旦启动就可能跑完’这个事实。**限制清单不是免责声明,是使用契约。**每一个‘没有’都在告诉使用者:这一块要你自己在外面补。”
“那 main 没有 signal 装配,这条怎么理解?”小a问,“exitCodeForStopReason 明明能返回 130。”
“返回 130 和真的响应 Ctrl-C 是两件事。”老z说,“exitCodeForStopReason("aborted") 是一个纯映射,它的输入是 RunResult.stopReason。要让 stopReason 真的变成 aborted,必须有人给 runAgent 传一个已经 abort 的 signal。main 里 runAgent 没有传 signal 字段,所以即使你在终端按 Ctrl-C,进程收到的 SIGINT 会走 Node 默认行为,不会触发 loop 的 checkpoint。**清单写这一条,是为了防止有人看到 130 就以为 Ctrl-C 已经实现。**映射完整 ≠ 功能完整。”
“那假设我真的想实现 Ctrl-C,要动哪几处?”小a问。
“两条小改:main 里建一个 AbortController,把它的 signal 传给 runAgent,再监听 SIGINT 调用 controller.abort()。就这样——loop 侧的 checkpoint 已经备好了,缺的只是入口把它接上。”老z说,“这正是‘装配点’的又一次体现:取消逻辑的实现成本全在 loop 与 checkpoint,入口接线的成本只有三五行。已知限制清单的价值就在这里——它告诉你某样东西缺的是‘功能’还是‘接线’。”
“那以后怎么演进?”小a问。
“后续演进应先以失败证据证明需要,再沿第37章 为未来留三个插孔——Extensions中的三个已实现接口或重新设计的契约推进。”
小a靠在椅背上,忽然想起入职第一天。他把手机翻出来,找到当初引子里那段让他一头雾水的故障日志:模型流中断、工具参数不合法、会话恢复失败。
“现在再看这三条,你分别会怎么查?”老z问。
小a指着屏幕,一条条说:“模型流中断——查 toModelEvents 的 protocolError,是不是 block 没闭合;工具参数不合法——查 toolInput 和 stringField,是不是输入不是对象;会话恢复失败——查 loadSession 的行号错误,是不是 JSONL 记录不符合 Message。”
老z笑了:“三个月前你连‘从哪个入口开始查’都说不出来。现在你能指出具体文件、具体函数、具体失败路径——这就是这本书给你的东西:一套能落地的共同语言,和一个你自己写出来、跑起来、测过的 Agent。”
小结
mini-agent 的价值不在功能全,而在控制流、数据类型、测试边界和已知限制能被同时阅读。全书从概念的十七章、源码的十二章,走到这里的十二章实现,落地产物是六个文件:types、model-client、tools、session、context、cli,四十个测试全部通过。它刻意把范围收得很紧——取消是协作式的,不会回滚已经发生的副作用;main 没有装配 signal,所以退出码 130 不等于 Ctrl-C 已经实现;MCP、Skill、多 Agent、重试、REPL、流式 UI、网络控制、OS sandbox,统统不在里面。这些缺位不是疏忽,而是经过权衡的设计决策:每一个被移出范围的功能,都对应着一个"没有失败证据就不该预先实现"的判断。
已知限制清单和已实现清单一样重要,因为它们共同定义了产物的真实边界。把"能启动"误读成"能在任意环境安全运行",是对这份产物最大的误读。后续的演进应该沿着一条纪律推进:先有失败证据证明某个能力确实需要,再沿三条已实现的接口或重新设计契约去实现它,同时配回归测试——而不是预先造一堆"以防万一"的抽象。这本书到此结束,但它建立的"可验证边界"的思维方式,才刚刚开始。
回头看这一章走过的装配路径,会发现一个反复出现的模式:main 只负责选零件、接线、定退出码,runAgent 只负责循环与记录,toModelEvents 只负责协议翻译,FakeClient 只负责离线替身。每一层都不知道上一层在做什么——main 不知道 checkpoint 长什么样,loop 不知道 SDK 怎么吐事件,测试不知道网络怎么走。这种"各守一段、接口相连"的划分,正是前面十二个实现章节逐层沉淀的结果:没有一个文件是"大而全"的,但把它们拼起来,控制流、数据流和失败路径都自洽。装配章的验收标准不是"能启动",而是"能说清楚每一段由谁负责"。
阶段验收
sh
cd examples/mini-agent
npm ci --ignore-scripts
npm test
npm run typecheck确认测试没有设置真实 key;若另行运行 npm start -- "Describe the workspace",先确认环境变量、费用、当前目录和进程权限。