- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — “好像变好了”的代价
改完提示、跑了几个例子,然后说一句“确实变好了”——想必您也这么干过。我也干过。问题出在下一步。
那次改动在某些输入上变好、在某些输入上变差,可您确认过的只有那几个恰好看到的。变差的那些会悄悄留到下一个版本。这个过程重复二十次,那个每一步都“好像变好了”的系统可能已经比最初更差;更糟的是,您没有办法确认这件事,因为每次改动的基准线都是一段不同的记忆。
评测体系不是用来提升模型质量的装置。它是为了留下一个可以退回去的位置。本文讲的是如何从成本最低的层次开始把这套体系搭起来,以及从中得到的数字该怎么解读。
评测的四个层次
不必全都做,但必须知道哪一层能抓住什么。
| 层次 | 能抓住的东西 | 每个样例的成本 | 可信度 | 执行频率 |
|---|---|---|---|---|
| 确定性断言 | 格式违规、禁用词、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 例黄金集,再加一个把新旧版本跑在同一批样例上做比较的脚本。当算出来的差落在置信区间之内,您就说“不知道”。能说出这句话,才是与凭感觉评测之间真正的区别。