六种主要原因:环境差异发不同请求、并发竞态、本地缓存掩盖方差、Flakiness放大器等。强调先 dump 两环境实际请求体逐字节比对,定位效率最高。
症状的精确定义
在诊断之前,先确认你遇到的是哪一种症状,因为它们的病因完全不重叠:
在 CI 中每次都失败,本地每次都通过。这是确定性的,因此是环境问题。这是容易的一种,下一节的所有内容都适用。
在 CI 中偶发失败,本地从不失败。通常是并发问题、速率限制,或者本地缓存默默返回相同响应,让你永远看不到差异。
在两者中都偶发失败,CI 中更多。这是普通的 flaky 测试加上环境放大器;先走一遍"区分 flaky 测试和真正回归"的流程,因为你可能在追踪一个回归问题。
第一步:diff 请求
不要从失败信息入手。先在两个环境中打印出精确的请求,逐字节比较。下面的几乎每个原因都会在这个 dump 中表现为可见的差异,而那些不表现出来的也可以由此排除。
# conftest.py — dump the outgoing request shape in both environments
import json, os, pytest
@pytest.fixture(autouse=True)
def dump_env(request):
if os.environ.get("DUMP_TEST_ENV"):
print(json.dumps({
"test": request.node.nodeid,
"model": os.environ.get("MODEL", "<unset>"),
"base_url": os.environ.get("OPENAI_BASE_URL", "<default>"),
"api_key_set": bool(os.environ.get("OPENAI_API_KEY")),
"api_key_suffix": (os.environ.get("OPENAI_API_KEY") or "")[-4:],
"temperature": os.environ.get("TEMPERATURE", "<unset>"),
"seed": os.environ.get("SEED", "<unset>"),
"tz": os.environ.get("TZ", "<unset>"),
"vcr_mode": os.environ.get("VCR_RECORD_MODE", "<unset>"),
}, indent=2))
只打印 key 的最后四个字符,不要打印完整的 key。一个测试在本地用个人 key 通过、在 CI 中用拥有不同模型访问权限的服务账号 key 失败,这是极其常见的版本,suffix 足够让你看出问题而不会把任何敏感信息泄露到构建日志中。
六种原因及区分方法
模型不同。你的 shell 从 dotfile 导出 MODEL;CI 没有,所以代码回退到两年前设置的默认值。检查:dump 中的 model 字段不同,或者响应中的 model id 不同。修复:对未设置的 model 报错而不是静默回退——缺失的环境变量应该抛出异常,而不是静默替换。
凭证权限不同。不同的 key、不同的组织、不同的启用模型,有时是不同的区域。检查:key suffix 不同,或者错误是对模型的 404 或 403,而不是断言失败。修复:CI 使用相同的模型访问层级,并在测试套件开始时断言服务端的 model id。
本地从未发送过请求。cassette、mock 或 HTTP 缓存服务了你的本地运行,而 CI 没有这样的文件,所以 CI 是唯一真正调用 provider 的环境。检查:禁用网络后本地运行是否仍然通过。修复:见下一节——这是最常见的原因,通常意味着某个文件被 gitignored 了。
采样未固定。没有 seed、非零 temperature,以及一个依赖于采样结果的严格断言。检查:本地运行二十次;如果本地也失败,则环境是无辜的。修复:固定你能固定的,放松断言——因为固定本身不够,OpenAI 文档指出 seed 是一个尽力而为的机制,系统变更的信号是 system_fingerprint。
时钟或地区不同。CI 在 UTC 和 C locale 下运行;你的笔记本不是。插入了 datetime.now()、日期格式或货币符号的 prompt 在两个环境下是不同的 prompt。检查:渲染后的 prompt 在日期或分隔符上不同。修复:将时钟作为 fixture 注入并冻结它;永远不要让 prompt 读取环境时间。
并发和速率限制。见下文单独说明,因为这是产生间歇性而非确定性失败的唯一原因。
录制响应陷阱
如果你使用 VCR.py,行为由 record_mode 控制,它的四个文档化值在 cassette 缺失时做的事情完全不同。once,默认值,会回放现有 cassette,仅在文件不存在时录制新的——这意味着 CI 中缺失的 cassette 会静默变成对 provider 的真实录制,本地通过的运行变成真实的、计费的、不稳定的 CI 运行。none 回放,对任何没有录制过的请求报错。all 始终录制;new_episodes 回放已有的并录制其余的请求。
# tests/conftest.py — CI 必须永不录制,且必须永不向外调用
import os, pytest, vcr
RECORD_MODE = "all" if os.environ.get("UPDATE_CASSETTES") else "none"
my_vcr = vcr.VCR(
cassette_library_dir="tests/cassettes",
record_mode=RECORD_MODE,
match_on=["method", "scheme", "host", "port", "path", "query", "body"],
filter_headers=["authorization", "api-key", "x-api-key"],
)
@pytest.fixture
def cassette(request):
with my_vcr.use_cassette(f"{request.node.name}.yaml"):
yield
其中两个细节比其他更重要。filter_headers 防止你的 key 进入你即将提交的文件,这不是可选项。而在 match_on 中包含 body 是让 prompt 变更大声报错而不是静默回放错误录制的关键——没有它,任何对相同 URL 的请求都会匹配,所以一个刚修改过 prompt 的测试会愉快地回放旧响应并通过。然后检查 tests/cassettes 没有被 gitignore,这才是半数情况下的真正 bug。
速率限制和并发
CI 以比你更高的并行度运行你的测试套件,一次爆发,一个 IP,通常使用共享组织 key,其他 pipeline 也在用。你的笔记本运行的是一分钟内零星分散的几个请求。因此 provider 的每分钟请求数和 token 限制在 CI 中会被触及,本地则不会。
区分性证据是状态码和响应头,不是断言。带 retry-after 头的 429,或者跨不相关测试集中爆发的失败,是速率限制。在测试工具中记录每次模型调用的状态码;没有这个,你会把结果断言失败读作内容问题。通用机制在 provider 速率限制如何工作里。
修复方案是限制测试套件的并发而不是重试到限制:pytest-xdist 的 -n、Vitest 的 maxConcurrency 和 fileParallelism,或客户端中的信号量。在没有退避的情况下重试受限的爆发只会让爆发更长。
每个环境独立的 key 让这一切可诊断:当 CI 有自己的凭证时,CI 中的 429 不可能是生产流量引起的,两者的消费行是可分离的。Multigrid 问题用各自限制和消费上限的每环境 key 对接一个 API,这样一个循环的 eval 运行不会悄悄消耗生产应用正在使用的预算。
修复清单
禁用网络后在本地运行测试。如果仍然通过,则是原因三——录制或 mock 在服务它,CI 是唯一进行真实调用的环境。
在两个环境中 dump 请求形状并 diff。Model、base URL、key suffix、temperature、seed、timezone。
比较响应中的服务 model id,而不是请求的 id。别名可能对两个 key 解析不同。
查看失败调用的 HTTP 状态。4xx 或 5xx 是环境问题;200 加断言失败是内容问题,属于 provider 与你代码的分工。
在本地将测试运行二十次。如果本地也失败,则 CI 从来不是变量,你有一个普通的 flaky 测试。
修复后,让环境差异变得不可能而不是仅仅被纠正:对未设置的 model 变量抛错、在 CI 中设置 record_mode 为 none、冻结时钟、提交 cassettes。
记录模式、插件标志和速率限制头名称都是 vendor 和库的表面,会变化。在采用上面的片段前,确认 VCR.py 和 provider 文档中的当前拼写。
区分 Provider 导致的 Flakiness 和你的代码导致的 Flakiness
区分 Flaky 测试和真正的回归