用模型调用录制回放稳定 AI 测试
文章提出将模型调用保存为本地 JSON,以模型、提示词和温度等输入的哈希匹配回放,减少测试中的随机波动与重复调用成本。方案包含 Pytest fixture、CI 命令和缓存未命中排查,但示例代码尚未运行验证。
AI 深度解读
改一行提示词,跑出几十次模型调用,失败的测试却每次不同:到底是代码坏了,还是模型换了个回答?作者给出的办法是把模型响应录成 JSON,日常测试直接回放,让排查过程有一份固定证据。
这套方案适合验证 AI 应用如何处理回答,不能证明模型今天仍然可靠。更需要先说明:原文代码只是草图,作者明确表示没有连接真实接口验证,也没有提供性能或稳定性对照数据。
为什么要把模型回答录下来
模型调用混进单元测试后,一次失败可能有两种来源:程序逻辑出了问题,或者外部服务返回了不同内容。重新运行也未必能帮助定位,还会继续消耗 token 和时间。
作者借用的是录制文件(cassette)的思路:首次请求真实模型,把响应存进 cassettes/,之后遇到相同输入便读取文件。它像给外部依赖留下一份样本,方便反复检查自己的代码。
回放固定的是测试输入,不能固定线上模型的行为。
整个周末项目只有一条范围约束:不能缩短「录制、回放」循环的功能,先不做。因此没有 embeddings、仪表盘或自动重新录制,只有 JSON 文件和一个默认拒绝联网的调用入口。
缓存键决定了哪些调用算同一次
录制能否可信,先看键怎么生成。作者要求只纳入会改变模型行为的输入,草图选择了模型、提示词和温度:
def key_for(model: str, prompt: str, temperature: float) -> str:
payload = json.dumps(
{"model": model, "prompt": prompt, "temperature": temperature},
sort_keys=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
要看的是 sort_keys=True 和最后的 [:16]:前者固定 JSON 字段顺序,后者截取 SHA-256 的前 16 个十六进制字符。实际请求若还有影响输出的参数,也必须进入键,否则不同请求可能错误复用同一份回答。
作者建议哈希前规范化提示词空白,并锁定精确的模型字符串。不过,代码没有实现空白规范化;这一步也应谨慎,代码缩进等空白可能具有语义。
模型别名还有另一层风险:别名字符串不变,背后的模型却可能变化。此时键不会改变,旧录制仍会命中,回放更无法发现服务端变化。原文把别名漂移与录制失效联系起来,具体机制说得不够准确。
怎样让测试彻底停止偷偷联网
作者把调用分成 record、replay、off 三种模式,CASSETTE_MODE 默认是 replay,CASSETTE_DIR 默认是 cassettes。下面的入口负责决定读文件还是调用模型:
def call(model: str, prompt: str, temperature: float = 0.0) -> str:
key = key_for(model, prompt, temperature)
path = CASSETTE_DIR / f"{key}.json"
if MODE == "replay":
if not path.exists():
raise CassetteMiss(key, prompt)
return json.loads(path.read_text(encoding="utf-8"))["text"]
text, usage = live_call(model, prompt, temperature) # your existing client
if MODE == "record":
CASSETTE_DIR.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({
"key": key,
"model": model,
"prompt_sha": hashlib.sha256(prompt.encode()).hexdigest(),
"text": text,
"usage": usage,
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}, indent=2), encoding="utf-8")
return text
最重要的是 raise CassetteMiss(key, prompt):文件不存在就报错,不能自动退回真实请求。live_call 则是需要接入的现有客户端,并非完整实现。
录制使用 temperature 0,保存响应及 usage,便于之后查看 token 用量;温度设为零也不等于服务端输出绝对可复现。作者给出的录制命令是 CASSETTE_MODE=record pytest tests/test_prompts.py -x,随后用 git add cassettes/ && git commit -m "record prompt cassettes" 提交样本。
CI 使用 CASSETTE_MODE=replay pytest -q,运行器不配置 API key。真正禁止网络还需要运行环境约束,代码里的模式判断并不是网络隔离措施。
原文还用 pytest 的自动 fixture,通过 monkeypatch.setenv 设置回放模式。但 MODE 在模块导入时就读取环境变量,模块若提前加载,fixture 未必有效。把模式放在启动命令里设置,更符合这份草图的实际行为。
遇到缺失录制,按提示词、模型字符串、温度及其他参数依次比较。若三类输入都一致仍未命中,就检查键是否漏了输入。重新录制后要像代码一样审查 JSON 差异,原文用一个 200 行差异的例子提醒:回答变化也可能暴露此前没被发现的提示词修改。
我的判断
我赞成把应用回归测试和模型在线检查分开。前者需要稳定证据,后者需要接触真实服务。固定回答尤其适合检查解析、字段处理和提示词组装;相比手写 mock,录制能保留真实回答的形态,但也需要人工审查样本。
边界不能省。开放式生成宜断言结构、字段是否存在及长度范围,不宜绑定整段措辞。作者建议每晚保留 5 次真实调用的小型冒烟测试,用来发现服务端漂移;这个数量是经验建议,没有覆盖率依据。代码也没有展示如何录制超时、限流等错误,不能据此认定异常路径已经覆盖。
录制前还要在写文件的边界脱敏,仓库会被克隆,JSON 样本同样会传播。需要真实延迟数据、主要担心供应商回归,或数据规则禁止第三方推理的产品,都不能靠这套方案解决核心问题。只有 3 个提示词的小项目,作者认为直接在线跑更省事。
原文也披露了 MonkeyCode 产品推广合作,并提及其免费模型访问和免费服务器选项。作者没有实测,额度、条款和硬件条件也未明确,不能当作已验证的 CI 方案。先试 10 次调用而非 100 次,是控制试验规模的建议,不是免费能力的保证。
能拿来做什么
- 如果你的 Node 服务要把模型回答解析成业务字段,可以录制少量真实样本,反复验证字段缺失、短回答等情况下的处理逻辑,并优先断言数据结构。
- 把更新录制设为明确的维护动作,CI 默认回放且缺失即失败;审查提示词修改时,一并检查对应 JSON 的变化。
- 为模型升级或供应商变化保留独立的在线冒烟任务,同时在录制写盘前脱敏。作者还建议后续增加
--strict,遇到未提交的录制变化就失败,但原文尚未实现这个参数。