Skip to content
Published on

不靠感觉做 LLM 评测 — 样本量、评判者偏差与 CI 回归测试

分享
Authors

引言 — “好像变好了”的代价

改完提示、跑了几个例子,然后说一句“确实变好了”——想必您也这么干过。我也干过。问题出在下一步。

那次改动在某些输入上变好、在某些输入上变差,可您确认过的只有那几个恰好看到的。变差的那些会悄悄留到下一个版本。这个过程重复二十次,那个每一步都“好像变好了”的系统可能已经比最初更差;更糟的是,您没有办法确认这件事,因为每次改动的基准线都是一段不同的记忆。

评测体系不是用来提升模型质量的装置。它是为了留下一个可以退回去的位置。本文讲的是如何从成本最低的层次开始把这套体系搭起来,以及从中得到的数字该怎么解读。

评测的四个层次

不必全都做,但必须知道哪一层能抓住什么。

层次能抓住的东西每个样例的成本可信度执行频率
确定性断言格式违规、禁用词、schema 错误、超时实际上为 0非常高每次提交
黄金数据集有标准答案的任务上的精度回归低(API 成本)每个 PR
LLM-as-judge答案不唯一的任务上的相对质量中(需要验证)夜间、发布前
人工评估上面几层都抓不住的细微质量非常高最高(作为基准)每季度、大改动时

顺序很重要。很多团队一上来就做 LLM-as-judge,因为它花哨、论文里也常见。可真正挡住事故的,绝大多数情况下是第一行。

便宜的断言排在最前面

即使是无法定义标准答案的任务,也能定义出绝对不许发生的事。这不是统计,是单元测试。

import re, json

def assert_output_contract(resp, ctx):
    """与模型质量无关、必须永远成立的那些条件"""
    checks = []

    checks.append(("파싱 가능", is_valid_json(resp.text)))
    checks.append(("스키마 준수", validate_schema(resp.text) is True))
    checks.append(("잘리지 않음", resp.finish_reason != "length"))
    checks.append(("금칙 표현 없음", not FORBIDDEN.search(resp.text)))
    checks.append(("문맥 밖 URL 없음", set(urls(resp.text)) <= set(urls(ctx))))
    checks.append(("지연 상한", resp.latency_ms < 4000))
    checks.append(("출력 길이 상한", resp.output_tokens < 800))

    return [name for name, ok in checks if not ok]

第五行尤其值回票价。模型造出来的链接,只要是上下文里没有的,就几乎可以肯定是幻觉,而这靠一行集合运算就能抓住。用同样的办法,还能确认被引用的数字是否真的出现在上下文里、引用编号指向的分块是否真实存在。这些都是不知道标准答案也能验证的东西。

这一层不会提高精度。它做的是把“格式坏掉导致故障”这一类事故基本消灭掉。真实事故中相当大一部分属于这一类,所以这里的投入产出比是最好的。

黄金数据集 — 构成比规模更重要

要测精度就需要带标准答案的输入。这里常见两个错误。

第一,只收集简单的样例。用能跑通的用例填出来的评测集,一开始精度就是 0.95,之后无论怎么改都纹丝不动。作为测量工具,它已经死了。评测集必须有意识地纳入曾经失败的输入、边界条件和含糊的输入,才会产生信号。

第二,做完就不更新。时间一长,系统就把评测集背下来了,因为提示一直是照着那些样例改的。如果不定期从生产日志里抽新样例补进去,评测集会慢慢变成训练集。

从日志里养大评测集的循环,可以这样写。

def harvest_candidates(logs, judge, sample_rate=0.02):
    """自动挑出值得放进评测集的请求"""
    out = []
    for r in logs:
        reason = None
        if r.user_feedback == "negative":     reason = "사용자 부정 피드백"
        elif r.retry_count > 0:               reason = "재시도 발생"
        elif r.assertion_failures:            reason = "어서션 실패"
        elif r.judge_score is not None and r.judge_score < 3:
            reason = "심사자 저점"
        elif random.random() < sample_rate:   reason = "무작위 표본"

        if reason:
            out.append({"input": r.input, "output": r.output, "reason": reason})
    return out   # 标准答案标签由人来打

最后那个随机抽样的分支不能删掉。只用失败信号来填,评测集就只往难的方向偏,正常请求上出现的回归就抓不到了。

LLM-as-judge 的偏差与缓解

答案不唯一的任务,比如摘要或客服回复,没法用精度来衡量。这时就要用评判模型。不过如果不处理三种已知偏差,它给的分数是不可信的。

位置偏差。把两个答案并排展示让它选,先呈现的那个会占便宜。缓解很简单:把顺序换过来评两次,只有两次都赢才算胜。

长度偏好。评判者倾向于给又长又详细的答案更高的分。麻烦在于长度有时确实与真实质量相关,所以不能无条件地做校正。务实的办法是把两个候选的输出长度一并记录下来,检查胜率与长度差之间的相关性。相关性很强,那这个胜率很可能测的是长度而不是质量。

自我偏好。已有报告称评判者会给自己或同系列模型的输出打更高的分。如果某个候选与评判者同属一个系列,请再用一个不同系列的评判者跑一遍,看结果会不会翻转。

def judge_pairwise(judge, question, answer_a, answer_b, rubric):
    """把顺序换过来评两次,只有两次一致才认定胜负"""
    def ask(first, second):
        prompt = (f"{rubric}\n\n질문: {question}\n\n"
                  f"[답변 1]\n{first}\n\n[답변 2]\n{second}\n\n"
                  "먼저 기준별 근거를 쓰고, 마지막 줄에 '승자: 1' 또는 '승자: 2' 또는 '승자: 무승부'.")
        return parse_winner(judge(prompt))

    r1 = ask(answer_a, answer_b)        # A 在前
    r2 = ask(answer_b, answer_a)        # B 在前
    if r1 == "1" and r2 == "2":  return "A"
    if r1 == "2" and r2 == "1":  return "B"
    return "무승부"                      # 换个顺序就翻转,说明判不了

这里“换顺序后结果翻转的比例”本身就是一个有用的指标。这个值高,要么说明两个候选的真实差距很小,要么说明评判者判别不了这个任务;无论哪一种,都不该拿这个评判者的分数去做上线决定。

而且最重要的一步还没做:验证评判者。拿一百个左右由人标注过的样例,测一测评判者的判定与人工的一致程度。一致率低,那这个评判者不过是自动化的感觉。跳过这一步验证、却把评判者分数挂上仪表盘,是这个领域里最常见的自欺欺人。

20 个样例说明不了任何事

20 个样例里答对了 17 个,也就是 85%。这个数字该信几分?

import math

def wilson(successes, n, z=1.96):
    if n == 0:
        return (0.0, 1.0)
    p = successes / n
    denom = 1 + z * z / n
    center = (p + z * z / (2 * n)) / denom
    half = (z / denom) * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
    return (max(0.0, center - half), min(1.0, center + half))

for n in (20, 60, 200, 1000):
    lo, hi = wilson(round(0.85 * n), n)
    print(f"n={n:4}  85% 관측 → 95% 신뢰구간 [{lo:.3f}, {hi:.3f}]  폭 {hi - lo:.3f}")
# n=  20  85% 관측 → 95% 신뢰구간 [0.640, 0.948]  폭 0.308
# n=  60  85% 관측 → 95% 신뢰구간 [0.739, 0.919]  폭 0.180
# n= 200  85% 관측 → 95% 신뢰구간 [0.794, 0.893]  폭 0.099
# n=1000  85% 관측 → 95% 신뢰구간 [0.827, 0.871]  폭 0.044

在 20 个样例上观测到的 85%,真值可能是 64%,也可能是 95%。所有在这个区间里晃动的变化,都与噪声无法区分。如果两个版本各用 20 个样例测出 80% 和 85%,这个结果与“没有差别”完全相容。

那到底需要多少个?

Z_A, Z_B = 1.96, 0.8416   # 显著性水平 5%,检验效能 80%

def n_unpaired(p1, p2):
    """用互不相同的样例衡量两个版本时,每组需要的样例数"""
    num = (Z_A + Z_B) ** 2 * (p1 * (1 - p1) + p2 * (1 - p2))
    return math.ceil(num / (p1 - p2) ** 2)

def n_paired(win_ratio, discordant_rate):
    """用同一批样例把两个版本对着比时需要的样例数。
    win_ratio: 意见分歧的样例中新版本获胜的比例"""
    num = (Z_A / 2 + Z_B * math.sqrt(win_ratio * (1 - win_ratio))) ** 2
    need_discordant = math.ceil(num / (win_ratio - 0.5) ** 2)
    return math.ceil(need_discordant / discordant_rate), need_discordant

print(f"짝짓지 않음: 그룹당 {n_unpaired(0.80, 0.85)}개 (총 {2 * n_unpaired(0.80, 0.85)}개)")
total, disc = n_paired(0.70, 0.20)
print(f"짝지은 비교: 총 {total}개 (의견 갈린 {disc}개 필요)")
# 짝짓지 않음: 그룹당 903개 (총 1806개)
# 짝지은 비교: 총 235개 (의견 갈린 47개 필요)

得出同样结论所需的样例,从 1806 个降到了 235 个。原因很简单:不配对的比较里,“样例难度的方差”和“版本之间的差异”混在一起,而用同一批样例比较两个版本,难度那一项就被抵消掉了。如果没有余力把评测集做大,至少也要让两个版本跑同一批样例。仅此一项,就能把可检测的最小效应量缩小好几倍。

有几条注脚要老实补上。上面的计算假设各样例彼此独立。从同一份文档派生出来的十个问题并不独立,那时的有效样本数会小于十。另外这个公式处理的是结果非对即错的情形,1 到 5 分这样的评分型要用另一套算法。还有,用同一个评测集去比十个提示候选,其中某一个碰巧看起来更好的概率会上升。做多次比较时,判定标准要相应收紧。

放进 CI,以及温度 0 无法复现的问题

只靠人手跑的评测,最终就是不会跑了。必须放进流水线。

在这里会撞上的是非确定性。把温度设成 0,看起来同样的输入该给出同样的输出,实际上并非如此。批次构成不同,浮点累加的顺序就不同,内核选择的路径也随之改变。当排名前二的 token 概率几乎相等时,这点极细微的差别就会让另一个 token 胜出,之后便是完全不同的句子。如果用的是供应商 API,还要再叠上不打招呼的服务端变更。

所以在 CI 里逐字比较输出字符串的测试必定会崩。改成这样来组织。

def ci_gate(baseline, candidate, n, threshold=0.03):
    """按各指标的下降阈值判定。不因单个样例不一致就判失败。"""
    failures = []
    for metric in ("accuracy", "assertion_pass", "citation_valid"):
        drop = baseline[metric] - candidate[metric]
        lo, hi = wilson(round(candidate[metric] * n), n)
        if drop > threshold and baseline[metric] > hi:
            failures.append(f"{metric}: {baseline[metric]:.3f}{candidate[metric]:.3f} "
                            f"(구간 상한 {hi:.3f})")
    return failures

只有当基准线落在候选的置信区间之上时才判为失败。不这么做,流水线会被噪声弄得一直是红的,几周之后就没人再看那盏红灯了。让人学会无视警报,比根本没有警报更糟。

执行层次也要分开。每个 PR 都跑的那一层,应该靠断言和小规模黄金集在几分钟内跑完;用评判者的完整评测放到夜间或发布前跑。不做这个区分,评测就会因为太慢而被关掉。

最后是线上评测。离线评测集再好,也和真实流量的分布不一样。上线要分阶段做,并在想要改进的指标旁边一起盯住绝对不能变差的护栏指标。为了提精度结果延迟翻倍、或者弃答率暴涨,这种事真的很常发生。而这里冒出来的失败案例再回流进黄金集,循环就闭合了。

结语 — 基准线若是记忆,回归就看不见

如果这篇文章只带走一点,我希望是这一点:评测体系的目的不是拿到好分数,而是把一条可比较的基准线用代码固定下来。只要基准线活在人的记忆里,回归在定义上就是观测不到的

所以今天就能开始的最小配置大概是这么多:十条必须永远成立的断言、一个用失败过的输入填起来的 50 例黄金集,再加一个把新旧版本跑在同一批样例上做比较的脚本。当算出来的差落在置信区间之内,您就说“不知道”。能说出这句话,才是与凭感觉评测之间真正的区别。