模型切换别只看平均值:用配对测试揪出隐藏回归

用冻结金标准集和配对McNemar检验,揭示聚合分数掩盖的模型切换回归。
文章指出,两个模型在固定测试集上聚合分数相同(如都是71%)并不代表切换安全,因为逐案例对比可能发现等量的变好与变差案例,而用户只感知变差的部分。解决方案不是换更大基准,而是搭建配对测试装置:实验前冻结金标准集并哈希记录,每个案例配确定性检查器;用金丝雀双向审计评分器,确保它既拒绝错误输出也接受容差案例;将相同提示词发到两个后端并缓存原始输出,记录后端标识与参数;用精确McNemar检验基于不一致对计算delta和p值,并按能力标签统计翻转集中度。报告应读成决策:金丝雀失败即停线,delta<0且p<0.05则阻止切换,翻转集中在一个标签时优先人工检查翻转列表。最后说明失效边界:后端路由抖动破坏配对假设,该装置不覆盖负载测试与产品级追踪,缺乏确定性锚点的场景应转人工审查。
两个模型在同一个固定测试集上都拿到71%的分数,看起来切换很安全——直到你逐案例对比,发现9个案例变好、9个案例变差,而用户只会注意到变差的那9个。聚合分数掩盖了真正会破坏产品的翻转案例。
解决思路不是换更大的基准测试,而是用配对测试配合冻结的金标准集,并且在信任评分器之前先审计它。下面从案例冻结、评分器审计、运行与报告,到失效边界,完整搭建这套测试装置。

冻结案例:实验开始前锁定金标准集
金标准集只有在实验期间不可变才有意义。做法是把案例一次性写入磁盘,对文件做哈希,并把该哈希记录在每份报告中。每个案例需要三个字段:稳定标识符、要发送的精确提示词、返回布尔值的确定性检查器。
检查器要刻意保持"无聊":JSON Schema 校验、带容差的数值比较、必需子串检查。这些判定不会因为底层裁判模型变化而漂移。给每个案例打上能力标签,比如 extraction、refusal、format,这样后续的性能下降可以被定位,而不是陷入争论。
首批80个案例是合理下限,这不是随便定的:配对检验的统计功效只来自不一致对(discordant pairs),如果两个模型在所有案例上都通过,集合再大也提供不了任何信息。策展时要刻意纳入靠近决策边界的案例——你怀疑更便宜或不同调优的模型会出错的那些。如果一个案例在你试过的所有后端上都从未失败,它是冒烟测试而非回归测试,应该放到另一个文件里。
先用金丝雀审计评分器
在跑任何对比之前,把几个你确知错误的输出喂给评分器,要求它全部拒绝。这是针对评估工作中最丢脸故障模式的廉价保险:某人在放松了正则之后,评分器悄悄开始放行一切。金丝雀文件与金标准集放在一起并同步版本化,金丝雀在评分器变更后仍然存活属于"停线事件",而不是警告。
# graders.py — 确定性检查器 + 金丝雀审计
import json, re
def check_json_keys(keys):
def _check(output: str) -> bool:
try:
obj = json.loads(output)
except json.JSONDecodeError:
return False
return all(k in obj for k in keys)
return _check
def check_number(expected: float, tol: float = 1e-6):
def _check(output: str) -> bool:
m = re.search(r"-?\d+(?:\.\d+)?", output)
return bool(m) and abs(float(m.group()) - expected) <= tol
return _check
CANARIES = [
("empty_json", check_json_keys(["id", "total"]), "{}"),
("prose_only", check_json_keys(["id", "total"]), "Sure! Here is the answer."),
("wrong_total", check_number(41.50), "The total is 14.50"),
("near_miss", check_number(41.50), "41.5 apples"), # 必须通过:容差案例
]
def audit(canaries):
must_fail = [c for c in canaries if c[0] != "near_miss"]
survived = [name for name, check, out in must_fail if check(out)]
if survived:
raise SystemExit(f"grader audit failed, canaries passed: {survived}")
for name, check, out in canaries:
if name == "near_miss" and not check(out):
raise SystemExit("grader too strict: tolerance case rejected")
最后一个金丝雀和前三个同样重要:一个拒绝一切的评分器会为你凭空制造回归故事。双向审计保持了检查的诚实性,每个项目写一次大约十分钟。
跑完两条腿并记录清单
测试装置需要把相同的冻结提示词发送到两个后端,并持久化原始输出。分析中途重跑不稳定的端点会悄悄破坏你的配对设计。按案例标识符、提示词文本和后端标签的哈希缓存每个响应,并写入一份清单,包含后端标识字符串、采样参数和金标准集哈希。
如果你无法说出服务该请求的确切后端版本,你拥有的不是可复现对比,而是一段带时间戳的轶事。
**配对设计(paired design)** 是这里统计有效性的根基:同一个案例分别喂给两个模型,比较的是同一案例上的通过/失败状态,而非两个独立样本的通过率。这样每个案例自身充当对照,消除了案例难度差异带来的方差。配对设计下,只有“不一致对”(一个模型过、另一个不过)携带信息;两个都过或都不过的案例对 delta 无贡献。因此样本量再大,若全是一致对,检验功效仍为零。实现上要求每个案例在固定后端下只判定一次(或 k 次重复后多数决),并保证两条腿使用完全相同的提示词。任何路由抖动、温度采样或重跑不稳定端点,都会把配对数据退化成非配对数据,使 McNemar 检验的前提失效。
# swap_test.py — 配对比较 + 精确 McNemar 检验
import json, hashlib, math, os, urllib.request
from collections import Counter
def sha(*parts: str) -> str:
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]
def call(endpoint: str, model_tag: str, prompt: str) -> str:
body = json.dumps({"model": model_tag, "temperature": 0,
"messages": [{"role": "user", "content": prompt}]}).encode()
req = urllib.request.Request(endpoint + "/chat/completions", data=body,
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ['API_KEY']}"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())["choices"][0]["message"]["content"]
def exact_mcnemar(b: int, c: int) -> float:
n = b + c
if n == 0:
return 1.0
tail = sum(math.comb(n, i) for i in range(0, min(b, c) + 1)) / 2 ** n
return min(1.0, 2 * tail)
def run(cases, backends, cache_path="cache.jsonl"):
cache = {}
if os.path.exists(cache_path):
for line in open(cache_path):
row = json.loads(line)
cache[row["key"]] = row
for case in cases:
for name, (endpoint, tag) in backends.items():
key = sha(name, case["id"], case["prompt"], tag)
if key not in cache:
out = call(endpoint, tag, case["prompt"])
cache[key] = {"key": key, "case": case["id"], "backend": name,
"tag": tag, "output": out,
"passed": bool(case["check"](out))}
with open(cache_path, "a") as f:
f.write(json.dumps(cache[key]) + "\n")
return cache
def report(cases, cache, old, new):
pairs = []
for case in cases:
a = next(r for r in cache.values() if r["case"] == case["id"] and r["backend"] == old)
b = next(r for r in cache.values() if r["case"] == case["id"] and r["backend"] == new)
pairs.append((case["id"], case.get("tag", "untagged"), a["passed"], b["passed"]))
b_only = sum(1 for _, _, p, q in pairs if q and not p) # 新通过、旧失败
c_only = sum(1 for _, _, p, q in pairs if p and not q) # 旧通过、新失败
delta = (b_only - c_only) / len(pairs)
flips = [(cid, tag) for cid, tag, p, q in pairs if p and not q]
return {"n": len(pairs), "old_pass": sum(p for _, _, p, _ in pairs),
"new_pass": sum(q for _, _, _, q in pairs), "gain": b_only,
"loss": c_only, "delta": round(delta, 4),
"p_value": round(exact_mcnemar(b_only, c_only), 4),
"flips_by_tag": Counter(t for _, t in flips),
"flip_list": [cid for cid, _ in flips]}
cache_backend_tag 在缓存里是一个两行的查找,单独拿出来让报告函数保持可读。注意 delta 只由不一致对计数计算,这正是两次运行平均值不变却同时存在真实回归的原因。
把报告读成决策,而不是分数
输出是一个小字典,每个字段对应一个动作。示例如 {"n": 96, "gain": 7, "loss": 11, "delta": -0.0417, "p_value": 0.48, "flips_by_tag": {"format": 6, "extraction": 5}},这些数字是模板而非任何系统的实际结果。
| 观察 | 解读 | 行动 |
|---|---|---|
| 金丝雀审计失败 | 评分器坏了 | 停止;任何对比前先修评分器 |
| delta ≥ 0 且 p 值较高 | 未检测到回归 | 保留切换,保留金标准集 |
| delta < 0,loss > gain,p < 0.05 | 此集合上真实回归 | 阻止切换或缩小提示词范围 |
| delta < 0,p > 0.2 | 统计功效不足,未必没问题 | 决策前增加边界案例 |
| 翻转集中在一个标签 | 局部破坏 | 手工检查 flip_list,通常6-12个案例 |
集中度这一列承载了大部分实用价值:11个损失均匀分布在五个能力上看起来像噪声,而11个损失全部落在 format 内通常指向某条缺失的指令。先读翻转列表,再读 p 值;先读原始输出,再读翻转列表。
精确 McNemar 检验 用于比较同一组受试对象在两种条件下的二分类结果。它只看两个不一致方向的计数:b = 旧失败而新通过,c = 旧通过而新失败。原假设下 b 与 c 服从参数为 n=b+c、p=0.5 的二项分布。精确版本直接计算该二项分布的双侧尾概率,而不依赖大样本卡方近似,因此在 b+c 较小(如本例的 18)时仍然可靠。p 值回答的是“观察到的翻转不平衡是否可能仅由随机波动产生”。p 值高不代表模型等价,只代表当前数据不足以拒绝“无差异”;这正是表格中 delta<0 且 p>0.2 时要求增加边界案例的原因——功效不足时,不显著不等于安全。
这套装置的失效边界
配对设计假设每个案例是在固定后端下的伯努利试验,因此把连续请求路由到不同模型版本的后端会放大方差,并悄悄破坏配对。共享免费层风险最高,这正是清单逐字记录返回标识字符串、而非信任你请求的名字的原因。如果在大于零的温度下采样,每个案例跑 k 次重复并以多数决出案例级判定,然后对多数判定结果再跑一遍金丝雀审计。
这也不是负载测试:它不涉及每秒 token 数、排队行为或规模化成本,那些需要单独的方法测量。需要监管级评估证据的团队,或输出完全没有确定性锚点的产品,应把这项工作交给人工审查,而不是把布尔检查器拉伸到它支撑不了的地步。它也不能替代产品级追踪,因为金标准集只认识你已经想象到的失败。
如果不想为两条完整后端账单付费,MonkeyCode 提供的免费模型访问和免费服务器选项足以在小集合上跑通上述装置。(披露:本文作为 MonkeyCode 产品推广的一部分撰写。)从二十个边界案例、一个金丝雀文件、每条腿一个固定后端标签开始,让翻转列表告诉你这次切换是否值得花掉剩下的下午。
伯努利试验假设 指每个案例的通过/失败是一次独立、同分布的二元结果,且通过概率由固定后端版本决定。配对 McNemar 检验在此基础上成立。若后端把连续请求路由到不同模型版本,同一案例的通过概率就不再固定,相当于把多个不同伯努利分布混在一起,方差被人为放大,不一致对的计数也失去明确含义。免费共享层、灰度发布、自动模型升级都会引入这种风险。缓解办法是清单中逐字记录响应返回的模型标识字符串(而非请求时填写的名字),并在分析前核对该字符串在整个运行期间是否一致;若不一致,应按版本分段重新配对,或固定路由到单一版本后再跑。
相关推荐

uGAP:统一100+非洲地理数据集的AI平台
uGAP是开普敦大学团队开发的非洲地理空间AI平台,统一120余个非洲数据集并提供Python SDK,支持数据发现、处理与模型训练一站式工作流,已用于气旋洪水制图等实战场景。

Cursor能控制你的电脑了:AI代理可驱动桌面应用测试
Cursor 新增桌面控制能力,AI 代理可驱动 iOS 模拟器等桌面应用测试自己构建的功能。本文分析这一更新对开发工作流的影响、技术实现可能路径及可靠性考量。

Vibecoding自研推理引擎:理想丰满,现实骨感
Reddit开发者分享Vibecoding自研推理引擎的真实经历:用WebGL2和GLSL在浏览器跑GGUF Q4模型,却陷入tokenizer调试地狱。本文分析浏览器端LLM推理的工程难点,以及为何直接clone llama.cpp是更理性的选择。