先说结论:你上一次评测说"新方案涨了 1.7 个点",很可能只是随机噪声。
我做过一个很朴素的实验:同一份 300 条的 RAG 测试集,同一套判定脚本,只把采样种子从 42 换成 7,A/B 两个检索方案的胜负直接反转。单次跑出来是 B 赢 1.8 个点,换种子之后 A 赢 0.9 个点。重复 10 次取分布,两者均值只差 0.3 个点,95% 置信区间大幅重叠——统计上这两个方案没有区别。
平均分是最容易被"跑出来的数字",方差才是决定这个数字能不能当证据的东西。这篇讲清楚三件事:方差从哪来、怎么量化、怎么把它做成 CI 门禁。
一、方差从哪来:四个常被当成"模型不稳定"的来源
很多人把评测抖动笼统归为"大模型就是不稳定",然后放弃量化。其实抖动来源是可以拆开的,拆开之后每一项都能压:
把这四项混在一起看,就会得到"评测结果不可信"的结论;拆开看,每一项都有对应手段。
二、把评测当 flaky test 对待:三步量化
核心思路很直接:单次运行的结果不是数据,分布才是数据。
2.1 每个样本跑 k 次,先看自己的抖动
import numpy as np
def sample_scores(evaluate, samples, k=5, base_seed=0):
"""对同一批样本重复 k 次评测,返回 (k, n) 的分数矩阵。
evaluate(sample, seed) -> float 单样本单次得分(0~1)
"""
n = len(samples)
out = np.zeros((k, n), dtype=float)
for i in range(k):
seed = base_seed + i
for j, s in enumerate(samples):
out[i, j] = evaluate(s, seed)
return out
def summarize(matrix):
"""给出每个样本的均值与标准差,以及整批的均值 ± 95% CI。"""
per_sample = matrix.mean(axis=0)
per_sample_std = matrix.std(axis=0, ddof=1)
batch_means = matrix.mean(axis=1) # 每次运行一个批均值
lo, hi = np.percentile(batch_means, [2.5, 97.5])
return {
"mean": float(batch_means.mean()),
"ci95": (float(lo), float(hi)),
"flaky_ratio": float((per_sample_std > 0.5).mean()), # 判定翻转的样本比例
}
flaky_ratio 比平均分更有信息量:它告诉你有多少比例的样本,你连"它到底算不算通过"都定不下来。这个比例超过 10%,说明你的判定条件本身太模糊(阈值卡在分布中间),先去修判定,不用急着换方案。
2.2 方案对比要配对,不要各跑各的
最常见的错误是:A 跑一次得 78.4%,B 跑一次得 80.1%,就宣布 B 更好。这是两次独立采样之间的比较,噪声全算进差异里了。
正确做法是配对比较:同一批样本,A 和 B 各跑,看每个样本上的差值。
def paired_bootstrap(a_scores, b_scores, iters=10000, seed=0):
"""配对 bootstrap:估计 B-A 差值分布,给出是否显著。"""
rng = np.random.default_rng(seed)
a = np.asarray(a_scores, dtype=float)
b = np.asarray(b_scores, dtype=float)
diff = b – a
n = len(diff)
idx = rng.integers(0, n, size=(iters, n))
boot = diff[idx].mean(axis=1)
lo, hi = np.percentile(boot, [2.5, 97.5])
significant = (lo > 0) or (hi < 0)
return {
"delta": float(diff.mean()),
"ci95": (float(lo), float(hi)),
"significant": bool(significant),
}
判读规则就一条:置信区间跨过 0,就不要说"变好了"。 上例那种 B 赢 1.8 点、换 seed 就反转的情况,跑一遍这个函数,significant 基本是 False。
2.3 二值判定用 McNemar,比算比例差更准
如果判定是"通过/不通过",用配对检验里的 McNemar,只看被判不一致的样本:
from scipy.stats import binomtest
def mcnemar(a_pass, b_pass):
"""a_pass / b_pass: 布尔数组。返回 (p值, b_优于_a, a_优于_b)"""
a = np.asarray(a_pass, dtype=bool)
b = np.asarray(b_pass, dtype=bool)
b_only = int(np.sum(b & ~a)) # B 通过、A 不通过
a_only = int(np.sum(a & ~b))
n = b_only + a_only
if n == 0:
return 1.0, 0, 0
p = binomtest(b_only, n, 0.5).pvalue
return p, b_only, a_only
p < 0.05 才谈得上"这个改动有效"。你在 CI 里拦住一次误判,比多跑十次评测都省时间。
三、落地成 CI 门禁:四件事必须做
1. 把随机性写进报告。 报告里只出现"准确率 80.1%"是没意义的,要写成 80.1%(95% CI 78.2–81.9,k=5)。没有区间的分数,不要拿去开会。
2. 缓存要按 seed 分键。 这是我自己踩过的坑:语义缓存按 hash(prompt) 缓存,评测脚本换了 seed 但 prompt 没变,于是第二次的"采样"直接命中缓存,拿到的是第一次的输出。结果看起来稳定得可疑——方差被缓存吃掉了,你看到的是假稳定。评测链路的缓存 key 必须包含 seed 和模型版本,或者在评测模式直接关缓存。
3. 边界样本单独看。 只看总分,会把"某个类别整体退化 15 个点、另一个类别涨了 2 个点"平均成 0。按类别切片再各算一次区间,通常能立刻定位是哪个环节坏了。
4. 用方差反过来定样本量。 如果置信区间总是很宽,先别加样本,先降方差:把模糊判定改成明确判定(能自动判的别交给 LLM 裁判)、把温度调到 0 并固定 seed 集合、把位置偏差用双顺序询问消掉。方差降下来,几十条样本也能得出可信结论。
四、一句提醒
评测的目的不是产出一个好看的百分数,是在你改代码之前,替你判断这个改动到底有没有用。一个会被随机种子反转的结论,等于没有结论。
下一步可以做两件更狠的事:一是把显著性检验做成发布门禁,不显著的 PR 不允许带"提升"字样;二是记录每次评测的方差,用历史方差反推"多小的差异我根本测不出来",从此不再为一个 0.3 个点的"提升"加班。
如果你也在做 AI 应用(RAG / Agent / LLM),不知道质量怎么测——我最近在给 AI 应用做免费质量体检,出一份可执行的测评报告(检索命中率、回答忠实度、噪声敏感度等维度),感兴趣可以直接私信我。

