上一章 评测的第一性问题 "感觉这次好多了"不是工程结论。这一章讲清评测要回答的三个问题:好在哪个维度、好了多少、这个差异是真的还是噪声。 III · 评测工程 · 第 01 章 · 入门 · 约 24 分钟
Agent 工程学习站 做棵大树 出品 主站 beatree.cn 免费 · 无需登录 · 进度存本机
III 版 · 评测工程 ◆ 第 02 章 ◆ Building the Set
III · 评测工程 02 / 22 进阶

评测集建设与防过拟合

Building the Set
预计阅读 30 分钟
难度 进阶
关键词 评测集 · 保留集
本机状态 未读

评测集从哪来?最靠谱的答案是「从线上长出来」。手工设计的问题集能覆盖的场景,永远少于真实用户踩出来的坑。这一章讲四件事:怎么分层、怎么设保留集防止过拟合、怎么让 badcase 回流、以及怎么判断你的评测集已经失效了。

编辑部注 · 本章开头那条纪律(评测集和调试集必须分开)是最容易被违反、后果也最严重的一条。

评测集最大的敌人不是「不够大」,而是「被污染」。当一个团队反复拿同一套题调优,这套题就失去了区分能力——你不是在提升系统能力,而是在提升系统在这套题上的分数。这一章处理的就是这个问题。

一、评测集必须分层

不同层回答不同的问题,混在一起会导致「不知道该看哪个数」——所以才要分出冒烟集(Smoke Set)、回归集、边界集、对抗集这样的层次,每层有独立的通过标准与告警阈值。

四层评测集:规模、频率、用途各不相同
层规模运行频率回答什么问题通过标准
冒烟集10-20 条每次提交系统还能不能跑通(有没有低级错误)100% 通过,一条挂就不能合
回归集100-300 条每次发版之前修好的问题有没有重新坏掉不允许低于基线(哪怕 1 条)
边界集50-150 条每次发版极端输入下会不会崩(超长、空值、多语言、歧义)不允许崩溃,失败率不高于阈值
对抗集30-80 条定期(月度)恶意输入、提示注入、诱导越权能不能被挡住不能出现安全事件,宁可返回「拒绝」

回归集的核心价值不是「测得多准」,而是「钉住不许退步」。 它的每一条样本都应该来自一个真实发生过的问题,并且带一条注释说明「这条是因为什么引入的」——这套纪律防的就是过拟合(Overfitting):反复在同一套题上调优,分数涨了但真实能力没变。回归集(Regression Set)就是用来锁住「曾经修好的东西不再坏」。

二、保留集:防过拟合的唯一手段

两个池子,不能是同一个 调试池(Dev Set) 约 80% 日常迭代看这个池子的结果 · 可以看到每条样本的输入输出 · 可以针对失败样本做定向优化 · 可以反复跑、反复调 代价:你会不自觉地针对它过拟合 所以它的分数是「乐观估计」 用途:指导改进方向,不作对外结论 保留池(Holdout) 约 20% 只在关键节点看,平时不看 · 不允许看单条样本的细节 · 不允许针对性优化 · 只在发版前 / 对外汇报时跑 作用:给出「真实泛化能力」的无偏估计 判据:保留池远低于调试池 → 已过拟合 用途:决策依据(敢不敢上、能不能大范围推) 保留池会「用旧」,必须定期轮换 每季度:从最新线上数据中抽取一批新样本补入保留池,把其中一批旧样本下放到调试池。 原因:用户行为、数据分布、模型版本都在变。一套一年前的题,测不出今天的问题。 评测集失效的三个信号 ① 分数长期停在 95% 以上不再变化 → 缺乏区分度,该加难度了(加边界与对抗样本)。 ② 调试池与保留池差距持续扩大 → 已经过拟合,改进没有泛化。
图 1 调试池与保留池的分工。关键纪律:保留池不允许看单条样本、不允许针对性优化。一旦你为了修某条样本而改代码,它就必须从保留池下放到调试池——否则保留池就失去了「无偏估计」的意义。
评测集为什么必须分层 四层回答不同的问题,频率与规模各不相同 冒烟集 10-20 条 每次提交 100% 通过才能合(有没有低级错误) 回归集 100-300 条 每次发版 钉住不许退步(真实问题沉淀) 边界集 50-150 条 每次发版 极端输入下不崩(超长/空值/多语言) 对抗集 30-80 条 月度 挡住恶意输入/提示注入/诱导越权 混在一层 → 不知道该看哪个数;分层 → 每类问题有独立通过标准与告警阈值。 对抗集缺失 → 安全类问题根本不会被评测覆盖,是最危险的空白层。
图 2 四层评测集不是「越多越好」,而是「各管一类问题」。对抗集常被省略,但一旦省略,安全类退步就彻底失去感知——这正是防刷分要盯的分层之一。
调试池与保留池的分数差 差距越大,过拟合越严重:这是评测集健康的体温计 0 50 100 调试池 92% 保留池 68% 差距 24 个百分点 告警阈值 +15pct 判据:两池差距超过 15 个百分点即视为明显过拟合;保留池是「真实泛化能力」的无偏估计,敢不敢上线看它。
图 3 调试池 92%、保留池 68%,24 分的差距是过拟合的典型体温。保留池才是敢不敢上线的依据——它的分数不会因为你对某批样本调优而水涨船高。

三、badcase 回流:评测集的生长机制

这是本章最有价值的部分:一个健康的评测集,其增长速度应该与线上问题发现速度相当——这正是badcase 回流(Badcase Backflow)机制的设计目标:线上每一次真实的失败,都沉淀成一条可复现的回归样本。

badcase 从发现到进入评测集的标准流程
步骤动作要求
1 发现线上 trace 筛选 / 用户反馈 / 人工巡检每条 badcase 必须带上完整 trace(输入、上下文、工具调用、输出)
2 定性判断问题归属:工程 / 产品 / 研究工程问题才进回归集;研究问题记录但不作为回归项(否则永远修不完)
3 脱敏去除真实用户信息与敏感数据脱敏后仍要保持问题的可复现性(这是难点)
4 标注写出期望行为(不是期望文本)标注「应该调用哪个工具、参数应该是什么」,而不是「应该说什么」
5 入集加入回归集,并记录引入原因注释格式:# 2025-11-03 工单 1234:user 字段传了 null 时崩溃
6 回归修复后必须跑通该条修 bug 的 PR 必须包含这条样本的通过截图
第 4 步是最关键也最常做错的一步

标注「期望输出文本」的问题是:同一件事有很多种正确的说法,文本比对会大量误判。而标注「期望行为」是可判定的——工具名、参数、是否应该拒绝、是否应该追问澄清,这些都有明确的对错。

举个具体的例子。用户说「帮我看看上周的数据」,正确的期望不是某段话,而是:应该先追问澄清(是哪个指标、哪个范围),而不是直接猜一个查询。这条期望可以用「是否发生了澄清动作」来判定,无需人工逐条读输出。

四、动手:评测集管理

eval_dataset.pypython
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 回流要求脱敏后标注期望行为、写清来源与原因、归到工程问题类。判据:缺来源或原因的样本必须拒收;无溯源的样本无法维护,等于给未来埋雷。

六、自测

本章自测第 1、3 题为高频考点
Q1为什么评测集必须划分出独立的保留池(holdout)?
B。过拟合在 Agent 上的表现非常隐蔽:你可能针对某批样本调了提示词、加了特判,调试池分数从 60% 涨到 85%,但线上毫无变化。保留池的存在就是为了让这种自欺欺人无处藏身——它的分数才是你敢不敢上线的依据。
Q2从线上 badcase 构造评测样本时,应该标注什么?
D。标注「期望文本」会带来两个问题:同一件事有无数种正确说法(文本比对大量误判),以及模型换了说法就被判失败。标注「期望行为」是可判定的,能自动化、能稳定复现,也正是「办事型 Agent 硬指标」能成立的前提。
Q3调试池分数 92%、保留池分数 68%,最可能的原因是什么?
C。24 个百分点的差距是过拟合的典型信号,说明系统学到的是「这批样本的解法」而不是「这类问题的解法」。处理方式:把更大比例的线上新样本补进保留池,并检查最近几次改动是否包含针对具体样本的特判逻辑。顺带一提,A 和 B 都要先排除,但它们无法解释这么大的差距。

七、小结

议题结论
分层冒烟(每次提交)/ 回归(每次发版)/ 边界 / 对抗
保留池20% 左右,不看单条、不针对性优化;低于调试池太多即为过拟合
生长机制badcase → 定性 → 脱敏 → 标注期望行为 → 入集并写明原因 → 回归验证
标注什么期望行为(工具、参数、拒绝、澄清),不是期望文本
失效信号分数长期不动、两层差距扩大
维护要求每条样本必须有来源与加入原因,否则无法维护

评测集不是一次性的资产,而是需要持续维护的基础设施。一个三个月没更新过的评测集,测的是三个月前的用户。本刊编辑部

下一章处理办事型 Agent 最核心、也最容易口径混乱的问题:那些硬指标到底怎么算。

八、参考与延伸

评测集不是一次设计出来的,是随线上问题长出来的。下面几份材料按「建立直觉 → 动手 → 对齐一手定义」排好。

先看图(建立直觉)

  • Promptfoo 文档 —— 开源评测与红队框架,自带冒烟 / 回归 / 对抗多套数据集范式。重点看它怎么把「期望行为」写成可判定的断言,正好对应本章第四节。
  • Eugene Yan · 评测与监控 —— 从 LLMOps 视角讲评测集如何随线上问题生长。他关于「评测集是活的系统」的观点,和本章 badcase 回流完全同构。

再看代码(动手实现)

  • OpenAI Evals —— 官方评测框架,直接看数据集怎么组织、grading 怎么写。对比本章第四节的 Dataset 类,能看出生产级框架多做了哪些版本化与回归管理。

最后读论文(对齐一手定义)

  • HELM · 斯坦福 CRFM —— 公开口径的评测基准,把场景、维度、指标做成一套可被复现的定义。它示范了「评测集为什么要分层、口径为什么要公开」。
  • tau-bench —— Agent 评测的一手基准,以「工具调用是否正确」定义完成度。想理解「可判定样本」为什么是评测集的骨架,看它怎么构造任务就够了。

◇ 面试官会怎么问

共 6 条 · 其中 2 条高频 · 先自己答一遍,再展开对照

你的评测集是怎么建起来的? 高频
主要来源是线上真实使用,而不是拍脑袋造题。具体三步:
① 采集——从线上失败案例(用户重试、纠错、放弃、人工介入的会话)中捞取原始输入与完整链路,这些是最有信息量的样本;
② 归因分类——把失败按原因打标(理解错意图 / 参数生成错 / 工具选择错 / 上下文缺失 / 表达问题),保证每个类别的样本数够,否则指标会被大类淹没;
③ 固化——整理成带期望输出的测试用例,人工确认标注正确后入库,并保留原始链路便于 replay。
另外要主动构造一部分边界与对抗样本:模糊表述、自相矛盾的输入、超长输入、以及"工具返回了恶意内容"的注入场景——这些线上出现频次低但破坏力大,靠等线上出现太被动。

追问链

  1. 多少条算够?
  2. 怎么保证评测集不过期?
评测集多少条才够用?
没有绝对数字,取决于你要检测多大的差异。经验规律是:样本量越小,能分辨的最小差异越大。
几十条的评测集只能发现"明显崩了"(掉了十几个百分点),无法判断"提升 2% 是不是真的";要稳定分辨几个百分点级别的差异,通常需要几百条量级,而且每个关键类别(而不是总数)都要有足够样本——因为指标是按类别看的。
更实用的建议是先建小而纯,再逐步扩充:30–50 条覆盖核心场景的"冒烟集",用来快速拦住大回归;300 条以上的"回归集",用来判断小改动是否有效。不要一开始就追求几千条,那会因为标注质量参差而变得不可信。
加分点:强调"按类别保证样本量"而不是只看总数,说明你真的用过评测集。
怎么防止系统把评测集"背下来"?
三个机制:
① 保留集(hold-out)——把评测集切成可见/不可见两部分。调优时只能看可见部分,最终验收用不可见部分。如果两者差距很大,说明过拟合了。
② 持续注入新样本——评测集不是一次建完的静态资产,线上新 badcase 要持续回流,让评测集不断变难。一个长期不更新的评测集,指标会逐渐失去区分度。
③ 生成式与参数化样本——对于可枚举的任务(查询条件生成、参数填充),用模板 + 参数组合自动生成大批样本,让"记住答案"在成本上不可行。
判断是否过拟合的信号很直接:评测集上稳步提升,但线上指标不动甚至下降。出现这个现象就要立刻检查评测集是否已经泄漏。
评测集三个月没更新,分数一直停在 96% 左右不动。这说明什么,你打算做什么? 高频
这通常不是「系统很好」,而是评测集失去区分度了。
96% 的稳定高分意味着这套题已经覆盖不了当前线上会遇到的难点,剩下那 4% 是长期修不动的硬骨头。指标不再变化,就无法反映改动的效果,评测作为工具的价值归零。
动作有三步:① 加难度,从最近三个月的线上 trace 里抽失败与重试点,构造边界集与对抗集样本;② 检查配比,如果四层里对抗集是空的,那安全类问题根本没被覆盖,96% 只是简单题占比过高的产物;③ 轮换,把保留池里一部分旧样本下放到调试池,从新数据里补新样本进保留池。
判据:一个评测集如果连续两个季度波动不超过 1 个百分点,就该重建了。

追问链

  1. 怎么区分「系统真的稳定」和「评测集失效」?
  2. 轮换保留池时,怎么保证历史分数仍然可比?
加分点:加分点:把「分数不动」读成「评测集有问题」,而不是「系统没问题」。
调试池 92%、保留池 68%,你接下来怎么处理这 24 个百分点的差距?
先不要急着改模型,先确认这是不是真的过拟合。
① 排除划分噪声:确认两个池子是不是用同一份哈希规则划分的;如果保留池只有二三十条,先看它的置信区间有多宽。
② 定位差距来源:把两个池子按分层切开对比。如果差距集中在回归集,说明存在针对具体样本的特判;如果各层普遍偏低,说明能力本身没有泛化。
③ 检查最近的改动:有没有出现「针对某几条样本加的特判」「在提示词里写死的例子」。这类改动的特征就是调试池涨、保留池不动。
④ 处理:补新样本稀释调试池,把最近一轮改动回滚重做,并且不再看调试池的单条样本。
判据:24 个百分点已远超噪声,必须当结构性问题处理,而不是当成一次运气不好。

追问链

  1. 保留池样本量很少时,怎么判断这 24 个百分点是真的?
  2. 为什么说「看调试池的单条样本」本身就是过拟合的开始?
加分点:加分点:把「保留池低」当成一个需要定位来源的信号,而不是直接归因于模型差。
从线上一条 trace 到一条能进回归集的样本,中间哪一步最容易做错?
第 4 步,标注「期望行为」而不是「期望输出文本」。
新手很自然地把当时正确的回答原文抄下来当标准答案,于是立刻出现两个问题:① 同一件事有无数种正确说法,换个措辞就被判失败,回归集天天报假警;② 无法自动判定,只能靠人读,样本一多就没人跑了。
正确做法是标注可判定的行为:应该调用哪个工具、关键参数应该是什么、是否应该拒绝、是否应该先追问澄清。
另一个高频错误是漏掉来源与原因,三个月后没人知道这条样本为什么在这里,于是谁也不敢删,回归集只涨不减。判据:缺 origin 或 reason 的样本一律拒收。

追问链

  1. 哪些 badcase 不该进回归集?
  2. 脱敏之后还能保证可复现吗?
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes
    下一章 办事型 Agent 的硬指标 可执行率、参数准确率、任务完成率——听起来都对,但分母是什么?枚举和自由文本能用同一个口径吗?口径不清的指标等于没有指标。 III · 评测工程 · 第 03 章 · 进阶 · 约 30 分钟