我看着同一次发布连续三次出问题。有人调整提示词或更换模型,评测平均分从 0.81 升到 0.83,PR 获得批准,两周后某个特定意图却在生产环境中失效了。回归确实存在,所谓改进却并不存在。没人粗心大意——评测给出了一个看似测量结果、实际上却像抛硬币一样随机的数字。
LLMOps 中不起眼的部分不是提示词管理或追踪,而是大多数评测套件无法检测团队据以发布的那些变化。解决这个问题只需半天统计工作和一个 CI 任务。
比较两种配置之前,先弄清楚哪些因素在变化:
示例抽样噪声。 你的黄金集有 200 个示例,每个示例值 0.5 分。所谓“提高 3 分”,可能只是 6 个示例的结果翻转,其中好几个还是边界案例。
评判器噪声。 如果用 LLM 给输出打分,而温度参数设为高于 0,那么同一输出可能周二通过、周三却不通过。顺序也有影响:先问评判器“A 和 B 哪个更好”,再问“B 和 A 哪个更好”,你就会发现它前后不一致。
任务噪声。 采样温度、工具超时和检索索引漂移都会改变输出,即使你这边没有改动任何代码。
这三种噪声无法全部消除。通过比较配对结果,你几乎可以彻底消除第一种:在相同示例上运行基线和候选配置,并分析每个示例的差值。示例难度的影响会相互抵消。现在,能修复一个难例、又弄坏另一个的提示词,会表现为一个 +1 和一个 -1,而不再是需要你费心解读的汇总分数。
对于通过/不通过结果的配对比较,平均差值的标准误取决于不一致率——也就是两种配置在同一示例上结果不同的频率——而不是准确率。
如果两种配置在 15% 的示例上结果不同,并且你有 200 个配对示例:
- SE = sqrt(0.15 × 0.85 / 200) = 2.5 个百分点
- 在 80% 检验功效、α=0.05 时的最小可检测效应 = 2.8 × SE = 7 个百分点
所以在这种设置下,提升 2 个百分点是检测不到的。若要在相同假设下检测 5 个百分点的变化,大约需要 400 个配对示例;检测 3 个百分点则需要约 1,100 个。这些只是算术结果,不是基准数据——代入你自己的不一致率;对于提示词变更,它通常比你预想的更高。
由此得出两个结论。第一,停止以原始平均分作为门槛。第二,如果你的黄金集只有 200 个示例,而且无法扩大,那么门槛应设为“不得回退超过 X”,而不是“必须有所提升”。
整个实现大约只需三十行 Python 标准库代码。对配对差值进行自助抽样,查看区间,并且只在区间不包含零时发布。
python
"""paired_gate.py â decide whether an LLM change is real or noise.
Run: python paired_gate.py Deps: none (stdlib only)."""
import random
def paired_bootstrap(baseline, candidate, iters=5000, seed=7):
"""Scores aligned on the SAME example ids, 0/1 or 0..1.
Returns (mean delta, 2.5th pct, 97.5th pct)."""
diffs = [c - b for b, c in zip(baseline, candidate)]
n = len(diffs)
rng = random.Random(seed) # fixed seed: the gate must be reproducible
means = sorted(
sum(diffs[rng.randrange(n)] for _ in range(n)) / n
for _ in range(iters)
)
return sum(diffs) / n, means[int(0.025 * iters)], means[int(0.975 * iters)]
def required_n(disagreement_rate, mde, z_alpha=1.96, z_power=0.84):
"""Paired binary comparison: examples needed per side."""
se = mde / (z_alpha + z_power)
p = disagreement_rate
return int((p * (1 - p)) / se**2) + 1
if __name__ == "__main__":
# 200 paired examples; candidate looks "+3 points" better.
baseline = [1] * 160 + [0] * 40
candidate = [1] * 166 + [0] * 34
delta, lo, hi = paired_bootstrap(baseline, candidate)
print(f"delta