09 三层评估体系: 用数字回答"我的 Agent 到底行不行"
一句话总结: 检索层量"资料找对了没有", 判分层量"分数算对了没有", 对话层量"问得好不好" —— 三层从下到上, 客观标签越来越少, 但每一层都必须有一个能写进简历的数字, 否则你所有的"我优化了一下"都只是自我感觉。
本章你能学到什么
- 为什么一个 LLM 应用必须分层评估, 而不是笼统地说"效果不错"
- 三层评估各自量什么、各自的数据成本, 以及为什么必须从下往上做
- 判分器一致性标注集怎么标 —— 覆盖面要求, 以及故意埋陷阱样本去暴露特定缺陷
- MAE、系统性偏差、皮尔逊相关、顺序一致率四个指标, 以及为什么顺序一致率比 MAE 重要
- 顺序一致率的朴素实现 (Kendall tau 简化版), 以及
tol打平区间存在的理由 - 从一张 15 行的人机对照表里读出判分器的具体缺陷, 而不是只看总分
- 对话级评测: 没有客观标签的时候怎么用 LLM Judge 做成对比较
- 裁判模型的三种偏置 (位置、长度、自我偏好) 和各自的消除办法
- 为什么"双向不一致就记平"是正确的, 而不是取第一次的结果
- 分辨率的概念: 15 个样本的一致率精度只有 1/105, 小于这个的差异不许解读
- 什么时候一个指标只能当"哨兵"用, 什么时候才能当"结论"用
一、为什么必须有评估体系
1.1 到目前为止我们其实什么都不知道
第 7 章做完 FSM 和判分器之后, 我们有 17 个测试。它们测的是什么?
- 满分答案要给高分 (
>= 8.0) - 胡说八道要给低分 (
<= 3.0) - 好答案的分要比差答案高 (单调性)
这些全都是下限。 它们保证判分器不是一个随机数生成器 —— 这确实很重要, 但仅此而已。
"7.5 分和 6.0 分的区别是否真实存在", 到现在为止没有任何证据。而这个区别恰恰决定了两件用户能看到的事:
- 报告里的水平判定 (
6.0是"待加强"和"良好"的分界线) - 错题本的薄弱项排序 (
< 6.0才进错题本)
换句话说: 我们的产品建立在一个从未被验证过的数字上。
1.2 三层评估的分工
| 层 | 量什么 | 标签来源 | 数据成本 | 本项目的数字 |
|---|---|---|---|---|
| 检索层 | 资料找对了没有 | 客观 (人标 gold_chunks) | 低 (15 题 × 标 1~2 个文档) | Recall@3 = 1.0000, MRR = 0.9667 |
| 判分层 | 分数算对了没有 | 半客观 (人打分, 有主观性但可比) | 中 (15 条 × 每条要读题读 rubric) | MAE = 0.833, 顺序一致率 = 0.8 |
| 对话层 | 问得好不好 | 无客观标签 (只能成对比较) | 高 (每对要跑两场面试 + 调裁判) | Win Rate (哨兵用) |
必须从下往上做, 顺序不能反。
理由是错误会往上传染: 检索错了, 选出来的题和 rubric 就是错的, 判分器再准也是在给错题打分; 判分器错了, 对话层比较的两套配置就是在比较噪声。
在下层还没量的时候去量上层, 你会得到一个数字, 但这个数字指向不了任何具体的修改动作。
1.3 分层的真正价值: 定位
假设用户反馈"这个面试官给我打的分不对"。没有分层评估, 你只能一个个猜。有了分层:
- 检索层数字掉了 → 是知识库或切分的问题, 改
chunker.py/ 换 embedding - 检索层正常但判分层 MAE 涨了 → 是 prompt 或 rubric 的问题, 改
grader.py - 前两层都正常但用户还是不满意 → 是对话策略的问题 (追问时机、难度节奏), 改
fsm.py
分层评估的产出不是"分数", 是"下一步该改哪个文件"。
二、第一层: 检索评测 (第 6 章已完成)
第 6 章已经把 eval/eval_retrieval.py 写完了。这里只做一次回归, 确认它还是活的 —— 因为第 7、8 章改了很多东西。
✅ Checkpoint 1: 检索层回归。
uv run python -m eval.eval_retrieval
真实输出:
评测集 15 题, 知识库 25 块, top_k=3 fetch_k=16
| 模式 | Recall@3 | MRR | nDCG@3 |
|----------------|-----------|-----------|-----------|
| bm25 | 0.9333 | 0.9333 | 0.9066 |
| vector | 1.0000 | 0.9556 | 0.9560 |
| hybrid | 1.0000 | 0.9667 | 0.9647 |
注: 本评测集只有 15 题, 差异小于 0.067 (1/15) 只是一题之差, 没有统计意义。
扩到 100 题以上再用这张表做技术选型决策。
注意最后那两行: 这不是谦虚, 是防止自己被自己的数字骗。hybrid 的 MRR 比 vector 高 0.0111 —— 这个差异比 1/15 = 0.067 小得多, 意思是它连一题的差距都不到, 换一批评测题就可能反过来。
能打印分辨率提示的评测脚本, 比只打印数字的评测脚本值钱十倍。
第一层过关。往上走。
三、第二层: 判分器一致性 —— 本项目最重要的一环
3.1 这是最容易被跳过、也最不该跳过的一环
"""判分器一致性评测: 机器打的分和人打的分差多少。
这是整个项目最容易被跳过、但最不该跳过的一环。检索评测告诉你"资料找对了没有",
判分器评测告诉你"分数算对了没有"。判分器不可信, 后面所有对照实验都是在比较噪声。
"""
"判分器不可信, 后面所有对照实验都是在比较噪声" —— 这句话值得单独拎出来。
想一下第 10 章要做的事: 微调一个模型, 然后对比"微调前 vs 微调后"哪个更好。用什么对比? 用判分器给出的均分。如果判分器本身误差有 3 分, 那么微调带来的 1 分提升完全被噪声吞掉了 —— 你会看到一个随机的数字, 然后据此做出一个随机的决定。
评估工具的精度必须高于你想测量的效果量级。 这是所有实验科学的第一条规矩, 在 LLM 应用里同样成立。
3.2 标注集怎么标: 覆盖面是硬要求
eval/grader_cases.yaml 的头部注释就是标注规范:
# 判分器一致性标注集。
#
# 怎么标: 自己扮演面试官, 对着 golden.yaml 里的 rubric 给分, 先写 human_score 再跑机器,
# 不要看了机器分再改自己的分 —— 那叫自我实现的预言, 评出来的一致率是假的。
#
# 覆盖面要求 (缺哪一档, 那一档的误差就测不出来):
# 满分档 9~10 / 良好档 6~8 / 及格档 4~5 / 差档 1~3 / 零分档 0
# 还要专门放几条"陷阱样本": 换词表达、正确但啰嗦、术语堆砌但没逻辑、答错方向。
#
# tol 的含义见 eval_grader.py: 人机分差 0.5 以内视为打平。
三条规矩, 每一条都对应一种把评测做假的方式:
规矩一: 先写 human_score 再跑机器。
如果先看机器分再写自己的分, 你会不自觉地被锚定 —— 机器给 7 分, 你觉得"嗯差不多, 就 7 吧"。这样评出来的一致率必然很高, 而且完全没有信息量。 这个坑叫锚定效应, 而且它非常隐蔽: 你会真心觉得自己是独立判断的。
规矩二: 五档必须都覆盖。
"缺哪一档, 那一档的误差就测不出来"。如果标注集里全是满分和零分样本, 判分器只要会区分"有内容"和"没内容"就能拿到很高的一致率 —— 而中间档恰恰是最难的, 也是产品里最常出现的。
规矩三: 必须有陷阱样本。
这是标注集从"能用"到"好用"的分界。看这两条:
# ---------- 陷阱: 术语堆砌但没有逻辑 ----------
- qid: android-hard-001
answer: >
配置变更 ViewModelStore SavedStateHandle Room onSaveInstanceState
ViewModelProvider ActivityThread 都跟这个有关系。
human_score: 1.0
note: >
关键词全都有, 但没有一句完整判断。人不会给这种回答分, 关键词匹配的判分器很可能给高分 ——
这条样本就是专门用来暴露这个缺陷的。
# ---------- 陷阱: 正确但极度啰嗦 ----------
- qid: android-hard-002
answer: >
嗯我先说一下我的思路吧, 就是那个点一下按钮卡住这个事情, 我觉得应该是 ANR,
因为 Android 里输入事件如果 5 秒之内没有处理完就会报 ANR, 这个我之前遇到过。
...
human_score: 10.0
note: 表达啰嗦但三个要点全部命中, 面试里这就是满分。用来验证判分器不因为口语化而扣分
这两条是一对, 分别测两个相反方向的偏差:
- 前者测假阳性: 有关键词就给分 (关键词匹配判分器的经典缺陷)
- 后者测假阴性: 因为不像标准答案而扣分 (LLM 判分器的经典缺陷)
"这条样本就是专门用来暴露这个缺陷的" —— 这才是标注集设计的正确姿势: 不是随机采样, 是按你担心的失效模式定向布防。
还有一条测换词表达:
- qid: android-hard-004
answer: >
省在不用为每个任务开线程, 一个线程上可以跑很多协程, 调度是在语言层面做的。
human_score: 5.0
note: 换了词表达但意思对 (第一个要点), 后两点没讲 —— 专门用来测关键词匹配漏不漏换词表达
记住这一条, 3.6 节它会给我们一个非常清晰的结论。
3.3 加载与校验: 让脏标注在第一行就报错
def load_cases(path: str) -> list[dict]:
data = yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8")) or []
for c in data:
if not all(k in c for k in ("qid", "answer", "human_score")):
raise ValueError(f"标注缺字段: {c}")
if not 0.0 <= float(c["human_score"]) <= 10.0:
raise ValueError(f"human_score 越界: {c}")
return data
手写的 YAML 一定会有错: 少个字段、分数写成 70 (漏了小数点)、缩进错位。
if not 0.0 <= float(c["human_score"]) <= 10.0 这一行拦住的是最阴的一种错误: human_score: 70 不会让程序崩溃, 它会让 MAE 变成一个巨大的数字, 然后你以为判分器坏了, 花两小时改 prompt。
校验前置的价值不是防崩溃, 是防止你去修一个不存在的 bug。
or [] 处理空文件 —— yaml.safe_load("") 返回 None, 直接 for 会 TypeError。
3.4 四个指标, 各回答一个不同的问题
"""
三个指标:
- MAE: 平均绝对误差, 直观 —— "平均差几分"
- 皮尔逊相关: 趋势对不对 —— 人给高分的机器也给高分
- 顺序一致率: 任取两个样本, 人和机器谁高谁低的判断是否一致 (Kendall tau 的朴素版)
为什么顺序一致率比 MAE 重要: 面试报告最终给出的是"水平判定"和"薄弱项排序",
系统性偏低 1 分不影响结论, 但把强项判成弱项就完全错了。
"""
| 指标 | 回答什么问题 | 一个反例 |
|---|---|---|
| MAE | 平均差几分 | 全体偏低 2 分 → MAE = 2, 但排序完全正确 |
| 系统性偏差 | 偏松还是偏严 | MAE = 2 但偏差 = 0, 说明是随机误差不是系统性偏移 |
| 皮尔逊相关 | 趋势对不对 | 机器分 = 人工分 × 0.5, 相关 = 1.0 但每个分都错 |
| 顺序一致率 | 谁比谁强判断对不对 | 这个才是产品真正依赖的 |
"为什么顺序一致率比 MAE 重要"这段注释是整个脚本最有价值的一句话。
看我们的产品实际用分数做什么:
LEVEL_RULES按均分给"水平判定" —— 依赖绝对值, 但阈值是我们自己定的, 可以校准- 薄弱项排序
topics.sort(key=lambda x: x.avg_score)—— 纯依赖顺序 - 错题本
< 6.0入库 —— 依赖绝对值, 同样可校准
系统性偏低 1 分是可以通过改阈值修掉的; 但把强项判成弱项, 用户会看到"你的 Kotlin 需要加强"而实际上他 Kotlin 最好 —— 这是一个改阈值救不了的错误。
所以 MAE <= 1.5 和 顺序一致率 >= 0.85 两条线里, 后者是更硬的那条。
系统性偏差为什么要单独算:
mae = round(sum(errs) / n, 3)
bias = round(sum(m - h for h, m in zip(human, machine, strict=True)) / n, 3)
MAE 取了绝对值, 所以"一半偏高 2 分一半偏低 2 分"和"全体偏低 2 分"的 MAE 一样。但这两种情况的修法完全不同:
- 系统性偏差 ≈ MAE → 判分器整体偏松/偏严, 改 prompt 里的分数标准或者改阈值就能修
- 系统性偏差 ≈ 0 而 MAE 很大 → 是随机误差, 说明判分器不稳定, 要动的是判分逻辑本身
用一个减法就能区分"要校准"和"要重做", 值得。
strict=True 是 Python 3.10+ 的 zip 参数, 长度不等直接报错。这里两个列表是同步 append 的, 理论上不可能不等长 —— 但"理论上不可能"的地方加一个断言, 成本是一个参数。
3.5 顺序一致率的朴素实现
def pair_agreement(xs: list[float], ys: list[float], tol: float = 0.5) -> tuple[float, int]:
"""任取两个样本比大小。差值在 tol 内视为"打平", 打平对打平也算一致。"""
ok = tot = 0
for i in range(len(xs)):
for j in range(i + 1, len(xs)):
tot += 1
dx, dy = xs[i] - xs[j], ys[i] - ys[j]
sx = 0 if abs(dx) <= tol else (1 if dx > 0 else -1)
sy = 0 if abs(dy) <= tol else (1 if dy > 0 else -1)
ok += sx == sy
return (round(ok / tot, 4) if tot else 0.0), tot
这是 Kendall tau 的简化版。 完整的 Kendall tau 要处理并列值的多种定义 (tau-a / tau-b / tau-c), 而我们只需要一个能解释给非专业同事听的数字: "任意抽两个答案, 人和机器在'谁答得更好'上意见一致的比例。"
手写 15 行胜过 import scipy: 少一个几十兆的依赖, 而且这个数字的定义你能百分之百讲清楚。
tol=0.5 的打平区间为什么必须有:
人打分是 0.5 为步长的 (1.5、7.0), 机器可以给 7.2。如果没有打平区间, 7.0 vs 7.2 会被判成"机器认为后者更好", 而人打的 7.0 vs 7.0 是"一样好" —— 两者被算成不一致, 但这个"不一致"完全没有产品含义。
ok += sx == sy 里 "打平对打平也算一致": sx == 0 and sy == 0 时 ok 加一。这是对的 —— 人说一样好, 机器也说一样好, 这就是一致。
sx == sy 直接用布尔值加进整数, 是 Python 里 True == 1 的惯用写法。
返回 tot 的理由: 主函数要用它打印分辨率。n 个样本有 n*(n-1)/2 个样本对, 15 个样本 = 105 对, 所以一致率的最小刻度是 1/105 = 0.0095。
3.6 主流程与那张对照表
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--cases", default="eval/grader_cases.yaml")
ap.add_argument("--golden", default="eval/golden.yaml")
ap.add_argument("--offline", action="store_true", help="评规则判分器, 不调 LLM")
args = ap.parse_args()
qs = {q.qid: q for q in load_golden_questions(args.golden)}
cases = load_cases(args.cases)
grader = get_grader(offline=args.offline)
human: list[float] = []
machine: list[float] = []
rows: list[tuple[str, float, float, str]] = []
for c in cases:
q = qs.get(c["qid"])
if q is None:
print(f"跳过: golden 里没有 {c['qid']}")
continue
r = grader.grade(q, c["answer"])
human.append(float(c["human_score"]))
machine.append(r.score)
rows.append((c["qid"], float(c["human_score"]), r.score, r.depth.value))
--offline 这个开关是本脚本的一个关键设计: 同一套标注集既能评规则判分器也能评 LLM 判分器。这带来两个好处:
- CI 里可以跑离线版 —— 不花钱、不联网、结果确定, 可以当质量闸门 (第 12 章会用到)
- 两个数字可以对比 —— "LLM 判分比规则判分好多少"变成一个可测量的量, 而不是一句"肯定更好吧"
if q is None: print("跳过") 而不是抛异常: 标注集和 golden 是两个手工维护的文件, golden 里删了一题很正常。跳过并打印, 让评测能跑完 —— 评测脚本的容错应该偏向"给出部分结果", 而不是"要么全对要么啥都没有"。
print(f"{'qid':26s} {'人工':>6s} {'机器':>6s} {'误差':>6s} depth")
for qid, h, m, d in rows:
flag = " <-- 偏差大" if abs(h - m) > 3.0 else ""
print(f"{qid:26s} {h:6.1f} {m:6.1f} {m - h:+6.1f} {d}{flag}")
逐条打印而不是只打总分, 是这个脚本能指导修改动作的原因。 <-- 偏差大 标记让你不用逐行看数字。
{m - h:+6.1f} 里的 + 强制显示正负号: 一眼看出是偏松还是偏严, 不用去比较两列。格式化里的一个字符能省掉读者的一次心算。
✅ Checkpoint 2: 评一遍规则判分器。
uv run python eval/eval_grader.py --offline
真实输出:
判分器: RuleGrader 样本 15 条
qid 人工 机器 误差 depth
android-lifecycle-001 10.0 10.0 +0.0 expert
android-kotlin-001 10.0 10.0 +0.0 expert
android-jetpack-001 7.0 7.0 +0.0 solid
android-perf-001 7.0 7.0 +0.0 solid
android-service-001 6.0 6.0 +0.0 solid
android-lifecycle-002 4.0 4.0 +0.0 surface
android-perf-003 3.0 3.0 +0.0 surface
android-hard-004 5.0 0.0 -5.0 blank <-- 偏差大
android-kotlin-002 2.0 0.0 -2.0 blank
android-jetpack-002 3.0 0.0 -3.0 blank
android-hard-003 1.5 0.0 -1.5 blank
android-perf-002 0.0 0.0 +0.0 blank
android-lifecycle-003 0.0 0.0 +0.0 blank
android-hard-001 1.0 0.0 -1.0 blank
android-hard-002 10.0 10.0 +0.0 expert
------------------------------------------------------------
MAE = 0.833 (平均差几分, 越小越好)
系统性偏差 = -0.833 (正=机器偏松, 负=机器偏严)
误差<=2分占比 = 0.8667
顺序一致率 = 0.8 (105 个样本对)
皮尔逊相关 = 0.9377
参考线: MAE<=1.5 且顺序一致率>=0.85 可以用来做对照实验;
MAE>2.5 或一致率<0.7 先修判分器, 别急着调模型。
注意: 样本 15 条, 顺序一致率的分辨率是 1/105 = 0.0095, 小于这个的差异是噪声。
3.7 从这张表里读出三条具体结论
别只看 MAE = 0.833 就走。 这张表能读出比总分多得多的东西。
结论一: 上半张表几乎完美, 下半张表全是 0 —— 误差不是随机的, 是结构性的。
前七行误差全部 +0.0, 后面所有非零误差全部是负数, 而且机器分全是 0.0、depth 全是 blank。
这不是"精度不够", 这是一整类输入被判成了同一个值。规则判分器的机制是"rubric 要点关键词的加权覆盖率 >= 0.5 才算命中该要点", 一个要点都没命中就是 0 分。所以下半张表的每一条都是"关键词没对上"。
结论二: android-hard-004 的 -5.0 精确命中了我们埋的陷阱。
回看 3.2 节那条标注:
- qid: android-hard-004
answer: 省在不用为每个任务开线程, 一个线程上可以跑很多协程, 调度是在语言层面做的。
human_score: 5.0
note: 换了词表达但意思对 (第一个要点), 后两点没讲 —— 专门用来测关键词匹配漏不漏换词表达
rubric 的要点原文是"协程是线程之上的调度单位, 一个线程可承载大量协程"。答案说的是"一个线程上可以跑很多协程" —— 意思完全一样, 用词完全不同。
人给 5 分, 机器给 0 分。陷阱样本按设计生效了: 它把"关键词匹配无法处理同义改写"这个缺陷从一句"我猜它可能不行"变成了一个 -5.0。
这就是标注集里放陷阱样本的全部回报。 没有这一条, MAE 会好看一些, 而这个缺陷会一直藏着。
结论三: 系统性偏差 = -0.833 恰好等于 MAE, 说明误差是纯单向的。
3.4 节说过怎么读这一对数字: |bias| ≈ MAE 意味着误差全部朝一个方向。这里两个数字完全相等 (0.833 vs -0.833), 说明没有任何一条是机器给高了的 —— 规则判分器只会偏严, 不会偏松。
这个性质其实有用: 它作为 CI 闸门时不会漏放坏答案 (不会把差答案判成好答案), 只会误伤好答案。对 CI 来说, 假阴性比假阳性安全。
再看两个数字的组合:
- 皮尔逊 0.9377 很高, 但顺序一致率只有 0.8 —— 这个组合本身就是信息。相关系数高是因为大趋势对 (高分给高分); 一致率低是因为下半张表的 7 条全是 0.0, 它们互相之间全部"打平", 而人给的是 5.0 / 3.0 / 2.0 / 1.5 / 1.0 / 0.0 / 0.0 —— 这些对里有一大半被判成不一致。
误差<=2分占比 = 0.8667意思是 15 条里 13 条误差在 2 分内。这个指标比 MAE 更抗离群值: 一条-5.0会把 MAE 拉高 0.33, 但只让这个占比掉 1/15。
3.8 参考线: 数字要能指向动作
print("\n参考线: MAE<=1.5 且顺序一致率>=0.85 可以用来做对照实验;")
print(" MAE>2.5 或一致率<0.7 先修判分器, 别急着调模型。")
print(f"注意: 样本 {n} 条, 顺序一致率的分辨率是 1/{pairs} = {1 / pairs:.4f}, 小于这个的差异是噪声。")
这三行是脚本里最不像代码、但最有价值的部分。
一个只打印 MAE = 0.833 的脚本, 使用者的第一反应是"这算好还是不好?"。加上参考线之后, 数字直接对应动作:
| 数字区间 | 动作 |
|---|---|
| MAE <= 1.5 且一致率 >= 0.85 | 可以用来做对照实验, 往上走 |
| 中间地带 | 能当哨兵用 (监测退步), 不能当结论用 (判优劣) |
| MAE > 2.5 或一致率 < 0.7 | 先修判分器, 别急着调模型 |
我们的 MAE = 0.833 达标, 但 一致率 = 0.8 差 0.05, 落在中间地带。诚实的结论是: 这个规则判分器可以当 CI 哨兵, 但不适合用来做"微调前后哪个好"的判优。 第 10 章做微调对比时要用 LLM 判分器, 并且先用同一套标注集把它的 MAE 和一致率量出来。
"先修判分器, 别急着调模型" 这句话对应一个非常常见的时间浪费: 花一周微调模型, 均分从 6.2 涨到 6.8, 兴奋地写进周报 —— 而判分器的 MAE 是 2.5, 这 0.6 分完全在噪声里。
最后那行分辨率提示: 15 个样本 = 105 对, 分辨率 0.0095。所以"一致率从 0.80 涨到 0.81"是一对样本的翻转, 不许解读成"变好了"。
四、第三层: 对话级评测 —— 没有标签怎么办
4.1 这一层为什么根本不同
"""对话级评测: 用 LLM Judge 比较两套配置谁的面试问得更好。
前两层评测 (检索、判分器) 都有客观标签, 这一层没有 —— "这场面试问得好不好"没有标准答案。
所以用另一个模型当裁判, 做成对比较 (A/B 谁更好), 输出 Win Rate。
"""
前两层都能问"对不对": 检索有 gold_chunks, 判分有 human_score。
这一层只能问"哪个更好"。
"这场面试问得好不好"没有标准答案 —— 你没法给一场面试标一个 good_score: 8.0, 因为不同的人会给完全不同的分, 而且同一个人隔一周再看也会变。
解法是把绝对判断换成相对判断: 不问"这场面试几分", 只问"A 和 B 哪场问得更好"。人类在成对比较上远比在绝对打分上稳定 —— 这也是所有 LLM 竞技场 (Chatbot Arena 之类) 采用 ELO 而不是绝对分的原因。
4.2 三个必须做的去偏
"""
三个必须做的去偏, 少一个结论就不可信:
1. 位置偏置: 同一对样本正反各跑一次 (A在前 / B在前), 只有两次都赢才算赢, 各赢一次算平
2. 长度偏置: 裁判 prompt 明确要求不因回答长而给高分, 同时打印两边平均字数供人工核对
3. 自我偏好: 裁判模型最好和被测模型不同家。同一个模型当裁判会偏爱自己的输出
"""
"少一个结论就不可信"不是修辞。 这三种偏置都是被大量论文和实践反复确认过的, 而且效应量都不小:
| 偏置 | 表现 | 我们的对策 | 对策的代价 |
|---|---|---|---|
| 位置偏置 | 裁判倾向于选先出现的那个 (有些模型反而偏后) | 正反各跑一次, 结果不一致就记平 | API 调用翻倍 |
| 长度偏置 | 更长、词更多的回答显得更专业 | prompt 明令禁止 + 打印两边平均字数供核对 | prompt 变长; 核对靠人 |
| 自我偏好 | 模型偏爱自己风格的输出 | 裁判换一家的模型 | 多配一个 API Key |
位置偏置是三个里最必须处理的, 因为它的效应量能达到 10 到 20 个百分点 —— 而我们想测的效果可能只有 5 个点。不消位置偏置, 你测出来的主要是"谁被放在前面"。
长度偏置的对策为什么是"prompt 禁止 + 打印字数"两条?
因为prompt 里写了不等于模型做到了。所以还要一个可核对的量:
def avg_chars(run: dict) -> int:
"""长度偏置的核对指标: 如果 Win Rate 高的一方字数明显更多, 结论要打问号。"""
qs = [_q_text(t) for t in run.get("turns", [])]
return round(sum(len(q) for q in qs) / len(qs)) if qs else 0
"如果 Win Rate 高的一方字数明显更多, 结论要打问号" —— 这是一个人工兜底的检查项。自动化消不掉的偏置, 至少要让它可见。
自我偏好的对策最简单也最容易忘: 如果你用 DeepSeek 当被测模型又用 DeepSeek 当裁判, 那这个评测在测什么就很难说了。所以 --judge-model 是一个显式参数, 而且 help 里写明了理由。
4.3 裁判 prompt: 维度排序 + 明确禁止
JUDGE_SYSTEM = """你是一位资深 Android 技术面试评审, 现在要评价两位面试官 (A 和 B) 对同一位候选人的提问质量。
评价维度, 按重要性排序:
1. 追问是否问到了点子上 —— 候选人含糊的地方有没有被追下去
2. 提问是否有区分度 —— 能不能把"背过答案"和"真懂"区分开
3. 评分和点评是否准确 —— 分数和回答质量是否匹配, 漏掉的要点指得对不对
4. 整场节奏 —— 知识点覆盖是否合理, 有没有在一个点上钻死
严格禁止的判断依据:
- 不要因为某一方说得更长、词更多就给它高分
- 不要因为某一方用词更华丽就给它高分
- 只看提问和评分的专业性
只输出一个 JSON 对象:
{"winner": "A" | "B" | "tie", "reason": "一句话说明, 不超过 50 字"}"""
三个设计点:
一, 维度"按重要性排序"而不是平铺。 如果只给四条并列的维度, 模型遇到"A 追问更好但 B 覆盖更广"就会随机选一个。给了排序, 冲突时有确定的裁决依据 —— 顺序本身就是一条评判规则。
二, "严格禁止的判断依据"是正面清单之外的负面清单。 只写"要看什么"不够, 因为长度偏置是模型的默认行为, 不写它就会发生。已知的失效模式要在 prompt 里点名禁止。
三, "reason": "一句话说明, 不超过 50 字" 不是给程序用的, 是给人用的。 让裁判说出理由, 你才能判断它是真的在看提问质量, 还是在数字数。跑评测的时候一定要读几条 reason —— 如果理由全是"A 的回答更详细", 那这次评测的结论直接作废。
{"winner": "A" | "B" | "tie"} 允许平局是重要的: 不给 tie 选项会强迫模型在两个差不多的样本上瞎选, 制造噪声。
4.4 轨迹渲染: 压成裁判能读的纯文本
def _q_text(t: dict) -> str:
"""轨迹里的 question 是嵌套的 Question 对象 (Turn.model_dump 的结果), 不是字符串。"""
q = t.get("question")
return q.get("question", "") if isinstance(q, dict) else str(q or "")
def render_run(run: dict, max_chars: int = 3000) -> str:
"""把一次面试轨迹压成裁判能读的纯文本。太长会挤爆上下文, 也会让裁判偏向信息多的那边。"""
lines: list[str] = []
for t in run.get("turns", []):
tag = "追问" if t.get("followup_depth", 0) else "提问"
lines.append(f"[{tag}] {_q_text(t)}")
lines.append(f"[答] {(t.get('answer') or '(未作答)')[:200]}")
g = t.get("grade") or {}
lines.append(f"[评] {g.get('score', 0)}/10 {g.get('depth', '')} {g.get('comment', '')}")
if g.get("missed_points"):
lines.append(f"[漏] {'; '.join(g['missed_points'][:3])}")
out = "\n".join(lines)
return out[:max_chars]
_q_text 那句注释是一个真实踩过的坑。 --dump 存的是 Turn.model_dump(), 而 Turn.question 是一个嵌套的 Question 对象, 所以 JSON 里 t["question"] 是一个 dict, 不是字符串。直接 f"{t['question']}" 会把整个 dict 塞给裁判 —— 包括 rubric 和标准答案。
那会让评测直接失效: 裁判看到了标准答案, 它评的就不再是提问质量了。
isinstance(q, dict) 的分支同时兼容老格式的轨迹文件。
三处截断, 各有不同的理由:
| 截断 | 理由 |
|---|---|
answer[:200] |
答案是输入变量, 两边完全相同, 给全文没有信息增量 |
missed_points[:3] |
漏掉的要点只要看趋势, 列全会淹没提问本身 |
out[:max_chars] (3000) |
两个: 挤爆上下文, 以及"让裁判偏向信息多的那边" |
最后那条是长度偏置的一个变种, 而且很隐蔽: 如果 A 跑了 4 题、B 跑了 6 题, B 的文本天然更长、信息更多, 裁判会觉得 B 更"充分"。统一上限至少把这个差异压在一个天花板下面。
[提问] / [追问] 的标签必须有: 追问是我们最想被评价的能力 (维度 1), 不标出来裁判分不清哪句是追问。渲染格式要把你想被评价的东西显式标出来。
✅ Checkpoint 3: 先跑两场面试存轨迹, 再看渲染结果。
uv run python -m app.cli --offline --auto --questions 3 --dump runs/a.json
uv run python -m app.cli --offline --auto --questions 3 --difficulty 3 --seed 11 --dump runs/b.json
尾部输出:
轨迹已存到 runs/a.json (3 轮)
轨迹已存到 runs/b.json (3 轮)
渲染成裁判看到的文本 (只调 render_run, 不调裁判模型, 所以不花钱):
=== A 渲染后 (前 600 字) ===
[提问] onSaveInstanceState 在什么时机被调用? 用户按返回键退出会调用吗?
[答] 在 onStop 之前调用。只在系统主动销毁时调用, 用户主动按返回键退出不调用。恢复可在 onCreate 的 savedInstanceState 或 onRestoreInstanceState 读。
[评] 10.0/10 expert [规则判分] 命中 3/3 个要点
[提问] 协程的挂起和线程的阻塞有什么本质区别?
[答] 大概知道一点, 好像和生命周期有关系。
[评] 0.0/10 blank [规则判分] 命中 0/3 个要点
[漏] 挂起不阻塞线程, 挂起时线程被释放去执行其他任务; 阻塞是线程占着资源什么都不做; 协程是线程之上的调度单位, 一个线程可承载大量协程
[提问] 为什么说协程比线程省资源? 它省在哪?
[答] (未作答)
[评] 0.0/10 blank 没有作答
[漏] 协程是线程之上的调度单位, 一个线程可承载大量协程; 挂起时把线程让出去, 而不是占着线程等待; 挂起恢复靠 CPS 变换出的状态机, 不需要额外线程栈
A 平均提问长度 = 27 字 B = 33 字
A 均分 = 3.33 B 均分 = 3.33
A 渲染总长 = 487 字符 B = 648 字符
这份输出本身就暴露了两件事:
一, B = 648 比 A = 487 长 33%, 而两边均分完全相同 (3.33)。 按 4.2 节的规则, 如果裁判判 B 赢, 这个结论要打问号 —— 因为 B 的文本天然更长。这正是 avg_chars 存在的意义: 在看 Win Rate 之前先看这两个数字。
二, [答] 两边不是同一批: A 第一题答的是 onSaveInstanceState, B 因为 --difficulty 3 --seed 11 选了不同的题。而 JUDGE_USER 里写的是"两场面试用的是同一批答案" —— 说明这样构造的 A/B 对不满足前提, 严格的对照实验要固定 --seed 只改被测变量。 这里为了演示渲染效果故意改了两个参数, 真做实验时不能这样。
4.5 双向消偏: 整个脚本的核心 12 行
FLIP = {"A": "B", "B": "A", "tie": "tie"}
def main() -> None:
...
votes: list[str] = []
for i in range(args.rounds):
# 正向: A 在前
v1 = judge_once(ta, tb, args.judge_model)
# 反向: B 在前, 结果翻回来。位置偏置就是在这里被消掉的
v2raw = judge_once(tb, ta, args.judge_model)
v1w = v1.get("winner", "tie")
v2w = FLIP.get(v2raw.get("winner", "tie"), "tie")
print(f"第 {i + 1} 轮 正向={v1w} ({v1.get('reason', '')})")
print(f" 反向={v2w} ({v2raw.get('reason', '')})")
# 两个方向不一致说明裁判在这一对上没有稳定判断, 记平
votes.append(v1w if v1w == v2w else "tie")
这段是整个脚本的核心, 逐行看:
judge_once(ta, tb) 和 judge_once(tb, ta) —— 同一对样本问两次, 位置交换。
FLIP 把反向结果翻回来: 反向问的时候 B 被放在"A 的位置"上, 所以裁判说"A 赢"实际是"B 赢"。翻译回来才能和正向比较。
FLIP.get(..., "tie") 的默认值是防御: 模型可能返回 "a"、"A "、"Neither" 等等。枚举映射一定要给不认识的值一个安全落点, 而"当成平局"是最保守的落点 —— 它不会把噪声算成任何一方的胜利。
v1.get("winner", "tie") 同理: 模型可能返回一个没有 winner 键的 JSON。
votes.append(v1w if v1w == v2w else "tie") —— 这一行是整个方法论的落点。
"两个方向不一致说明裁判在这一对上没有稳定判断, 记平" 为什么是对的?
想清楚不一致意味着什么: 正向说 A 好、反向说 B 好, 也就是裁判两次都选了排在前面的那个 —— 它的判断完全由位置决定, 和内容无关。这一票不包含任何关于 A 和 B 优劣的信息, 所以唯一诚实的处理是把它作废 (记平)。
如果取第一次的结果, 你就把纯位置偏置当成了真实偏好。
代价是 API 调用翻倍, 而且"平"会变多。 后者看起来像是损失, 其实是收获: 多出来的"平"恰恰是原本会被伪造成"胜"的那些。
✅ Checkpoint 4: 用假裁判验证消偏逻辑真的有效 (不花钱, 不联网)。
把 chat_json 换成一个"永远选排在前面的那个"的假裁判 —— 这是最极端的位置偏置:
import eval.eval_dialog as ed
def fake_judge(msgs, temperature=0.0, model=None):
return {"winner": "A", "reason": "(假裁判) 永远选排在前面的"}
ed.chat_json = fake_judge
sys.argv = ["eval_dialog.py", "--a", "runs/a.json", "--b", "runs/b.json", "--rounds", "3"]
ed.main()
真实输出:
第 1 轮 正向=A ((假裁判) 永远选排在前面的)
反向=B ((假裁判) 永远选排在前面的)
第 2 轮 正向=A ((假裁判) 永远选排在前面的)
反向=B ((假裁判) 永远选排在前面的)
第 3 轮 正向=A ((假裁判) 永远选排在前面的)
反向=B ((假裁判) 永远选排在前面的)
--------------------------------------------------------
A 平均提问长度 = 27 字 B = 33 字
A 均分 = 3.33 B 均分 = 3.33
有效对局 3: A 胜 0 B 胜 0 平 3
Win Rate: A=0.0 B=0.0
分辨率 1/3 = 0.3333, 小于这个的差异不要解读。
样本太少, 这个数字只能当哨兵用 (看有没有明显退步), 不能当结论用 (谁更好)。
裁判被调用 6 次 (3 轮 x 正反 2 次)
一个纯位置偏置的裁判, 被完整地压成了 A=0.0 B=0.0、全平。 这就是消偏生效的证据 —— 没有双向, 这个假裁判会给出 A=1.0 的漂亮结论。
注意 裁判被调用 6 次: 3 轮 × 正反 2 次。这是双向消偏的真实账单, 做预算时要按 2 倍算。
✅ Checkpoint 5: 反过来验证 —— 真实偏好能不能穿过消偏活下来?
如果消偏把所有结论都吃掉, 那它也没用。换一个假裁判: 认准"包含内存泄漏这个话题的那份更好", 与位置无关:
def fake_judge(msgs, temperature=0.0, model=None):
u = msgs[1]["content"]
ia, ib = u.find("面试官 A 的过程"), u.find("面试官 B 的过程")
a_txt, b_txt = u[ia:ib], u[ib:]
return {"winner": "A" if "内存泄漏" in a_txt else "B", "reason": "(假裁判) 认准内容, 不看位置"}
真实输出:
第 1 轮 正向=B ((假裁判) 认准内容, 不看位置)
反向=B ((假裁判) 认准内容, 不看位置)
第 2 轮 正向=B ((假裁判) 认准内容, 不看位置)
反向=B ((假裁判) 认准内容, 不看位置)
--------------------------------------------------------
A 平均提问长度 = 27 字 B = 33 字
A 均分 = 3.33 B 均分 = 3.33
有效对局 3: A 胜 0 B 胜 3 平 0
Win Rate: A=0.0 B=1.0
两个 Checkpoint 合起来才是完整的验证: Checkpoint 4 证明假信号被滤掉, Checkpoint 5 证明真信号能通过。只做前者会得出"消偏很严格"的错觉, 只做后者证明不了消偏在工作。
(B 赢是因为 --difficulty 3 那场选到了内存泄漏的题, 和面试官质量无关 —— 这只是在验证机制。)
4.6 结果汇总: 主动声明自己的局限
c = Counter(votes)
n = len(votes)
win_a = round(c["A"] / n, 4)
win_b = round(c["B"] / n, 4)
print("-" * 56)
print(f"A 平均提问长度 = {avg_chars(ra)} 字 B = {avg_chars(rb)} 字")
print(f"A 均分 = {ra.get('avg_score')} B 均分 = {rb.get('avg_score')}")
print(f"有效对局 {n}: A 胜 {c['A']} B 胜 {c['B']} 平 {c['tie']}")
print(f"Win Rate: A={win_a} B={win_b}")
print(f"分辨率 1/{n} = {1 / n:.4f}, 小于这个的差异不要解读。")
if n < 20:
print("样本太少, 这个数字只能当哨兵用 (看有没有明显退步), 不能当结论用 (谁更好)。")
输出顺序是刻意的: 先打长度和均分, 后打 Win Rate。
因为长度是读 Win Rate 的前置条件 —— 4.2 节说了, 如果赢的那方明显更长, 结论要打问号。把前置检查项放在结论前面, 读者就不会跳过它。
Win Rate: A=0.0 B=0.0 同时为 0 是合法的, 意思是全平。Win Rate 不是互补的 (win_a + win_b != 1), 因为有 tie。这一点必须清楚, 否则你会误以为"A=0.4 所以 B=0.6"。
if n < 20 那句话是这个脚本最诚实的一行。
"哨兵"和"结论"的区别:
| 用法 | 需要什么 | 3 个对局能做吗 |
|---|---|---|
| 哨兵 (监测明显退步) | 大的变化能被看见 | 能。改坏了会从 0.6 掉到 0.0 |
| 结论 (判断谁更好) | 小的差异可信 | 不能。分辨率 0.33 |
分辨率 1/3 = 0.3333 意思是这个数字只有 4 个可能取值 (0, 0.33, 0.67, 1)。 拿它说"A 比 B 好 5 个百分点"是荒谬的。
这也是本章最想传达的态度: 主动声明自己指标的局限, 比把数字做得漂亮重要得多。 面试里说"我做了 LLM Judge, Win Rate 62%"很容易被追问一句"多少个样本"就崩掉; 说"我只跑了 3 个对局, 所以它在 CI 里只当退步哨兵用, 判优劣要扩到 20 对以上"反而站得住。
4.7 真的调裁判模型的时候
上面两个 Checkpoint 都用假裁判, 不花钱。要真跑:
uv run python eval/eval_dialog.py --a runs/a.json --b runs/b.json \
--judge-model deepseek-chat --rounds 5
--judge-model 落到 chat_json 的 model 参数上, 而它是 app/core/llm.py 里就留好的口子:
def chat(
messages: list[dict[str, str]],
temperature: float | None = None,
max_tokens: int = 1500,
model: str | None = None,
) -> str:
"""model 显式传入时覆盖 .env 里的默认模型。第 09 篇的 LLM Judge 要用它指定裁判模型 ——
裁判和被测最好不是同一个模型, 否则会偏爱自己的输出风格。"""
第 1 章把所有模型调用收口到一个文件, 到这里开始还债: 换裁判模型是一个参数, 不是一次改造。
裁判必须返回合法 JSON, 而模型很爱在 JSON 外面加东西。chat_json 已经处理了:
def chat_json(messages, retries=2, temperature=0.0, model=None) -> dict[str, Any]:
"""要求模型输出 JSON, 解析失败就带着错误信息重试。"""
注意 temperature=0.0: 裁判要可复现。同一对样本跑两次给出不同结果的话, --rounds 就失去意义了 (它想测的是裁判的稳定性, 不是随机性)。
✅ Checkpoint 6: JSON 抽取的健壮性 (离线自测, 不调模型)。
uv run python -m app.core.llm
真实输出:
'{"score": 8.5}' -> {'score': 8.5}
'```json\n{"score": 8.5, "note": "带围栏"}\n``' -> {'score': 8.5, 'note': '带围栏'}
'好的, 以下是评分结果:\n{"score": 8.5, "missed": ["' -> {'score': 8.5, 'missed': ['a', 'b']}
'{"nested": {"a": 1}, "s": "里面有个 } 符号"}' -> {'nested': {'a': 1}, 's': '里面有个 } 符号'}
预期报错: 回复里没有 JSON: 完全没有 json
第四条是最容易写错的那条: 字符串里出现 } 的时候, 用 text.rfind("}") 找结尾就会截错。extract_json 靠一个带 in_str / esc 状态的括号计数器解决 —— 手写 JSON 提取要处理字符串内的括号和转义, 少一个状态就会在生产环境的某条回复上炸。
成本估算: --rounds 5 = 10 次调用 (双向), 每次输入约 2 千 token (两份 3000 字符的轨迹) 输出 50 token。按 DeepSeek 的价格算, 一次实验几分钱。双向消偏的代价在钱上可以忽略, 在时间上是 2 倍。
4.8 三层评估在 CI 里的位置 (第 12 章预习)
| 层 | 能进 CI 吗 | 为什么 |
|---|---|---|
| 检索层 | 能 | 离线 (HashEmbedder), 结果确定, 秒级 |
| 判分层 | 能 (--offline) |
规则判分器不联网, 结果确定 |
| 对话层 | 不能自动跑 | 要调裁判模型: 花钱、慢、结果有波动 |
所以第 12 章的质量闸门会是: 检索层和判分层的数字挂在 CI 上, 掉了不许合并; 对话层手工跑, 在改动了 FSM 策略的 PR 上贴结果。
"数字挂在 CI 上, 掉了不许合并"是这个项目最值钱的一句话 —— 它把"我觉得没改坏"变成了一条机器执行的规则。
五、踩坑与专家提示
坑 1: 先看机器分再写人工分。 锚定效应会让你不自觉地靠过去, 评出来的一致率虚高且毫无信息量。必须先标完再跑机器, 而且标完之后不许回改。
坑 2: 标注集只有满分和零分。 判分器只要会区分"有内容"和"没内容"就能拿高分, 而中间档才是产品里最常见、也最难的。五档缺一档, 那一档的误差就测不出来。
坑 3: 标注集没有陷阱样本。
我们埋的 android-hard-004 (同义改写) 直接产出了一条 -5.0。没有它, 规则判分器的最大缺陷会一直藏在 0.833 的 MAE 后面。 陷阱样本要按你担心的失效模式定向设计, 不是随机采样。
坑 4: 只看 MAE 不看系统性偏差。 "一半偏高 2 分一半偏低 2 分"和"全体偏低 2 分"的 MAE 一样, 但前者要重做判分逻辑, 后者改个阈值就行。一个减法区分"要校准"和"要重做"。
坑 5: 只看总分不看逐条对照表。
MAE = 0.833 看起来很好, 而逐条表显示下半张全是 0.0 —— 误差是结构性的, 不是精度问题。 只打印总分的评测脚本无法指导任何修改动作。
坑 6: 用一个 MAE = 2.5 的判分器去比较微调前后。 微调带来的 1 分提升会被完全吞掉, 你得到一个随机数字并据此做决定。评估工具的精度必须高于你想测量的效果量级。
坑 7: human_score: 70 不会崩溃, 只会让 MAE 变成一个巨大的数字。
然后你会花两小时去修一个不存在的判分器 bug。加载时校验取值范围。
坑 8: LLM Judge 不做位置交换。 位置偏置的效应量能有 10~20 个百分点, 而你想测的效果可能只有 5 个点 —— 不消偏, 你测的是"谁被放在前面"。 Checkpoint 4 用假裁判把这件事演示得很清楚。
坑 9: 双向不一致时取第一次的结果。 不一致恰恰说明这一票纯由位置决定, 不含任何优劣信息。唯一诚实的处理是记平。
坑 10: FLIP[raw] 直接下标取值。
模型会返回 "a"、"Neither"、甚至没有 winner 键。枚举映射必须有默认落点, 而"当成平局"是最保守的落点。
坑 11: 裁判和被测是同一个模型。
自我偏好会让它偏爱自己的输出风格。换一家的模型当裁判, 这是 --judge-model 存在的唯一理由。
坑 12: 轨迹渲染时把整个 question dict 丢给裁判。
Turn.model_dump() 里 question 是嵌套对象, 直接格式化会把 rubric 和标准答案一起送过去 —— 裁判看到答案后, 它评的就不再是提问质量了。这个坑不会报错, 只会让评测静默失效。
坑 13: 不统一渲染长度上限。
A 跑 4 题、B 跑 6 题的话 B 天然更长更"充分"。统一 max_chars 至少把差异压在天花板下。 而且要打印 avg_chars 供人工核对。
坑 14: 只在 prompt 里写"不要因为长而给高分"就以为解决了。 prompt 里写了不等于模型做到了。自动化消不掉的偏置, 至少要让它可见 —— 这就是先打字数后打 Win Rate 的原因。
坑 15: 不读裁判的 reason。 如果理由全是"A 的回答更详细", 这次评测直接作废。让裁判说理由不是为了程序, 是为了你能判断它有没有在按你的维度评。
坑 16: 把 Win Rate 当互补的。
有 tie 的存在, win_a + win_b != 1。A=0.0 B=0.0 是合法的 (全平)。
坑 17: 不打印分辨率。 15 个样本 = 105 对 → 一致率刻度 0.0095; 3 个对局 → Win Rate 刻度 0.3333。不打印这个数字, 你迟早会把一对样本的翻转解读成"优化生效了"。
坑 18: 想在 CI 里跑对话层评测。
它要调裁判模型: 花钱、慢、结果有波动。CI 里只能跑离线的检索层和判分层 (--offline)。
六、面试视角
问 1: 你怎么评估你这个 Agent 的效果?
分三层, 从下往上做。
检索层量"资料找对了没有": 15 题黄金评测集标了 gold_chunks, 算 Recall@3 / MRR / nDCG@3。BM25 单路 Recall 是 0.9333, 混合检索到 1.0000, MRR 0.9667。
判分层量"分数算对了没有": 人工标了 15 条 (答案, 分数) 样本, 算 MAE、系统性偏差、顺序一致率、皮尔逊。规则判分器 MAE 0.833、一致率 0.8、皮尔逊 0.9377。
对话层量"问得好不好": 这一层没有客观标签, 所以用另一家的模型当裁判做成对比较, 输出 Win Rate。
顺序不能反, 因为错误会往上传染: 检索错了, 选出来的题和 rubric 就是错的, 判分器再准也是在给错题打分; 判分器不准, 上层的对照实验就是在比较噪声。
而且分层的产出不是分数, 是"下一步该改哪个文件" —— 检索掉了改 chunker, 判分掉了改 grader, 都正常但用户不满意就是对话策略问题。
问 2: 为什么顺序一致率比 MAE 重要?
因为看产品实际拿分数干什么。薄弱项排序是纯依赖顺序的; 水平判定依赖绝对值, 但阈值是我们自己定的、可以校准。
所以系统性偏低 1 分可以通过改阈值修掉, 但把强项判成弱项修不了 —— 用户会看到"你的 Kotlin 需要加强"而他 Kotlin 恰恰最好。
我的实现是 Kendall tau 的朴素版: 任取两个样本比大小, 人和机器判断一致就算一票, 差值 0.5 以内视为打平、打平对打平也算一致。手写 15 行, 不引 scipy —— 好处是这个数字的定义我能百分之百讲清楚。
tol=0.5 那个打平区间是必须的: 人打分以 0.5 为步长, 机器能给 7.2, 没有打平区间的话 7.0 vs 7.2 会被算成"机器认为后者更好", 而这个"不一致"没有任何产品含义。
问 3: 你的判分器 MAE 是 0.833, 这个数字够用吗?
不够做判优, 够做哨兵。
我在脚本里写了参考线: MAE ≤ 1.5 且一致率 ≥ 0.85 才能做对照实验。我的 MAE 达标但一致率只有 0.8, 差 0.05 —— 所以诚实的结论是它可以挂在 CI 上监测退步, 但不能用来判断"微调前后哪个更好"。要做那件事得先把 LLM 判分器用同一套标注集量出来。
而且我不只看总分, 逐条对照表暴露了更重要的东西: 前七行误差全部为 0, 后面所有非零误差全是负的、机器分全是 0.0。这说明误差不是随机的精度问题, 是一整类输入 (关键词没对上的) 被压成了同一个值。
其中最大的一条 -5.0 正好是我故意埋的陷阱样本: 答案把 rubric 的"协程是线程之上的调度单位"改写成"一个线程上可以跑很多协程", 意思一样、用词全不同。人给 5 分, 关键词匹配给 0 分。这条样本存在的唯一目的就是把这个缺陷从"我猜它不行"变成一个具体数字。
另外系统性偏差 -0.833 恰好等于 MAE, 说明误差纯单向 —— 规则判分器只会偏严不会偏松。这个性质让它当 CI 闸门是安全的: 只会误伤好答案, 不会放过坏答案。
问 4: LLM Judge 有什么坑? 你怎么处理的?
三个偏置, 少处理一个结论就不可信。
位置偏置最要紧: 裁判倾向于选先出现的那个, 效应量能有 10~20 个百分点, 比我想测的效果还大。处理办法是同一对样本正反各跑一次, 反向结果翻回来, 两次一致才算胜, 不一致记平。
不一致记平这一点我想强调: 正向说 A 好、反向说 B 好, 意味着裁判两次都选了排在前面的, 判断完全由位置决定 —— 这一票不含任何优劣信息, 唯一诚实的处理是作废。取第一次的结果就是把位置偏置当成了真实偏好。
代价是 API 调用翻倍, 而且"平"变多 —— 但多出来的平恰恰是原本会被伪造成"胜"的那些。
我用假裁判验证过这套逻辑: 一个"永远选排在前面的"的假裁判, 双向消偏后输出是 A=0.0 B=0.0 全平; 换一个"认准内容不看位置"的假裁判, 结论完整通过。两个方向都测, 才能既证明假信号被滤掉、又证明真信号能过。
长度偏置的处理是两条: prompt 里明令禁止, 同时打印两边平均字数供人工核对 —— 因为 prompt 里写了不等于模型做到了。而且渲染时统一 3000 字符上限, 否则题数多的那方天然更长更"充分"。
自我偏好: 裁判换一家的模型, 所以 --judge-model 是显式参数。
还有一个非偏置的坑: 轨迹 JSON 里 question 是嵌套对象, 直接格式化会把 rubric 和标准答案送给裁判 —— 那评测就静默失效了, 因为它评的不再是提问质量。
问 5: 你的评测样本这么少, 结论可信吗?
不完全可信, 而且我在脚本里主动打印了这一点。
检索评测 15 题, 差异小于 1/15 = 0.067 就是一题之差; 判分一致率 105 个样本对, 分辨率 0.0095; 对话层 3 个对局, 分辨率 0.3333 —— 也就是这个 Win Rate 只有 4 个可能取值, 拿它说"好 5 个百分点"是荒谬的。
所以我把用法分成两档: 样本少的时候只当哨兵 (监测有没有明显退步), 只有分辨率够了才能当结论 (判断谁更好)。脚本里 if n < 20 会直接把这句话打出来。
能打印分辨率提示的评测脚本比只打印数字的值钱十倍 —— 因为后者迟早会让你把一对样本的翻转解读成"优化生效了"。
问 6: 这套评测在 CI 里怎么用?
检索层和判分层都能进 CI: 都能离线跑 (HashEmbedder + 规则判分器), 不联网、不花钱、结果确定。这两层的数字挂成质量闸门, 掉了不许合并。
对话层进不去: 要调裁判模型, 花钱、慢、有波动。它是手工跑的, 在改动了 FSM 对话策略的 PR 上贴结果。
这套东西的价值在于它把"我觉得没改坏"变成了一条机器执行的规则。
七、划重点
- 三层评估必须从下往上做, 因为错误会往上传染。检索错了, 判分器再准也是在给错题打分。
- 分层的产出不是分数, 是"下一步该改哪个文件"。
- 评估工具的精度必须高于你想测量的效果量级。 MAE 2.5 的判分器测不出 1 分的微调收益。
- 标注要先写人工分再跑机器。 反过来是自我实现的预言。
- 五档 (满分/良好/及格/差/零) 必须都覆盖, 缺哪档那档的误差就测不出来。
- 陷阱样本按你担心的失效模式定向设计。 同义改写测关键词匹配, 啰嗦口语测风格扣分, 术语堆砌测假阳性。
- 加载时校验取值范围。
human_score: 70不崩溃, 只会让你去修一个不存在的 bug。 - 顺序一致率比 MAE 重要, 因为绝对偏移能靠改阈值校准, 顺序错了不能。
- 系统性偏差和 MAE 一起读: 两者接近 → 要校准; 偏差近 0 而 MAE 大 → 要重做。
- 逐条打印对照表, 不要只打总分。 本项目的
-5.0就藏在 0.833 后面。 - 参考线要写进脚本的输出里。 数字要能指向动作, 而不是让读者问"这算好还是不好"。
- 对话层没有客观标签, 只能做成对比较。 人类在"哪个更好"上比在"打几分"上稳定得多。
- 位置偏置必须双向消除, 效应量比你想测的效果还大。
- 双向不一致就记平, 因为那一票纯由位置决定, 不含优劣信息。
- 枚举映射要有默认落点,
FLIP.get(x, "tie")而不是FLIP[x]。 - 裁判换一家的模型, 否则测的是自我偏好。
- 渲染轨迹时绝不能把 rubric 和标准答案送给裁判 —— 这个坑不报错, 只静默失效。
- prompt 里禁止 + 打印可核对的量, 两条都要。自动化消不掉的偏置至少要让它可见。
- 一定要读裁判的 reason。 理由全是"更详细"的话, 这次评测作废。
- 打印分辨率: 15 题 → 0.067, 105 对 → 0.0095, 3 局 → 0.3333。小于它的差异不许解读。
- 区分"哨兵"和"结论"两种用法。 样本少时只能当哨兵。
- 主动声明指标的局限, 比把数字做得漂亮重要。 面试里前者站得住, 后者一问就崩。
- CI 只能跑离线的检索层和判分层, 对话层手工跑。
--offline开关让同一套标注集既能评规则判分器也能评 LLM 判分器, 顺带让 CI 可用。
八、下一章预告
到这里我们完成了一件很少有人做完的事: 给自己的 Agent 装上了尺子。
现在你有了三个可以写进简历的数字, 以及比数字更重要的东西 —— 知道每个数字什么时候不能用。
但第 3 节留了一条明确的欠款: 规则判分器的一致率只有 0.8, 达不到做对照实验的 0.85 线。 而云 API 的 LLM 判分器有另一组问题: 每次判分都要花钱、要联网、延迟 2~5 秒, 而且判分标准会随着服务商更新模型而漂移。
第 10 章做微调与私有化部署, 正面解决这两件事:
- 数据导出 —— 第 8 章
qa_records表埋的伏笔在这里兑现: 把真实面试记录导成两份训练集 (grader 判分任务 / interviewer 提问任务), 并且用gen-前缀过滤掉 LLM 生成的题 - Mac 上用 MLX 跑 LoRA —— Apple Silicon 的统一内存反而是优势, 不用租卡就能微调一个小模型
- 租卡跑 QLoRA —— 需要更大模型时的路径, 以及怎么把成本控制在几十块
- Ollama / GGUF 本地部署 —— 微调完怎么变成一个能被
app/core/llm.py调用的服务, 改.env三行就能切过去 - 切过去之后, 用本章这套评测量一遍 —— 微调模型的 MAE 和一致率是多少, 有没有真的比云 API 好
第 10 章结束时, 你的 Agent 可以完全离线运行, 而且"离线之后效果掉了多少"是一个量出来的数字, 不是一句感觉。