Skip to content

Part III · 实现篇 / Ch 12

实现篇第12章 把零件拼成机器——Assembly ​

小A与老Z把不同模块接成一台小型 Agent,并检查测试灯,未使用的零件留在边界之外。

本章导读: 这一步把实现篇前面章节写出的源文件(types、model-client、agent、tools、approval、session、context、cli)由 main 拼成一个能跑的系统,并盘点已知限制清单。读完你会带走一条纪律:已实现和故意不做的同样重要,它们共同定义产物的真实边界。

十二个章节走完:前七章的代码、测试和命令都齐了,后四章的扩展、Hooks、Skill 和 MCP 留下了推演笔记。小A盯着满屏的绿色测试输出,长长地吐了口气,问老Z:“所以……这就是一个生产 Agent 了?”

老Z沉默了一会儿:“得到的是同一份能测试、能启动、且边界写明的 TypeScript 产物。不是生产保证,这两件事别混。”

装配路径 ​

小A把刚写完的几个文件从上往下翻了一遍,问老Z:“单看每个都懂,合起来是从哪儿进、往哪儿走?”

老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 通过和 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 一行不改。”

小A换了个思路:“传一个 provider: "anthropic" 字符串不是也一样吗?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,因为这条规则属于入口的决策,不属于循环的逻辑。”

小A顺着想:“那我能不能让 loop 自动把工作区目录也拼进 system?省得 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 消息,两条路径都碰到同一个磁盘文件。这一对语义合起来,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 typecheckTypeScript 静态一致性运行时 Provider 契约
npm start -- "任务"真实 key/模型下的一次调用无成本、无副作用或安全隔离
VitePress build书稿链接与渲染example 真实 API

fake 测试究竟证明什么 ​

测试都过了,小A心里却没底:“这些用例跑的全是假的 client,真能替我担保什么、担保不了什么?”

“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 构造的 MessageStreamEventblock 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 里有没有越权。”

“举个具体的例子。”小A说。

“tool-failure-session.test.ts 只有两个用例,但每一个都覆盖一整条失败链。”老Z说,“第一个让第一个工具抛出一个带敏感信息的异常,断言 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 未执行BeforeToolCallmain 默认拒绝
会话无法加载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") 能返回 130130 是纯映射,不是已实现的 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问。

“后续演进,先拿失败证据证明确实需要,再沿实现篇第8章 为未来留三个插孔——Extensions中的三个已实现接口或重新设计的契约推进。”

小A靠在椅背上,忽然想起入职第一天。他把手机翻出来,找到当初引子里那段让他一头雾水的故障日志:模型流中断、工具参数不合法、会话恢复失败。

“现在再看这三条,你分别会怎么查?”老Z问。

小A指着屏幕,一条条说:“模型流中断,查 toModelEvents 的 protocolError,是不是 block 没闭合;工具参数不合法,查 toolInput 和 stringField,是不是输入不是对象;会话恢复失败,查 loadSession 的行号错误,是不是 JSONL 记录不符合 Message。”

老Z笑了:“三个月前你连‘从哪个入口开始查’都说不出来,现在你能指出具体文件、具体函数、具体失败路径。这就是这本书给你的东西:一套能落地的共同语言,和一个你自己写出来、跑起来、测过的 Agent。”

小结 ​

mini-agent 的价值在于控制流、数据类型、测试边界和已知限制能被同时阅读,功能齐全与否倒在其次。全书从概念的十七章、源码的十二章,走到这里的十二章实现,落地产物是九个源文件:types、model-client、agent、tools、approval、session、context、cli、main,四十个测试全部通过。它刻意把范围收得很紧:取消是协作式的,不会回滚已经发生的副作用;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",先确认环境变量、费用、当前目录和进程权限。