全栈开发知识库

09 三层评估体系: 用数字回答"我的 Agent 到底行不行"

一句话总结: 检索层量"资料找对了没有", 判分层量"分数算对了没有", 对话层量"问得好不好" —— 三层从下到上, 客观标签越来越少, 但每一层都必须有一个能写进简历的数字, 否则你所有的"我优化了一下"都只是自我感觉。

本章你能学到什么

  1. 为什么一个 LLM 应用必须分层评估, 而不是笼统地说"效果不错"
  2. 三层评估各自量什么、各自的数据成本, 以及为什么必须从下往上做
  3. 判分器一致性标注集怎么标 —— 覆盖面要求, 以及故意埋陷阱样本去暴露特定缺陷
  4. MAE、系统性偏差、皮尔逊相关、顺序一致率四个指标, 以及为什么顺序一致率比 MAE 重要
  5. 顺序一致率的朴素实现 (Kendall tau 简化版), 以及 tol 打平区间存在的理由
  6. 从一张 15 行的人机对照表里读出判分器的具体缺陷, 而不是只看总分
  7. 对话级评测: 没有客观标签的时候怎么用 LLM Judge 做成对比较
  8. 裁判模型的三种偏置 (位置、长度、自我偏好) 和各自的消除办法
  9. 为什么"双向不一致就记平"是正确的, 而不是取第一次的结果
  10. 分辨率的概念: 15 个样本的一致率精度只有 1/105, 小于这个的差异不许解读
  11. 什么时候一个指标只能当"哨兵"用, 什么时候才能当"结论"用

一、为什么必须有评估体系

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 判分器。这带来两个好处:

  1. CI 里可以跑离线版 —— 不花钱、不联网、结果确定, 可以当质量闸门 (第 12 章会用到)
  2. 两个数字可以对比 —— "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 上贴结果。

这套东西的价值在于它把"我觉得没改坏"变成了一条机器执行的规则。


七、划重点

  1. 三层评估必须从下往上做, 因为错误会往上传染。检索错了, 判分器再准也是在给错题打分。
  2. 分层的产出不是分数, 是"下一步该改哪个文件"。
  3. 评估工具的精度必须高于你想测量的效果量级。 MAE 2.5 的判分器测不出 1 分的微调收益。
  4. 标注要先写人工分再跑机器。 反过来是自我实现的预言。
  5. 五档 (满分/良好/及格/差/零) 必须都覆盖, 缺哪档那档的误差就测不出来。
  6. 陷阱样本按你担心的失效模式定向设计。 同义改写测关键词匹配, 啰嗦口语测风格扣分, 术语堆砌测假阳性。
  7. 加载时校验取值范围。 human_score: 70 不崩溃, 只会让你去修一个不存在的 bug。
  8. 顺序一致率比 MAE 重要, 因为绝对偏移能靠改阈值校准, 顺序错了不能。
  9. 系统性偏差和 MAE 一起读: 两者接近 → 要校准; 偏差近 0 而 MAE 大 → 要重做。
  10. 逐条打印对照表, 不要只打总分。 本项目的 -5.0 就藏在 0.833 后面。
  11. 参考线要写进脚本的输出里。 数字要能指向动作, 而不是让读者问"这算好还是不好"。
  12. 对话层没有客观标签, 只能做成对比较。 人类在"哪个更好"上比在"打几分"上稳定得多。
  13. 位置偏置必须双向消除, 效应量比你想测的效果还大。
  14. 双向不一致就记平, 因为那一票纯由位置决定, 不含优劣信息。
  15. 枚举映射要有默认落点, FLIP.get(x, "tie") 而不是 FLIP[x]。
  16. 裁判换一家的模型, 否则测的是自我偏好。
  17. 渲染轨迹时绝不能把 rubric 和标准答案送给裁判 —— 这个坑不报错, 只静默失效。
  18. prompt 里禁止 + 打印可核对的量, 两条都要。自动化消不掉的偏置至少要让它可见。
  19. 一定要读裁判的 reason。 理由全是"更详细"的话, 这次评测作废。
  20. 打印分辨率: 15 题 → 0.067, 105 对 → 0.0095, 3 局 → 0.3333。小于它的差异不许解读。
  21. 区分"哨兵"和"结论"两种用法。 样本少时只能当哨兵。
  22. 主动声明指标的局限, 比把数字做得漂亮重要。 面试里前者站得住, 后者一问就崩。
  23. CI 只能跑离线的检索层和判分层, 对话层手工跑。
  24. --offline 开关让同一套标注集既能评规则判分器也能评 LLM 判分器, 顺带让 CI 可用。

八、下一章预告

到这里我们完成了一件很少有人做完的事: 给自己的 Agent 装上了尺子。

现在你有了三个可以写进简历的数字, 以及比数字更重要的东西 —— 知道每个数字什么时候不能用。

但第 3 节留了一条明确的欠款: 规则判分器的一致率只有 0.8, 达不到做对照实验的 0.85 线。 而云 API 的 LLM 判分器有另一组问题: 每次判分都要花钱、要联网、延迟 2~5 秒, 而且判分标准会随着服务商更新模型而漂移。

第 10 章做微调与私有化部署, 正面解决这两件事:

  1. 数据导出 —— 第 8 章 qa_records 表埋的伏笔在这里兑现: 把真实面试记录导成两份训练集 (grader 判分任务 / interviewer 提问任务), 并且用 gen- 前缀过滤掉 LLM 生成的题
  2. Mac 上用 MLX 跑 LoRA —— Apple Silicon 的统一内存反而是优势, 不用租卡就能微调一个小模型
  3. 租卡跑 QLoRA —— 需要更大模型时的路径, 以及怎么把成本控制在几十块
  4. Ollama / GGUF 本地部署 —— 微调完怎么变成一个能被 app/core/llm.py 调用的服务, 改 .env 三行就能切过去
  5. 切过去之后, 用本章这套评测量一遍 —— 微调模型的 MAE 和一致率是多少, 有没有真的比云 API 好

第 10 章结束时, 你的 Agent 可以完全离线运行, 而且"离线之后效果掉了多少"是一个量出来的数字, 不是一句感觉。

本页由 Markdown 自动渲染 · 共 628 篇笔记