评测集从哪来?最靠谱的答案是「从线上长出来」。手工设计的问题集能覆盖的场景,永远少于真实用户踩出来的坑。这一章讲四件事:怎么分层、怎么设保留集防止过拟合、怎么让 badcase 回流、以及怎么判断你的评测集已经失效了。
编辑部注 · 本章开头那条纪律(评测集和调试集必须分开)是最容易被违反、后果也最严重的一条。
评测集最大的敌人不是「不够大」,而是「被污染」。当一个团队反复拿同一套题调优,这套题就失去了区分能力——你不是在提升系统能力,而是在提升系统在这套题上的分数。这一章处理的就是这个问题。
一、评测集必须分层
不同层回答不同的问题,混在一起会导致「不知道该看哪个数」。
| 层 | 规模 | 运行频率 | 回答什么问题 | 通过标准 |
|---|---|---|---|---|
| 冒烟集 | 10-20 条 | 每次提交 | 系统还能不能跑通(有没有低级错误) | 100% 通过,一条挂就不能合 |
| 回归集 | 100-300 条 | 每次发版 | 之前修好的问题有没有重新坏掉 | 不允许低于基线(哪怕 1 条) |
| 边界集 | 50-150 条 | 每次发版 | 极端输入下会不会崩(超长、空值、多语言、歧义) | 不允许崩溃,失败率不高于阈值 |
| 对抗集 | 30-80 条 | 定期(月度) | 恶意输入、提示注入、诱导越权能不能被挡住 | 不能出现安全事件,宁可返回「拒绝」 |
回归集的核心价值不是「测得多准」,而是「钉住不许退步」。 它的每一条样本都应该来自一个真实发生过的问题,并且带一条注释说明「这条是因为什么引入的」。
二、保留集:防过拟合的唯一手段
三、badcase 回流:评测集的生长机制
这是本章最有价值的部分:一个健康的评测集,其增长速度应该与线上问题发现速度相当。
| 步骤 | 动作 | 要求 |
|---|---|---|
| 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 个百分点,说明已经有明显过拟合。
五、自测
六、小结
| 议题 | 结论 |
|---|---|
| 分层 | 冒烟(每次提交)/ 回归(每次发版)/ 边界 / 对抗 |
| 保留池 | 20% 左右,不看单条、不针对性优化;低于调试池太多即为过拟合 |
| 生长机制 | badcase → 定性 → 脱敏 → 标注期望行为 → 入集并写明原因 → 回归验证 |
| 标注什么 | 期望行为(工具、参数、拒绝、澄清),不是期望文本 |
| 失效信号 | 分数长期不动、两层差距扩大 |
| 维护要求 | 每条样本必须有来源与加入原因,否则无法维护 |
评测集不是一次性的资产,而是需要持续维护的基础设施。一个三个月没更新过的评测集,测的是三个月前的用户。本刊编辑部
下一章处理办事型 Agent 最核心、也最容易口径混乱的问题:那些硬指标到底怎么算。
◇ 面试官会怎么问
共 3 条 · 其中 1 条高频 · 先自己答一遍,再展开对照
你的评测集是怎么建起来的? 高频
① 采集——从线上失败案例(用户重试、纠错、放弃、人工介入的会话)中捞取原始输入与完整链路,这些是最有信息量的样本;
② 归因分类——把失败按原因打标(理解错意图 / 参数生成错 / 工具选择错 / 上下文缺失 / 表达问题),保证每个类别的样本数够,否则指标会被大类淹没;
③ 固化——整理成带期望输出的测试用例,人工确认标注正确后入库,并保留原始链路便于 replay。
另外要主动构造一部分边界与对抗样本:模糊表述、自相矛盾的输入、超长输入、以及"工具返回了恶意内容"的注入场景——这些线上出现频次低但破坏力大,靠等线上出现太被动。
追问链
- 多少条算够?
- 怎么保证评测集不过期?
评测集多少条才够用?
几十条的评测集只能发现"明显崩了"(掉了十几个百分点),无法判断"提升 2% 是不是真的";要稳定分辨几个百分点级别的差异,通常需要几百条量级,而且每个关键类别(而不是总数)都要有足够样本——因为指标是按类别看的。
更实用的建议是先建小而纯,再逐步扩充:30–50 条覆盖核心场景的"冒烟集",用来快速拦住大回归;300 条以上的"回归集",用来判断小改动是否有效。不要一开始就追求几千条,那会因为标注质量参差而变得不可信。
怎么防止系统把评测集"背下来"?
① 保留集(hold-out)——把评测集切成可见/不可见两部分。调优时只能看可见部分,最终验收用不可见部分。如果两者差距很大,说明过拟合了。
② 持续注入新样本——评测集不是一次建完的静态资产,线上新 badcase 要持续回流,让评测集不断变难。一个长期不更新的评测集,指标会逐渐失去区分度。
③ 生成式与参数化样本——对于可枚举的任务(查询条件生成、参数填充),用模板 + 参数组合自动生成大批样本,让"记住答案"在成本上不可行。
判断是否过拟合的信号很直接:评测集上稳步提升,但线上指标不动甚至下降。出现这个现象就要立刻检查评测集是否已经泄漏。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。