Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
III 版 · 评测工程 第 01 章 How Do You Know It Got Better
III · 评测工程 01 / 22 入门

评测的第一性问题

How Do You Know It Got Better
预计阅读 24 分钟
难度 入门
关键词 评测设计 · 基线
本机状态 未读

「感觉这次好多了」不是工程结论。评测要回答三个问题:好在哪个维度、好了多少、这个差异是真的还是噪声。这三个问题分别对应维度定义、量化口径、显著性判断——缺任何一个,迭代方向就是凭直觉。

编辑部注 · 本章建立评测的判断框架。后面四章都是它的展开:评测集(怎么取样)、指标(怎么量化)、评审(怎么让机器打分可信)、闭环(怎么让评测真正影响决策)。

先承认一件不太体面的事实:大多数 Agent 团队的第一次迭代都是「改提示词 → 手动试几条 → 感觉好了 → 上线」。这条路偶尔能走通,但它不可持续——因为你不知道这次变好是不是运气好,也不知道下一条改动会不会把之前修好的问题重新弄坏。

一、评测的三个第一性问题

问题要回答什么典型错误正确做法
哪个维度「好」具体指什么?拆成可独立判断的几个维度用「效果」这个模糊词,导致改进无法归因拆成可分别评估的维度(如方向相关性 / 证据充分性 / 可执行性)
好了多少用什么数字衡量?分母是什么?口径不清,同一个指标两个人算出两个数指标定义要写到「能被别人复现」的程度
是真的吗这个差异超出噪声了吗?拿 5 条样本的差异当结论扩大样本、看置信区间、做 A/B 对照

这三个问题看起来朴素,但它们对应了三种最常见的评测失败:维度混在一起导致改对了也不知道口径不清导致数字无法比较样本太小导致追噪声

二、维度怎么拆:正交是唯一标准

拆维度的唯一要求是正交——两个维度不能相互包含,否则你无法判断是哪一项在变化。

从「效果好」到可评估维度 不要这样拆 效果好 准确 有用 「准确」与「有用」互相包含: 不准确的东西谈不上有用 → 不可归因 办事型:四个正交维度 ① 可执行率(能不能跑) ② 参数准确率(填对没) ③ 任务完成率(做完没) ④ 交互轮数 / 成本 可分别独立判断,改动可归因 洞察型:四个正交维度 ① 方向相关性 ② 证据充分性 ③ 可操作性 ④ 表达清晰度 「有洞察」不是维度,是四项的合成 正交性检验(三句话就能测出来) ① 能不能构造一个样本,A 维度满分而 B 维度零分?不能 → 两者高度相关,需要合并。 ② 改动 X 只影响 A 不影响 B 吗?如果我改任何一处都同时动两个维度,拆分就没意义。 第三个问题:差异是真的还是噪声 样本量:30 条以内的差异不要下结论。8/20 与 9/20 的差别(45% vs 40%)在统计上等同于「没区别」。 对照组:改动前后必须用同一套评测集、同一版本模型、同一温度设置,否则差异无法归因。 多次运行:Agent 有随机性。同一配置跑 3 次取均值与方差,方差大的指标要谨慎解读。 区分「回归」与「波动」:跌幅小于历史波动区间的,不要当成回归去修。
图 1 维度拆解的正反例与正交性检验。核心判据:能否构造出一个「A 好 B 差」的样本——能构造,说明两维正交可用;构造不出,说明它们其实是一件事。

三、指标口径:必须写到能被复现

这是最容易出问题的地方。以「可执行率」为例,下面三种口径会给出三个完全不同的数字:

口径定义问题
A · 宽松模型输出的 DSL 能被解析器解析成功解析成功 ≠ 能执行。语法对但字段名错的情况被算作成功
B · 适中解析成功且查询引擎接受(不报错)引擎不报错 ≠ 结果正确。查了错的条件也算通过
C · 严格解析成功、引擎接受、且返回结果与人工标注的期望结果一致标准最严,人工标注成本最高

三种口径都不是错的,但混用是错的。 关键是:口径一旦定了,要写进评测代码的注释里、要能被别人复现,并且在对比两个版本时保持同一口径。

口径定义的三条硬要求
  • 写分母:分母是「全部样本」还是「成功进入该环节的样本」?两者差异巨大(后者会系统性高估)。
  • 写边界:超时算不算失败?用户主动中断算不算?空结果算成功还是失败?
  • 写例子:至少给一个「算通过」和一个「不算通过」的具体样本。

满足这三条的口径,任何同事接手都能算出同一个数。不满足的口径,等于没有口径。

四、动手:一个可复现的评测骨架

eval_framework.pypython
from dataclasses import dataclass, field
from typing import Callable, Any
import statistics, json
@dataclass
class Sample:
    id: str
    input: str
    expected: Any = None        # 有标准答案的样本才有
    tags: list[str] = field(default_factory=list)   # 分层标签:冒烟/回归/边界/对抗
@dataclass
class Metric:
    """
    指标必须自带口径说明。没有口径的指标不可能被复现,
    因此在数据结构层面就强制要求写清楚。
    """
    name: str
    numerator_desc: str         # 分子是什么
    denominator_desc: str       # 分母是什么
    boundary: str               # 边界情况怎么算(超时/中断/空结果)
    fn: Callable[[dict], bool]
    def describe(self) -> str:
        return (f"{self.name}\n"
                f"  分子:{self.numerator_desc}\n"
                f"  分母:{self.denominator_desc}\n"
                f"  边界:{self.boundary}")
@dataclass
class EvalReport:
    metrics: dict[str, float]
    n: int
    failures: list[dict]        # 必须保留失败样本,否则无法归因
    by_tag: dict[str, float]
def run_eval(samples: list[Sample], run_once: Callable[[Sample], dict],
             metrics: list[Metric], repeats: int = 1) -> EvalReport:
    """
    repeats > 1 用于估计方差 —— Agent 有随机性,
    只跑一遍得到的差异无法与噪声区分。
    """
    per_metric: dict[str, list[float]] = {m.name: [] for m in metrics}
    failures: list[dict] = []
    tag_scores: dict[str, list[float]] = {}
    for run_i in range(repeats):
        for s in samples:
            try:
                out = run_once(s)
            except Exception as e:
                # 异常也算一次「未通过」,不能让异常样本静默消失,
                # 否则分母被人为缩小,指标会虚高。
                out = {"error": f"{type(e).__name__}: {e}"}
            for m in metrics:
                per_metric[m.name].append(1.0 if m.fn(out) else 0.0)
            # 记录失败样本:这是最有价值的产出,而不是那个百分比
            main = metrics[0]
            if not main.fn(out):
                failures.append({"sample": s.id, "tags": s.tags, "output": out,
                                 "run": run_i})
                for t in s.tags:
                    tag_scores.setdefault(t, []).append(0.0)
            else:
                for t in s.tags:
                    tag_scores.setdefault(t, []).append(1.0)
    report = EvalReport(
        metrics={m.name: statistics.fmean(v) for m, v in
                 ((m, per_metric[m.name]) for m in metrics)},
        n=len(samples) * repeats,
        failures=failures,
        by_tag={t: statistics.fmean(v) for t, v in tag_scores.items()},
    )
    # 顺带输出方差:方差大的指标在决策时要更保守
    for m in metrics:
        v = per_metric[m.name]
        if len(v) > 1:
            report.metrics[f"{m.name}__stdev"] = statistics.pstdev(v)
    return report
# --- 指标定义示例(注意每一条都把口径写全了) ---
m_parse_ok = Metric(
    name="解析成功率",
    numerator_desc="输出能被 JSON 解析器成功解析的样本数",
    denominator_desc="全部参评样本数(含超时、异常、中断)",
    boundary="超时与异常一律计入分母且计为不通过;空输出计为不通过",
    fn=lambda out: "parsed" in out and out["parsed"] is not None,
)
m_tool_ok = Metric(
    name="工具调用成功率",
    numerator_desc="工具调用返回状态为 ok 或 empty 的样本数(empty 视为成功)",
    denominator_desc="全部参评样本数(含超时、异常、中断)",
    boundary="超时/异常计为不通过;empty 计为通过(查询成功但无数据)",
    fn=lambda out: out.get("tool_status") in ("ok", "empty"),
)
def compare(baseline: EvalReport, candidate: EvalReport) -> str:
    """
    对比报告。原则:
      · 差异小于历史波动(stdev)的不下结论
      · 主指标提升但分层指标恶化,必须显式提示(防刷分)
    """
    lines = [f"样本数:baseline {baseline.n} → candidate {candidate.n}"]
    for k, v_new in candidate.metrics.items():
        if k.endswith("__stdev"):
            continue
        v_old = baseline.metrics.get(k)
        if v_old is None:
            continue
        delta = v_new - v_old
        noise = max(candidate.metrics.get(f"{k}__stdev", 0),
                    baseline.metrics.get(f"{k}__stdev", 0))
        verdict = "差异不显著" if abs(delta) <= noise else ("提升" if delta > 0 else "回归")
        lines.append(f"{k}: {v_old:.1%}{v_new:.1%}(Δ{delta:+.1%}{verdict}")
    # 防刷分:整体提升但某个分层恶化
    for tag, v_new in candidate.by_tag.items():
        v_old = baseline.by_tag.get(tag)
        if v_old is not None and v_new < v_old - 0.05:
            lines.append(f"⚠ 分层「{tag}」出现回落:{v_old:.1%}{v_new:.1%},"
                         f"整体指标可能掩盖了局部退化,请检查")
    return "\n".join(lines)
实操任务
  • 给你手上的一个 Agent 定义三个正交维度,并各写一句「口径说明」(分子 / 分母 / 边界);
  • 构造一个「总体指标上升但某个分层指标下降」的例子,验证 compare() 能发出警告;
  • repeats 从 1 改成 3,观察各指标的方差——哪个指标最不稳定?为什么?

五、自测

本章自测第 2 题为高频考点
Q1评测要回答的三个第一性问题不包括下面哪一个?
C。三个第一性问题是:维度(哪个方向)、量化(多少)、显著性(真的吗)。代码好不好看属于工程规范,不属于评测要回答的问题——把这两类事混起来会导致评测报告里出现无法量化的主观判断。
Q2判断两个维度是否正交,最实用的检验方法是什么?
B。这是最实用的一条判据。例如「准确」与「有用」——你能构造出「准确但无用」的样本吗?很难,说明它们高度相关,应该合并或重新拆分。能构造出反例,才是真正正交的两个维度。
Q3为什么评测里必须保留「失败样本列表」而不只是那个百分比?
D。这是评测从「考核指标」变成「研发工具」的关键一步。只报百分比,团队会陷入「刷分」;保留失败样本并做归因,才能把评测结果转化为具体的改动清单。这也是下一章「badcase 回流」的起点。

六、小结

问题结论
哪个维度拆到正交为止;判据是能否构造出「A 好 B 差」的反例
好了多少口径三要素:分子、分母、边界;不写口径等于没有指标
真的吗样本量 ≥ 30、同集同配置、多次运行看方差
失败样本必须保留并归因,这是评测真正的产出
防刷分看分层指标,警惕整体提升掩盖局部退化

评测不是打分表,是研发工具。它的产出不是那个百分比,而是「下一步该改哪里」的清单。本刊编辑部

下一章讲评测集从哪来——答案可能会让你意外:它不是设计出来的,是长出来的。

面试官会怎么问

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

你怎么知道一版改动真的让 Agent 变好了? 高频
需要三样东西同时具备,缺一个结论就不成立:
① 固定的评测集——改动前后跑的是同一批题,否则数字不可比;
② 明确的基线——上一版的数据是什么,没有基线就没有"变好"这个概念;
③ 噪声估计——同一版本重复跑多次,看指标的波动区间。如果提升幅度落在波动区间内,那不能算改进。
实践上还要注意两点:看分布而不只看均值(平均分涨了但某些关键场景崩塌,这是净损失);区分"变好"和"变长"(轮数变多、token 变多往往能刷高完成率,但用户体验和成本都变差了,所以指标必须成组看)。

追问链

  1. 怎么估计噪声?
  2. 如果提升只有 2 个百分点,你上线吗?
为什么说"感觉这次好多了"不能作为工程结论?
因为人的主观判断有三个系统性偏差:
① 样本偏差——你试的往往是自己熟悉的几个 case,而线上失败集中在长尾;
② 确认偏差——你刚改完,倾向于把符合预期的表现看成改进,把不符合的看成偶然;
③ 无基线——没有同时跑旧版本,等于没有对照,无法排除"这次任务本来就简单"。
不是说主观判断没价值,它的正确用途是发现"哪里不对"(产生假设),而不是判断"是否变好"(验证假设)。前者靠人,后者靠评测。
离线评测和线上指标的关系是什么?
两者回答不同的问题,不能互相替代:
离线评测回答"在受控条件下,这一版的正确性有没有退化"——可复现、可归因、能拦住回归,但它的任务分布是过去的,未必代表未来。
线上指标回答"在真实流量下,用户是否真的受益"——分布真实,但噪声大、混杂因素多(用户群变化、流量结构变化),归因困难。
正确的用法是用离线守回归底线,用线上看真实收益:离线不通过不上线;离线通过后灰度上线,用线上数据验证;线上出现新的 badcase,回流进离线评测集。离线评测集就是由线上 badcase 长出来的,这样两者才形成一个闭环而不是两套体系。
答这类题的通用结构

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

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