评测集从哪来?最靠谱的答案是「从线上长出来」。手工设计的问题集能覆盖的场景,永远少于真实用户踩出来的坑。这一章讲四件事:怎么分层、怎么设保留集防止过拟合、怎么让 badcase 回流、以及怎么判断你的评测集已经失效了。
编辑部注 · 本章开头那条纪律(评测集和调试集必须分开)是最容易被违反、后果也最严重的一条。
评测集最大的敌人不是「不够大」,而是「被污染」。当一个团队反复拿同一套题调优,这套题就失去了区分能力——你不是在提升系统能力,而是在提升系统在这套题上的分数。这一章处理的就是这个问题。
一、评测集必须分层
不同层回答不同的问题,混在一起会导致「不知道该看哪个数」——所以才要分出冒烟集(Smoke Set)、回归集、边界集、对抗集这样的层次,每层有独立的通过标准与告警阈值。
| 层 | 规模 | 运行频率 | 回答什么问题 | 通过标准 |
|---|---|---|---|---|
| 冒烟集 | 10-20 条 | 每次提交 | 系统还能不能跑通(有没有低级错误) | 100% 通过,一条挂就不能合 |
| 回归集 | 100-300 条 | 每次发版 | 之前修好的问题有没有重新坏掉 | 不允许低于基线(哪怕 1 条) |
| 边界集 | 50-150 条 | 每次发版 | 极端输入下会不会崩(超长、空值、多语言、歧义) | 不允许崩溃,失败率不高于阈值 |
| 对抗集 | 30-80 条 | 定期(月度) | 恶意输入、提示注入、诱导越权能不能被挡住 | 不能出现安全事件,宁可返回「拒绝」 |
回归集的核心价值不是「测得多准」,而是「钉住不许退步」。 它的每一条样本都应该来自一个真实发生过的问题,并且带一条注释说明「这条是因为什么引入的」——这套纪律防的就是过拟合(Overfitting):反复在同一套题上调优,分数涨了但真实能力没变。回归集(Regression Set)就是用来锁住「曾经修好的东西不再坏」。
二、保留集:防过拟合的唯一手段
三、badcase 回流:评测集的生长机制
这是本章最有价值的部分:一个健康的评测集,其增长速度应该与线上问题发现速度相当——这正是badcase 回流(Badcase Backflow)机制的设计目标:线上每一次真实的失败,都沉淀成一条可复现的回归样本。
| 步骤 | 动作 | 要求 |
|---|---|---|
| 1 发现 | 线上 trace 筛选 / 用户反馈 / 人工巡检 | 每条 badcase 必须带上完整 trace(输入、上下文、工具调用、输出) |
| 2 定性 | 判断问题归属:工程 / 产品 / 研究 | 工程问题才进回归集;研究问题记录但不作为回归项(否则永远修不完) |
| 3 脱敏 | 去除真实用户信息与敏感数据 | 脱敏后仍要保持问题的可复现性(这是难点) |
| 4 标注 | 写出期望行为(不是期望文本) | 标注「应该调用哪个工具、参数应该是什么」,而不是「应该说什么」 |
| 5 入集 | 加入回归集,并记录引入原因 | 注释格式:# 2025-11-03 工单 1234:user 字段传了 null 时崩溃 |
| 6 回归 | 修复后必须跑通该条 | 修 bug 的 PR 必须包含这条样本的通过截图 |
标注「期望输出文本」的问题是:同一件事有很多种正确的说法,文本比对会大量误判。而标注「期望行为」是可判定的——工具名、参数、是否应该拒绝、是否应该追问澄清,这些都有明确的对错。
举个具体的例子。用户说「帮我看看上周的数据」,正确的期望不是某段话,而是:应该先追问澄清(是哪个指标、哪个范围),而不是直接猜一个查询。这条期望可以用「是否发生了澄清动作」来判定,无需人工逐条读输出。
四、动手:评测集管理
from dataclasses import dataclass, field, asdict
from typing import Literal
import json, random, hashlib
Layer = Literal["smoke", "regression", "boundary", "adversarial"]
@dataclass
class EvalCase:
id: str
layer: Layer
input: str
# 期望行为(可判定),而不是期望文本
expect_tool: str | None = None
expect_args: dict | None = None
expect_refuse: bool = False # 是否应该拒绝
expect_clarify: bool = False # 是否应该追问澄清
# 溯源信息:没有这个字段的样本是无法维护的
origin: str = "" # 来源:工单号 / trace id / 人工设计
reason: str = "" # 为什么加入:xxx 场景下会崩
added_at: str = ""
tags: list[str] = field(default_factory=list)
def is_judgeable(self) -> bool:
"""
可判定性检查:一条样本如果既没有期望工具也没有明确的拒绝/澄清要求,
就只能靠人读输出判断 —— 这类样本不能进回归集(无法自动化)。
"""
return bool(self.expect_tool or self.expect_refuse or self.expect_clarify)
class Dataset:
def __init__(self):
self.cases: dict[str, EvalCase] = {}
# 保留池划分:用稳定的哈希决定,保证多次运行划分一致
self.holdout_ratio = 0.2
def add(self, case: EvalCase) -> str:
if not case.is_judgeable():
return "rejected: 样本不可判定,请补充期望行为(工具/拒绝/澄清)"
if not case.reason:
return "rejected: 缺少加入原因,无法维护"
self.cases[case.id] = case
return "added"
def holdout_id(self, case_id: str) -> bool:
"""
用内容哈希划分,而不是随机数 ——
随机数会导致每次运行划分不同,分数无法比较。
"""
h = int(hashlib.sha256(case_id.encode()).hexdigest()[:8], 16)
return (h % 100) < self.holdout_ratio * 100
def split(self) -> tuple[list[EvalCase], list[EvalCase]]:
dev = [c for c in self.cases.values() if not self.holdout_id(c.id)]
hold = [c for c in self.cases.values() if self.holdout_id(c.id)]
return dev, hold
def by_layer(self, layer: Layer) -> list[EvalCase]:
return [c for c in self.cases.values() if c.layer == layer]
def health(self) -> dict:
"""健康度报告:用来发现评测集自身的问题"""
layers = {l: len(self.by_layer(l)) for l in
("smoke", "regression", "boundary", "adversarial")}
no_origin = [c.id for c in self.cases.values() if not c.origin]
return {
"total": len(self.cases),
"layers": layers,
"missing_origin": len(no_origin), # 无溯源的样本无法维护
"advice": (
"冒烟集偏少,建议至少 10 条" if layers["smoke"] < 10 else
"对抗集缺失,安全类问题不会被评测覆盖" if layers["adversarial"] == 0 else
"结构基本健康"
),
}
def to_json(self) -> str:
return json.dumps([asdict(c) for c in self.cases.values()],
ensure_ascii=False, indent=1)
@classmethod
def from_json(cls, text: str) -> "Dataset":
d = cls()
for item in json.loads(text):
d.cases[item["id"]] = EvalCase(**item)
return d
def from_badcase(trace_id: str, expect_tool: str, expect_args: dict,
reason: str, layer: Layer = "regression") -> EvalCase:
"""从线上 badcase 构造样本的标准入口"""
return EvalCase(
id=f"bc-{trace_id}", layer=layer,
input="", # 由 trace 回填(已脱敏)
expect_tool=expect_tool, expect_args=expect_args,
origin=f"trace:{trace_id}", reason=reason,
)- 给你的项目建三层的评测集(冒烟 10 / 回归 30 / 边界 10),每条都要写
reason; - 跑一次
health(),检查有没有「无溯源」的样本——这类样本三个月后没人知道为什么要留着; - 用同一份评测集连跑两次,比较 dev 与 holdout 的分数差。如果差距超过 15 个百分点,说明已经有明显过拟合。
五、常见误区与追问
5.1 误区:评测集和调试集可以共用(其实必须用两个池子)
很多人把同一套题既用来调优又用来验收。错。一旦你为了修某条样本而改代码,那条样本就「被你背下来了」,它再也不能代表真实能力。保留池(Holdout Set)存在的意义就是无偏估计:平时不准看单条、不准针对性优化。判据:调试池与保留池分数差距 > 15 个百分点,即视为明显过拟合,必须补新样本稀释。
5.2 误区:样本越多越准(其实是分层配比错了更危险)
盲目堆数量不如把四层配齐。没有对抗集(Adversarial Set),安全类退步完全无感知;没有边界集(Boundary Set),极端输入下的崩溃没人发现。判据:先保证四层都在线(冒烟/回归/边界/对抗),再谈加量;对抗集哪怕只有 30 条,也比 500 条全是正常样本有用。
5.3 误区:标注「期望输出文本」就够(其实它不可判定)
新手常把标准答案写成一段期望文本然后做文本比对。错。同一件事有无数种正确说法,文本比对会大量误判(该对的判错),也会放过偷换说法的错。期望行为(Expected Behavior)要求标注「该调哪个工具、参数是什么、是否拒绝或澄清」——这些有明确对错。判据:一条样本若无法用工具/拒绝/澄清判定,禁止进入回归集。
5.4 误区:能跑过的样本就是好样本(其实是过拟合温床)
调试池能反复跑、反复调,很容易变成「针对这 80% 样本特训」。调试池(Dev Set)给出的是乐观估计,它的高分不能对外汇报。判据:对外结论一律以保留池为准;每次发版前必须看保留池,而不是看自己调了一下午的调试池。
5.5 误区:样本能不能自动判定无所谓(其实决定评测能否持续)
如果一条样本只能靠人读输出判断,它就没法进回归集、也没法接 CI。可判定性(Judgeability)是评测集可维护性的前提。判据:入库前先问「这条能不能不靠人眼判定」;不能判定的要么改写成可判定形式,要么只作人工抽检、不进自动门禁。
5.6 误区:badcase 回流就是「把报错存下来」(其实是带溯源的结构化沉淀)
把线上失败随便丢进一个文件夹,三个月后没人知道为什么留它、该怎么判。badcase 回流要求脱敏后标注期望行为、写清来源与原因、归到工程问题类。判据:缺来源或原因的样本必须拒收;无溯源的样本无法维护,等于给未来埋雷。
六、自测
七、小结
| 议题 | 结论 |
|---|---|
| 分层 | 冒烟(每次提交)/ 回归(每次发版)/ 边界 / 对抗 |
| 保留池 | 20% 左右,不看单条、不针对性优化;低于调试池太多即为过拟合 |
| 生长机制 | badcase → 定性 → 脱敏 → 标注期望行为 → 入集并写明原因 → 回归验证 |
| 标注什么 | 期望行为(工具、参数、拒绝、澄清),不是期望文本 |
| 失效信号 | 分数长期不动、两层差距扩大 |
| 维护要求 | 每条样本必须有来源与加入原因,否则无法维护 |
评测集不是一次性的资产,而是需要持续维护的基础设施。一个三个月没更新过的评测集,测的是三个月前的用户。本刊编辑部
下一章处理办事型 Agent 最核心、也最容易口径混乱的问题:那些硬指标到底怎么算。
八、参考与延伸
评测集不是一次设计出来的,是随线上问题长出来的。下面几份材料按「建立直觉 → 动手 → 对齐一手定义」排好。
先看图(建立直觉)
- Promptfoo 文档 —— 开源评测与红队框架,自带冒烟 / 回归 / 对抗多套数据集范式。重点看它怎么把「期望行为」写成可判定的断言,正好对应本章第四节。
- Eugene Yan · 评测与监控 —— 从 LLMOps 视角讲评测集如何随线上问题生长。他关于「评测集是活的系统」的观点,和本章 badcase 回流完全同构。
再看代码(动手实现)
- OpenAI Evals —— 官方评测框架,直接看数据集怎么组织、grading 怎么写。对比本章第四节的 Dataset 类,能看出生产级框架多做了哪些版本化与回归管理。
最后读论文(对齐一手定义)
- HELM · 斯坦福 CRFM —— 公开口径的评测基准,把场景、维度、指标做成一套可被复现的定义。它示范了「评测集为什么要分层、口径为什么要公开」。
- tau-bench —— Agent 评测的一手基准,以「工具调用是否正确」定义完成度。想理解「可判定样本」为什么是评测集的骨架,看它怎么构造任务就够了。
◇ 面试官会怎么问
共 6 条 · 其中 2 条高频 · 先自己答一遍,再展开对照
你的评测集是怎么建起来的? 高频
① 采集——从线上失败案例(用户重试、纠错、放弃、人工介入的会话)中捞取原始输入与完整链路,这些是最有信息量的样本;
② 归因分类——把失败按原因打标(理解错意图 / 参数生成错 / 工具选择错 / 上下文缺失 / 表达问题),保证每个类别的样本数够,否则指标会被大类淹没;
③ 固化——整理成带期望输出的测试用例,人工确认标注正确后入库,并保留原始链路便于 replay。
另外要主动构造一部分边界与对抗样本:模糊表述、自相矛盾的输入、超长输入、以及"工具返回了恶意内容"的注入场景——这些线上出现频次低但破坏力大,靠等线上出现太被动。
追问链
- 多少条算够?
- 怎么保证评测集不过期?
评测集多少条才够用?
几十条的评测集只能发现"明显崩了"(掉了十几个百分点),无法判断"提升 2% 是不是真的";要稳定分辨几个百分点级别的差异,通常需要几百条量级,而且每个关键类别(而不是总数)都要有足够样本——因为指标是按类别看的。
更实用的建议是先建小而纯,再逐步扩充:30–50 条覆盖核心场景的"冒烟集",用来快速拦住大回归;300 条以上的"回归集",用来判断小改动是否有效。不要一开始就追求几千条,那会因为标注质量参差而变得不可信。
怎么防止系统把评测集"背下来"?
① 保留集(hold-out)——把评测集切成可见/不可见两部分。调优时只能看可见部分,最终验收用不可见部分。如果两者差距很大,说明过拟合了。
② 持续注入新样本——评测集不是一次建完的静态资产,线上新 badcase 要持续回流,让评测集不断变难。一个长期不更新的评测集,指标会逐渐失去区分度。
③ 生成式与参数化样本——对于可枚举的任务(查询条件生成、参数填充),用模板 + 参数组合自动生成大批样本,让"记住答案"在成本上不可行。
判断是否过拟合的信号很直接:评测集上稳步提升,但线上指标不动甚至下降。出现这个现象就要立刻检查评测集是否已经泄漏。
评测集三个月没更新,分数一直停在 96% 左右不动。这说明什么,你打算做什么? 高频
96% 的稳定高分意味着这套题已经覆盖不了当前线上会遇到的难点,剩下那 4% 是长期修不动的硬骨头。指标不再变化,就无法反映改动的效果,评测作为工具的价值归零。
动作有三步:① 加难度,从最近三个月的线上 trace 里抽失败与重试点,构造边界集与对抗集样本;② 检查配比,如果四层里对抗集是空的,那安全类问题根本没被覆盖,96% 只是简单题占比过高的产物;③ 轮换,把保留池里一部分旧样本下放到调试池,从新数据里补新样本进保留池。
判据:一个评测集如果连续两个季度波动不超过 1 个百分点,就该重建了。
追问链
- 怎么区分「系统真的稳定」和「评测集失效」?
- 轮换保留池时,怎么保证历史分数仍然可比?
调试池 92%、保留池 68%,你接下来怎么处理这 24 个百分点的差距?
① 排除划分噪声:确认两个池子是不是用同一份哈希规则划分的;如果保留池只有二三十条,先看它的置信区间有多宽。
② 定位差距来源:把两个池子按分层切开对比。如果差距集中在回归集,说明存在针对具体样本的特判;如果各层普遍偏低,说明能力本身没有泛化。
③ 检查最近的改动:有没有出现「针对某几条样本加的特判」「在提示词里写死的例子」。这类改动的特征就是调试池涨、保留池不动。
④ 处理:补新样本稀释调试池,把最近一轮改动回滚重做,并且不再看调试池的单条样本。
判据:24 个百分点已远超噪声,必须当结构性问题处理,而不是当成一次运气不好。
追问链
- 保留池样本量很少时,怎么判断这 24 个百分点是真的?
- 为什么说「看调试池的单条样本」本身就是过拟合的开始?
从线上一条 trace 到一条能进回归集的样本,中间哪一步最容易做错?
新手很自然地把当时正确的回答原文抄下来当标准答案,于是立刻出现两个问题:① 同一件事有无数种正确说法,换个措辞就被判失败,回归集天天报假警;② 无法自动判定,只能靠人读,样本一多就没人跑了。
正确做法是标注可判定的行为:应该调用哪个工具、关键参数应该是什么、是否应该拒绝、是否应该先追问澄清。
另一个高频错误是漏掉来源与原因,三个月后没人知道这条样本为什么在这里,于是谁也不敢删,回归集只涨不减。判据:缺
origin 或 reason 的样本一律拒收。追问链
- 哪些 badcase 不该进回归集?
- 脱敏之后还能保证可复现吗?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。