ScriptedModel:把模型行为写成可执行规格
用按轮消费的确定性脚本生成模型事件,稳定验证请求快照、事件顺序、终态和失败。
- 预计
- 100 分钟
- 难度
- 核心
- 产物
packages/pi-course/src/scripted-model.ts
从 parent 到本章,只增加这一层复杂性
f4713907这是 Pi 仓库中的真实可检出提交,不是页面占位符。parent 是本章开始时的干净起点;target 是聚焦测试已经通过的终点。 先定位两者,再从 parent 创建一个只有聚焦测试、没有 target 实现和 Git 历史的隔离练习目录。
- 起点
f14e72ad- 目标
f4713907- 聚焦测试
packages/pi-course/test/04-scripted-model.test.ts
npm run checkpoint -w @pi/course -- 04npm run practice -w @pi/course -- 04不要直接给完整答案。先问我对下一次测试输出的预测,然后一次只给一个动作; 再阅读练习目录里的 LEARNING.md。我卡住时按“定位文件 → 指出签名 → 伪代码 → 局部代码”逐级提示。
#你将得到什么
前两章已经定义了消息和事件流,但还没有组件负责生产模型事件。如果现在直接连接 真实 LLM,网络、鉴权、费用、限流和随机输出会同时进入测试。测试失败时,你很难 判断究竟是 Agent 协议写错了,还是远端服务临时发生了变化。
本章处理一个新问题:如何构造一个遵守真实模型边界、行为又完全确定的事件生产者。
你将实现 ScriptedModel。它按轮读取预先写好的脚本,记录每次传入的 context,
并把成功、错误和预先取消都转换成统一的模型事件。
本章不变量是:
ScriptedModel和真实 adapter 都满足同一个Model.stream()契约。上层代码 只消费模型事件,不需要判断当前接入的是脚本模型还是真实服务。
重新练习时,运行 npm run practice -w @pi/course -- 04 <新目录>。练习目录会
保留第 03 章实现,只移除本章的 scripted-model.ts,并注入第 04 章聚焦测试。
#先建立全景
ScriptedModel 是一种 test double,也就是专门替代外部依赖的测试实现。它和一个
随便返回 "hello" 的 mock 不同:脚本描述的是某一轮模型最终应产生什么消息,
ScriptedModel 仍要按照真实协议生成中间事件和终态。
AgentContext
│
▼
ScriptedModel.stream(context, { signal })
├─ 立即返回 AssistantMessageEventStream
├─ 保存 context 在调用时的快照
└─ 在 microtask 中读取一个 ScriptedTurn
TextContent → text_delta
ToolCall → toolcall_delta → toolcall_end
正常消息 → done
错误或取消 → errormicrotask 是“当前同步代码结束后立刻执行”的小任务。stream() 先把流返回给调用者,
再由 microtask 发送事件。这样消费者可以先拿到流并开始等待;认证、模型运行或网络
阶段发生的失败,也能统一通过流内的 error 终态报告。
运行前先判断
先写判断,再看推理如果 requests 直接保存传入的 context 引用,调用者随后向
context.messages 追加一条消息,requests[0] 记录的是调用发生时的输入,
还是修改后的输入?
展开参考推理
它会跟着原对象一起变化,因此记录的是修改后的输入。调用时应保存
structuredClone(context)。只有快照固定下来,测试才能准确回答“这一轮模型
当时看到了什么”。
#先固定 Model 的唯一入口
第 03 章已经在 types.ts 中定义了 Model 和 ModelStream。本章不修改这些接口,
只实现它们:
export interface Model {
stream(
context: AgentContext,
options?: { signal?: AbortSignal },
): ModelStream;
}
export interface ModelStream extends AsyncIterable<ModelEvent> {
result(): Promise<AssistantMessage>;
}脚本中的一个回合有两种写法。正常回合直接提供最终 AssistantMessage;错误回合
提供停止原因、错误说明和可选的部分文本:
export type ScriptedTurn =
| AssistantMessage
| {
stopReason: "error" | "aborted";
errorMessage: string;
partialText?: string;
};ScriptedTurn 记录脚本预先规定的最终结果,不直接保存 ModelEvent[]。
ScriptedModel 仍要先发送 start,再按 content block 的顺序生成事件,最后根据
stop reason 发送 done 或 error。因此,测试验证的是事件生成边界,而非简单
回放一组预置事件。
Checkpoint 04 · 让脚本回合满足真实模型边界
先跨过从理解到动手的第一步模式: 重建。从 03 的 target 开始,只加入确定性事件生产者。
起终点: parent 是本章开始时的起点快照;target 是聚焦测试通过的终点快照。
怎么使用这张卡: 先把它当作路线图。读完上面的全景和接口,再从实践 4.1 开始写代码。
教学文件: packages/pi-course/src/scripted-model.ts
动手前只需知道: 一个 turn 表示一轮模型的最终结果;请求快照记录
stream() 被调用时的输入;microtask 让方法先返回流,再发送事件。
第一次红灯: 首次 build 只报告 TS2307:找不到
../src/scripted-model.js。这说明本章只缺一个源文件。
第一步:
- 运行 build,确认上面的 TS2307。
- 定义
ScriptedTurn、请求记录和按轮读取所需的 cursor。 - 完成实践 4.1,只运行名称含“脚本消息”的第一项测试。
- 完成实践 4.2,再运行本章全部测试。
聚焦测试: packages/pi-course/test/04-scripted-model.test.ts
定位命令: npm run checkpoint -w @pi/course -- 04
练习目录: npm run practice -w @pi/course -- 04
聚焦运行: npm run build -w @pi/course,然后 node --test packages/pi-course/dist/test/04-*.test.js
通过证据: 3 项聚焦测试覆盖事件顺序与载荷、累计 partial、两个 turn 的消费
顺序、请求快照、显式错误回合、脚本耗尽和预取消。调用者使用
ScriptedModel 或真实 Model 时,无需编写两套消费逻辑。
第一次尝试禁止查看完整答案。若事件顺序不清楚,陪练只指出当前 content block 应该产生的下一个事件。
#播放一个成功回合
先看第一项测试中的脚本。它包含一个 text block 和一个 tool call,所以完整事件 顺序应为:
start
text_delta
toolcall_delta
toolcall_end
donestart 携带空 content 的 partial message。处理每个 block 时,先把当前增量应用到
partial,再发送事件。这样 text_delta 的 partial 已经包含当前文本;
toolcall_delta 的 partial 既保留前面的文本,也包含正在生成的 tool call;
toolcall_end 再把完整参数写入同一位置。播放结束后,终态直接提交脚本中的最终
消息,不从事件文本重新拼装一次。
可以先写出类的外框:
export class ScriptedModel implements Model {
readonly requests: AgentContext[] = [];
private cursor = 0;
constructor(private readonly turns: ScriptedTurn[]) {}
stream(
context: AgentContext,
options: { signal?: AbortSignal } = {},
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
this.requests.push(structuredClone(context));
const turn = this.turns[this.cursor++];
queueMicrotask(() => {
// 先处理不会播放的分支,再投影一个有效 turn。
});
return stream;
}
}第一阶段仍要让整个测试文件通过 TypeScript 编译。你可以先声明完整
ScriptedTurn 联合。在 microtask 内先判断三种暂不播放的情况:signal 已取消、
turn 不存在,或者 turn 没有 role 字段。前两项是失败入口,第三项代表显式错误
turn。遇到它们时,先临时抛出 "not implemented in lab 4.1"。通过这些判断后,
TypeScript 也能确认剩下的 turn 就是 AssistantMessage。局部测试只提供正常
turn,不会执行临时分支。
实践 4.1 · 投影成功消息并保存请求快照
在真实文件中建立能力目标: 让一条混合消息产生确定事件,同时固定调用时的 context。
文件: packages/pi-course/src/scripted-model.ts
动作:
- 声明完整
ScriptedTurn,并建立requests、cursor和构造器。 stream()先克隆 context,再取出当前 turn,然后立即返回新事件流。- 在 microtask 中先判断预取消、脚本耗尽和没有
role的错误 turn;这三个分支 暂时抛出明确异常。 - 把判断后剩下的正常消息转换成
start、block 事件和done。 - text block 产生一个
text_delta;它的contentIndex指向当前 block,delta是本段文字,partial已经包含这段文字。 - tool call 先产生
toolcall_delta,再产生toolcall_end。两个事件都保留 前面已经播放的 block;end 事件还要携带完整 tool call。 - 让模型连续播放两个正常 turn,确认 cursor 每次只前进一格。
- 只运行第一项测试。
运行: npm run build -w @pi/course,然后
node --test --test-name-pattern="脚本消息" packages/pi-course/dist/test/04-*.test.js
预期: 局部测试 1/1。事件名称、delta、content index、tool call 和累计
partial 都与脚本一致;两个 turn 按声明顺序播放。调用后再修改原 context,
model.requests[0].messages 仍只有调用时的那一条消息。
#按轮消费,并把失败放回流里
同一个模型实例会被 Agent 多次调用。cursor 每次只前进一个 turn:
const model = new ScriptedModel([
assistantMessage([
text("我先读取文件。"),
{
type: "toolCall",
id: "c1",
name: "read",
arguments: { path: "README.md" },
},
], "toolUse"),
assistantMessage([text("项目用于学习 Agent。")], "stop"),
]);第一次调用产生工具请求,第二次调用给出最终回答。第 07 章会把这两个回合接入 Agent loop。本章只负责模型边界,不负责把上一轮消息写进下一轮 context。
脚本还必须能稳定表达失败:
{
stopReason: "error",
partialText: "正在",
errorMessage: "rate limited",
}这条 turn 应产生:
start
text_delta "正在"
error reason=error
result.stopReason = "error"
result.errorMessage = "rate limited"result() 仍然 resolve 一条 canonical assistant message。上层因此可以同时读取
部分文本和错误说明。它不会把错误伪装成普通 stop,也不会迫使调用者额外处理
一个同步 throw 分支。
脚本耗尽时,ScriptedModel 自己构造 stopReason: "error" 的消息;播放开始前,
如果 signal 已处于 aborted 状态,就构造 stopReason: "aborted" 的消息。
这两个分支都只发送 error 终态,不发送 start,也不播放原 turn。所有分支都在
microtask 内完成,所以 stream() 始终先返回事件流。
实践 4.2 · 补齐错误、耗尽和预取消
在真实文件中建立能力目标: 让三类失败也遵守同一个流协议。
文件: packages/pi-course/src/scripted-model.ts
动作:
- 写一个 helper,把正常 turn 克隆为最终消息,把错误 turn 转成带 partial text
和
errorMessage的最终消息。 - 删除 Lab 4.1 的临时异常。
- signal 已处于
aborted状态时,发送对应的error事件并结束本次播放。 - turn 不存在时,发送错误说明为
"ScriptedModel 没有更多响应"的error事件。 - 显式错误 turn 仍先发送
start和已有文本,最后发送error。 - 运行完整聚焦测试。
运行: npm run build -w @pi/course,然后
node --test packages/pi-course/dist/test/04-*.test.js
预期: 3 项测试全部通过。每项流测试都设有一秒超时;遗漏终态会明确失败, 不会让练习一直等待。
确定性模型负责控制变量
把现象连接到不变量以后测试 Agent loop 时,可以用脚本精确规定“先请求 read,再给出回答”。模型行为 固定后,测试中只剩 loop 或 tool 发生变化,失败位置就容易判断。真实服务用于验证 兼容性;需要稳定证明控制流时,仍应使用确定输入和确定事件。
#故意把它弄坏
一种常见错误是在脚本耗尽时,从 stream() 同步抛出异常:
stream(): ModelStream {
throw new Error("script exhausted");
}这样调用者还没拿到流,就被迫处理另一条失败通道。
预期失败 · 让脚本耗尽同步抛错
寻找第一次偏差临时把“turn 不存在”的判断移到 queueMicrotask() 外,并直接 throw。运行完整
聚焦测试,第三项测试应立即失败,因为 stream() 没有返回事件流。把判断移回
microtask,恢复为流内 error 终态,再确认 3/3 通过。
#本章验收
Checkpoint 04 · 模型行为成为可执行规格
以证据进入下一状态运行 npm run build -w @pi/course,再运行
node --test packages/pi-course/dist/test/04-*.test.js,结果应为 3/3。确认本章
只新增 scripted-model.ts。你要能解释 turn 如何变成事件、为什么 context 必须
保存快照,以及为什么脚本耗尽和预取消都通过流内终态报告。下一章会保留这套模型
协议,只把 turn 的来源替换成 OpenAI-compatible transport。
#可选迁移练习
迁移 · 连续播放两个成功回合
完成引导重建后再减少脚手架在独立测试中构造两个 turn:第一轮产生 toolUse,第二轮产生普通 stop。依次调用
两次 stream(),检查每轮事件和结果都来自对应 turn,并确认 requests 保存两次
调用时的 context 快照。不要读取私有 cursor,也不要为测试增加 isFake 分支。
#小结
ScriptedModel 是模型协议的一种确定性实现。它把脚本 turn 转成真实事件,保存
请求发生时的 context,并让成功、错误、脚本耗尽和预取消走同一条流。Agent 上层
由此获得了稳定的测试基座。下一章接入真实 provider 时,只需证明 adapter 产生
相同的边界行为,无需另建一套模型接口。
完成验收后再点亮本章
阅读进度只保存在这台设备;本章证据是聚焦测试、commit diff 与你对首次偏差的解释。 迁移练习是熟练后的可选挑战。