主题
Model Provider 与 Fake Model
本课交付结果
CLI 已经能把用户任务和工作区变成稳定命令,下一步似乎应该立刻调用真实大模型。但如果课程测试、调试和安全回归都依赖一个远程模型,同一份代码可能上午通过、下午失败;断网、限流、模型升级、温度和系统提示都会改变结果。更糟的是,失败时你无法判断是 Runtime 有 Bug,还是模型这次选择了不同动作。
本课交付 FakeModelProvider:它实现一个最小 complete(request, signal) 合同,按照构造时给定的脚本逐项返回 ModelResponse,保留 action、usage 和 model,支持取消且不会消耗响应,脚本耗尽时明确失败,并通过深拷贝隔离调用方修改。它不是随手写的 mock,而是训练营全部离线验证的确定性模型替身。
完成后,你能把“模型会怎么回答”固定为测试输入,把 Agent Loop、工具调用、预算、Trace 和 Evaluation 的行为单独证明。真实 Provider 与 Fake Provider 共享同一接口:Runtime 不知道请求最终去了内存数组、OpenAI-compatible HTTP 端点还是别的模型服务。供应商差异被压缩到适配层,核心系统只处理自己的合同。
岗位问题
模型调用是 Agent 系统最不稳定的边界之一。网络可能超时,服务可能返回非预期结构,usage 可能缺失,工具动作可能不合法,取消信号可能在请求前或请求中到达。若业务代码直接依赖某个 SDK 的响应类型,供应商字段、异常和重试策略会渗透到 Agent Loop,换模型就要改核心逻辑。
测试问题更加直接。假设你要验证“工具结果会进入下一轮模型观察”。使用真实模型时,需要期待它第一轮一定调用 read_file,第二轮一定 finish;任何 Prompt 或模型版本变化都可能打破这个序列。用普通函数 mock 虽能返回固定值,却往往不实现取消、用量、耗尽和对象所有权,测试通过的是一个比生产接口更弱的世界。
Fake Model 的价值在于把不确定性移出被测范围。测试作者显式写下动作脚本:先 message,再 tool_call,最后 finish。Provider 每次只前进一步,Agent 的任何多调或少调都会暴露为脚本耗尽或未消费项。usage 也由脚本提供,使预算测试无需伪造全局计数。确定性不是为了假装模型可靠,而是为了让 Runtime 的可靠性能够被独立测量。
同时要避免另一个极端:把 Fake 写成与生产完全不同的玩具。若 Fake 不接受 AbortSignal、不返回模型名和 token usage、不做防御性复制,核心代码可能依赖这些缺失行为,直到接真实 Provider 才出错。一个好的 test double 共享生产合同,但把外部非确定性换成可控制输入。
前置检查
前置知识快照
你需要理解接口替换、异步函数和对象所有权。Provider 接口描述 Runtime 需要的最小能力,不暴露供应商 SDK。Promise 表示模型调用天然异步,即使 Fake 在内存中立即得到结果也保持异步签名。AbortSignal 是协作式取消:调用方发出信号,被调用方检查并以可识别的 AbortError 终止。structuredClone 创建深拷贝,适用于本课仅包含普通对象、数组、字符串和数字的响应。
先完成 CLI,再运行 Starter:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 02
pnpm --dir bootcamps/coding-agent/labs/03-model-provider/starter testStarter 四项测试都会失败:顺序返回、usage 与防御性复制、取消不消费响应、脚本耗尽。这里的重点不是“让第一个测试绿”,而是同时兑现完整 Provider 合同。特别注意取消测试:一次已取消调用之后,下一次正常调用仍应得到脚本第一项。
原理拆解
Provider 分层把不稳定世界挡在适配器之后:
mermaid
flowchart LR
A[Agent Runtime] --> B[ModelProvider.complete]
B --> C{具体适配器}
C -->|课程与测试| D[FakeModelProvider 脚本]
C -->|真实演示| E[OpenAI-compatible HTTP]
C -->|其他环境| F[本地或网关 Provider]
D --> G[统一 ModelResponse]
E --> G
F --> G
G --> H[action + usage + model]请求合同应表达对话消息和可用工具,而不是 SDK 特定参数;响应合同应表达 Runtime 关心的动作、用量和模型身份,而不是完整 HTTP body。适配器负责把统一请求转换成供应商格式,再把返回值解析、验证和归一化。这样模型名称、finish reason、请求 id 等可作为可选元数据保留,但核心循环不需要知道供应商字段路径。
Fake 的内部状态只有脚本副本和当前索引。构造时克隆脚本,防止测试在 Provider 创建后修改原数组;返回时再次克隆当前响应,防止调用方修改 usage 或 action 后污染未来断言。索引只在确认未取消、响应存在之后增加。这个顺序构成重要事务边界:失败前不提交消费动作。
取消是一种状态转换
取消不是普通模型失败。用户按 Ctrl+C、上层预算耗尽或任务被撤销时,Runtime 需要停止等待,并把最终状态标记为 cancelled,而不是重试模型。Fake 在调用入口检查 signal.aborted,创建 name = 'AbortError' 的错误。测试和 Runtime 可以依据错误类型区分取消,无需匹配易变文字。
为什么取消不应消费脚本?脚本表示“成功发起的模型轮次将观察到的响应”。如果请求在发起前已取消,模型没有完成一步,索引就不应前移。否则恢复或下一次测试会跳过响应,产生难以解释的状态漂移。真实网络 Provider 也应尽量遵循同一语义:只有收到并接受有效响应后,才把它计入运行历史。
真实请求中取消可能在 fetch 进行时到达,底层会抛 AbortError。适配器要保留这个类别,不能统一包装为“模型调用失败”。Agent Loop 再把它转换成取消终态,并写入 Trace。取消、超时和供应商错误虽然都导致没有响应,却具有不同恢复策略。
usage 为什么属于响应合同
Agent 预算不能靠估算每轮字符串长度。真实 Provider 通常返回输入、输出 token 用量,Fake 也必须携带相同结构。LoopGuard 累加 usage,Evaluation 可以计算成本,Trace 可以解释某轮为什么耗尽预算。若 Fake 永远返回零,预算测试永远不会触发;若它根据本地字符串临时估算,报告可能随实现变化而抖动。
脚本化 usage 使测试能精确设计边界:第一轮输入十个、输出两个 token,第二轮再增加十二和三个;将上限设在中间即可证明停止时机。它还允许测试价格表和分类报告,而不调用付费服务。确定性数据不必“像真实模型一样随机”,它要覆盖真实合同的边界。
Provider 合同应该多薄
接口太薄会把关键差异留给调用方。例如只返回字符串,Agent Loop 就要猜这是普通消息、工具动作还是完成信号,也拿不到 usage。接口太厚则会复制某一家供应商的全部请求字段:temperature、top_p、seed、reasoning effort、缓存标记和专有 finish reason 全部进入核心层。换 Provider 时,大量字段没有等价物,Runtime 被迫写条件分支。
更稳健的方法是围绕领域行为建模。请求包含按角色排列的消息和 Runtime 当前允许的工具定义;响应包含经过验证的 AgentAction、token 用量和实际模型身份。供应商专有选项放在适配器配置中,必要的诊断元数据以可选、只读字段进入 Trace。核心循环只关心“下一步做什么”“花了多少预算”,不关心 HTTP endpoint 的原始 JSON 形状。
合同还应明确异常边界。输入配置无效应在 Provider 构造时失败;调用被取消抛 AbortError;网络、鉴权、限流和服务端错误应归一为可分类错误,同时保留安全的状态码与请求 id;响应结构错误必须在适配器内拒绝,不能把未验证对象交给工具调度。第 4 课会进一步验证 action,但真实 Provider 仍应保证解码过程可观察。
在实际项目中,还需要决定重试属于谁。HTTP 适配器可以对连接重置或明确限流做有限退避,Agent 恢复层则依据任务语义决定是否重新规划。若两层都无限重试,一个模型失败会形成倍增请求。Provider 合同最好把尝试次数、最终错误类别和用量写进 Trace,让上层预算能看到隐藏重试成本。
并发、可重入与实例生命周期
本课 Fake 用一个递增索引,明确假设同一实例按顺序调用。若两个 Agent 并发共享它,响应分配取决于调度时机,确定性立即丢失。解决方式不是随手加锁,而是先选择生命周期:每次 Agent run 创建独立 Fake 实例;或者脚本按 runId 分组,每个运行维护自己的游标。测试通常采用前者,简单且隔离。
真实 Provider 客户端往往可以并发共享,因为 HTTP 请求没有脚本游标,但它仍可能持有连接池、限流器和关闭逻辑。composition root 决定客户端是每次运行创建还是进程级复用,并在 CLI finally 中关闭。核心 Agent 只依赖 complete,不应偷偷创建或销毁客户端。
可重入还关系到取消。一个请求被取消不应取消同客户端的其他请求;因此 signal 必须按调用传入,而不是存成 Provider 全局字段。Fake 检查当前 signal,真实 fetch 也把当前 signal 传给当前请求。将取消状态放进实例共享变量,会让一个用户的 Ctrl+C 误伤其他任务。
确定性测试不等于脆弱快照
确定性脚本应该描述领域事件,而不是把整个供应商 JSON 存成巨大快照。巨大快照会因无关字段、时间戳和请求 id 变化频繁更新,评审者也很难看出测试意图。本课脚本只写 action、usage 和 model,恰好对应 Runtime 合同;每一项都能解释为什么存在。
同样,测试不应只对完整对象做一次快照比较。顺序测试关注 action;用量测试关注 usage 与所有权;取消测试关注错误类别和游标;耗尽测试关注调用次数。把不同不变量拆开,失败信息会直接指出被破坏的合同。稳定测试不是断言越少,而是每个断言都与一个明确行为对应。
Fake、mock 与录制回放的区别
临时 mock 往往只针对某个测试返回一个对象,创建快,却容易遗漏共享语义。Fake 是可复用的轻量实现,有自己的状态和错误规则。录制回放则保存真实 HTTP 交互,能覆盖供应商格式,但 fixture 可能包含秘密、随 API 版本过期,也难以构造精确边界。训练营用 Fake 验证必过逻辑,少量 Provider 合同测试用合成 HTTP 响应,真实调用只作可选演示。
代码实验
失败实现
最短实现可能直接返回构造数组第一项:
ts
export class FakeModelProvider {
constructor(private responses: ModelResponse[]) {}
async complete(): Promise<ModelResponse> {
return this.responses.shift()!;
}
}它会修改调用方数组,忽略取消,在耗尽时返回 undefined 冒充响应,并把内部对象引用交给调用方。测试之间若共享脚本,还会因执行顺序互相污染。
参考实现把所有权和提交点写清楚:
ts
export class FakeModelProvider {
private readonly responses: ModelResponse[];
private index = 0;
constructor(responses: readonly ModelResponse[]) {
this.responses = structuredClone(responses) as ModelResponse[];
}
async complete(
_request: ModelRequest,
signal?: AbortSignal,
): Promise<ModelResponse> {
if (signal?.aborted === true) {
const error = new Error("模型请求已取消");
error.name = "AbortError";
throw error;
}
const response = this.responses[this.index];
if (response === undefined) throw new Error("FakeModelProvider 响应已耗尽");
this.index += 1;
return structuredClone(response) as ModelResponse;
}
}取消测试同时检查“抛什么”和“没有消费什么”:
ts
it("rejects cancellation without consuming a response", async () => {
const provider = new FakeModelProvider(scripted);
const controller = new AbortController();
controller.abort();
await expect(provider.complete(request, controller.signal))
.rejects.toMatchObject({ name: "AbortError" });
expect((await provider.complete(request)).usage.inputTokens).toBe(10);
});这个断言比只检查错误更强。如果实现先增加索引再检查 signal,第一句仍会抛 AbortError,但第二句会得到脚本第二项,测试立即揭示状态被错误提交。
关键实现讲解
构造参数使用 readonly ModelResponse[],表达 Provider 不要求调用方交出可变所有权。内部仍保存普通数组,但它是克隆得到的私有副本。总项目使用原生私有字段 #responses 和 #index,进一步防止运行时外部访问;Lab 用 TypeScript private 保持教学代码直观,两者合同一致。
structuredClone 比浅展开更合适,因为响应内包含嵌套 action 和 usage。{ ...response } 只复制外层,调用方仍能修改 response.usage.inputTokens。JSON stringify/parse 也能复制本课数据,却会丢失 undefined、特殊数字和未来可能出现的类型。使用平台深拷贝明确表达对象图所有权。
索引增加必须位于三项检查之后:取消检查、响应存在检查、响应读取。若脚本耗尽,索引无需继续增长;错误每次都稳定。返回前增加索引则确保即使调用方随后修改克隆或异步处理,Provider 已经提交这一轮。JavaScript 单线程让这个同步临界区简单,但若未来 Provider 允许并发 complete,需要明确是否支持以及如何分配脚本项。
_request 前缀表示 Fake 当前不使用请求,但接口必须接收它。更高级的 Fake 可以为每项脚本加入请求匹配器,验证上一轮工具观察确实进入 messages;不能因为最小实现不用就从合同删除。真实 Provider 正是依靠请求中的消息与工具定义工作。
脚本耗尽是设计信号,不应自动重复最后一项。重复会掩盖 Agent 多调用一次模型的 Bug,可能让循环无限继续。明确抛错把控制流偏差暴露在最接近原因的位置。测试需要更多轮次,就应把意图写进脚本。
运行与验证
bash
pnpm --dir bootcamps/coding-agent/labs/03-model-provider/starter test
pnpm --dir bootcamps/coding-agent/labs/03-model-provider/solution test
pnpm --filter @coding-agent/model test真实运行输出
Starter 四项测试因响应出队未实现而失败。Solution 的真实稳定摘要:
text
✓ ../tests/lab.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)四项分别证明顺序、usage 与副本、取消事务、耗尽错误。总项目 model 包还验证 action parser 和 OpenAI-compatible adapter,但本课门禁不需要网络。你可以重复运行 Solution,输出与结果保持一致;脚本状态在每个测试新建 Provider,因此测试顺序不会泄漏。
验证时不要只看“测试绿”。故意把 this.index += 1 移到取消检查前,取消测试应失败;把返回深拷贝改成原对象,防御性复制测试应失败;把耗尽改为返回最后响应,耗尽测试应失败。能看到门禁抓住错误,才证明测试真正约束合同。
常见失败与排查
故障案例 1
症状:某个测试把返回 usage 改成 999 后,后续断言或成本报告也出现 999。根因:Provider 返回了内部脚本对象或只做浅拷贝,调用方共享嵌套引用。定位:比较返回对象与输入脚本中 usage 的引用,修改返回值后重新检查原 fixture。修复:构造时深拷贝脚本,返回时再次深拷贝单项响应。
故障案例 2
症状:用户取消一次请求后恢复运行,Agent 跳过预定第一动作,直接 finish。根因:实现先增加索引或 shift,再检查取消。定位:复现“已取消调用→正常调用”,观察第二次 usage 和 action;检查索引提交顺序。修复:在读取和消费响应前处理 signal.aborted,取消必须是无状态变化的失败。
故障案例 3
症状:Agent 意外多调用模型时仍然继续运行,最后报告与脚本不一致。根因:Fake 在耗尽后循环使用最后响应或返回 undefined,掩盖了调用次数偏差。定位:把脚本缩短为一项并调用两次,要求第二次稳定抛出“响应已耗尽”。修复:对越界索引显式失败,不自动循环、补默认值或重复最后响应。
另一类问题是把取消包装成普通 Error。Runtime 看到“模型调用失败”后可能触发重试,与用户意图相反。排查时检查 error.name 是否被适配层保留,并为 AbortError 单独映射 cancelled 状态。
课后作业
第一项作业是为 Fake 脚本增加可选的 assertRequest(request)。每轮返回前验证消息数量、最后角色或工具定义;断言失败时不消费响应。写测试证明错误请求修正后仍能取得同一脚本项。
第二项作业是增加 remainingResponses 只读诊断值,并在测试结束时断言为零。讨论为什么它适合测试诊断,却不应成为 Agent Loop 的控制条件。
第三项作业是设计一个录制回放 Provider 的脱敏规则:请求头、API Key、用户路径和消息中的秘密怎样处理?哪些字段必须保留才能重放 action、usage 和错误?给出 fixture 版本策略。
知识检查:为什么 Fake 比临时 mock 更适合贯穿课程?为什么取消不能消费响应?为什么构造和返回都需要复制?usage 为零会让哪些测试产生假阳性?脚本耗尽为什么应该失败而不是自动重复?
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 合同 | complete 接收请求与 signal,返回统一响应 | Fake 使用另一套接口 |
| 确定性 | 严格按脚本顺序,每个实例状态独立 | 依赖测试顺序或随机值 |
| 取消 | AbortError 且不消费响应 | 取消后索引前移 |
| 所有权 | 输入脚本和返回响应均防御性复制 | 嵌套 usage 共享引用 |
| 耗尽 | 明确、稳定失败 | 返回 undefined 或重复最后项 |
| 证据 | Solution 四项测试与总项目测试通过 | 只测第一项响应 |
总项目增量
总项目包:@coding-agent/model
总项目路径:packages/model/src/fake-model.ts
总项目测试:packages/model/tests/model.test.ts
总项目验证命令:pnpm --filter @coding-agent/model test
本课把 fake-model 能力映射到第 1 周检查点。它让后续 Agent Loop、工具、Trace、Evaluation 和端到端修复都能在无 API Key、无网络的环境中重复运行。第 37 课会实现真实 OpenAI-compatible Provider,但必过门禁仍保留 Fake,避免发布质量依赖外部模型状态。
总项目的 Provider 合同还包含工具定义和更完整消息角色。课程最小版本先聚焦状态与所有权,随后逐课扩展请求,而不改变 complete 这一核心替换点。
在第 1 周检查点里,Fake 提供预定的 echo 工具调用和 finish 动作,使整个最小 Agent 在完全离线环境中完成。这个检查点证明的不是模型能力,而是 Provider、动作、工具观察和循环能够按照合同连接;它为后续每个周检查点提供同样稳定的底座。
因此,评审 Fake 时不要问“它像不像聪明模型”,而要问“它是否忠实实现生产合同、是否让每次状态变化都可预测、是否能让错误在正确边界暴露”。这三个问题决定了测试能否真正保护 Runtime。
只要模型边界可替换,课程就能在离线、低成本和可重复的条件下持续演进。
延伸阅读
方案对比与工程取舍
临时 mock 创建最快,适合验证一次调用,却容易让不同测试各自发明 Provider 语义。确定性 Fake 多维护一个小实现,换来共享合同、状态规则和可组合脚本。录制真实 HTTP 能提高供应商格式覆盖,但有秘密、体积、过期和非确定错误问题。最稳妥的测试金字塔是大量 Fake 驱动的 Runtime 测试、少量合成 HTTP 适配器测试,以及极少数可选真实冒烟测试。
Fake 返回完整预制响应,无法证明模型“理解”任务;这不是缺陷,因为本层目标就是排除模型能力变量。模型质量由 Benchmark 和 Evaluation 测量,Runtime 正确性由确定脚本测量。把两者混成一个测试,失败时就无法定位。
下一课衔接
Provider 现在能稳定给出响应,但 Runtime 还不能安全解释任意对象。下一课建立结构化 Action Protocol:把 message、tool_call 和 finish 解析成可区分联合,拒绝缺失字段和未知动作。Fake 负责“何时返回哪项”,Action Parser 负责“这项是否具有可执行意义”,两道边界缺一不可。
延伸阅读可关注 Test Double 中 mock、stub、fake 的区别,以及 AbortController 的协作式取消语义。阅读真实 SDK 时,不要直接照搬类型;先列出 Runtime 真正需要的字段,再设计自己的 Provider 合同。