欢迎光临
我们一直在努力

LLM 评测方差控制:同一份测试集换个 seed 结论就反转,别只盯平均分

先说结论:你上一次评测说"新方案涨了 1.7 个点",很可能只是随机噪声。

我做过一个很朴素的实验:同一份 300 条的 RAG 测试集,同一套判定脚本,只把采样种子从 42 换成 7,A/B 两个检索方案的胜负直接反转。单次跑出来是 B 赢 1.8 个点,换种子之后 A 赢 0.9 个点。重复 10 次取分布,两者均值只差 0.3 个点,95% 置信区间大幅重叠——统计上这两个方案没有区别。

平均分是最容易被"跑出来的数字",方差才是决定这个数字能不能当证据的东西。这篇讲清楚三件事:方差从哪来、怎么量化、怎么把它做成 CI 门禁。


一、方差从哪来:四个常被当成"模型不稳定"的来源

很多人把评测抖动笼统归为"大模型就是不稳定",然后放弃量化。其实抖动来源是可以拆开的,拆开之后每一项都能压:

  • 采样随机性。temperature > 0 时同一 prompt 每次输出都不同,判定结果自然不同。注意:temperature=0 也不等于确定性——并行推理、分批调度、不同 kernel 实现都会让结果漂移。
  • 位置偏差(position bias)。让裁判模型在 A/B 两个答案里选一个,把两个答案交换顺序再问一遍,有相当比例的样本会改判。这是裁判的偏差,不是模型的能力。
  • 样本量太小。300 条样本上 1 个点的差异只对应 3 条样本。样本越少,同样的真实差异越容易被淹没在抖动里。
  • 裁判模型自身的不稳定。用 LLM 当裁判时,裁判也在采样。同一份回答被判「忠实」的概率会波动,尤其在边缘样本上。
  • 把这四项混在一起看,就会得到"评测结果不可信"的结论;拆开看,每一项都有对应手段。


    二、把评测当 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 应用做免费质量体检,出一份可执行的测评报告(检索命中率、回答忠实度、噪声敏感度等维度),感兴趣可以直接私信我。

    赞(0)
    未经允许不得转载:171主机测评 » LLM 评测方差控制:同一份测试集换个 seed 结论就反转,别只盯平均分
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址