Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
III 版 · 评测工程 第 02 章 Building the Set
III · 评测工程 02 / 22 进阶

评测集建设与防过拟合

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

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

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

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

一、评测集必须分层

不同层回答不同的问题,混在一起会导致「不知道该看哪个数」。

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

回归集的核心价值不是「测得多准」,而是「钉住不许退步」。 它的每一条样本都应该来自一个真实发生过的问题,并且带一条注释说明「这条是因为什么引入的」。

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

两个池子,不能是同一个 调试池(Dev Set) 约 80% 日常迭代看这个池子的结果 · 可以看到每条样本的输入输出 · 可以针对失败样本做定向优化 · 可以反复跑、反复调 代价:你会不自觉地针对它过拟合 所以它的分数是「乐观估计」 用途:指导改进方向,不作对外结论 保留池(Holdout) 约 20% 只在关键节点看,平时不看 · 不允许看单条样本的细节 · 不允许针对性优化 · 只在发版前 / 对外汇报时跑 作用:给出「真实泛化能力」的无偏估计 判据:保留池远低于调试池 → 已过拟合 用途:决策依据(敢不敢上、能不能大范围推) 保留池会「用旧」,必须定期轮换 每季度:从最新线上数据中抽取一批新样本补入保留池,把其中一批旧样本下放到调试池。 原因:用户行为、数据分布、模型版本都在变。一套一年前的题,测不出今天的问题。 评测集失效的三个信号 ① 分数长期停在 95% 以上不再变化 → 缺乏区分度,该加难度了(加边界与对抗样本)。 ② 调试池与保留池差距持续扩大 → 已经过拟合,改进没有泛化。
图 1 调试池与保留池的分工。关键纪律:保留池不允许看单条样本、不允许针对性优化。一旦你为了修某条样本而改代码,它就必须从保留池下放到调试池——否则保留池就失去了「无偏估计」的意义。

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

这是本章最有价值的部分:一个健康的评测集,其增长速度应该与线上问题发现速度相当。

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 个百分点,说明已经有明显过拟合。

五、自测

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

六、小结

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

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

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

面试官会怎么问

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

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

追问链

  1. 多少条算够?
  2. 怎么保证评测集不过期?
评测集多少条才够用?
没有绝对数字,取决于你要检测多大的差异。经验规律是:样本量越小,能分辨的最小差异越大。
几十条的评测集只能发现"明显崩了"(掉了十几个百分点),无法判断"提升 2% 是不是真的";要稳定分辨几个百分点级别的差异,通常需要几百条量级,而且每个关键类别(而不是总数)都要有足够样本——因为指标是按类别看的。
更实用的建议是先建小而纯,再逐步扩充:30–50 条覆盖核心场景的"冒烟集",用来快速拦住大回归;300 条以上的"回归集",用来判断小改动是否有效。不要一开始就追求几千条,那会因为标注质量参差而变得不可信。
加分点:强调"按类别保证样本量"而不是只看总数,说明你真的用过评测集。
怎么防止系统把评测集"背下来"?
三个机制:
① 保留集(hold-out)——把评测集切成可见/不可见两部分。调优时只能看可见部分,最终验收用不可见部分。如果两者差距很大,说明过拟合了。
② 持续注入新样本——评测集不是一次建完的静态资产,线上新 badcase 要持续回流,让评测集不断变难。一个长期不更新的评测集,指标会逐渐失去区分度。
③ 生成式与参数化样本——对于可枚举的任务(查询条件生成、参数填充),用模板 + 参数组合自动生成大批样本,让"记住答案"在成本上不可行。
判断是否过拟合的信号很直接:评测集上稳步提升,但线上指标不动甚至下降。出现这个现象就要立刻检查评测集是否已经泄漏。
答这类题的通用结构

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

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes