Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

14 项目二:带评估集的工具调用平台

本章回答 · 项目目标

怎么把「这次改版是不是更好」变成一个可回滚的决定?评估集怎么版本化,采样怎么记录,置信区间怎么算,门禁凭什么拦下一次发布?为什么平均分涨了却可能是退化?成本、延迟、质量三者怎么一起报告?

读完你会 · 完成后你能

为一个工具调用平台设计注册表、执行器、版本化评估集、多次采样 runner、Wilson/bootstrap 统计、配对检验、回归门禁与 Markdown/JSON 报告;用 30 条确定性样本比较 baseline 与候选;用门禁拦截一次工具版本回归;把成本与 P95 延迟纳入同一条决策链。

项目一给出了一个可沙箱执行的代码 agent,并留下了 trace。但它没有回答一个更根本的问题:改动之后,怎么知道它变好了?

看最终答案不够。一个 agent 可能答案更准,但工具调用次数翻倍;可能平均延迟下降,但 P99 爆炸;可能修好了 6 个旧 case,却悄悄弄坏 2 个权限 case。项目二把这些变成可计算的证据:版本化评估集、多次采样、置信区间、配对检验、成本/延迟约束、回归门禁。

代码在 projects/eval-platform/,约 900 行(含测试),22 个测试;评估集 30 条,其中 6 条是瞬时失败、2 条是权限边界。正文只摘关键片段,完整实现可直接运行。

评估流水线与回归门禁 从左到右是流水线,从右往左是回滚路径。门禁读的不是一个分数,而是一组带不确定性的差值:质量差值的置信区间下界、安全回归数、成本与 P95 延迟。图中「门禁」是唯一有写权限的环节,runner 与统计只产出证据;没有证据链的放行,等于把发布交给运气。 评估流水线:从版本化数据集到可回滚的门禁 数据集 v3 分层 + 污染检查 runner N 次采样 + 成本 指标 准确率 / 工具 / 引用 统计 CI + 配对检验 门禁 阈值 + 灰度 一次比较要同时报告 Δ质量 = +4.2pp [95% CI +1.1, +7.3] Δ成本 = +18% ΔP95 = +240ms n = 400 配对样本;同一 seed 集 失败样本:12 个新增,3 个修复 门禁不是单指标 ① 质量下界 > baseline(配对) ② 安全用例 0 回归 ③ 成本/延迟在预算内 ④ 任一失败 → 可回滚到上一版本 没有「平均分涨了」就放行;平均分可以掩盖尾部、失败类型与成本漂移。 观测:每次评估写 dataset_version / model / prompt_hash / seed / git_sha,否则结果不可复现。
读图:从左到右是流水线,从右往左是回滚路径。门禁读的不是一个分数,而是一组带不确定性的差值:质量差值的置信区间下界、安全回归数、成本与 P95 延迟。图中「门禁」是唯一有写权限的环节,runner 与统计只产出证据;没有证据链的放行,等于把发布交给运气。

读图:评估流水线的每一段都产出证据,只有门禁有写权限。数据集版本、采样记录、指标、置信区间、成本与 P95,都是为了让门禁的那一个决定可解释、可回滚。如果门禁读的是一个没有区间的平均分,它就不是门禁,是投票。

14.1 需求与平台边界

14.1.1 只回答一个问题

项目二只回答一个问题:

给定同一个评估集,候选配置相对 baseline 是更好、持平,还是更差?

「更好」不是一个分数,而是一组带条件的判断:

维度证据门禁条件
质量配对差值 + 95% CICI 下界 ≥ 阈值(例如 +0.05)
回归逐 case 的 broken 数broken ≤ 0(可按风险放宽)
安全权限/安全标签的失败数安全回归 ≤ 0
成本cost_units 比值≤ 1.25
延迟P95 比值≤ 1.5

这张表说明什么:门禁不是「分数涨了」,而是「质量有下界、没有破坏、成本可控」。任何一项不满足,都应该阻止发布或要求人工强制放行并留痕。

14.1.2 平台边界

做不做
工具注册、schema 校验、版本真实网络/数据库;用 mock 工具
评估集加载、版本哈希、分层自动生成评估集
N 次采样、成本/延迟记录分布式压测
Wilson/bootstrap/配对检验完整贝叶斯分析
门禁、强制放行、审计自动部署/回滚编排
Markdown/JSON 报告前端大屏

这不是「简化版生产平台」,而是决策链的最小闭环。真实平台会把 mock 工具换成真实工具、把 JSONL 换成数据库、把报告接到 CI;但统计口径与门禁逻辑不变。

定义 14.1.1 · 评估平台

一个评估平台是一个四元组 (T,D,R,G)(\mathcal{T}, \mathcal{D}, R, G):

  • T\mathcal{T} 是版本化工具注册表;
  • D\mathcal{D} 是版本化评估集,D=(d1,…,dn)\mathcal{D}=(d_1,\ldots,d_n),每条含输入、期望、难度与标签;
  • RR 是 runner,把配置 θ\theta 在 D\mathcal{D} 上跑 mm 次,产出记录;
  • GG 是门禁,读 baseline 与 candidate 的记录,输出 pass/fail 与理由。

平台的可信度取决于四者的可复现性:同一 D\mathcal{D}、同一 θ\theta、同一 seed,结果必须一致。

14.1.3 三个反模式

评估平台最容易变成三种东西,而不是决策工具:

反模式表现后果
排行榜只报一个总分,不报配置与数据版本无法复现,也无法解释差异
许愿池评估集只放“希望模型会”的题分数很高,线上失败没人负责
橡皮图章门禁永远通过,失败靠人肉发现回归累积,最后一次性爆炸

对应的三条设计约束:

  1. 每个分数都带坐标:数据集版本、工具签名、配置、采样次数、seed。没有坐标的分数不进入报告。
  2. 每个失败都能回到样本:失败记录带 case_id 与最小复现输入;不能只说“某类题不行”。
  3. 门禁有否决权:至少一个条件能单独阻止发布;否则它只是建议。

这张表说明什么:平台的价值不在“能跑评估”,而在“能让一个不同意的人用证据说服另一个同意的人”。排行榜、许愿池、橡皮图章都做不到这一点。

14.1.4 平台的成功标准

一个评估平台做得好不好,不看它有多少指标,而看三件事:

标准可检验的问题反例
可复现换一个人、换一台机器,能否得到同一结论?结果依赖本机缓存或未记录的模型版本
可解释每个 pass/fail 能否指到证据?只报一个分数,失败样本找不到
可行动结论能否直接决定发布/回滚?报告很长,但没有一个否决点

三条都满足,平台才在「决策链」里;缺一条,它就只是数据展示。项目二用「同数据、同配置、同 seed」保证可复现,用「失败聚类 + 报告字段」保证可解释,用「五个否决点」保证可行动。

14.2 工具注册表与 schema

定义 14.2.1 · 工具注册表

工具注册表是一个映射 R:extnameo(extversion,extschema,extscope,extcost,exthandler)\mathcal{R}: ext{name} o ( ext{version}, ext{schema}, ext{scope}, ext{cost}, ext{handler}),并满足:同名工具必须带版本;schema 在执行前重新校验;scope 不符即拒绝;signature() 对所有条目的 name/version/schema 做规范化哈希,作为「这次评估用了哪套工具」的证据。

14.2.1 注册表不只是字典

一个工具在平台里至少要有七个字段:

@dataclass(frozen=True)
class Tool:
    name: str
    version: str
    description: str
    schema: dict[str, Any]
    cost_units: int
    latency_ms: int
    scope: str
    handler: Callable[[dict[str, Any]], dict[str, Any]]
  • name + version:同名不同版本必须能区分;评估报告要记录用的是哪个版本。
  • schema:参数类型、必填、额外字段;宿主侧重新校验。
  • cost_units:用于成本比较的相对单位,不一定是钱。
  • latency_ms:用于延迟模型;真实平台用实测。
  • scope:调用者必须具备的权限。
  • handler:确定性 mock;真实平台替换成 adapter。

注册表提供 signature():把所有工具的 name/version/schema 规范化后哈希。它用于回答「这次评估用的是哪套工具」,比人肉记录可靠。

def signature(self) -> str:
    payload = json.dumps(
        [{"name": t.name, "version": t.version, "schema": t.schema}
         for t in sorted(self.tools.values(), key=lambda x: x.name)],
        sort_keys=True, separators=(",", ":"), ensure_ascii=False,
    ).encode()
    return hashlib.sha256(payload).hexdigest()[:16]

14.2.2 校验顺序:scope → schema → handler

def validate(self, name, args, *, scope):
    tool = self.get(name)
    if tool.scope != scope:
        raise RegistryError("EPERM", f"scope {scope} cannot call {tool.scope}")
    for key in tool.schema.get("required", []):
        if key not in args:
            raise RegistryError("EINVAL", f"missing required field: {key}")
    if tool.schema.get("additionalProperties") is False:
        extra = set(args) - set(tool.schema.get("properties", {}))
        if extra:
            raise RegistryError("EINVAL", f"unexpected fields: {sorted(extra)}")
    return tool

顺序不能反:先 scope,再 schema。为什么?如果先校验 schema,攻击者可以通过错误信息探测「这个工具接受哪些字段」,即使他没有权限。先拒绝无权调用者,能减少信息泄露面。

关于注册表的三个误解
  1. 「schema 校验是模型的事」——模型可能被注入,参数可能来自不可信数据;宿主必须再校验一次。
  2. 「版本只是标签」——版本要参与签名、评估集匹配与门禁比较;同名不同版混用,会让「回归」无法归因。
  3. 「scope 是文档」——scope 是执行前的硬检查;没有它,工具描述里的「只读」只是建议。

14.2.3 版本回归:同名 v2 怎么被抓住

工具升级经常只改内部实现,不改名字。评估平台如果只按 name 找工具,v2 会静默替换 v1,历史报告就失去可比性。项目二的做法是:

  1. ToolRegistry 的 key 是 name@version;
  2. signature() 把 schema 与 version 一起哈希;
  3. runner 明确记录 tool_version;
  4. 评估集里保留能区分版本的 case(例如取消订单必须返回 cancelled)。
def _lookup_order_v2(args):
    data = _lookup_order(args)
    data["status"] = "paid"          # 回归:把 cancelled 也报成 paid
    data["refundable_cents"] = 20000
    return data

这条“回归”不是真实代码,而是刻意注入的变异:它让 order-005、order-006 从通过变成失败。门禁看到 broken=2 与 safety=2,就会拒绝。没有这两条 case,v2 的“总分提升”会掩盖字段口径变化。

评估集是版本的地雷阵

工具版本变更时,评估集里那些“只有旧版本才对”的 case 就是地雷。它们不追求覆盖所有行为,只负责在版本漂移时炸一下。每修一个线上事故,就埋一颗雷;雷越多,静默回归越难溜过去。

14.3 调度器与执行器

14.3.1 执行器:只对可重试错误重试

for _ in range(self.retries + 1):
    attempts += 1
    cost += tool.cost_units
    latency += tool.latency_ms
    if tool.latency_ms > self.timeout_ms:
        last_error = {"code": "ETIMEDOUT", "retryable": True}
        continue
    try:
        data = tool.handler(args)
    except PermissionError as exc:
        return ExecResult(False, None, {"code": "EPERM", "retryable": False}, ...)
    except ValueError as exc:
        return ExecResult(False, None, {"code": "EINVAL", "retryable": False}, ...)
    except TransientError:
        last_error = {"code": "ETRANSIENT", "retryable": True}
        continue
    return ExecResult(True, data, None, attempts, latency, cost)

三个设计点:

  1. 权限/参数错误立即返回,不重试。 重试不会改变 EPERM 或 EINVAL,只会消耗预算。
  2. 每次尝试都计成本。 重试不是免费的;报告里的 cost_units 必须包含失败尝试。
  3. 超时是模型而非真实计时。 本项目用 latency_ms > timeout_ms 模拟;真实平台要用墙钟,并处理线程/进程取消。

14.3.2 调度器:按 case 还是按工具

本项目按 case 调度:每个 case 指定工具与参数,runner 逐个执行。真实平台可能按工具分组、并发执行、限制 QPS。两种调度的取舍:

调度优点缺点
按 case简单、可复现、易分层并发度低;工具级配额难统一
按工具配额清晰、可批处理结果与 case 的对应关系变复杂
混合兼顾实现复杂,容易出现「先跑哪个」的偏差

教学项目选按 case,因为它让「一条 case 一条记录」的对应关系最清楚;真实平台在并发前,先把这条对应关系固定下来,否则统计时会对不上。

14.3.3 并发与配额

真实平台不会串行跑 30 条 case。并发带来两个新问题:配额竞争与结果乱序。

from concurrent.futures import ThreadPoolExecutor

def run_parallel(cases, fn, workers=4):
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(fn, c): c.id for c in cases}
        out = {}
        for fut, cid in futures.items():
            out[cid] = fut.result()
    return out

三个约束:

  1. 配额是全局的:并发 worker 共享工具配额;扣减要原子(锁或令牌桶),否则会超发。
  2. 结果按 case id 归位:不要依赖完成顺序;报告与门禁都按 case_id 对齐。
  3. 失败要隔离:一个 case 抛异常不能取消其他 case;收集异常并记录 error_code。

项目二为了可复现,默认串行;并发是扩展项。先保证「同输入同输出」,再谈吞吐。

14.4 评估集设计与版本

14.4.1 30 条样本的构成

类别数量难度标签目的
加法12easy/mediummath基础正确性
订单查询6easy/hardorder、permission权限与状态边界
退款6medium/hardrefund、money金额上限与不可逆动作
瞬时搜索6mediumflaky、search重试策略

这张表说明什么:评估集不是「越多越好」,而是每类失败模式都有代表。12 条加法看起来冗余,但它们覆盖不同的输入边界(负数、零、大数);6 条 flaky 专门用来测重试;2 条取消订单用来测「工具版本回归」。

14.4.2 版本哈希:内容变了,版本必须变

def canonical(cases):
    rows = [c.to_dict() for c in cases]
    rows.sort(key=lambda r: r["id"])
    return json.dumps(rows, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False).encode("utf-8")

version = hashlib.sha256(canonical(cases)).hexdigest()[:12]

三条规则:

  1. 排序后再哈希:否则文件行序变化会产生新版本,虽然内容一样。
  2. 包含所有影响结果的字段:输入、期望、难度、标签;不含注释与展示文本。
  3. 版本进报告:tool-calling-v1@82d2ff796fb8;没有版本号的比较不可复现。
为什么不直接用文件名当版本

文件名 tool-calling-v1 只表示「人给这一版起的名字」,不保证内容没被改。有人加了一条 case、删了一条注释,文件名不变,历史报告就失去了可比性。内容哈希是廉价的防篡改:改一个字节,版本就变。

14.4.3 污染与泄漏:评估集也会骗人

评估集的三类污染:

类型例子检测
训练集泄漏公开基准的题被模型记住用 held-out 私有集;看 exact match 异常高
时间泄漏用未来数据评估过去的模型按时间切分;记录数据截止日
标签泄漏期望答案出现在输入里检查 input 是否包含 expected 字段

项目二的样本是自造的,不存在训练集泄漏;但它仍有“标签泄漏”的风险:如果 case 的 args 里直接包含 status="paid",工具什么都不用做就能通过。写 case 时要把“输入”和“期望”严格分开,期望只能出现在 expected 字段。

一个简单的自动检查:

def check_leak(case):
    blob = json.dumps(case.args, ensure_ascii=False)
    for key, value in case.expected.items():
        if isinstance(value, str) and value and value in blob:
            raise ValueError(f"{case.id}: expected {key} appears in args")

这类检查很粗糙,但能挡住最明显的“答案写在题面里”。评估集的质量,首先取决于你允不允许这种题存在。

14.5 runner:多次采样与成本

14.5.1 一条记录包含什么

@dataclass
class Record:
    case_id: str
    sample: int
    ok: bool
    attempts: int
    latency_ms: int
    cost_units: int
    error_code: str | None
    difficulty: str
    tags: tuple[str, ...]

字段的设计对应四类问题:

  • ok / error_code:质量与失败类型;
  • attempts:重试是否生效;
  • latency_ms / cost_units:成本;
  • difficulty / tags:分层与安全回归。

没有 sample,多次采样会被误当成多条独立 case,置信区间会算窄。

14.5.2 采样与聚合

run_suite 对每个 case 跑 samples 次,case 的成功定义为「所有采样都成功」:

per_case.setdefault(case.id, []).append(ok)
...
out.case_scores = {cid: (1.0 if all(v) else 0.0) for cid, v in per_case.items()}

这个口径比「任一次成功」严格。为什么?因为生产里你不会因为「重试三次中一次成功」就说这个 case 稳定通过;你要么报告成功率(含重试),要么报告 pass@k。项目二选「全过才算过」,是为了让门禁对不稳定更敏感。

真实运行(--runs 2):

python3 report.py --suite tool-calling-v1 --runs 2 --gate --audit /tmp/audit.jsonl
| run | pass rate | 95% Wilson | cost units | P95 ms | failures |
| baseline | 80.0% | [0.627, 0.905] | 132 | 85 | 6 |
| retry | 100.0% | [0.886, 1.000] | 144 | 85 | 0 |

这张表说明什么:baseline 的 80% 不是「模型不行」,而是 6 条 flaky case 在 retries=0 时必然失败;候选的 100% 来自 retries=1,成本从 132 涨到 144(约 9%)。质量提升与成本上升同时出现,门禁要一起看。

方法 14.5.1 · 评估前的五个检查
  1. 评估集版本是否固定?报告里有没有 hash?
  2. 工具注册表签名是否记录?同名工具是否换了版本?
  3. 采样次数是否一致?baseline 与 candidate 是否用同一批 case?
  4. 失败样本是否保存?能否用同一命令复现?
  5. 成本/延迟口径是否包含失败尝试与重试?

五条缺一条,比较就不可信;先补证据,再谈结论。

14.5.3 pass@k 与多次采样

pass@k 回答的是「采样 k 次,至少一次成功」的概率;它与「跑 k 次取平均」不是一回事。

pass@k=1−(n−ck)(nk) \mathrm{pass@}k = 1 - \frac{\binom{n-c}{k}}{\binom{n}{k}}

其中 nn 是采样总数,cc 是成功数。k=1k=1 时就是成功率;k>1k \gt 1 时,它奖励“多样性”——只要有一次对,就算过。

def pass_at_k(n, c, k):
    if c == 0:
        return 0.0
    if n - c < k:
        return 1.0
    return 1.0 - math.comb(n - c, k) / math.comb(n, k)

什么时候用哪个:

场景指标理由
生产只跑一次成功率(k=1)pass@k 高估实际体验
允许重试/多候选pass@k反映“至少一次成功”的能力
稳定性全过率(所有采样都成功)反映波动与一致性

项目二默认报告成功率与全过率;pass@k 在 stats.py 里提供,但门禁用成功率与配对差值。不要让 pass@k 的高数字掩盖“第一次就失败”的体验。

14.6 统计:CI、配对与分层

定义 14.6.1 · 配对比较

给定同一评估集上的 baseline 分数 bib_i 与候选分数 cic_i,配对差值为 di=ci−bid_i=c_i-b_i。配对 bootstrap 对 did_i 有放回重采样 BB 次,得到 dˉ\bar d 的经验分布;取 2.5% 与 97.5% 分位作为 95% 区间。配对消掉了 case 难度带来的方差,因此比两个独立区间更敏感。

14.6.1 Wilson 区间:小样本的比例

30 条 case 的准确率是一个比例。正态近似在 pp 接近 0 或 1、nn 较小时会给出越界区间(小于 0 或大于 1);Wilson 区间更稳:

p^+z22n±zp^(1−p^)n+z24n21+z2n \frac{\hat p + \frac{z^2}{2n} \pm z\sqrt{\frac{\hat p(1-\hat p)}{n} + \frac{z^2}{4n^2}}}{1 + \frac{z^2}{n}}

其中 p^=k/n\hat p = k/n,z=1.96z=1.96 对应 95%。实现:

def wilson_interval(successes: int, n: int, z: float = 1.96) -> Interval:
    p = successes / n
    denom = 1 + z * z / n
    center = (p + z * z / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return Interval(max(0.0, center - half), min(1.0, center + half))

baseline 24/30 的区间是 [0.627, 0.905],候选 30/30 是 [0.886, 1.000]。两个区间有重叠,但这不意味着差异不显著——因为它们是同一批 case 上的配对比较,应该看配对差值,而不是看两个独立区间是否重叠。

关于置信区间的三个误解
  1. 「两个 CI 重叠就不显著」——独立样本的粗略经验,不适用于配对设计;配对要看差值分布。
  2. 「CI 是参数落在区间内的概率」——频率派 CI 的含义是「重复采样时,95% 的区间会覆盖真值」,不是对当前区间的概率陈述。
  3. 「n 越大越好」——样本量要跟效应量匹配;为了检测 +0.01 的差异跑一万条,成本可能远高于收益。

14.6.2 配对 bootstrap:同一批 case 上比较

配对设计的关键是:baseline 与 candidate 跑的是同一批 case。差值 di=ci−bid_i = c_i - b_i 消掉了 case 难度带来的方差。

def paired_bootstrap_diff(baseline, candidate, *, n_resamples=2000, seed=42):
    diffs = [c - b for b, c in zip(baseline, candidate)]
    rng = random.Random(seed)
    stats = []
    for _ in range(n_resamples):
        sample = [diffs[rng.randrange(len(diffs))] for _ in range(len(diffs))]
        stats.append(sum(sample) / len(sample))
    alpha = 0.025
    return mean(diffs), Interval(percentile(stats, 2.5), percentile(stats, 97.5))

真实输出:

配对差值:+0.200,95% CI [+0.067, +0.367]

这张输出说明什么:候选比 baseline 高 20 个百分点,区间下界 +6.7 个百分点。即使只有 30 条 case,只要 6 条 flaky 全部被修复、没有新增破坏,下界仍然为正。反例是工具 v2:差值 +0.133,但区间 [-0.033, +0.300] 跨 0,且 broken=2——质量点估计更高,却不可信。

14.6.3 分层:平均值掩盖的两种东西

按难度、标签分层后,常见两种「平均提升但局部退化」:

现象平均值分层证据结论
修好简单题,弄坏难题+5ppeasy +15pp,hard −10pp不可发布;难题退化更危险
修好 flaky,拖慢退款+20ppflaky +100%,refund P95 +80%需按预算决定;退款是资金路径

项目二的报告先给总体,再给 by_tag / by_difficulty。门禁检查 broken 数,不只看总差值,就是为了抓住第二类。

for tag in ("flaky", "refund", "permission"):
    cases = [c for c in evalset.cases if tag in c.tags]
    b = sum(baseline.case_scores[c.id] for c in cases) / len(cases)
    c = sum(candidate.case_scores[c.id] for c in cases) / len(cases)
    print(f"{tag:12s} baseline={b:.2%} candidate={c:.2%} delta={c-b:+.2%}")

真实分层(--runs 1):

flaky        baseline=0.00% candidate=100.00% delta=+100.00%
refund       baseline=100.00% candidate=100.00% delta=+0.00%
permission   baseline=83.33% candidate=100.00% delta=+16.67%

这张输出说明什么:候选的全部提升来自 flaky 与 permission 两类;refund 没有变化。如果只看总分,你会以为「全面变好」;分层之后,你知道收益集中在重试策略,退款路径没有被验证到更多边界。

14.6.4 样本量:先问能检测多大的差异

给定基线比例 pp、希望检测的最小差值 δ\delta、显著性 α\alpha 与功效 1−β1-\beta,两个比例的样本量近似:

n≈(z1−α/2+z1−β)2⋅2p(1−p)δ2 n \approx \frac{(z_{1-\alpha/2}+z_{1-\beta})^2 \cdot 2p(1-p)}{\delta^2}

在 p=0.8p=0.8、δ=0.05\delta=0.05、α=0.05\alpha=0.05、功效 0.8 时,nn 约 500。项目二只有 30 条,所以它只能检测较大的差异;报告必须写清这一点,而不是把 +2pp 当成结论。

方法 14.6.1 · 比较两个配置的顺序
  1. 固定评估集版本与工具签名;
  2. 用同一批 case、同一采样次数跑 baseline 与 candidate;
  3. 先看 broken/fixed 的逐 case 差异,再看总分;
  4. 计算配对差值 CI,不只看点估计;
  5. 按标签/难度分层,找被平均值掩盖的退化;
  6. 把成本与 P95 放进同一张表,做 Pareto 判断;
  7. 结论写成「在什么条件下、对哪类 case、提升了多少、代价是什么」。

七步的产物不是一句「更好」,而是一张可复核的证据表。

14.6.5 效应量与业务决策

统计显著不等于业务重要。一个 +0.5pp 的提升,在 30 条样本上可能连区间都撑不开;在 30000 条样本上可能“显著”,但业务上毫无感知。做决策时同时看三个量:

量含义决策用途
绝对差值candidate − baseline 的百分点是否值得改
相对差值差值 / baseline与历史改进比较
区间下界最悲观的可信估计是否敢发布

项目二的候选:绝对 +20pp、相对 +25%、下界 +6.7pp。三个量都支持发布;v2 的绝对 +13.3pp 看起来不错,但下界 −3.3pp,业务上不能接受“可能更差”的发布。

还要看失败类型,而不只是数量:

  • 修好 6 条 flaky(可重试的瞬时错误)与修好 6 条权限错误,价值不同;
  • 弄坏 2 条退款(资金路径)与弄坏 2 条搜索(可降级),风险不同。

门禁的 tags 与 max_safety_regressions 就是把“类型”纳入决策。没有标签,所有失败长得一样,只能数数量。

14.6.6 成本模型:把重试算进去

cost_units 不是“一次成功调用的价格”,而是“一次评估尝试的总成本”:

cost(case) = Σ_{attempt=1}^{attempts} (tool.cost_units)

这一定义让重试的代价可见:retries=1 的候选在 6 条 flaky case 上各多花一次尝试,总成本从 66 涨到 78。如果只统计成功调用,重试看起来免费,成本比会失真。

延迟同理:latency_ms 是各次尝试的和,不是最后一次的耗时。P95 报告的是“用户实际等待”,包括失败重试。把失败尝试排除在成本/延迟之外,是评估平台最常见的“美化”手法;它会让门禁在成本维度上失明。

14.7 回归门禁与灰度

14.7.1 门禁的五个条件

@dataclass(frozen=True)
class Thresholds:
    min_delta_lower: float = 0.05
    max_broken: int = 0
    max_cost_ratio: float = 1.25
    max_p95_ratio: float = 1.5
    max_safety_regressions: int = 0

五个条件分别防五种发布事故:

条件防什么放宽的后果
min_delta_lower点估计好看但区间跨 0把噪声当提升
max_broken修一批、坏一批局部退化被总分掩盖
max_cost_ratio质量提升但成本失控单位经济性崩溃
max_p95_ratio平均延迟可接受但尾部爆炸用户体感变差
max_safety_regressions权限/安全用例退化事故风险

这张表说明什么:门禁不是「一个阈值」,而是一组互相独立的否决点。任何一个都能单独阻止发布;这比加权总分更难被「优化」——你不能靠提高准确率来抵消一次权限回归。

14.7.2 决策与审计

def evaluate(baseline, candidate, thresholds=None, *, force=False, audit_path=None):
    cmp = compare(baseline, candidate)
    delta, ci = paired_bootstrap_diff(...)
    reasons = []
    if ci.low < th.min_delta_lower:
        reasons.append(f"quality lower bound {ci.low:.3f} < {th.min_delta_lower:.3f}")
    if cmp["broken"] > th.max_broken:
        reasons.append(f"broken cases {cmp['broken']} > {th.max_broken}")
    ...
    passed = not reasons

真实门禁输出(候选,PASS):

- 结论:**PASS**
- 配对差值:+0.200,95% CI [+0.067, +0.367]
- fixed=6 broken=0 cost_ratio=1.09 p95_ratio=1.00

真实门禁输出(工具 v2,FAIL):

- 结论:**FAIL**
- 配对差值:+0.133,95% CI [-0.033, +0.300]
- fixed=6 broken=2 cost_ratio=1.18 p95_ratio=1.00
- quality lower bound -0.033 < 0.050
- broken cases 2 > 0
- safety regressions 2 > 0

第二段是关键:v2 的总分仍然比 baseline 高(93.3% vs 80.0%),如果门禁只看总分,它会被放行;但 broken=2、安全回归=2、区间跨 0,三个否决点同时触发。平均分不是护栏。

14.7.3 强制放行与留痕

有时业务要求「明知有回归也要发」。门禁允许 --force,但必须留下记录:

{
  "at": "2026-10-19T01:44:02Z",
  "baseline": {"name": "baseline", "pass_rate": 0.8, "cost": 66},
  "candidate": {"name": "v2", "pass_rate": 0.933, "cost": 78},
  "evalset": {"name": "tool-calling-v1", "version": "82d2ff796fb8"},
  "decision": {
    "passed": true,
    "original_passed": false,
    "forced": true,
    "reasons": ["FORCED: broken cases 2 > 0", "FORCED: safety regressions 2 > 0"]
  }
}

这张记录说明什么:passed=true 是「放行」的结果,original_passed=false 是「门禁原本不同意」。两者都留下,审计时才能回答「谁在什么证据下批准了这次发布」。只写 passed=true 的审计日志,等于把否决意见抹掉。

关于门禁的四个误解
  1. 「门禁就是跑测试」——测试通过只说明没坏已知用例;门禁还要比较 baseline、看区间、看成本。
  2. 「强制放行不留痕也没关系」——留痕不是形式;它是事后复盘与责任界定的唯一证据。
  3. 「阈值越低越安全」——过低会阻止所有改进,团队会绕过门禁;阈值要跟风险与样本量匹配。
  4. 「门禁只看质量」——成本与尾部延迟也是发布条件;质量提升 2pp、成本翻倍,通常不该发。

14.7.4 灰度与回滚

门禁通过不等于全量发布。灰度的最小流程:

candidate 通过门禁
  → 5% 流量(同一评估集 + 在线指标)
  → 观察 24h:错误率、P95、成本、人工投诉
  → 50% → 100%
  → 任一指标越界:回滚到 baseline,保留 trace 与报告

灰度期间要持续跑同一评估集的小样本回归,而不是只看在线指标。在线指标有滞后与选择偏差;评估集是稳定的对照。

回滚要能回到「版本组合」:代码 SHA + 工具注册表签名 + 评估集版本 + 模型版本。只回滚代码,工具或模型没回,问题可能还在。

门禁是刹车,灰度是点刹

门禁在发布前替你踩一次刹车;灰度在发布中允许你点刹。两者不能互相替代:没有门禁,灰度会把明显坏的版本放出去;没有灰度,门禁通过后的未知风险没有缓冲。项目二实现门禁;灰度是部署层的事,但它的判据应该复用同一套指标。

14.7.5 回滚演练:门禁之外的最后一道闸

门禁通过不代表回滚可用。每季度做一次「无预演回滚」:

  1. 从生产版本回退到上一个版本组合(代码 + 工具 + 模型 + 评估集);
  2. 用同一评估集跑一次,确认分数回到 baseline;
  3. 检查 trace 是否仍能关联两个版本;
  4. 记录耗时、失败点与人工步骤。

演练的目标不是「证明能回滚」,而是发现回滚依赖了哪些没写下来的手工步骤。常见问题:数据库迁移不可逆、工具版本没回、评估集版本没打标签、trace 的 git_sha 没记。任何一项都会让「回滚」变成「重新部署一个不确定的旧版本」。

14.8 仪表盘与报告

14.8.1 报告的最小字段

一份评估报告,至少要回答六个问题:

问题字段例子
用的是什么数据?suite.name + suite.versiontool-calling-v1@82d2ff796fb8
用的是什么配置?config(strict/retries/tool_version)retry: retries=1
质量如何?pass_rate + Wilson CI100.0% [0.886, 1.000]
比 baseline 好吗?配对差值 + CI + fixed/broken+0.200 [+0.067, +0.367], fixed=6 broken=0
代价是什么?cost + p95_ms 比值cost_ratio=1.09, p95_ratio=1.00
失败在哪?失败样本表(case/error/tags)order-005 EWRONG order,permission

缺任何一项,报告都不能支撑发布决定。例如只写「通过率 100%」,读者无法判断是不是评估集太小、是不是只跑了一次、是不是把失败样本删了。

真实报告的头部:

# 评估报告 · tool-calling-v1@82d2ff796fb8

| run | pass rate | 95% Wilson | cost units | P95 ms | failures |
| baseline | 80.0% | [0.627, 0.905] | 132 | 85 | 6 |
| retry | 100.0% | [0.886, 1.000] | 144 | 85 | 0 |

这张表说明什么:版本、配置、质量、代价、失败数在同一屏。读者不需要打开代码就知道「比较的是什么、差多少、代价多大」。

14.8.2 失败聚类:按错误码与标签分组

单个失败样本只能定位;一组失败才能决定优先级。按 error_code 与 tags 交叉分组:

from collections import Counter
buckets = Counter((r.error_code or "OK", ",".join(r.tags)) for r in result.records)
for (code, tags), n in buckets.most_common():
    print(f"{n:3d}  {code:10s}  {tags}")

真实输出(v2 候选):

  2  EWRONG      order,permission

这张输出说明什么:两个失败都落在 order + permission,不是随机噪声;它们同源于「v2 把 cancelled 报成 paid」。如果只看错误码 EWRONG,你会以为是模型答错;加上标签,才知道是工具版本回归。

14.8.3 Pareto:质量、成本、延迟

把候选画在「质量 vs 成本」平面上,只有位于 Pareto 前沿的候选才值得考虑:

质量
1.00 ┤            ● retry (cost 78)
0.95 ┤
0.90 ┤   ● v2 (cost 78, broken 2)
0.85 ┤
0.80 ┤ ● baseline (cost 66)
     └──────────────────────────── 成本

读图:retry 在相同成本下质量最高;v2 与 retry 成本相同但质量更低且有破坏,被支配;baseline 成本最低但质量差 20pp。如果预算上限是 80,retry 是唯一合理选择;如果预算是 66,只能在 baseline 与「先优化成本」之间选。

这张图不是装饰:它把「质量提升 20pp、成本增加 18%」变成一句可判断的话——在预算 80 以内,这是值得的;如果预算卡在 70,就不值得,应该先降低重试成本或只对 flaky 工具开启重试。

方法 14.8.1 · 读一份评估报告
  1. 先看数据集版本与大小:30 条以下的结论要谨慎;
  2. 看 baseline 与 candidate 的配置差异:只改了一个变量吗?
  3. 看配对差值 CI:下界是否超过阈值?是否跨 0?
  4. 看 fixed/broken:有没有「修 6 个、坏 2 个」;
  5. 看失败聚类:失败是否集中在某个工具/标签;
  6. 看成本与 P95:质量提升是否以不可接受的代价换来;
  7. 看门禁理由:如果 FAIL,是哪个否决点;如果 force,谁批准、为什么。

七步之后,你应该能用一句话回答「发还是不发,为什么」。

14.8.4 报告 JSON:让机器也能读

Markdown 给人看,JSON 给 CI 与审计看。最小 schema:

{
  "suite": {"name": "tool-calling-v1", "version": "82d2ff796fb8", "size": 30},
  "baseline": {"config": {"retries": 0}, "pass_rate": 0.8,
               "wilson": [0.627, 0.905], "cost": 66, "p95_ms": 85},
  "candidate": {"config": {"retries": 1}, "pass_rate": 1.0,
                "wilson": [0.886, 1.0], "cost": 78, "p95_ms": 85},
  "decision": {"passed": true, "delta": 0.2, "ci": [0.067, 0.367],
               "comparison": {"fixed": 6, "broken": 0,
                              "cost_ratio": 1.09, "p95_ratio": 1.0},
               "reasons": [], "forced": false, "original_passed": true}
}

三个字段最容易被省略,也最不该省略:

  • suite.version:没有它,报告不可复现;
  • ci:没有它,只看到点估计;
  • comparison.broken:没有它,看不到局部退化。

CI 里可以直接断言:

jq -e '.decision.passed == true and .decision.comparison.broken == 0' report.json

这条命令比“人看报告觉得还行”可靠。报告的第一读者是机器,第二读者才是人。

14.8.5 一份完整报告的骨架

把前面的字段拼起来,一份可发布的报告长这样:

# 评估报告 · tool-calling-v1@82d2ff796fb8

## 配置
- baseline: retries=0, strict=true, tool=v1
- candidate: retries=1, strict=true, tool=v1
- samples: 2/case, seed=42

## 质量
| run | pass | 95% Wilson | cost | P95 |
...
## 配对
+0.200 [+0.067, +0.367], fixed=6, broken=0

## 分层
flaky +100pp; permission +16.7pp; refund +0

## 门禁
PASS; cost_ratio=1.09; p95_ratio=1.00

## 失败样本
(空)

## 复现
python3 report.py --suite tool-calling-v1 --runs 2 --gate

这份骨架的重点不是排版,而是每一节都能被独立质疑:配置节回答「比的是什么」,质量节回答「多少」,配对节回答「差异可信吗」,分层节回答「收益在哪」,门禁节回答「为什么放行」,复现节回答「我能不能自己跑」。

14.9 交付与扩展

14.9.1 交付清单

项达标线证据
注册表4 个工具,版本/scope/schema 齐全test_platform.py
执行器重试只对瞬时错误;成本含重试test_platform.py
评估集30 条,版本哈希,分层tool-calling-v1.jsonl
runnerN 次采样,记录 attempts/cost/latencyreport.py
统计Wilson + 配对 bootstraptest_stats.py
门禁5 个否决点 + force 审计test_gate.py
报告Markdown + JSON,含失败聚类report.py --json
回归v2 被拦截report.py --candidate v2 --gate

这张表说明什么:每一项都能指向一个测试或一条命令。没有「已完成」这种无法验证的状态。

14.9.2 与项目一的接法

项目二目前用 mock 工具;接项目一只需要一个适配器:

def code_agent_tool(case):
    """把一条 case 变成项目一的 Agent 调用。"""
    ws = Workspace(make_temp_workspace(case["files"]))
    agent = Agent(ws, policy=ScriptedPolicy("good"), trace_path=None)
    out = agent.run(case["goal"])
    return {
        "ok": out["stop_reason"] == "done",
        "data": {"answer": out["answer"], "steps": out["steps"]},
        "refs": [{"kind": "trace", "steps": out["steps"]}],
    }

接的时候有三件事必须对齐:

  1. 错误码:项目一的 EPERM/EPRECOND/ETRANSIENT 要映射到平台的门禁标签,否则失败聚类不成立。
  2. 成本口径:项目一记录工具次数与耗时;平台要把它换算成同一套 cost_units,否则成本比没有意义。
  3. trace 字段:平台报告要能引用项目一的 run_id/step,否则失败样本无法回放。

这三件事对齐后,项目二就能直接评估项目一的 agent;评估集里可以混入代码任务与工具任务,按 tags 分层。

14.9.3 扩展路线

按收益/成本排序:

  1. 真实工具适配器:把 mock 换成项目一的工具或外部 API;保留 schema/scope/cost 字段。
  2. 并发执行:按工具分组、限 QPS;注意 case 与记录的对应关系。
  3. 更多统计:分层 bootstrap、贝叶斯区间、序贯检验(早停);先保证口径一致。
  4. LLM-as-judge:为开放式答案加评审;必须校准、双盲、报告一致性(第 9.7 节)。
  5. CI 集成:门禁接 GitHub Actions;失败时上传报告与失败样本。
  6. 在线灰度:把评估集的指标与在线指标对齐,做 5%→50%→100% 的灰度。

不要在第一步就做并发与 judge;先把「同数据、同配置、可复现」做稳,再增加变量。

14.9.4 一页运行手册

# 1) 跑 baseline 与候选,出门禁结论
python3 report.py --suite tool-calling-v1 --runs 3 --gate --audit /tmp/audit.jsonl

# 2) 失败样本落盘,供下一次回归
python3 report.py --suite tool-calling-v1 --runs 1 --json /tmp/report.json
jq -r '.candidate' /tmp/report.json

# 3) 强制放行(仅当有明确业务理由)
python3 report.py --suite tool-calling-v1 --candidate v2 --gate --force \
  --audit /tmp/audit.jsonl

# 4) 看审计
jq -c 'select(.decision.forced == true)' /tmp/audit.jsonl

四条命令覆盖「评估 → 记录 → 放行 → 审计」。运行手册要短到能贴在 CI 页面上;长了就没人看。

关于评估平台的五个误解
  1. 「评估集越大越好」——关键是覆盖失败模式;30 条有代表性的样本,比 3000 条重复样本更能发现回归。
  2. 「通过率提升就能发」——还要看区间、broken、成本、延迟与安全回归。
  3. 「强制放行是例外」——如果经常 force,说明门禁阈值或评估集与业务脱节;要修门禁,不是习惯性绕过。
  4. 「报告是给人看的」——报告首先是给门禁与审计读的;字段不稳定,自动化就无法接。
  5. 「统计太复杂,不如看均值」——均值会把「修 6 个、坏 2 个」平均成 +13pp;不看分布就会发错版本。

14.9.5 失败复盘模板

每一次门禁 FAIL 或被 force 的发布,都应该留下一条复盘:

## 2026-10-19 · tool-calling-v1 · v2 候选

- 结论:FAIL(broken=2, safety=2, CI [-0.033, +0.300])
- 失败样本:order-005、order-006(cancelled 被报成 paid)
- 根因:工具 v2 改了状态字段口径,评估集保留了旧口径的 case
- 影响:2 条权限相关 case;若发布,退款入口会对已取消订单开放
- 修复:回滚 v2 的状态映射;补 3 条 cancelled/refunded 边界 case
- 回归:新 case 加入 tool-calling-v1,版本从 82d2ff 变为 <新 hash>
- 责任:工具 owner + 评估集 owner 双签

七个字段缺一不可:结论、样本、根因、影响、修复、回归、责任。没有“影响”,读者不知道为什么要拦;没有“回归”,同样的 bug 会再来一次。

复盘不是追责文档,而是把一次失败变成一条永久用例。项目二的 failures.jsonl 就是这些复盘的机器可读版本;两者要互相对应。

14.9.6 平台成熟度:四级台阶

级别特征缺口下一步
L0 手工跑几个 prompt,看感觉无数据、无复现建 30 条评估集
L1 可复现固定数据与配置,能重复跑无区间、无门禁加 CI 与配对比较
L2 可决策区间、门禁、审计无灰度、无在线对齐接灰度与在线指标
L3 可运营灰度、回滚、成本与安全预算组织与合规流程接治理与审计

项目二在 L2:能复现、能比较、能拦截、能审计。它不包含 L3 的灰度编排与在线指标,但字段与口径是为 L3 留好的(suite.version、tool signature、decision.original_passed、audit)。

这张表说明什么:成熟度不是「功能多少」,而是「证据链能否支撑更大的决定」。L1 的证据只能回答「跑过没有」,L2 才能回答「该不该发」,L3 才能回答「发了之后要不要退」。

14.9.7 最后的检查

发布前,用一分钟回答:

  • 这份报告换了机器还能复现吗?
  • 每个失败样本都能找到最小输入吗?
  • 门禁的每个否决点都有对应的阈值来源吗?
  • 强制放行的记录里,能看出谁、为什么、当时看到了什么证据吗?

四问都答得出,平台才算交付;答不出,先把证据补齐,再谈功能。

母本对应

母本章节本书落点程度
《Linux 系统与内核》第 2 篇2.7–2.8 进程与子进程监控14.3 执行器超时交叉引用
《深度学习导论》第 9 讲 大规模训练14.6 成本口径背景
SWE-bench(2023)真实仓库修复评测14.4 数据集设计精选
τ-bench(2024)工具-用户交互评测14.4 分层与标签精选
AgentBench(2023)多环境评测14.4 数据集分层背景
NIST AI RMFMeasure / Manage14.7 门禁与审计背景
OTel GenAI semconv 1.44.0指标与 span 属性14.8 报告字段背景

本章陷阱

#陷阱症状正确做法去哪
1用文件名当评估集版本内容改了,历史报告不可比内容哈希,排序后规范化14.4
2baseline 与候选跑不同 case差值不可解释同一批 case 配对14.6
3只报点估计把噪声当提升报 Wilson/配对 CI14.6
4两个独立 CI 重叠就判不显著配对设计被当成独立样本看配对差值 CI14.6
5只看总分修 6 坏 2 被平均掉看 broken 与分层14.7
6忽略成本与 P95质量升 2pp、成本翻倍门禁加成本/延迟否决点14.7
7force 不留痕事后无法审计original_passed + 理由14.7
8多次采样当独立样本CI 算窄,过度自信按 case 聚合,记录 sample14.5
9失败样本不落盘下次无法回归failures.jsonl + 最小复现14.4
10报告字段不稳定自动化接不上固定 JSON schema14.8
11评估集与业务脱节分数高但线上差定期回流真实失败14.9
12门禁阈值拍脑袋要么全拦要么全放按风险、样本量、成本定14.7

本章出口检查

检查项达标线没达标去哪
能定义评估平台四元组 + 可复现性14.1
能写工具注册表版本/scope/schema/cost14.2
能实现重试只对瞬时错误,成本含重试14.3
能版本化评估集内容哈希,改一字节即变14.4
能算配对 CI30 条样本给出差值区间14.6
能读门禁说出五个否决点14.7
能拦截回归v2 候选 FAIL14.7
能写报告六个问题一屏回答14.8
即时自测 · 14

Q1 baseline 24/30、候选 30/30,Wilson 区间有重叠,能否说「不显著」?应该看什么?

Q2 候选总分 93.3%,比 baseline 80% 高,为什么门禁仍然 FAIL?

Q3 为什么门禁要记录 original_passed,而不只记录 passed?

答案

A1 不能。两个 Wilson 区间是各自独立的单样本区间;baseline 与候选跑的是同一批 case,应该看配对差值的 bootstrap CI。项目二的差值是 +0.200,95% CI [+0.067, +0.367],下界为正,说明在这批 case 上提升是可信的(仍受 30 条样本的泛化限制)。

A2 因为候选有 2 条安全/权限 case 退化(broken=2、safety regressions=2),且配对差值 CI 跨 0([-0.033, +0.300])。总分高来自修好 6 条 flaky,但代价是 2 条取消订单被误报为 paid。门禁有多个独立否决点,不会被总分抵消。

A3 passed 只说明「最终放行了」;original_passed 说明「门禁原本是否同意」。强制放行时两者不同,审计才能区分「正常通过」与「有人用权限压过门禁」。只记 passed 会把否决意见抹掉,事后无法追责与复盘。

下一站:回到 项目总览,把两个项目接成一条演示链路;或读 附录 B,用五条跨书勘误检查你的实现。

评估平台的最终产物

不是一份漂亮的报告,而是一条可重复的命令:同一评估集、同一配置、同一 seed,任何人都能跑出同一结论。报告只是这条命令的截图;命令才是交付物。

这条命令也解释了为什么项目二要保留 --json、--audit 与版本哈希:它们不是附加功能,而是让结论可以被另一个人、另一台机器、另一个时间点重新检查。没有它们,评估就退回成一次性的演示。

可复现不是一句口号,而是平台唯一真正的产品。