用统计思维判断测试失败是测试本身问题还是代码变更引入,避免错误归类导致缺陷泄漏。
你真正在问的问题
"这是不稳定测试吗?"这个问题本身并不成立。真正的问题是:这个测试的失败率是否受你的代码变更影响?不稳定测试的失败率是测试本身和外部环境的属性,你的提交并没有改变它;而回归则是失败率确实被你的提交改变了。两种情况在单次红色构建中看起来完全一样,而这正是困难所在。
注意,这种框架下一次尝试无法作为任何一种方向的证据。单次失败是从一个你尚未表征的分布中采样,等它变绿了再重跑不是测试,而是搜索。下面所有方法都是为了更低成本地获取更多样本,并比较两个分布而非两个结果。
值得把这种不对称性明确说出来,因为它决定了这件事值得投入多少精力。把回归称为"不稳定"会放出缺陷并摧毁测试的可信度,导致下一次真正的失败被更快地忽略;把不稳定称为回归则只会浪费一个人一晚上的时间。流程上要偏向第二种错误。
检查一:重跑这个提交
单独重跑失败的测试,在同一个提交上,跑多次。不是整个套件——而是单个测试,循环执行,这样你得到的是比率而非结果。
# pytest: 运行一个测试 20 次并统计结果
pytest tests/test_router.py::test_router_emits_a_tool_call \
--count 20 -p no:randomly -q
# (--count 来自 pytest-repeat 插件)
# vitest: 同样的思路,使用内置的 repeats 选项
npx vitest run src/router.test.ts -t "emits a tool call" --repeats 20
把结果解读为三种情况。二十次全部失败:这是硬失败,几乎肯定是回归,因为一个真正不稳定的测试连续失败二十次,意味着每次运行的失败率高到你早就会发现了。二十次全部通过:你有不稳定性的弱证据,但没有关于你的变更的证据。混合结果:你有一个比率,值得与别的对比。
中间情况是每个团队都过早停止的地方,因为二十次绿色运行感觉很有结论性。但并非如此。如果你的变更把失败率从零变成了三十分之一,二十次通过是最可能的结果,什么都证明不了。
有两个细节使这个循环可靠可信。关闭测试顺序随机化,这样你是在对模型采样而不是同时对排序采样——上面 -p no:randomly 就是这个意思。而且只跑单个测试而不是整个文件:一个相邻测试如果留下了打补丁的客户端或填充好的缓存,会改变你正在测量的测试的失败率,你会把差异归因于你的变更。
检查二:重跑上一个已知正常的提交
这是回答真正问题的检查,也是最常被跳过的一个,因为在构建已经变红的时候感觉像是额外工作。检出这个测试最后一次变绿时的提交,不做任何修改,用相同的次数运行相同的循环。
git stash --include-untracked
git checkout <last-green-sha>
pytest tests/test_router.py::test_router_emits_a_tool_call --count 20 -q
git checkout -
git stash pop
如果旧提交的失败率相近,你的变更就洗清了,是存储库外部的东西变了——模型、提供商、一个你没有固定的依赖。如果旧提交二十次全部通过而新提交三分之一的时间失败,那就是回归,无论 diff 看起来多么无关。Diff 看起来无关不是证据;prompt 变更的影响范围比表面上看起来的要远,这正是 bisect 一个引入了不稳定性的 prompt 变更的前提。
两次循环要在时间上接近地运行,使用相同的凭证、模型版本和并发数。如果你晚跑了一小时、用更安静的共享密钥跑已知正常的提交,你同时改变了三件事,对比毫无意义。如果这个检查值得自动化,这正是 git bisect run 重复做的事情,所以一个把单个测试跑 N 次并报告通过次数的脚本可以同时用于两项工作。
检查三:来自存储库之外的证据
三个来源能在不到一分钟内解决一大部分这类争论,而且都不需要运行任何东西:
提供商的 status 页面和变更日志。一个与你的失败时间吻合的事故窗口是接近结论性的证据。模型的弃用或静默的点发布也是一样——参见"静默模型更新"了解为什么未固定的模型别名是这件事最常见的版本。
你记录到的响应元数据。如果你记录了实际服务的模型 ID,以及提供商返回 system_fingerprint 时的值,那么绿色运行和红色运行之间其中任何一个发生变化就是答案。OpenAI 将 system_fingerprint 文档化为模型权重和后端配置的当前组合的标识符,并将带种子的采样描述为"尽力而为而非保证"——所以 fingerprint 变化正是使复现失效的事件。
其他测试是否同时发生了变化。一个测试失败是测试的问题。十一个测试同时失败,跨越不相关的功能,是基础设施的问题,按原因而非按名称分组正是消除不稳定失败重复的技术。
判断失败是你的还是提供商的,当每个提供商的响应元数据都以相同方式记录时会更容易回答。Multigrid 通过一个 API 记录每次请求的服务模型 ID、上游状态和延迟,所以"绿色运行和红色运行之间模型是否变了"是一个查询而非在两个提供商的仪表板之间做考古式搜索。
把两个循环结果并排放。HEAD 是正在审查的提交,GOOD 是上一个已知正常的提交,每个单元格的比较基于两者相同的重复次数。
HEAD GOOD 结论
-------- -------- ----------------------------------------------
0/20 pass 20/20 pass 你的变更中有回归。不要合并。
0/20 pass 0/20 pass 存储库外部坏了:提供商、模型、依赖。
固定模型后再检查,不要直接怪代码。
mixed 20/20 pass 只在部分时候出现的回归。当作回归处理;
比率就是你的证据。
mixed mixed 预先存在的不稳定性。比较比率;如果 HEAD
明显更差,当作回归处理。
20/20 pass 任意 无法复现。记录下来,不要因为单次失败
就隔离,下周检查仪表板是否有规律。
最后一行需要纪律性。单次无法复现的失败不足以隔离一个测试,因为因为一天不好就隔离是套件逐一失去覆盖率的途径。记录下来,让累积的历史来决定——已发布的稳定性容差阈值存在的原因正是让这个决定作为政策做一次,而不是每次轮到谁值班就重新争论一次。
隔离一个不稳定的 LLM 测试而不删除它
将提供商引起的不稳定性与你的代码引起的不稳定性分开