固定涉及三件事:API契约(版本头)、模型权重快照、客户端库,只有模型快照是最常被忽视且导致最多破坏的环节。
Pinning 是一个被混为一谈的三个东西:API 契约、模型权重和客户端库。只有其中一个才有版本头,而另外两个才是导致大多数破坏性变更的根源。
一个带日期的 API 版本——如果提供商提供了的话。Claude API 要求每个请求都带一个 anthropic-version 头;2023-06-01 是 Anthropic 自身文档示例中出现的值。附带在上面的版本策略才是测试的关键部分:Anthropic 在其错误页面注明,其错误对象内部的值可能会扩展,新的类型值也可能随时间出现。因此,固定版本能保护你免受破坏性变更的影响,而不是新增类变更;代码如果对一个错误类型集合做穷举 switch,就必须有一个 default 分支。
一个带日期的模型快照。模型别名指向提供商当前认为"最新"的那个版本,它会漂移。而带日期的快照 ID 则不会。这个固定对输出影响最大,却又最常因为别名更短、在配置文件里更好看而被忽略。
客户端库。锁文件可以固定它。但锁文件无法固定的是——当你真的更新它时库的行为变化,这就是下面快照要讨论的主题。
Beta opt-in。当提供商把某个特性藏在某个头后面时,那个头就是请求契约的一部分,而特性从 beta 毕业时可能会改变这个头的行为。把它当成版本一样对待。
在配置文件中设一个版本什么都证明不了;真正的问题是它能不能穿过客户端、重试包装器、你经过的代理,以及某人在上个月写的自己构造客户端的辅助函数。所以要在发出的请求上做断言,用拦截器捕获,在针对每个调用点而不是只针对一个的测试里跑。
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, it } from "vitest";
import { PINNED_API_VERSION, PINNED_MODEL } from "../src/config";
import { summarise, extract, classify } from "../src/calls";
const seen: Request[] = [];
const server = setupServer(
http.post("https://api.anthropic.com/v1/messages", ({ request }) => {
seen.push(request.clone());
return HttpResponse.json({ type: "message", content: [{ type: "text", text: "ok" }] });
}),
);
beforeAll(() => server.listen());
afterEach(() => { seen.length = 0; server.resetHandlers(); });
afterAll(() => server.close());
it.each([summarise, extract, classify])("%o pins version and model", async (call) => {
await call({ input: "x" });
expect(seen).toHaveLength(1);
expect(seen[0].headers.get("anthropic-version")).toBe(PINNED_API_VERSION);
const body = await seen[0].json();
expect(body.model).toBe(PINNED_MODEL);
expect(body.model).toMatch(/-\d{8}$|^claude-[a-z]+-\d/); // a snapshot, not a bare alias
});
调用点列表是整个测试的承重结构。在一个单一的包装器上跑单个测试可以永远通过,而第三方辅助函数被人悄悄加进去后可能构造出一个完全没有固定的默认客户端。
一些提供商不给任何东西加日期。API 以增量方式演进,特性藏在 flag 后面,根本没有头可以发送。在这种情况下,固定必须从你能够控制的碎片重新构建,测试的形态也要相应变化:
用正则表达式断言模型 ID 是一个带日期的快照而不是别名,这样切换到别名时测试会失败而不是在 review 时通过。
断言你发送的精确参数集。一个你从未设置的参数就是这个参数的默认值属于提供商,而默认值是可以漂移的。显式发送 temperature 是一种固定;省略它就是在订阅默认值未来的任何变化。
对响应信封的必填字段做断言,而不是对它的精确键集合做断言,这样增量变更不会导致测试套件失败,而删除则会导致。这是版本策略的整个实用内容,当提供商没有提供时,你得自己编码它。
还有一个没有任何头的东西需要固定:分词器(tokenizer)。带日期的模型快照会隐式固定它,但一个用独立库计数的管道固定了模型却让计数器自由漂移,两者在常规依赖更新后可能产生分歧。断言一个已知字符串产生已知计数,测试中要写明模型 ID,这样这对值就保持一致了。
对 SDK 升级来说,最佳的单一测试是序列化请求的快照。通过你的正常代码路径构造一个请求,捕获客户端会发送的精确 JSON,然后存起来。当你去升级 SDK 时,快照上的 diff 就是新版本变化的一份精确、可读的声明:重命名的字段、新的默认值、从顶层移入嵌套对象的参数、分词器 schema 序列化方式的编码变化。
两条规则让这个快照有用而不是带来噪音。在快照之前将任何真正不确定的东西(幂等键、时间戳、请求 ID)规范化,否则测试会因为没有意义的原因每次都失败,然后被删除。保持快照最小化:每种不同的调用形态一个快照,而不是每个测试用例一个快照,因为二十个几乎相同的快照会产生二十个几乎相同的 diff,没有人会去看第二十个。
包含 prompt 文本的快照是版本控制里一个包含 prompt 文本的文件。只快照内容的结构和摘要,而不是内容——除非每个 fixture 都是合成的。
一个失败的固定测试是信息,不是事故,响应应该是例行的。读 diff,判断变化是增量类的还是行为类的,在一个不做其他事情的 commit 里更新快照,这样变化在历史中可见,而不是埋在 feature branch 里。如果变化是行为类的——默认值移了、字段序列化方式变了——这就是触发行为测试的原因,而不只是更新一个文件。
固定本身需要一个审查周期,因为固定太久也是一种风险:你冻结的版本最终停止被支持,而你一次升了三年份的变更。在版本常量上加一个日历提醒,主动地轮换它们,并通过金丝雀发布而不是直接部署来推送这个轮换。Pinning 是让静默模型更新变得不可能的原因;它不是让更新变得不必要的手段。
最后,把固定值放在一个所有模块都导入的模块里,而不是作为字面量散落在各个调用点。上面的测试之所以有意义,是因为它把传输层和 production 读取的同一个常量做比较;如果测试单独硬编码版本字符串,它会通过而应用实际发送的是别的东西——这恰恰是固定测试这个机制要排除的失败模式。一个常量、一个导入、每个调用点一次断言——背后是一条回归流水线,用来处理头无法描述的变化。