模型名是别名而非固定版本,生产商持续更新会导致三月写的断言在六月被执行对象已不同,是测试套件最大漂移源。
模型测试中固定版本以避免静默漂移
模型测试套件中最大的漂移来源不是采样器,而是你在模型字段中填写的那个字符串是一个别名,而它背后指向的东西在你不知情的情况下已经变了。
别名实际解析到什么
提供商发布两种模型标识符。一种是有日期的快照——家族名称附加一个发布日期后缀,格式为大多数提供商使用的样式,例如 -2024-08-06 或 -20250929 后缀——它指向一组固定的权重。另一种是别名:即不带后缀的家族名称,或带有 -latest 后缀的名称,它会解析为提供商当前认为是最新的那个快照。
在某些场景下,别名是生产的正确默认值,但它几乎从不适合测试套件,因为它意味着你在三月写的断言是在用六月到达的模型进行评估。当套件变红时,导致它失败的 diff 不在你的代码库里,人们会花一天时间在那里寻找。库的静默模型更新页面涵盖了同样问题的生产侧;测试侧范围更窄,也更容易修复。
精确的快照字符串和别名约定因提供商而异,且每次发布都会变化。从提供商自己的模型列表中获取当前标识符——例如 OpenAI 的 models reference 或 Anthropic 的 model overview——而不是从本页的任何列表中获取。
别名的代价不仅仅是模型变了。它还意味着这种变化在团队通常用来解释回归的所有证据中都是不可见的。diff 中没有任何改动,部署日志是空的,依赖锁文件原封不动,周五通过的套件在周一失败了。由于每一条可用证据都指向内部,调查首先从对你的提交进行二分查找开始,而它找不到任何东西,因为原因不在那里。
一个配置文件中读取的固定版本
固定版本应该放在整个套件读取的一个文件里,而不是分散在各个调用点,也不是硬编码在辅助函数里。有两个属性很重要:一是单次 grep 能找到它,二是运行时环境可以覆盖它而不必修改测试代码。
# tests/models.py
import os
# The model under test. Dated snapshot, never an alias.
# Bump this deliberately; see the upgrade procedure in the README.
DEFAULT_MODEL = "<provider-family>-<yyyy-mm-dd>"
MODEL = os.environ.get("TEST_MODEL", DEFAULT_MODEL)
# A separate, explicit pin for the judge, if the suite uses one. Changing the
# judge changes every score in the suite, so it must not ride on the same pin.
JUDGE_MODEL = os.environ.get("JUDGE_MODEL", DEFAULT_MODEL)
将裁判模型保持在自己的固定版本上是值得多写一行的。评估器模型和被测模型因不同原因、以不同节奏漂移,如果用同一个常量就不可能在不静默改变另一个的情况下改变其中一个——这会使所有已存储的分数同时失效。
断言你实际获得的模型
设置字段并不能证明它被遵守了。提供商会把解析后的模型回显在响应中,而这个回显是整个套件中最便宜的断言。加一次,在 fixture 层级加,所有测试都会继承它。
# tests/conftest.py
import pytest
from tests.models import MODEL
@pytest.fixture
def complete(client):
def _complete(messages, **kw):
resp = client.chat.completions.create(
model=MODEL, messages=messages, temperature=0, **kw
)
# A gateway, a proxy or an alias expansion all show up here.
assert resp.model == MODEL, f"served {resp.model}, expected {MODEL}"
return resp
return _complete
这个断言能捕获三件独立的事情:你以为固定了但实际没有固定的别名、因为主模型不可用而将你路由到备用模型的网关,以及提供商将简写展开为你未选择的快照。没有这个断言,这三种情况都是不可见的,而每一种都会产生一个描述你并未发布的模型的测试套件结果。
当降级是刻意为之的时候——比如一个有意在故障时切换的路由层——断言应该在 mock 环境中作为警告,在真实环境中作为硬失败,因为在评估运行期间发生故障转移意味着数据来自两个不同的模型。
固定版本还必须触及不是测试客户端的地方。用于构建检索索引的 embedding 模型、裁判模型、在 pipeline 内部用于路由决策的小模型——每一个都是独立的模型标识符,每一个都可能是某人设置过的别名。特别是 embedding 模型会改变存储索引的含义,而不仅仅是改变下一次响应,所以那里的别名会产生一个检索结果漂移的套件,而测试中没有任何模型调用看起来发生了变化。
固定版本有过期日期
固定版本不舒适的另一半是快照会被停用。提供商会发布弃用日期,固定到已停用快照的套件会抛出一个 404 形状的错误,提示某个模型已不存在——通常是在弃用当天的早晨,同时发生在所有人的 pipeline 中。
将退役日期放在固定版本旁边的注释里,取自提供商的弃用页面,这样谁读到这个文件都能看到。
添加一个定时任务,每周调用一次固定版本的模型并在失败时告警。每周花费几个 token 的成本,把故障变成警告。
在错误消息中区分两种失败。"Model not found" 和 "assertion failed" 在早上九点被阅读的方式完全不同。
把退役当作调度问题而不是意外来处理。快照的弃用公告和关闭之间通常有数月间隔,而迁移工作只需要一天——但前提是选择了那一天。任由它到截止日期就会变成紧急情况,届时提示词和模型同时改变,没有人能把结果差异归因于其中任何一个。
主动升级
在一个分支上把 TEST_MODEL 设置为新的快照,然后不做任何其他改动运行完整套件。现在每个失败都可以归因于模型,因为其他东西都没动。
将失败分类到三堆:提示词确实需要调整、断言过度指定了本应是属性的一切,以及新模型在这项任务上表现更差。
重新录制任何针对旧快照捕获的 fixture。用一个模型录制的 cassette 在套件声称测试另一个模型时回放,是套件对自身撒的一个谎。
在一个只触碰那个文件的提交中更改默认固定版本,这样改动在历史中可 grep,也能独立回滚。
将旧的固定版本保留为环境覆盖变量一个发布周期,这样在切换后发现的问题可以针对模型而不是代码进行二分查找。
通过固定 system_fingerprint 测试来捕捉静默模型变化
决定哪些测试需要确定性,哪些不需要
将测试拆分为 Mock 层和付费真实层