把工具响应录下来回放,评测就公平了吗?
回放把环境钉死了:同一个请求,永远拿到同一份响应。换模型、换 prompt 时,比的终于是同一道题。
但它有三个坑:新模型走出录制时没走过的路(未命中),录下来的内容里藏着答案(数据泄漏),被测应用早就升级了而录制还是旧的(快照过期)。三个坑都会让回放结论偏离真实情况(差距被压扁、被抹平,甚至翻号),而且回放跑得越稳定,越不容易被发现。
1线上评测,分数里混着三种随机
BugHunt 的测试 Agent 在被测 Web 应用里找注入的 Bug。直接对着线上环境跑,一次评测的召回率里混着三样东西:
| 来源 | 例子 | 回放能去掉吗 |
|---|---|---|
| 环境的偶发故障 | 某次请求 503、超时、页面没加载完 | 能:录下来的响应每次都一样 |
| 被测应用在变 | 今天发了版,昨天的 Bug 修了,又多了一个 | 能:回放的是录制那一刻的版本 |
| 模型自己的随机性 | 同一页面,这次看出了 Bug,下次没看出 | 不能:回放的是工具响应,模型照样现场生成 |
所以回放之后照样要跑多次、照样要算区间(第 1 周)。回放解决的是"两个模型是不是在同一个环境里比",不是"一次就够"。
2回放什么:三层
| 层 | 录 / 固定的是什么 | 模型是真的吗 | 用途 |
|---|---|---|---|
| 模型响应回放 | 模型每一轮的输出(SSE 剧本) | 假的 | 测 harness:重试、配对、停止条件(第 3 周) |
| 工具响应回放 | 每次工具调用的返回(cassette) | 真的 | 换模型、换 prompt,在同一份环境响应上比 |
| 环境快照 | 被测系统的初始状态(代码版本、数据、镜像);工具照常真实执行 | 真的 | SWE-bench:每道题带 base_commit,评测在 Docker 里跑 |
工具响应回放最便宜:不用起被测应用,不怕它挂。代价是只能回答"录过的请求"。环境快照能回答任何请求,但要真的跑起被测系统。本章讲的主要是中间一层,第三层出现在"数据泄漏"那一节。
3录制:键、指纹、校验
- 键:回放按什么判断"这是同一个请求"。本章的合成实验用 (页面, 视图);Playwright 的 HAR 回放严格匹配 URL 和 HTTP 方法(POST 还匹配请求体);VCR.py 默认匹配
method、scheme、host、port、path、query。键太严,换个参数顺序就未命中;太松,不同的请求拿到同一份响应。 - 指纹:每条录制存一个内容哈希,算之前先去掉每次都变的字段(时间戳、请求 id)。不去掉的话,每一条都会被判成"和线上不一致"。
- 录制校验:录到 5xx、超时就重录,或者整条丢掉。不校验的话,录制那一刻的偶发故障会被冻结进 cassette,以后每次回放都复现它。
- 元数据:被测应用版本、录制时间、工具 schema 版本、答案表版本,和 cassette 存在一起。后面判断过期全靠它。
- 脱敏:请求头里的 token、cookie 不要进 cassette(VCR.py 有
filter_headers)。
4新模型走了没录过的路
cassette 是用基线模型 A 跑一遍录的,里面只有 A 发过的请求。候选模型 B 更爱探索:除了每页的默认视图,还会打开排序视图(?sort=price)。B 的这些请求在 cassette 里查不到,叫未命中。三种处理办法:
| 策略 | 做法 | 对应工具 | 代价 |
|---|---|---|---|
| 当成报错 | 返回 404 / 连接失败,让 Agent 继续 | HAR 回放 not_found="abort"(默认) | B 永远看不到排序视图上的 Bug,系统性低估探索得多的模型 |
| 整回合作废 | 抛异常,这一回合不计分 | VCR.py record_mode="none" / "once"(已有 cassette 时) | 不会算错,但走偏的模型可能一个有效回合都没有 |
| 回落线上 | 去真实环境取,可以顺手补录 | HAR not_found="fallback"(发到真实网络);VCR.py new_episodes | 又引入了环境随机性;线上已升级时,一个回合里混着两个版本 |
本文推导(合成设定:答案表里 \(|K_\text{默认}|\) 个 Bug 在默认视图、\(|K_\text{排序}|\) 个在排序视图;模型看到 Bug 时以概率 \(d\) 认出来;线上每次请求以概率 \(f\) 返回 503;B 以概率 \(e\) 打开排序视图;cassette 里的默认视图都是 200):
$$\mathbb{E}[\text{召回}_\text{线上}] = d\,(1-f)\,\frac{|K_\text{默认}| + e\,|K_\text{排序}|}{|K|},\qquad \mathbb{E}[\text{召回}_\text{回放, 报错}] = d\,\frac{|K_\text{默认}|}{|K|}$$回放那一项里没有 \(e\):B 探索得再多,回放分数也不涨。在默认设定(\(d_A = 0.80,\ d_B = 0.85,\ f = 0.1,\ e = 0.5\),4 个 Bug 两两分布)下,B − A 的期望差距线上是 +21.375 个百分点,回放只有 +2.50。各跑 20 次的实际结果更极端:线上 B 比 A 高 20.00 个百分点,回放 B 反而低 3.75 个百分点。期望差距是 +2.50,负号是 20 次的抽样误差;多跑几次 B 会回到略高于 A,但追不回线上那 20 个百分点。
5数据泄漏:答案混进了录制
| 类型 | 例子 | 怎么查 |
|---|---|---|
| 答案藏在响应里 | 录制用的是 debug 构建,Bug 页带注入标记;页面源码里的注释直接写着 Bug 在哪(本章用 HAR 录第 5 周的购物车页,真的录到了 // buggy 版忘了乘数量) | 按答案表的关键词和注入标记扫一遍 cassette |
| 未来的信息 | SWE-bench 的 issue #465(2025-09-03)报告:Agent 用 git log --all 看到了修复这个问题的未来提交 | 快照里删掉录制时间点之后的东西;工具返回的数据按时间截断 |
| 评测集进了训练或调优 | 拿评测用的 cassette 调 prompt,再在同一批上报分数;公开评测集被爬进训练数据 | 留出集;BIG-bench 的 canary 字符串约定 |
6快照过期:应用升级了,录制还是旧的
录制之后被测应用从 v1 升到 v2:p07 和 p05 排序视图的 Bug 修了,p09、p12 多了新 Bug,p02 改了文案。回放还在放 v1,它测的是一个已经不存在的应用。
- 怎么发现:只取页面、不调模型,把每条录制的指纹和线上比一遍(很便宜)。合成实验里比出 4/12 条过期:p02、p07、p09、p12。
- 常见错误:答案表更新到 v2,cassette 还是 v1。A 的回放召回从 43.75% 掉到 21.25%,每回合平均多出 0.90 个"误报"(报的是 v2 里已修好的 p07)。答案表和 cassette 必须同版本。
- 回落线上也不安全:未命中时去线上取(答案表仍是 v1),B 的 20 个回合每一个都混着 v1 录制和 v2 线上响应。
过期了就重录过期的条目(或整份重录),并把重录前后各跑一遍的分数都留着,不然分数的变化说不清是模型变了还是应用变了。
▶互动演示:录一次,回放给两个模型
合成数据,不调任何模型。基线 A 只看 12 个页面的默认视图,录制用的就是它的一次运行;候选 B 还会以"探索比例"打开排序视图。答案表 v1 有 4 个 Bug(p03、p07 在默认视图,p05、p10 在排序视图)。每个模型在线上和回放各跑 k 次,同一个回合编号在两种模式下用同一串模型随机数(配对)。所有数字由页面上的 JS 现算,和本地 replay_eval.py 的输出逐位一致。
| 模型 | 线上召回 均值 ± sd | 回放召回 均值 ± sd | 未命中 | 作废回合 | 回落线上 | 混版本回合 | 回放误报 / 回合 |
|---|---|---|---|---|---|---|---|
| A 基线 | |||||||
| B 候选 | |||||||
| B − A | |||||||
每个回合的召回(每个点是一次运行,同一值的点横向排开)
cassette:录了什么,现在还对不对
✎练习
7面试要点与代码
一句话讲清楚
回放评测把工具响应录成 cassette,换模型时按键回放,环境就固定了,但模型的随机性还在,照样要跑多次。它有三个坑:新模型走了没录过的路(报告未命中率,别把未命中当成"页面打不开"直接计分);录制里藏着答案或未来信息(扫关键词、截断时间、留出集);被测应用升级了(指纹比对,cassette 和答案表同版本,过期就重录)。
追问准备
- 回放和第 3 周的假模型服务器有什么区别?假模型服务器固定模型响应,测 harness;回放固定工具响应,比模型。两者可以叠加:模型和工具都录下来,就是一份完整的回归剧本。
- 未命中率多少算高?没有通用阈值。至少做两件事:分数和未命中率一起报;在未命中为 0 的回合子集上单独算一遍分数,看结论变不变。
- 为什么不全部回落线上?那就回到了线上评测:有环境随机性,有版本漂移,还贵。回落可以做,但要记录哪些响应来自线上、来自哪个版本。
- SWE-bench 怎么固定环境?每道题带
base_commit,2024 年 6 月宣布评测改用 Docker 容器。即便如此,2025 年 9 月还有人报告 Agent 用git log --all看到了未来的修复提交:快照里留了不该留的东西。
常见错误说法
❌ "回放分数低,说明新模型差":先看未命中率。在这个合成设定下,B 线上高 20 个百分点,回放低 3.75:排序视图都没录过,把 B 的优势压到只剩约 +2.5 个百分点,20 次的抽样误差又把它翻了号。
❌ "分数变好、方差变小,说明更稳定了":也可能是泄漏。
❌ "应用升级了,更新答案表就行":cassette 和答案表要同版本,否则修好的 Bug 会被算成误报、新 Bug 永远看不到。
❌ "录到什么就存什么":录制时的偶发 503 会被冻结,每次回放都复现。
回放环境的核心(Python,合成实验里的写法)
class ReplayEnv:
def __init__(self, cassette: Cassette, policy: str, fallback: LiveEnv | None):
self.cas, self.policy, self.fallback = cassette, policy, fallback
self.misses = 0
self.fallbacks = 0
def fetch(self, page: int, view: str) -> Response:
entry = self.cas.entries.get((page, view))
if entry is not None:
return Response(entry["status"], entry["body"], "cassette")
self.misses += 1
if self.policy == "strict":
raise Unreplayable(f"p{page:02d}/{view} 没有录制")
if self.policy == "live":
self.fallbacks += 1
return self.fallback.fetch(page, view)
return Response(404, "not recorded", "miss") # policy == "error"
完整代码在 week07_回放评测与模型对比/code/replay_eval/:sim.py(环境、录制、回放、评测)、replay_eval.py(打印全部实验结果)、test_replay_eval.py(16 个单测,含公式和 20000 次模拟的核对)、har_replay.py(用 Playwright 的 HAR 录制 / 回放第 5 周的购物车页面)。
下一讲换到评测框架。
公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。