实操 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 篇):
- 20 条真实 case 胜过 200 条编造 case——先求覆盖真实分布,再求数量
- 失败 case 是最宝贵的资产——线上每出现一个 bad case,就沉淀进评测集,它天然防复发
- 评测集必须版本化——与代码同仓库、同 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-transformerspython
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 可不可信):
- 必须与人工校准:抽 30~50 条人工打分,算与 Judge 的一致率;低于 80% 就改 rubric,不要硬用
- 位置偏差:A/B 对比时把两个答案交换顺序各评一次取平均
- 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 必须做的三件防偏事?评测集来源的优先级?
延伸方向
- 还没有可测的系统 → 实操 02 RAG 实战(第八节是本篇的迷你版)
- 要评测 Agent 的多步行为 → 实操 06 Agent 实战
- 评测三层次、LLM-as-Judge 5 要点、反馈闭环 → 05 篇 · 工程化与 LLMOps
- 评测结果如何驱动成本与体验权衡 → 01 篇 选型实操