Mock/Fixture 只能保存历史快照,无法感知 Provider 变更;解法是将已有的 Provider 测试套件定时跑真实接口,在 CI 阶段捕获破坏性变更而非等到生产。
Mocks 会冻结,Provider 不会
你的仓库里的每一个 mock、fixture 和 cassette 都是过去某个响应的快照。这正是它们适合单元测试的原因:它们很快,而且永远不会变,所以测试失败一定是你的代码的问题。但这也正是它们对本文讨论的问题毫无用处的原因。一个冻结的 fixture 无法在它所拷贝的那个东西发生变化时变红,因为它根本不知道那个东西的存在。
其结果是一种大家熟悉的、令人不快的形状。你的测试套件是绿的,部署也是绿的。Provider 发布了一个变更,第一个注意到这一点的系统是生产环境——通常是在那个变更发布到你们区域的某个时间点,而且通常是以解析器里的一个异常的形式出现,而不是任何指名道姓地指出是哪个 Provider 的方式。调试从错误的一端开始,因为你拥有的所有东西都通过了。
解决办法很小,但它是结构性的:把你已经为候选 Provider 套件写的断言取出来,按一个定时器对真实的端点运行它们。你不是在测试你的代码,你是在监控一个外部依赖,用这个角度来思考能回答后面的大多数设计问题。
变更的分类,按破坏程度排序
并非所有变更都是破坏性的,把它们一视同仁的套件会变成噪音。以下是分类,按最严重的在前排序:
你读取的字段发生了类型变更。 整数变成了字符串,字符串变成了对象,标量变成了数组。最严重的情况是你的代码往往继续运行——改成拼接而不是相加,改成截断而不是遍历——从而产生错误的输出而不是报错。
字段被删除或重命名。 在强类型语言里会大声报错,在弱类型语言里则是静默的——缺失的 key 被读取为 undefined 并继续流动。Schema 检查两种都能捕获。
新的枚举值。 新的 finish_reason 或新的错误码。你的 switch 语句会落入 default 分支,而那个分支原本是为「不可能」的情况写的。这是 Provider 最可能认为是非破坏性的、但实际上最可能破坏你的那一类变更。
默认值发生了变化。 一个参数你从未发送过,因为默认值恰好是你想要的。响应结构没有任何变化,但行为变了。只有不变式测试能捕获它——断言你依赖的那个属性,而不是参数。
稳定名称背后的模型被替换了。 相同的契约,不同的权重。根本不是契约变更,这就是为什么它属于静默的模型更新、属于你的评估套件而不是这里——但一个记录了模型字段的定时任务往往最先看到它,因为解析后的版本字符串会动。
新增字段。 不是破坏性变更。报告它,不要因为它失败。这是目前最常见的变更,幅度远超前述所有类型,因为新增字段而失败是监控失去受众的方式。
作为监控运行,而不是门禁
诱人的做法是把实时套件加入 pull-request 流水线。别这样做。一个调用第三方公网 API 的测试会因为与被审查的变更毫无关系的理由而失败:速率限制、区域性宕机、响应慢、key 过期。把这些挂到合并按钮上,不出两周团队就学会了重新运行流水线而不去读它,这就让你为了信号而构建的东西成本化为零。
所以:一个独立的定时任务,有自己的节奏,有自己的通知路径。对大多数团队来说每小时一次已经算慷慨,每天一次也说得过去;有用的问法是你愿意在生产环境里坏多久才有人告诉你,调度周期应该比那个时间更短。用生产凭证和生产模型名称来运行它,因为为一个你不用的模型验证的契约不等于已验证。
两个设计细节比调度周期更重要。对任何看起来像传输层的问题——超时、5xx、429——重试一次,只有重试也失败了才报告失败,这用几行代码去掉了大多数噪音。把每次运行的结果记录到某个持久的地方,因为在事故期间你实际被问的问题是「这从什么时候开始失败的」,一个只告警的 job 回答不了这个问题。把结果作为指标随你其他 LLM 遥测一起发出,你就免费获得了那段历史。
一次失败应该做什么
把它按依赖告警来路由,而不是按构建破坏来路由。应该看到它的人是负责这个集成的人,消息应该包含失败的断言和实际的响应体,因为第一个问题永远是「它返回了什么替代品」。一个只说 job 失败了的Message会让人手工复现,而那是事故中花几分钟恢复这个 job 本来就有过的信息。
把 schema 失败告警和新增变更报告分开。它们是不同的紧急程度,不应该共用一个通道。删除的字段会叫醒人;新增字段是一份周刊文摘,阅读它是让你在竞争对手之前发现新能力的方式。如果你把记录的 key 集合保持为提交的基线,每次运行之间的 diff 是一个小的产物,值得原样放进那份文摘。
定时运行不应该做的一件事是每次失败时自动开 ticket。Provider 发生事故会每小时产生一个红色运行,直到问题被修复,而二十四个相同的 ticket 是监控被静音的方式。按断言名称去重。
保持成本极低
契约断言不需要长 completion。套件里的每个请求都可以用短 prompt、temperature 零和小 max_tokens,因为你断言的东西不依赖于回复的长度或质量。这让经常性支出小到不需要任何人审批,这是这个 job 能在第一次预算审查中存活下来的实际条件。多 Provider 版本的算术,假设都写出来,就是对每个 Provider 运行相同的套件。
唯一不能做便宜的测试是截断断言,因为它设计就是要请求比它允许的更多的输出。它仍然很小——上限是成本的边界,而上限是 8 个 token。
把整个东西放在与它保护的代码同一个仓库里。放在独立基础设施项目里的监控会与它本应守护的解析器失去同步,而这种漂移在重要的那一天之前是不可见的。
Running the Same Contract Suite Against Every Provider You Support
Contract Testing an OpenAI-Compatible API Before You Switch Providers
What a Contract Test Catches That a Mocked Unit Test Misses