保存一次完整的工具往返
用一段 README transcript 看清文本、工具调用和工具结果怎样成为可保存的消息。
- 预计
- 100 分钟
- 难度
- 核心
- 产物
packages/pi-course/src/types.ts- 前置
- 02
从 parent 到本章,只增加这一层复杂性
f14e72ad这是 Pi 仓库中的真实可检出提交,不是页面占位符。parent 是本章开始时的干净起点;target 是聚焦测试已经通过的终点。 先定位两者,再从 parent 创建一个只有聚焦测试、没有 target 实现和 Git 历史的隔离练习目录。
- 起点
febad621- 目标
f14e72ad- 聚焦测试
packages/pi-course/test/03-message-ir.test.ts
npm run checkpoint -w @pi/course -- 03npm run practice -w @pi/course -- 03不要直接给完整答案。先问我对下一次测试输出的预测,然后一次只给一个动作; 再阅读练习目录里的 LEARNING.md。我卡住时按“定位文件 → 指出签名 → 伪代码 → 局部代码”逐级提示。
#你将得到什么
用户说:“请读取 README,然后告诉我项目名。”模型没有立刻回答项目名。它先说明
自己要做什么,接着请求 read 工具;工具读完文件后,又把内容交回 Agent。
这次往返可以保存成三个对象:
const transcript = [
{
role: "user",
content: [
{ type: "text", text: "请读取 README,然后告诉我项目名。" },
],
timestamp: 1_000,
},
{
role: "assistant",
content: [
{ type: "text", text: "我先读取项目说明。" },
{
type: "toolCall",
id: "call_1",
name: "read",
arguments: { path: "README.md" },
},
],
provider: "scripted",
model: "scripted-v1",
usage: { input: 18, output: 12, totalTokens: 30 },
stopReason: "toolUse",
timestamp: 1_010,
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "# tiny-pi\nA small agent runtime." }],
isError: false,
timestamp: 1_020,
},
];数组顺序就是事实发生的顺序。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.tspackages/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:
export interface TextContent {
type: "text";
text: string;
}
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: unknown;
rawArguments?: string;
}
export type AssistantContent = TextContent | ToolCall;数组中的一个元素叫 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 先发出文本,再提出工具调用:
content[0] text 我先读取项目说明。
content[1] toolCall read({ path: "README.md" })若模型先提出调用、后补充说明,两个 block 的位置也会随之交换。保存消息时不能把所有 文本挪到数组前面。
#三个 role 记录三种来源
transcript 中每个对象的 role 都回答同一个问题:这项事实是谁产生的?
| role | 当前对象记录的事实 | 合法 content |
|---|---|---|
user |
用户要求读取 README | 文本 |
assistant |
模型的说明和 read 请求 |
文本、工具调用 |
toolResult |
环境执行 read 后得到的结果 |
文本 |
类型定义把这三种所有权分别写开:
export interface UserMessage {
role: "user";
content: TextContent[];
timestamp: number;
}
export interface AssistantMessage {
role: "assistant";
content: AssistantContent[];
provider: string;
model: string;
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
timestamp: number;
}
export interface ToolResultMessage<TDetails = unknown> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: TextContent[];
details?: TDetails;
isError: boolean;
timestamp: number;
}
export type AgentMessage =
| UserMessage
| AssistantMessage
| ToolResultMessage;AgentMessage 是三种消息的联合。读取一条消息时,先检查 role,随后才能访问该角色
独有的字段。例如,只有 toolResult 拥有 toolCallId 和 isError。
工具请求里的 id 与工具结果里的 toolCallId 是一对配对键:
assistant.content[1].id = "call_1"
toolResult.toolCallId = "call_1"toolName: "read" 方便人和工具层检查结果来源,真正把请求与结果连起来的是 id。
以后即使两个 read 同时执行,各自的结果也能回到正确请求。
#stopReason 说明这次模型调用为何停下
当前 assistant message 使用 stopReason: "toolUse"。它表示模型已经提出工具请求,
Agent 接下来应该把完整调用交给工具层。五种结束原因写成一个联合:
export type StopReason =
| "stop"
| "length"
| "toolUse"
| "error"
| "aborted";它们分别要求不同的后续动作:
| 值 | 已发生的事情 | Agent 的下一步 |
|---|---|---|
stop |
模型正常完成回答 | 结束本轮 |
toolUse |
模型完成了工具请求 | 验证并执行工具 |
length |
输出长度达到上限 | 保留已有内容,不执行可能残缺的调用 |
error |
模型调用失败 | 保留 partial 与错误说明,结束当前运行 |
aborted |
调用被取消 | 保留 partial 与取消事实,结束当前运行 |
errorMessage 只在需要诊断时携带文字说明。usage 则记录本次调用的输入、输出和总
token 数:
export interface Usage {
input: number;
output: number;
totalTokens: number;
}这些字段与 content 一起构成最终 assistant message。即使调用失败,已经生成的文字、 用量和失败原因仍能作为同一条事实保存下来。
#AgentContext 把 transcript 交给下一次模型调用
工具结果到达后,下一次模型调用需要同时看到用户请求、工具请求和工具结果:
如果要把开头的数组交给下一次调用,可以把它显式标成
const transcript: AgentMessage[] = [...]。三个对象的内容不变,只增加数组的类型。
const nextContext: AgentContext = {
systemPrompt: "回答前先读取相关文件。",
messages: transcript,
};AgentContext 是本次模型调用的输入视图:
export interface AgentContext {
systemPrompt?: string;
messages: AgentMessage[];
}messages 保持 transcript 的顺序。模型读到最后一条 toolResult 后,才有依据回答
项目名是 tiny-pi。这个 context 不负责保存整个 session,也不裁剪旧消息;第 10 章
会保存 session,第 11 章再根据预算构造 context。
这一章的 target 还没有工具定义字段。第 05 章接入 Provider 时,AgentContext 才会
增加可选的 tools。此处只保存模型已经看到的消息。
#textOf() 只提供文本视图
终端想显示 assistant 的说明时,不需要展示整个工具对象。textOf() 逐个检查 block,
只收集文本:
export function textOf(message: AgentMessage): string {
const blocks: readonly AssistantContent[] = message.content;
return blocks.flatMap((block) =>
block.type === "text" ? [block.text] : []
).join("\n");
}把 transcript 中的 assistant message 传进去,结果是:
我先读取项目说明。read、call_1 和 { path: "README.md" } 没有进入结果,原来的 content 数组仍然
完整。这个从完整对象取出的只读视图叫投影。textOf() 是有损投影,适合终端显示、
搜索和摘要;保存或重放 transcript 时要使用原始消息。
同一文件还提供两个小构造函数。它们把常用默认值放在一个位置:
export function text(value: string): TextContent {
return { type: "text", text: value };
}
export function userMessage(value: string): UserMessage {
return {
role: "user",
content: [text(value)],
timestamp: Date.now(),
};
}assistantMessage() 同样创建 assistant message,并允许测试通过 overrides 固定
provider、model、usage、错误说明或时间。构造函数减少重复对象字面量,但返回值仍是
前面定义的消息。
#模型事件最终汇合成一条 assistant message
第 02 章的 EventStream<T, R> 已经能同时服务异步迭代和 result()。现在两个类型参数
都有了具体含义:
EventStream<ModelEvent, AssistantMessage>ModelEvent 描述生成过程。AssistantMessage 是这次生成结束后保存的结果。
仍用开篇那条 README assistant message。第 04 章的 ScriptedModel 会把它按下面的顺序
交给消费者:
start
partial.content = []
text_delta(contentIndex=0, delta="我先读取项目说明。")
partial.content = [text("我先读取项目说明。")]
toolcall_delta(contentIndex=1, delta='{"path":"README.md"}')
partial.content = [text(...), toolCall(call_1, read, arguments={}, rawArguments=...)]
toolcall_end(contentIndex=1)
partial.content = [text(...), toolCall(call_1, read, arguments={ path: "README.md" })]
done(reason="toolUse")
message = 上面的完整 assistant message每一步都围绕同一个 partial 的后继快照。文本到达后,content[0] 可见;工具参数片段
到达后,content[1] 先保存 raw text;toolcall_end 才把这个槽位收束成结构化
ToolCall。done 不再提供增量,而是交出最终要保存的 message。
这些可观察状态对应下面五种正常事件和一条错误终态:
export type ModelEvent =
| { type: "start"; partial: AssistantMessage }
| {
type: "text_delta";
contentIndex: number;
delta: string;
partial: AssistantMessage;
}
| {
type: "toolcall_delta";
contentIndex: number;
delta: string;
partial: AssistantMessage;
}
| {
type: "toolcall_end";
contentIndex: number;
toolCall: ToolCall;
partial: AssistantMessage;
}
| {
type: "done";
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
message: AssistantMessage;
}
| {
type: "error";
reason: Extract<StopReason, "error" | "aborted">;
error: AssistantMessage;
};这里的 contentIndex 就是上面 partial 数组中的位置。toolcall_end 只表示参数片段已经
组成一个 ToolCall;第 06 章的 schema 还会检查它能否执行。若生成过程失败,最后一步
从 done(message) 换成 error(error)。两条终态路径都会给 result() 一条
AssistantMessage。
因此专用流只需告诉通用流两件事:哪些事件是终态,以及怎样从终态取出结果。
export class AssistantMessageEventStream
extends EventStream<ModelEvent, AssistantMessage>
implements ModelStream
{
constructor() {
super(
(event) => event.type === "done" || event.type === "error",
(event) => {
if (event.type === "done") return event.message;
if (event.type === "error") return event.error;
throw new Error("非终态事件不能生成最终消息");
},
);
}
}当 push() 收到 error 事件时,第一段函数返回 true。第 02 章实现的通用流随即完成
最终 Promise,并把这条终态留给异步迭代器。第二段函数返回 event.error;所以
stream.result() 与事件引用的是同一个 assistant message。
实践 3.1 · 保存消息并读取文本
在真实文件中建立能力目标: 写出消息协议和 helper,让 README transcript 能保留完整 content,同时得到 只含文本的显示结果。
文件:
packages/pi-course/src/types.tspackages/pi-course/src/event-stream.ts
动作:
- 在
types.ts定义 content block、三种 message、Usage、StopReason、AgentContext、ModelEvent、ModelStream和Model。 - 实现
text()、userMessage()、assistantMessage()与textOf()。 - 为了让整份测试文件能够编译,在
event-stream.ts临时声明AssistantMessageEventStream。它继承EventStream<ModelEvent, AssistantMessage>;构造器暂时传入永不结束的判断函数, 结果提取函数抛出"not implemented in lab 3.1"。 - 只运行名称含“文本投影”的测试。它不会执行这个临时流。
运行:
npm run build -w @pi/course
node --test --test-name-pattern="文本投影" \
packages/pi-course/dist/test/03-*.test.js预期: 1/1。textOf() 得到“先读取\n再回答”,中间的 read tool call 仍完整
留在原消息中。
实践 3.2 · 让 error 自己结束消息流
在真实文件中建立能力目标: 用 done | error 结束第 02 章的通用流,并返回终态事件携带的消息。
文件: packages/pi-course/src/event-stream.ts
动作:
- 删除实践 3.1 中的临时构造逻辑。
- 让终态判断函数识别
done和error。 - 从
done读取event.message,从error读取event.error;其余事件不能产生 最终结果。 - 运行完整聚焦测试。测试不会额外调用
end(),error事件需要自行结束迭代。
运行:
npm run build -w @pi/course
node --test packages/pi-course/dist/test/03-*.test.js预期: 2/2。异步迭代只观察到 "error",随后结束;result() 返回传给
push() 的同一个错误消息,并保留 errorMessage: "socket reset"。
#用一次可观察的错误检查 textOf()
完成正常实现后,可以临时让 textOf() 把工具名也加入结果:
return blocks.flatMap((block) =>
block.type === "text" ? [block.text] : [block.name]
).join("\n");预期失败 · 把工具名混进文本投影
寻找第一次偏差运行名称含“文本投影”的测试。实际结果会变成“先读取\nread\n再回答”,而期望结果是 “先读取\n再回答”。恢复只提取 text block 的实现,并重新确认 2 项聚焦测试通过。
#本章验收
Checkpoint 03 · transcript 有了稳定形状
以证据进入下一状态运行:
npm run build -w @pi/course
node --test packages/pi-course/dist/test/03-*.test.js结果应为 2/2。再沿 README transcript 检查五个位置:
- 用户请求保存在哪一种 message 中?
- assistant 的文字与 tool call 怎样保持原顺序?
id: "call_1"与toolCallId: "call_1"怎样配对?stopReason: "toolUse"要求 Agent 接下来做什么?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 章。
完成验收后再点亮本章
阅读进度只保存在这台设备;本章证据是聚焦测试、commit diff 与你对首次偏差的解释。 迁移练习是熟练后的可选挑战。