文章将真实模型响应按输入指纹保存,回放时只读取匹配记录,缺失则明确报错。通过限制网络调用仅发生在录制阶段,可以单独检查评测逻辑变化,减少模型波动对回归判断的干扰。
一套 prompt 测试套件里,有三个不断变化的部分:prompt 文本、grader,以及模型快照。只要其中任何一个发生变化,整体通过率就可能跟着变。Replay cassette 通过把模型输出固定保存在磁盘上,消除了这种歧义,让你可以只重跑实际修改过的部分。要让这套机制成立,规则必须足够严格:网络请求只能出现在一条代码路径中,也就是录制路径;其余所有路径,要么读取按哈希寻址的文件,要么明确报错。下面介绍的是一套花一个下午就能搭起来的 harness,以及它会在哪些情况下误导你。
大多数团队首先想到的是 mock:手写一些看起来合理的假响应,结果不到一周就与真实情况脱节。Cassette 不一样,它来自一次真实调用,并通过生成这次输出的完整输入指纹来寻址。只要改动 prompt 中的一个词,指纹就会变化,旧记录就无法悄无声息地回答新问题。正是这一点,让缓存变成了回归检测器。
# cassette.py
import hashlib, json, os, time
DIR = os.environ.get("CASSETTE_DIR", "cassettes")
class CassetteMiss(Exception):
"""Raised in replay mode when no tape matches the input fingerprint."""
def fingerprint(prompt, params):
blob = json.dumps({"prompt": prompt, "params": params}, sort_keys=True)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
def get(prompt, params, produce, mode="record"):
key = fingerprint(prompt, params)
path = os.path.join(DIR, key + ".json")
if os.path.exists(path):
with open(path) as fh:
return json.load(fh), True # hit
if mode == "replay":
raise CassetteMiss(key) # never fall through to network
tape = produce(prompt, params)
tape.update({"key": key, "params": params,
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
os.makedirs(DIR, exist_ok=True)
with open(path, "w") as fh:
json.dump(tape, fh, indent=2, sort_keys=True)
return tape, False # miss, now recorded
这里值得专门说一下 params 参数,因为大家往往就是在这里不知不觉破坏了确定性。Temperature、seed、max tokens、system prompt,以及任何工具选择标志,都应该纳入指纹,即便服务商声称这些参数不起作用。只要一个参数可能改变输出文本,就应该放进 key;即便它不会改变文本,存下来也不过多占几个字节。
下面的 runner 读取 JSONL 用例文件,向 cassette 层索取录制记录,再把录制的文本交给指定名称的 grader。注意,grader 是通过名称从注册表中查找的,而不是存进 JSON,因为函数无法序列化;一个无法查看 diff 的 grader,也就无法审计。还要注意,回放未命中时会以退出码 2 结束。这与断言失败是不同的信号,也应该通知不同的负责人。
# harness/run.py
import json, os, sys
from cassette import get, CassetteMiss
from client import call_model # the only module that opens a socket
MODE = os.environ.get("CASSETTE_MODE", "replay")
GRADERS = {
"json_shape": lambda text, exp: _parses(text) and set(exp["keys"]) <= set(_parses(text)),
"contains": lambda text, exp: exp["needle"].lower() in text.lower(),
"refusal": lambda text, exp: text.strip().lower().startswith(exp["prefix"]),
}
def run(suite_path):
hits = misses = 0
for line in open(suite_path):
if not line.strip():
continue
case = json.loads(line)
try:
tape, hit = get(case["prompt"], case.get("params", {}),
produce=call_model, mode=MODE)
except CassetteMiss as exc:
print("CASSETTE_MISS", exc, file=sys.stderr)
return 2
hits += hit
misses += not hit
verdict = GRADERS[case["grader"]](tape["text"], case.get("expect", {}))
print(json.dumps({"id": case["id"], "hit": hit, "verdict": verdict,
"finish": tape.get("finish_reason")}))
total = hits + misses
print(f"hit_rate={hits/total:.2f}", file=sys.stderr)
return 0 if hits + misses else 1
if __name__ == "__main__":
sys.exit(run(sys.argv[1]))
运行两次,就能看明白这套设计的意义。第一次用 CASSETTE_MODE=record 执行,为真实调用付费并写入录制记录;第二次用 CASSETTE_MODE=replay 执行,几毫秒就能结束,而且可以证明没有发出任何网络请求。如果你的 CI 无法在禁用网络的情况下成功完成回放,就说明你还没有真正把 harness 变化与模型变化分开,通过率仍然只是一个无法区分变化来源的数字。
$ CASSETTE_MODE=record python -m harness.run cases/canary.jsonl # needs a model
$ CASSETTE_MODE=replay python -m harness.run cases/golden.jsonl # offline, exit 2 on miss
$ git diff --stat cassettes/ | tail -1 # which tapes moved
在这套设计中,录制是成本高的那一半:每个新用例、每次 prompt 修改,都会导致缓存未命中,需要一次真实生成;回放则完全不需要。这种不对称性,恰好适合 MonkeyCode 提供的免费模型访问和免费服务器选项,这也是这套工作流与该产品的结合点。利益披露:本文是作为 MonkeyCode 产品推广活动的一部分撰写的。实际操作时,可以把录制任务放在免费服务器实例上,调用免费模型档位;回放任务则留在运行测试套件的机器上。
要让评估依据保持可靠,因为免费档位本质上属于推广资源,可能随时调整,且不另行通知。应到项目页面核对当前的模型列表、额度和服务器规格,不要把博客文章里的假设写死。应该把 cassette 目录视为能长期保留的资产,而不是依赖某个服务商。即便一个免费模型下线了,你的录制记录仍然可以回放,只有刷新记录的任务需要重新接入服务。
均匀采样会把大部分预算浪费在那些结果几乎不可能翻转的用例上:一个得分 0.9、判定阈值为 0.5 的用例,几乎无论怎么修改 prompt 都还能通过。真正容易变化的是那些靠近决策边界的用例,所以应该根据得分与阈值的距离来挑选 canary 用例,而不是按类别挑选。这个函数只要五行,通常就能把 canary 套件的录制成本降低一个数量级。
def boundary_band(rows, band=0.10):
"""rows: [{"id", "score", "threshold"}]; keep cases that can actually flip."""
return [r for r in rows if abs(r["score"] - r["threshold"]) <= band]
先对一批新样本执行边界区间筛选,录制筛选出的用例,然后把它们冻结下来。提交 prompt 修改后,先回放这批冻结的边界用例;如果它们的判定结果都没有翻转,这轮低成本回归检查就完成了,完整套件可以等到夜间再跑。如果确实出现了翻转,你手里就有修改前后的 cassette,可以直接比较两个文件的 diff,不必各执一词。
回放能证明的是,面对一组固定输出,你的 harness 保持稳定。它无法证明当前模型的任何情况,因为你刚停止录制,模型就可能已经发生变化。一旦用录制记录替代 socket,延迟、token 用量统计,以及负载下的拒绝行为,就都无法再从运行结果中观察到。因此,绝不能把回放结果当作性能测量。Grader 仍然是最薄弱的一环:即使它已经悄悄偏离人工标注,cassette 套件也照样可能判定它一切正常。
如果你还没有一套稳定的用例集,就先别采用这种方法,因为 cassette 基础设施只会放大糟糕套件的问题,无法修好它。如果你真正关心的唯一问题是新模型是否更好,也应该跳过它,因为回答这个问题需要新样本和配对比较,而不是冻结的文本。任何要求证明每一次独立输出来源的场景,也都不适合这种方法,因为回放记录是缓存产物,不是一次新的决策。
如果你想先试试录制这部分,又不想配置任何基础设施,MonkeyCode 的免费模型访问和免费服务器选项,足以录制第一批几百个边界用例。把 cassettes/ 目录与 harness 一起提交,让这些录制记录逐渐积累,成为团队手中成本最低的回归证据。
如果还想采取进一步行动,可以考虑屏蔽此人和/或举报滥用行为。