Appearance
第 05 章:模拟测试 — 不花钱验证 Agent 逻辑
每次测试都要调真实 API?太慢、太贵、还不可复现。怎样脱离真模型验证 Agent 的逻辑?
这一章要解决什么问题?
到目前为止,每次验证 Agent 行为都要发真实的 API 请求。这有三个问题:
- 慢 — 一次调用要等 3-10 秒,测五种场景就是一分钟
- 贵 — 每次测试都烧 token,日积月累是真金白银
- 不可复现 — 同一个 prompt 每次回复都不一样,测试结果不稳定
这一章引入"录播模型":预先设定好模型的响应序列,替换掉真实的 API 调用。Agent Loop 跑起来跟用真模型一模一样,但响应是你控制的、确定性的、免费的。
为什么能这样做?
回忆一下 AgentConfig 的 streamFn:
typescript
interface AgentConfig {
streamFn: (model: Model<any>, context: Context) => AssistantMessageEventStream;
// ...
}Agent Loop 不直接调用 API,它只调用 streamFn。只要这个函数返回一个 AssistantMessageEventStream,Agent Loop 不关心数据来自真实 API 还是本地预设。这个其实就是软件工程里最重要的一条:依赖接口编程。
这就是协议边界带来的可测试性 — 只要实现相同的接口,替换实现对调用方透明。
录播模型的概念
想象一台录像机。你提前录好两段视频(两个预设响应),按播放键后它们按顺序播出:
第 1 次调用 streamFn → 播放第 1 个预设响应
第 2 次调用 streamFn → 播放第 2 个预设响应
第 3 次调用 streamFn → 没有更多预设了,报错像放录像带一样。游标从第一个开始,每次调用推进一格。
实现 createScriptedModel
typescript
import type {
AssistantMessage,
AssistantMessageEventStream,
Context,
} from "@earendil-works/pi-ai";
import { AssistantMessageEventStream as EventStreamClass } from "@earendil-works/pi-ai";
interface ScriptedModel {
/** 已经被调用了多少次 */
callCount: number;
/** 每次调用收到的 Context 快照 */
calls: Context[];
/** 消费下一个预设响应,返回流 */
next(ctx: Context): AssistantMessageEventStream;
}
function createScriptedModel(responses: AssistantMessage[]): ScriptedModel {
let cursor = 0;
const calls: Context[] = [];
return {
get callCount() { return calls.length; },
get calls() { return calls; },
next(ctx: Context): AssistantMessageEventStream {
// 记录这次调用收到的 Context(用于事后断言)
calls.push(ctx);
if (cursor >= responses.length) {
throw new Error(
`ScriptedModel: no more responses (called ${cursor + 1} times, only ${responses.length} responses)`
);
}
const response = responses[cursor++];
// 将完整的 AssistantMessage 投影为一组流式事件
const stream = new EventStreamClass();
// 异步推送事件(模拟流式到达)
queueMicrotask(() => {
for (const block of response.content) {
if (block.type === "text") {
stream.push({ type: "text_delta", delta: block.text, partial: response });
}
// toolCall 块也可以投影为 toolcall_start + toolcall_end
}
stream.end(response);
});
return stream;
},
};
}三个关键点:
- 游标推进:每次
next()调用消费一个预设,cursor 加 1 - 请求快照:把收到的 Context 存入
calls数组,测试结束后可以断言"Agent 第二次调用模型时传了什么" - 消息投影:把完整的 AssistantMessage 拆成流式事件推入 stream,然后 end
消息到事件的投影
真实 API 是逐 token 推送的。录播模型为了简单,把整个文本块作为一个 text_delta 推送。对 Agent Loop 来说没有区别 — 它只关心遍历完流之后 stream.result() 返回的完整消息。
如果你想模拟逐字到达的效果(比如测试 UI 的流式渲染),可以把文本拆成多个 delta:
typescript
// 逐字投影
for (const char of block.text) {
stream.push({ type: "text_delta", delta: char, partial: response });
}但对于测试 Agent 逻辑,一次性推完就够了。
完整测试用例
看一个完整的测试场景 — 验证 Agent 能完成"读取文件并总结"的任务:
typescript
import { runAgent, type AgentConfig, type MiniTool } from "./agent-loop";
// 准备一个假的 read_file 工具
const fakeReadFile: MiniTool = {
name: "read_file",
description: "Read file",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
async execute(params) {
return { content: "# Hello\n\nThis is a test file with 3 lines." };
},
};
// 预设两轮响应
const scripted = createScriptedModel([
// 第 1 轮:模型决定调用 read_file
{
role: "assistant",
content: [{ type: "toolCall", id: "tc1", name: "read_file", arguments: { path: "README.md" } }],
stopReason: "toolUse",
usage: { input: 100, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 120, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
model: "scripted", api: "anthropic-messages", provider: "anthropic", timestamp: Date.now(),
},
// 第 2 轮:模型看到文件内容后给出总结
{
role: "assistant",
content: [{ type: "text", text: "这个文件有 3 行,是一个简单的测试文件。" }],
stopReason: "stop",
usage: { input: 150, output: 30, cacheRead: 0, cacheWrite: 0, totalTokens: 180, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
model: "scripted", api: "anthropic-messages", provider: "anthropic", timestamp: Date.now(),
},
]);
// 配置 Agent 使用录播模型
const config: AgentConfig = {
model: { id: "scripted" } as any,
streamFn: (_model, ctx) => scripted.next(ctx),
tools: [fakeReadFile],
systemPrompt: "You are a helpful assistant.",
};
// 运行 Agent
const messages = await runAgent("总结 README.md", [], config);
// ═══ 断言 ═══
// 模型被调用了 2 次
console.assert(scripted.callCount === 2, "Should call model twice");
// 第 2 次调用的 messages 中包含 toolResult
const secondCall = scripted.calls[1];
const hasToolResult = secondCall.messages.some(m => m.role === "toolResult");
console.assert(hasToolResult, "Second call should include toolResult");
// 最终消息是文本回复
const lastMsg = messages[messages.length - 1];
console.assert(lastMsg.role === "assistant", "Last message should be assistant");
console.log("All assertions passed!");请求快照的用途
scripted.calls 数组记录了每次调用模型时 Agent 传入的完整 Context。你可以用它断言:
- Agent 第一次调用时传了几条 messages?(应该只有 1 条 user message)
- Agent 第二次调用时传的 messages 里有没有 toolResult?(必须有)
- toolResult 的内容是不是工具实际返回的?
- systemPrompt 对不对?
这比"看最终输出是否正确"更精确 — 你在验证 Agent 的行为过程,而不只是最终结果。
小结
录播模型把"依赖真实 API"的测试变成了确定性的本地验证。核心技巧是替换 streamFn — Agent Loop 只认接口,不认实现。createScriptedModel 预设一组响应按顺序播放,同时快照每次收到的 Context 用于事后断言。这让你能验证 Agent 的完整行为路径:调用了几次模型、每次传了什么、工具结果有没有正确回传。
下一章
到这里我们有了统一接口、有了测试手段,但模型只能说话不能做事。下一章让模型"动手" — 看工具调用的完整协议:模型返回 toolCall,程序执行工具,结果通过 toolResult 传回。