第一部 · 建立可执行语言Checkpoint 03

保存一次完整的工具往返

用一段 README transcript 看清文本、工具调用和工具结果怎样成为可保存的消息。

预计
100 分钟
难度
核心
产物
packages/pi-course/src/types.ts
前置
02
REAL CHECKPOINT · course/build-your-own-pi

从 parent 到本章,只增加这一层复杂性

f14e72ad

这是 Pi 仓库中的真实可检出提交,不是页面占位符。parent 是本章开始时的干净起点;target 是聚焦测试已经通过的终点。 先定位两者,再从 parent 创建一个只有聚焦测试、没有 target 实现和 Git 历史的隔离练习目录。

起点
febad621
目标
f14e72ad
聚焦测试
packages/pi-course/test/03-message-ir.test.ts
1 · 定位本章
npm run checkpoint -w @pi/course -- 03
2 · 创建无答案练习目录
npm run practice -w @pi/course -- 03
不要直接给完整答案。先问我对下一次测试输出的预测,然后一次只给一个动作; 再阅读练习目录里的 LEARNING.md。我卡住时按“定位文件 → 指出签名 → 伪代码 → 局部代码”逐级提示。

#你将得到什么

用户说:“请读取 README,然后告诉我项目名。”模型没有立刻回答项目名。它先说明 自己要做什么,接着请求 read 工具;工具读完文件后,又把内容交回 Agent。

这次往返可以保存成三个对象:

ts
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.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

ts
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 先发出文本,再提出工具调用:

text
content[0]  text      我先读取项目说明。
content[1]  toolCall  read({ path: "README.md" })

若模型先提出调用、后补充说明,两个 block 的位置也会随之交换。保存消息时不能把所有 文本挪到数组前面。

#三个 role 记录三种来源

transcript 中每个对象的 role 都回答同一个问题:这项事实是谁产生的?

role 当前对象记录的事实 合法 content
user 用户要求读取 README 文本
assistant 模型的说明和 read 请求 文本、工具调用
toolResult 环境执行 read 后得到的结果 文本

类型定义把这三种所有权分别写开:

ts
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 拥有 toolCallIdisError

工具请求里的 id 与工具结果里的 toolCallId 是一对配对键:

text
assistant.content[1].id       = "call_1"
toolResult.toolCallId         = "call_1"

toolName: "read" 方便人和工具层检查结果来源,真正把请求与结果连起来的是 id。 以后即使两个 read 同时执行,各自的结果也能回到正确请求。

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

当前 assistant message 使用 stopReason: "toolUse"。它表示模型已经提出工具请求, Agent 接下来应该把完整调用交给工具层。五种结束原因写成一个联合:

ts
export type StopReason =
  | "stop"
  | "length"
  | "toolUse"
  | "error"
  | "aborted";

它们分别要求不同的后续动作:

已发生的事情 Agent 的下一步
stop 模型正常完成回答 结束本轮
toolUse 模型完成了工具请求 验证并执行工具
length 输出长度达到上限 保留已有内容,不执行可能残缺的调用
error 模型调用失败 保留 partial 与错误说明,结束当前运行
aborted 调用被取消 保留 partial 与取消事实,结束当前运行

errorMessage 只在需要诊断时携带文字说明。usage 则记录本次调用的输入、输出和总 token 数:

ts
export interface Usage {
  input: number;
  output: number;
  totalTokens: number;
}

这些字段与 content 一起构成最终 assistant message。即使调用失败,已经生成的文字、 用量和失败原因仍能作为同一条事实保存下来。

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

工具结果到达后,下一次模型调用需要同时看到用户请求、工具请求和工具结果:

如果要把开头的数组交给下一次调用,可以把它显式标成 const transcript: AgentMessage[] = [...]。三个对象的内容不变,只增加数组的类型。

ts
const nextContext: AgentContext = {
  systemPrompt: "回答前先读取相关文件。",
  messages: transcript,
};

AgentContext 是本次模型调用的输入视图:

ts
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, 只收集文本:

ts
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 传进去,结果是:

text
我先读取项目说明。

readcall_1{ path: "README.md" } 没有进入结果,原来的 content 数组仍然 完整。这个从完整对象取出的只读视图叫投影。textOf() 是有损投影,适合终端显示、 搜索和摘要;保存或重放 transcript 时要使用原始消息。

同一文件还提供两个小构造函数。它们把常用默认值放在一个位置:

ts
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()。现在两个类型参数 都有了具体含义:

ts
EventStream<ModelEvent, AssistantMessage>

ModelEvent 描述生成过程。AssistantMessage 是这次生成结束后保存的结果。

仍用开篇那条 README assistant message。第 04 章的 ScriptedModel 会把它按下面的顺序 交给消费者:

text
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 才把这个槽位收束成结构化 ToolCalldone 不再提供增量,而是交出最终要保存的 message。

这些可观察状态对应下面五种正常事件和一条错误终态:

ts
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

因此专用流只需告诉通用流两件事:哪些事件是终态,以及怎样从终态取出结果。

ts
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.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. 只运行名称含“文本投影”的测试。它不会执行这个临时流。

运行:

bash
npm run build -w @pi/course
node --test --test-name-pattern="文本投影" \
  packages/pi-course/dist/test/03-*.test.js

预期: 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 事件需要自行结束迭代。

运行:

bash
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() 把工具名也加入结果:

ts
return blocks.flatMap((block) =>
  block.type === "text" ? [block.text] : [block.name]
).join("\n");
故障实验

预期失败 · 把工具名混进文本投影

寻找第一次偏差

运行名称含“文本投影”的测试。实际结果会变成“先读取\nread\n再回答”,而期望结果是 “先读取\n再回答”。恢复只提取 text block 的实现,并重新确认 2 项聚焦测试通过。

#本章验收

本章关卡

Checkpoint 03 · transcript 有了稳定形状

以证据进入下一状态

运行:

bash
npm run build -w @pi/course
node --test packages/pi-course/dist/test/03-*.test.js

结果应为 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 章。

Checkpoint 03

完成验收后再点亮本章

阅读进度只保存在这台设备;本章证据是聚焦测试、commit diff 与你对首次偏差的解释。 迁移练习是熟练后的可选挑战。