Skip to main content

保存一次完整的工具往返

你将得到什么

用户说:“请读取 README,然后告诉我项目名。”模型没有立刻回答项目名。它先说明 自己要做什么,接着请求 read 工具;工具读完文件后,又把内容交回 Agent。 这次往返可以保存成三个对象:
数组顺序就是事实发生的顺序。assistant 的 content[0] 是它给用户看的说明, content[1] 才是工具请求。工具返回的对象使用 toolCallId: "call_1",所以 Agent 知道这份 README 内容回答的是哪一次请求。 这三个对象已经足够表达一次工具往返。课程把这种由 Agent 自己定义、可以保存和重放 的统一表示称为 canonical message,也称消息 IR(intermediate representation)。 Provider 的请求体和终端显示文字都可以由它转换出来,但它们不会取代这三个原始对象。 完成这一章后,你会得到:
  • types.ts 中三种消息、两种 content block、五种结束原因和模型事件;
  • event-stream.ts 中专门运送模型事件并返回最终 assistant message 的流;
  • 一个只读取文本、却不会改写原消息的 textOf()
Checkpoint 03 · 保存一段工具往返模式: 重建。起终点: parent 是第 02 章完成后的起点快照;target 是这两份教学文件完成、聚焦测试 通过后的终点快照。教学文件:
  • packages/pi-course/src/types.ts
  • packages/pi-course/src/event-stream.ts
动手前只需知道: transcript 用 role 与 content block 保留消息来源和顺序; ModelEvent 描述生成过程,AssistantMessage 是过程结束后保存的结果。第一次红灯: 在 parent 上运行 build,会报告没有导出 AssistantMessageEventStream,同时找不到 ../src/types.js。两条错误分别指向上面的 两份教学文件。第一步: 先不看 target diff。运行 build 记录红灯,然后在 types.ts 写出消息和 helper;在 event-stream.ts 声明临时 AssistantMessageEventStream。实践 3.1 只运行 文本投影测试,实践 3.2 再完成终态映射。聚焦测试: packages/pi-course/test/03-message-ir.test.ts定位命令: npm run checkpoint -w @pi/course -- 03练习目录: npm run practice -w @pi/course -- 03聚焦运行: npm run build -w @pi/course,然后 node --test packages/pi-course/dist/test/03-*.test.js通过证据: 2 项聚焦测试通过。第一项证明文本投影不修改原 content;第二项证明 error 会自行结束流,并且 result() 返回事件中同一个 AssistantMessage

content 数组保留“说了什么”和“要做什么”

assistant 的两个 content 使用同一个数组,却有不同的 type
数组中的一个元素叫 content block。TextContent 保存文字;ToolCall 保存调用 id、 工具名和结构化参数。两者的 type 是第 01 章学过的判别字段。代码检查 block.type === "text" 后,TypeScript 才允许读取 block.text arguments 暂时是 unknown。消息只能证明模型输出了 { path: "README.md" },还不能 证明这个对象符合 read 工具的参数规则。第 06 章的 schema 检查通过后,工具层才会 执行它。rawArguments 保存 Provider 给出的原始参数字符串;完整调用也可以保留它, 参数被截断时它尤其重要。保留原文不等于允许执行。 content block 的数组位置同样属于消息。当前 assistant 先发出文本,再提出工具调用:
若模型先提出调用、后补充说明,两个 block 的位置也会随之交换。保存消息时不能把所有 文本挪到数组前面。

三个 role 记录三种来源

transcript 中每个对象的 role 都回答同一个问题:这项事实是谁产生的? 类型定义把这三种所有权分别写开:
AgentMessage 是三种消息的联合。读取一条消息时,先检查 role,随后才能访问该角色 独有的字段。例如,只有 toolResult 拥有 toolCallIdisError 工具请求里的 id 与工具结果里的 toolCallId 是一对配对键:
toolName: "read" 方便人和工具层检查结果来源,真正把请求与结果连起来的是 id。 以后即使两个 read 同时执行,各自的结果也能回到正确请求。

stopReason 说明这次模型调用为何停下

当前 assistant message 使用 stopReason: "toolUse"。它表示模型已经提出工具请求, Agent 接下来应该把完整调用交给工具层。五种结束原因写成一个联合:
它们分别要求不同的后续动作: errorMessage 只在需要诊断时携带文字说明。usage 则记录本次调用的输入、输出和总 token 数:
这些字段与 content 一起构成最终 assistant message。即使调用失败,已经生成的文字、 用量和失败原因仍能作为同一条事实保存下来。

AgentContext 把 transcript 交给下一次模型调用

工具结果到达后,下一次模型调用需要同时看到用户请求、工具请求和工具结果: 如果要把开头的数组交给下一次调用,可以把它显式标成 const transcript: AgentMessage[] = [...]。三个对象的内容不变,只增加数组的类型。
AgentContext 是本次模型调用的输入视图:
messages 保持 transcript 的顺序。模型读到最后一条 toolResult 后,才有依据回答 项目名是 tiny-pi。这个 context 不负责保存整个 session,也不裁剪旧消息;第 10 章 会保存 session,第 11 章再根据预算构造 context。 这一章的 target 还没有工具定义字段。第 05 章接入 Provider 时,AgentContext 才会 增加可选的 tools。此处只保存模型已经看到的消息。

textOf() 只提供文本视图

终端想显示 assistant 的说明时,不需要展示整个工具对象。textOf() 逐个检查 block, 只收集文本:
把 transcript 中的 assistant message 传进去,结果是:
readcall_1{ path: "README.md" } 没有进入结果,原来的 content 数组仍然 完整。这个从完整对象取出的只读视图叫投影。textOf() 是有损投影,适合终端显示、 搜索和摘要;保存或重放 transcript 时要使用原始消息。 同一文件还提供两个小构造函数。它们把常用默认值放在一个位置:
assistantMessage() 同样创建 assistant message,并允许测试通过 overrides 固定 provider、model、usage、错误说明或时间。构造函数减少重复对象字面量,但返回值仍是 前面定义的消息。

模型事件最终汇合成一条 assistant message

第 02 章的 EventStream<T, R> 已经能同时服务异步迭代和 result()。现在两个类型参数 都有了具体含义:
ModelEvent 描述生成过程。AssistantMessage 是这次生成结束后保存的结果。 仍用开篇那条 README assistant message。第 04 章的 ScriptedModel 会把它按下面的顺序 交给消费者:
每一步都围绕同一个 partial 的后继快照。文本到达后,content[0] 可见;工具参数片段 到达后,content[1] 先保存 raw text;toolcall_end 才把这个槽位收束成结构化 ToolCalldone 不再提供增量,而是交出最终要保存的 message。 这些可观察状态对应下面五种正常事件和一条错误终态:
这里的 contentIndex 就是上面 partial 数组中的位置。toolcall_end 只表示参数片段已经 组成一个 ToolCall;第 06 章的 schema 还会检查它能否执行。若生成过程失败,最后一步 从 done(message) 换成 error(error)。两条终态路径都会给 result() 一条 AssistantMessage 因此专用流只需告诉通用流两件事:哪些事件是终态,以及怎样从终态取出结果。
push() 收到 error 事件时,第一段函数返回 true。第 02 章实现的通用流随即完成 最终 Promise,并把这条终态留给异步迭代器。第二段函数返回 event.error;所以 stream.result() 与事件引用的是同一个 assistant message。
实践 3.1 · 保存消息并读取文本目标: 写出消息协议和 helper,让 README transcript 能保留完整 content,同时得到 只含文本的显示结果。文件:
  • packages/pi-course/src/types.ts
  • packages/pi-course/src/event-stream.ts
动作:
  1. types.ts 定义 content block、三种 message、UsageStopReasonAgentContextModelEventModelStreamModel
  2. 实现 text()userMessage()assistantMessage()textOf()
  3. 为了让整份测试文件能够编译,在 event-stream.ts 临时声明 AssistantMessageEventStream。它继承 EventStream<ModelEvent, AssistantMessage>;构造器暂时传入永不结束的判断函数, 结果提取函数抛出 "not implemented in lab 3.1"
  4. 只运行名称含“文本投影”的测试。它不会执行这个临时流。
运行:
预期: 1/1textOf() 得到“先读取\n再回答”,中间的 read tool call 仍完整 留在原消息中。
实践 3.2 · 让 error 自己结束消息流目标:done | error 结束第 02 章的通用流,并返回终态事件携带的消息。文件: packages/pi-course/src/event-stream.ts动作:
  1. 删除实践 3.1 中的临时构造逻辑。
  2. 让终态判断函数识别 doneerror
  3. done 读取 event.message,从 error 读取 event.error;其余事件不能产生 最终结果。
  4. 运行完整聚焦测试。测试不会额外调用 end()error 事件需要自行结束迭代。
运行:
预期: 2/2。异步迭代只观察到 "error",随后结束;result() 返回传给 push() 的同一个错误消息,并保留 errorMessage: "socket reset"
测试覆盖到哪里2 项聚焦测试直接证明:文本投影会跳过工具调用,而且不修改原 content;error 是流的 终态,result() 返回事件里的同一条消息。正常 start → delta → done 时间线会在第 04、 05 章进入可执行模型与 Provider 测试;tool call/result 的 id 配对由第 06、07 章接手。
与当前上游 Pi 对照固定提交 8479bd8packages/ai/src/types.ts 也使用 user、assistant、toolResult 三种消息、content blocks、五种 StopReason 和 Context。上游还支持图片、thinking、 签名、缓存和成本等字段。课程保留 README 往返需要的最小形状,让每个字段都能在后续 调用链中找到用途。

用一次可观察的错误检查 textOf()

完成正常实现后,可以临时让 textOf() 把工具名也加入结果:
预期失败 · 把工具名混进文本投影运行名称含“文本投影”的测试。实际结果会变成“先读取\nread\n再回答”,而期望结果是 “先读取\n再回答”。恢复只提取 text block 的实现,并重新确认 2 项聚焦测试通过。

本章验收

Checkpoint 03 · transcript 有了稳定形状运行:
结果应为 2/2。再沿 README transcript 检查五个位置:
  1. 用户请求保存在哪一种 message 中?
  2. assistant 的文字与 tool call 怎样保持原顺序?
  3. id: "call_1"toolCallId: "call_1" 怎样配对?
  4. stopReason: "toolUse" 要求 Agent 接下来做什么?
  5. textOf() 省略了哪些信息,原信息还保存在何处?
npm run checkpoint -w @pi/course -- 03 可以重新定位 parent 与 target; npm run practice -w @pi/course -- 03 <新目录> 会从同一 parent 创建新的隔离练习目录。 第 04 章会让 ScriptedModel 按这套消息协议播放两次确定的模型调用。

小结

README 往返现在保存为三条有顺序的消息。user 记录请求,assistant 依次记录说明和工具 调用,toolResult 使用同一个调用 id 记录环境返回。StopReason 说明模型为何停下, AgentContext 把这段 transcript 交给下一次调用,textOf() 则从完整消息中取出文本 视图。 AssistantMessageEventStream 把生成过程中的 ModelEvent 与最终 AssistantMessage 接到第 02 章的同一个流上。下一章会在不接网络的情况下,让同一个 ScriptedModel 连续接收两次 context,并播放两个确定的模型 turn;真正把 tool result 送进第二次调用的循环留到第 07 章。