「感觉这次好多了」不是工程结论。评测要回答三个问题:好在哪个维度、好了多少、这个差异是真的还是噪声。这三个问题分别对应维度定义、量化口径、显著性判断——缺任何一个,迭代方向就是凭直觉。
编辑部注 · 本章建立评测的判断框架。后面四章都是它的展开:评测集(怎么取样)、指标(怎么量化)、评审(怎么让机器打分可信)、闭环(怎么让评测真正影响决策)。
先承认一件不太体面的事实:大多数 Agent 团队的第一次迭代都是「改提示词 → 手动试几条 → 感觉好了 → 上线」。这条路偶尔能走通,但它不可持续——因为你不知道这次变好是不是运气好,也不知道下一条改动会不会把之前修好的问题重新弄坏。
一、评测的三个第一性问题
| 问题 | 要回答什么 | 典型错误 | 正确做法 |
|---|---|---|---|
| 哪个维度 | 「好」具体指什么?拆成可独立判断的几个维度 | 用「效果」这个模糊词,导致改进无法归因 | 拆成可分别评估的维度(如方向相关性 / 证据充分性 / 可执行性) |
| 好了多少 | 用什么数字衡量?分母是什么? | 口径不清,同一个指标两个人算出两个数 | 指标定义要写到「能被别人复现」的程度 |
| 是真的吗 | 这个差异超出噪声了吗? | 拿 5 条样本的差异当结论 | 扩大样本、看置信区间、做 A/B 对照 |
这三个问题看起来朴素,但它们对应了三种最常见的评测失败:维度混在一起导致改对了也不知道、口径不清导致数字无法比较、样本太小导致追噪声。
二、维度怎么拆:正交是唯一标准
拆维度的唯一要求是正交——两个维度不能相互包含,否则你无法判断是哪一项在变化。
三、指标口径:必须写到能被复现
这是最容易出问题的地方。以「可执行率」为例,下面三种口径会给出三个完全不同的数字:
| 口径 | 定义 | 问题 |
|---|---|---|
| A · 宽松 | 模型输出的 DSL 能被解析器解析成功 | 解析成功 ≠ 能执行。语法对但字段名错的情况被算作成功 |
| B · 适中 | 解析成功且查询引擎接受(不报错) | 引擎不报错 ≠ 结果正确。查了错的条件也算通过 |
| C · 严格 | 解析成功、引擎接受、且返回结果与人工标注的期望结果一致 | 标准最严,人工标注成本最高 |
三种口径都不是错的,但混用是错的。 关键是:口径一旦定了,要写进评测代码的注释里、要能被别人复现,并且在对比两个版本时保持同一口径。
- 写分母:分母是「全部样本」还是「成功进入该环节的样本」?两者差异巨大(后者会系统性高估)。
- 写边界:超时算不算失败?用户主动中断算不算?空结果算成功还是失败?
- 写例子:至少给一个「算通过」和一个「不算通过」的具体样本。
满足这三条的口径,任何同事接手都能算出同一个数。不满足的口径,等于没有口径。
四、动手:一个可复现的评测骨架
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,观察各指标的方差——哪个指标最不稳定?为什么?
五、自测
六、小结
| 问题 | 结论 |
|---|---|
| 哪个维度 | 拆到正交为止;判据是能否构造出「A 好 B 差」的反例 |
| 好了多少 | 口径三要素:分子、分母、边界;不写口径等于没有指标 |
| 真的吗 | 样本量 ≥ 30、同集同配置、多次运行看方差 |
| 失败样本 | 必须保留并归因,这是评测真正的产出 |
| 防刷分 | 看分层指标,警惕整体提升掩盖局部退化 |
评测不是打分表,是研发工具。它的产出不是那个百分比,而是「下一步该改哪里」的清单。本刊编辑部
下一章讲评测集从哪来——答案可能会让你意外:它不是设计出来的,是长出来的。
◇ 面试官会怎么问
共 3 条 · 其中 1 条高频 · 先自己答一遍,再展开对照
你怎么知道一版改动真的让 Agent 变好了? 高频
① 固定的评测集——改动前后跑的是同一批题,否则数字不可比;
② 明确的基线——上一版的数据是什么,没有基线就没有"变好"这个概念;
③ 噪声估计——同一版本重复跑多次,看指标的波动区间。如果提升幅度落在波动区间内,那不能算改进。
实践上还要注意两点:看分布而不只看均值(平均分涨了但某些关键场景崩塌,这是净损失);区分"变好"和"变长"(轮数变多、token 变多往往能刷高完成率,但用户体验和成本都变差了,所以指标必须成组看)。
追问链
- 怎么估计噪声?
- 如果提升只有 2 个百分点,你上线吗?
为什么说"感觉这次好多了"不能作为工程结论?
① 样本偏差——你试的往往是自己熟悉的几个 case,而线上失败集中在长尾;
② 确认偏差——你刚改完,倾向于把符合预期的表现看成改进,把不符合的看成偶然;
③ 无基线——没有同时跑旧版本,等于没有对照,无法排除"这次任务本来就简单"。
不是说主观判断没价值,它的正确用途是发现"哪里不对"(产生假设),而不是判断"是否变好"(验证假设)。前者靠人,后者靠评测。
离线评测和线上指标的关系是什么?
离线评测回答"在受控条件下,这一版的正确性有没有退化"——可复现、可归因、能拦住回归,但它的任务分布是过去的,未必代表未来。
线上指标回答"在真实流量下,用户是否真的受益"——分布真实,但噪声大、混杂因素多(用户群变化、流量结构变化),归因困难。
正确的用法是用离线守回归底线,用线上看真实收益:离线不通过不上线;离线通过后灰度上线,用线上数据验证;线上出现新的 badcase,回流进离线评测集。离线评测集就是由线上 badcase 长出来的,这样两者才形成一个闭环而不是两套体系。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。