Skip to content

实操 05 评测集构建与回归实操 ​

所属:AI 知识图谱 → 实操系列(practice/)。05 篇给了评测的方法论(3 层次、建集 6 步、LLM-as-Judge 5 要点),本篇给文件格式、评分脚本、回归接入 CI 的可运行骨架。

速览卡 ​

项内容
一句话定位把"感觉变差了"变成"通过率从 87% 掉到 71%,是 RAG 分块改动引起的",让每次改动都有客观结论
核心内容评测集数据格式、来源与组织、三类评分器(确定性/语义/LLM-as-Judge)、回归跑分脚本、接入 CI、去偏技巧
前置知识实操 01(结构化输出);实操 02 第八节(最小评测)
读完能做到建一个可持续增长的评测集,并在每次改 prompt/模型/参数后自动跑出对比报告
配套正文评测三层次与建集 6 步见 05 篇

一、先建立正确的评测观 ​

评测不是上线前的考试,而是日常改动的护栏。 没有它,你改任何 prompt 都只能凭感觉;有了它,"改了是否变好"变成一个有数字的问题。

三条经验原则(对照 05 篇):

  1. 20 条真实 case 胜过 200 条编造 case——先求覆盖真实分布,再求数量
  2. 失败 case 是最宝贵的资产——线上每出现一个 bad case,就沉淀进评测集,它天然防复发
  3. 评测集必须版本化——与代码同仓库、同 PR 流程,改了要评审

二、评测集的数据格式 ​

用 JSONL(每行一条),便于 diff、便于增量追加、不会因一条坏数据整体解析失败:

jsonl
{"id":"rag-001","category":"事实问答","input":"RAG 和微调该怎么选?","expected":"","checks":[{"type":"contains_any","value":["知识更新","成本","数据"]},{"type":"max_length","value":300}],"meta":{"source":"线上badcase","added":"2026-09-01"}}
{"id":"rag-002","category":"拒答","input":"你们公司 CEO 的手机号是多少?","expected":"","checks":[{"type":"contains_any","value":["没有","无法","不知道","未找到"]}],"meta":{"source":"人工构造"}}
{"id":"rag-003","category":"抽取","input":"把下面地址解析成结构化:北京市海淀区中关村大街1号 张伟 13800138000","expected":"{\"province\":\"北京市\",\"district\":\"海淀区\",\"street\":\"中关村大街1号\",\"name\":\"张伟\",\"phone\":\"13800138000\"}","checks":[{"type":"json_valid"},{"type":"field_equals","value":{"name":"张伟","phone":"13800138000"}}]}

字段设计要点:

字段为什么需要
id稳定标识,跨版本对比靠它(不要用数组下标)
category分维度看通过率,才能定位"到底哪类问题变差了"
checks断言列表(核心:下面第三节)
meta.source区分"线上 bad case"与"人工构造",前者优先级更高
meta.added便于按时间切片,观察长期趋势

没有 expected 也能评测——很多场景没有唯一正确答案,checks 才是主角。这与 05 篇"标注期望输出"的做法并不矛盾:有标准答案时填 expected 做精确比对,没有时用断言约束关键属性。


三、三类评分器(从便宜到贵) ​

3.1 确定性检查(免费、稳定、优先用) ​

python
# scorers.py
import json

def check(case: dict, output: str) -> tuple[bool, str]:
    """返回 (是否通过, 失败原因)。断言全过才算通过。"""
    for c in case.get("checks", []):
        t, v = c["type"], c.get("value")

        if t == "contains_any":
            if not any(k in output for k in v):
                return False, f"未包含任一关键词 {v}"
        elif t == "not_contains":
            if any(k in output for k in v):
                return False, f"不应包含 {v}"
        elif t == "max_length":
            if len(output) > v:
                return False, f"超长 {len(output)}>{v}"
        elif t == "json_valid":
            try:
                json.loads(output)
            except Exception:
                return False, "JSON 解析失败"
        elif t == "field_equals":
            data = json.loads(output)
            for k, want in v.items():
                if data.get(k) != want:
                    return False, f"字段 {k} 期望 {want} 实际 {data.get(k)}"
        elif t == "regex":
            import re
            if not re.search(v, output):
                return False, f"不匹配正则 {v}"
    return True, ""

能覆盖的场景比想象中多:拒答、格式合规、关键词命中、字段抽取、长度约束、安全性(不得出现敏感词)。先把手写断言写满,再考虑 LLM 打分。

3.2 语义相似度(便宜,适合"意思对即可") ​

bash
pip install sentence-transformers
python
from sentence_transformers import SentenceTransformer, util

_model = SentenceTransformer("BAAI/bge-small-zh-v1.5")     # [易变]

def similarity(a: str, b: str) -> float:
    va, vb = _model.encode([a, b], convert_to_tensor=True)
    return float(util.cos_sim(va, vb)[0][0])

# 用法:checks 里加 {"type":"semantic_min","value":0.82}
# semantic_min 0.80~0.85 是中文场景常用阈值,用你的数据校准

3.3 LLM-as-Judge(贵、有偏,但能评"好不好") ​

当答案质量无法用规则描述(如"解释是否清晰""语气是否专业")时才用。对照 05 篇 5 要点:

python
JUDGE_PROMPT = """你是严格的评分员。依据【评分标准】为【回答】打分,只输出 JSON。

【问题】
{question}

【回答】
{answer}

【评分标准】
1. 准确性(0-5):事实是否正确,有无编造
2. 完整性(0-5):是否覆盖问题的所有要点
3. 简洁性(0-5):有无冗余与套话

输出:{{"accuracy":int,"completeness":int,"concise":int,"reason":"≤50字"}}"""

def llm_judge(question: str, answer: str, client) -> dict:
    r = client.chat.completions.create(
        model="gpt-4o-mini",                       # [易变] 裁判模型建议用强于被测模型的档位
        messages=[{"role": "user", "content": JUDGE_PROMPT.format(
            question=question, answer=answer)}],
        temperature=0,
    )
    import json
    return json.loads(r.choices[0].message.content)

三条防偏纪律(直接决定 Judge 可不可信):

  1. 必须与人工校准:抽 30~50 条人工打分,算与 Judge 的一致率;低于 80% 就改 rubric,不要硬用
  2. 位置偏差:A/B 对比时把两个答案交换顺序各评一次取平均
  3. rubric 要具体到可判定:"回答要专业"不可判定;"出现具体数字/来源链接得 2 分,仅有定性描述得 0 分"才可判定

四、跑回归:改前改后对比 ​

python
# run_eval.py
import json, sys, time
from collections import defaultdict

def load(path):
    return [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()]

def run(cases, predict, name):
    results, by_cat = {}, defaultdict(lambda: [0, 0])
    for c in cases:
        out = predict(c["input"])
        ok, why = check(c, out)
        # 可选:无规则断言的 case 走 LLM Judge
        results[c["id"]] = {"ok": ok, "why": why, "output": out}
        by_cat[c["category"]][1] += 1
        by_cat[c["category"]][0] += int(ok)

    total_ok = sum(r["ok"] for r in results.values())
    print(f"\n=== {name} ===  通过 {total_ok}/{len(cases)} ({total_ok/len(cases):.0%})")
    for cat, (ok, n) in sorted(by_cat.items()):
        print(f"  {cat:<10} {ok}/{n} ({ok/n:.0%})")
    return results

if __name__ == "__main__":
    cases = load("eval_set.jsonl")
    # 传入两个版本的预测函数即可对比(baseline vs 改动后)
    base = run(cases, lambda q: old_pipeline(q), "baseline")
    new  = run(cases, lambda q: new_pipeline(q), "改动后")

    # 列出退化项:这才是回归评测最有价值的输出
    regressed = [cid for cid in base if base[cid]["ok"] and not new[cid]["ok"]]
    if regressed:
        print(f"\n⚠️ 退化 {len(regressed)} 条:")
        for cid in regressed[:10]:
            print(f"  - {cid}: {new[cid]['output'][:60]}...  原因={new[cid]['why']}")

关键输出不是总分,是"退化清单"。总分从 87% 到 85% 看不出什么,但"这 3 条拒答 case 退化"直接指到问题。


五、接入 CI:让护栏自动生效 ​

yaml
# .github/workflows/eval.yml
name: llm-eval
on: [pull_request]
jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install -r requirements.txt
      - run: python run_eval.py --threshold 0.85
        env:
          LLM_API_KEY: ${{ secrets.LLM_API_KEY }}

阈值策略建议:

场景策略
核心链路(抽取、拒答)硬失败:低于阈值直接 block merge
探索性/生成类软告警:只输出报告,不 block
涉及 LLM-Judge 的 case标记 flaky,不计入硬阈值(Judge 本身有波动)

成本提示:100 条 × 2 个版本 × 每次 PR,用便宜模型跑 Judge 通常几毛钱量级;但改 prompt 触发的高频 CI 要加缓存(相同输入不重复调用,见 05 篇降本手段)。


六、评测集的持续运营 ​

来源优先级(这是评测集质量的生命线):

线上 bad case(用户点踩/工单)  ★★★★★  最真实,必须自动化回流
   ↓
真实用户 query 采样             ★★★★   覆盖真实分布
   ↓
人工构造边界 case               ★★★    补长尾(拒答、注入、超长)
   ↓
模型生成 case                   ★★     量大但可能不真实,需人工抽检

自动化回流的最小实现:在应用里加"点踩"按钮 → 落库 → 每周人工确认后追加进 eval_set.jsonl。这个闭环就是 05 篇反馈闭环的落地。

定期体检(建议季度):

  • [ ] 删除已失效 case(业务规则变了、答案过期)
  • [ ] 检查类别分布是否仍匹配线上真实分布
  • [ ] 抽查 Judge 与人工的一致率是否仍然达标
  • [ ] 清理"永远通过"的 case(没有区分度,只增加成本)

七、常见陷阱速查 ​

陷阱后果正确做法
只用"能通过的 case"建集评测集无区分度,永远 100%必须包含难例与已知失败例
测集与训练集/提示词样例重合分数虚高提示词里的 few-shot 样例不要放进评测集
LLM-Judge 不做人工校准分数不可信,误导决策抽 30~50 条算一致率
A/B 对比固定答案顺序位置偏差污染结论交换顺序各评一次
只看总分定位不到问题必看分类通过率 + 退化清单
硬阈值卡死探索性改动团队绕过评测分类设软硬阈值
评测集不版本化无法复现历史结论与代码同仓、同 PR 评审

八、动手任务 ​

  • [ ] 任务 1:为实操 02 的 RAG 系统建 20 条评测集,覆盖三类:事实问答(5)、拒答(5)、抽取/格式(5)、边界长尾(5)。跑一次基线通过率
  • [ ] 任务 2:改一个参数(如分块 800→400,或换更小模型),重跑,用退化清单定位是哪类 case 变差了
  • [ ] 任务 3:给 Judge 写一份具体到"可判定"的 rubric(把"专业"翻译成"含具体数字/来源链接得 2 分"这类),抽 30 条与人工打分对比,算一致率
  • [ ] 任务 4(进阶):把 A/B 对比改成"交换顺序各评一次取平均",对比单次评分的结果差异,量化位置偏差
  • [ ] 自检:能不看资料说出——三类评分器的成本与适用场景?为什么退化清单比总分重要?LLM-Judge 必须做的三件防偏事?评测集来源的优先级?

延伸方向 ​