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,都是为了让门禁的那一个决定可解释、可回滚。如果门禁读的是一个没有区间的平均分,它就不是门禁,是投票。
14.1 需求与平台边界
14.1.1 只回答一个问题
项目二只回答一个问题:
给定同一个评估集,候选配置相对 baseline 是更好、持平,还是更差?
「更好」不是一个分数,而是一组带条件的判断:
| 维度 | 证据 | 门禁条件 |
|---|---|---|
| 质量 | 配对差值 + 95% CI | CI 下界 ≥ 阈值(例如 +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;但统计口径与门禁逻辑不变。
一个评估平台是一个四元组 :
- 是版本化工具注册表;
- 是版本化评估集,,每条含输入、期望、难度与标签;
- 是 runner,把配置 在 上跑 次,产出记录;
- 是门禁,读 baseline 与 candidate 的记录,输出 pass/fail 与理由。
平台的可信度取决于四者的可复现性:同一 、同一 、同一 seed,结果必须一致。
14.1.3 三个反模式
评估平台最容易变成三种东西,而不是决策工具:
| 反模式 | 表现 | 后果 |
|---|---|---|
| 排行榜 | 只报一个总分,不报配置与数据版本 | 无法复现,也无法解释差异 |
| 许愿池 | 评估集只放“希望模型会”的题 | 分数很高,线上失败没人负责 |
| 橡皮图章 | 门禁永远通过,失败靠人肉发现 | 回归累积,最后一次性爆炸 |
对应的三条设计约束:
- 每个分数都带坐标:数据集版本、工具签名、配置、采样次数、seed。没有坐标的分数不进入报告。
- 每个失败都能回到样本:失败记录带
case_id与最小复现输入;不能只说“某类题不行”。 - 门禁有否决权:至少一个条件能单独阻止发布;否则它只是建议。
这张表说明什么:平台的价值不在“能跑评估”,而在“能让一个不同意的人用证据说服另一个同意的人”。排行榜、许愿池、橡皮图章都做不到这一点。
14.1.4 平台的成功标准
一个评估平台做得好不好,不看它有多少指标,而看三件事:
| 标准 | 可检验的问题 | 反例 |
|---|---|---|
| 可复现 | 换一个人、换一台机器,能否得到同一结论? | 结果依赖本机缓存或未记录的模型版本 |
| 可解释 | 每个 pass/fail 能否指到证据? | 只报一个分数,失败样本找不到 |
| 可行动 | 结论能否直接决定发布/回滚? | 报告很长,但没有一个否决点 |
三条都满足,平台才在「决策链」里;缺一条,它就只是数据展示。项目二用「同数据、同配置、同 seed」保证可复现,用「失败聚类 + 报告字段」保证可解释,用「五个否决点」保证可行动。
14.2 工具注册表与 schema
工具注册表是一个映射 ,并满足:同名工具必须带版本;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,攻击者可以通过错误信息探测「这个工具接受哪些字段」,即使他没有权限。先拒绝无权调用者,能减少信息泄露面。
- 「schema 校验是模型的事」——模型可能被注入,参数可能来自不可信数据;宿主必须再校验一次。
- 「版本只是标签」——版本要参与签名、评估集匹配与门禁比较;同名不同版混用,会让「回归」无法归因。
- 「scope 是文档」——scope 是执行前的硬检查;没有它,工具描述里的「只读」只是建议。
14.2.3 版本回归:同名 v2 怎么被抓住
工具升级经常只改内部实现,不改名字。评估平台如果只按 name 找工具,v2 会静默替换 v1,历史报告就失去可比性。项目二的做法是:
ToolRegistry的 key 是name@version;signature()把 schema 与 version 一起哈希;- runner 明确记录
tool_version; - 评估集里保留能区分版本的 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)
三个设计点:
- 权限/参数错误立即返回,不重试。 重试不会改变
EPERM或EINVAL,只会消耗预算。 - 每次尝试都计成本。 重试不是免费的;报告里的
cost_units必须包含失败尝试。 - 超时是模型而非真实计时。 本项目用
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
三个约束:
- 配额是全局的:并发 worker 共享工具配额;扣减要原子(锁或令牌桶),否则会超发。
- 结果按 case id 归位:不要依赖完成顺序;报告与门禁都按
case_id对齐。 - 失败要隔离:一个 case 抛异常不能取消其他 case;收集异常并记录
error_code。
项目二为了可复现,默认串行;并发是扩展项。先保证「同输入同输出」,再谈吞吐。
14.4 评估集设计与版本
14.4.1 30 条样本的构成
| 类别 | 数量 | 难度 | 标签 | 目的 |
|---|---|---|---|---|
| 加法 | 12 | easy/medium | math | 基础正确性 |
| 订单查询 | 6 | easy/hard | order、permission | 权限与状态边界 |
| 退款 | 6 | medium/hard | refund、money | 金额上限与不可逆动作 |
| 瞬时搜索 | 6 | medium | flaky、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]
三条规则:
- 排序后再哈希:否则文件行序变化会产生新版本,虽然内容一样。
- 包含所有影响结果的字段:输入、期望、难度、标签;不含注释与展示文本。
- 版本进报告:
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%)。质量提升与成本上升同时出现,门禁要一起看。
- 评估集版本是否固定?报告里有没有 hash?
- 工具注册表签名是否记录?同名工具是否换了版本?
- 采样次数是否一致?baseline 与 candidate 是否用同一批 case?
- 失败样本是否保存?能否用同一命令复现?
- 成本/延迟口径是否包含失败尝试与重试?
五条缺一条,比较就不可信;先补证据,再谈结论。
14.5.3 pass@k 与多次采样
pass@k 回答的是「采样 k 次,至少一次成功」的概率;它与「跑 k 次取平均」不是一回事。
其中 是采样总数, 是成功数。 时就是成功率; 时,它奖励“多样性”——只要有一次对,就算过。
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、配对与分层
给定同一评估集上的 baseline 分数 与候选分数 ,配对差值为 。配对 bootstrap 对 有放回重采样 次,得到 的经验分布;取 2.5% 与 97.5% 分位作为 95% 区间。配对消掉了 case 难度带来的方差,因此比两个独立区间更敏感。
14.6.1 Wilson 区间:小样本的比例
30 条 case 的准确率是一个比例。正态近似在 接近 0 或 1、 较小时会给出越界区间(小于 0 或大于 1);Wilson 区间更稳:
其中 , 对应 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 上的配对比较,应该看配对差值,而不是看两个独立区间是否重叠。
- 「两个 CI 重叠就不显著」——独立样本的粗略经验,不适用于配对设计;配对要看差值分布。
- 「CI 是参数落在区间内的概率」——频率派 CI 的含义是「重复采样时,95% 的区间会覆盖真值」,不是对当前区间的概率陈述。
- 「n 越大越好」——样本量要跟效应量匹配;为了检测 +0.01 的差异跑一万条,成本可能远高于收益。
14.6.2 配对 bootstrap:同一批 case 上比较
配对设计的关键是:baseline 与 candidate 跑的是同一批 case。差值 消掉了 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 分层:平均值掩盖的两种东西
按难度、标签分层后,常见两种「平均提升但局部退化」:
| 现象 | 平均值 | 分层证据 | 结论 |
|---|---|---|---|
| 修好简单题,弄坏难题 | +5pp | easy +15pp,hard −10pp | 不可发布;难题退化更危险 |
| 修好 flaky,拖慢退款 | +20pp | flaky +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 样本量:先问能检测多大的差异
给定基线比例 、希望检测的最小差值 、显著性 与功效 ,两个比例的样本量近似:
在 、、、功效 0.8 时, 约 500。项目二只有 30 条,所以它只能检测较大的差异;报告必须写清这一点,而不是把 +2pp 当成结论。
- 固定评估集版本与工具签名;
- 用同一批 case、同一采样次数跑 baseline 与 candidate;
- 先看 broken/fixed 的逐 case 差异,再看总分;
- 计算配对差值 CI,不只看点估计;
- 按标签/难度分层,找被平均值掩盖的退化;
- 把成本与 P95 放进同一张表,做 Pareto 判断;
- 结论写成「在什么条件下、对哪类 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 的审计日志,等于把否决意见抹掉。
- 「门禁就是跑测试」——测试通过只说明没坏已知用例;门禁还要比较 baseline、看区间、看成本。
- 「强制放行不留痕也没关系」——留痕不是形式;它是事后复盘与责任界定的唯一证据。
- 「阈值越低越安全」——过低会阻止所有改进,团队会绕过门禁;阈值要跟风险与样本量匹配。
- 「门禁只看质量」——成本与尾部延迟也是发布条件;质量提升 2pp、成本翻倍,通常不该发。
14.7.4 灰度与回滚
门禁通过不等于全量发布。灰度的最小流程:
candidate 通过门禁
→ 5% 流量(同一评估集 + 在线指标)
→ 观察 24h:错误率、P95、成本、人工投诉
→ 50% → 100%
→ 任一指标越界:回滚到 baseline,保留 trace 与报告
灰度期间要持续跑同一评估集的小样本回归,而不是只看在线指标。在线指标有滞后与选择偏差;评估集是稳定的对照。
回滚要能回到「版本组合」:代码 SHA + 工具注册表签名 + 评估集版本 + 模型版本。只回滚代码,工具或模型没回,问题可能还在。
门禁在发布前替你踩一次刹车;灰度在发布中允许你点刹。两者不能互相替代:没有门禁,灰度会把明显坏的版本放出去;没有灰度,门禁通过后的未知风险没有缓冲。项目二实现门禁;灰度是部署层的事,但它的判据应该复用同一套指标。
14.7.5 回滚演练:门禁之外的最后一道闸
门禁通过不代表回滚可用。每季度做一次「无预演回滚」:
- 从生产版本回退到上一个版本组合(代码 + 工具 + 模型 + 评估集);
- 用同一评估集跑一次,确认分数回到 baseline;
- 检查 trace 是否仍能关联两个版本;
- 记录耗时、失败点与人工步骤。
演练的目标不是「证明能回滚」,而是发现回滚依赖了哪些没写下来的手工步骤。常见问题:数据库迁移不可逆、工具版本没回、评估集版本没打标签、trace 的 git_sha 没记。任何一项都会让「回滚」变成「重新部署一个不确定的旧版本」。
14.8 仪表盘与报告
14.8.1 报告的最小字段
一份评估报告,至少要回答六个问题:
| 问题 | 字段 | 例子 |
|---|---|---|
| 用的是什么数据? | suite.name + suite.version | tool-calling-v1@82d2ff796fb8 |
| 用的是什么配置? | config(strict/retries/tool_version) | retry: retries=1 |
| 质量如何? | pass_rate + Wilson CI | 100.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 工具开启重试。
- 先看数据集版本与大小:30 条以下的结论要谨慎;
- 看 baseline 与 candidate 的配置差异:只改了一个变量吗?
- 看配对差值 CI:下界是否超过阈值?是否跨 0?
- 看 fixed/broken:有没有「修 6 个、坏 2 个」;
- 看失败聚类:失败是否集中在某个工具/标签;
- 看成本与 P95:质量提升是否以不可接受的代价换来;
- 看门禁理由:如果 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 |
| runner | N 次采样,记录 attempts/cost/latency | report.py |
| 统计 | Wilson + 配对 bootstrap | test_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"]}],
}
接的时候有三件事必须对齐:
- 错误码:项目一的
EPERM/EPRECOND/ETRANSIENT要映射到平台的门禁标签,否则失败聚类不成立。 - 成本口径:项目一记录工具次数与耗时;平台要把它换算成同一套
cost_units,否则成本比没有意义。 - trace 字段:平台报告要能引用项目一的
run_id/step,否则失败样本无法回放。
这三件事对齐后,项目二就能直接评估项目一的 agent;评估集里可以混入代码任务与工具任务,按 tags 分层。
14.9.3 扩展路线
按收益/成本排序:
- 真实工具适配器:把 mock 换成项目一的工具或外部 API;保留 schema/scope/cost 字段。
- 并发执行:按工具分组、限 QPS;注意 case 与记录的对应关系。
- 更多统计:分层 bootstrap、贝叶斯区间、序贯检验(早停);先保证口径一致。
- LLM-as-judge:为开放式答案加评审;必须校准、双盲、报告一致性(第 9.7 节)。
- CI 集成:门禁接 GitHub Actions;失败时上传报告与失败样本。
- 在线灰度:把评估集的指标与在线指标对齐,做 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 页面上;长了就没人看。
- 「评估集越大越好」——关键是覆盖失败模式;30 条有代表性的样本,比 3000 条重复样本更能发现回归。
- 「通过率提升就能发」——还要看区间、broken、成本、延迟与安全回归。
- 「强制放行是例外」——如果经常 force,说明门禁阈值或评估集与业务脱节;要修门禁,不是习惯性绕过。
- 「报告是给人看的」——报告首先是给门禁与审计读的;字段不稳定,自动化就无法接。
- 「统计太复杂,不如看均值」——均值会把「修 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 RMF | Measure / Manage | 14.7 门禁与审计 | 背景 |
| OTel GenAI semconv 1.44.0 | 指标与 span 属性 | 14.8 报告字段 | 背景 |
本章陷阱
| # | 陷阱 | 症状 | 正确做法 | 去哪 |
|---|---|---|---|---|
| 1 | 用文件名当评估集版本 | 内容改了,历史报告不可比 | 内容哈希,排序后规范化 | 14.4 |
| 2 | baseline 与候选跑不同 case | 差值不可解释 | 同一批 case 配对 | 14.6 |
| 3 | 只报点估计 | 把噪声当提升 | 报 Wilson/配对 CI | 14.6 |
| 4 | 两个独立 CI 重叠就判不显著 | 配对设计被当成独立样本 | 看配对差值 CI | 14.6 |
| 5 | 只看总分 | 修 6 坏 2 被平均掉 | 看 broken 与分层 | 14.7 |
| 6 | 忽略成本与 P95 | 质量升 2pp、成本翻倍 | 门禁加成本/延迟否决点 | 14.7 |
| 7 | force 不留痕 | 事后无法审计 | original_passed + 理由 | 14.7 |
| 8 | 多次采样当独立样本 | CI 算窄,过度自信 | 按 case 聚合,记录 sample | 14.5 |
| 9 | 失败样本不落盘 | 下次无法回归 | failures.jsonl + 最小复现 | 14.4 |
| 10 | 报告字段不稳定 | 自动化接不上 | 固定 JSON schema | 14.8 |
| 11 | 评估集与业务脱节 | 分数高但线上差 | 定期回流真实失败 | 14.9 |
| 12 | 门禁阈值拍脑袋 | 要么全拦要么全放 | 按风险、样本量、成本定 | 14.7 |
本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 能定义评估平台 | 四元组 + 可复现性 | 14.1 |
| 能写工具注册表 | 版本/scope/schema/cost | 14.2 |
| 能实现重试 | 只对瞬时错误,成本含重试 | 14.3 |
| 能版本化评估集 | 内容哈希,改一字节即变 | 14.4 |
| 能算配对 CI | 30 条样本给出差值区间 | 14.6 |
| 能读门禁 | 说出五个否决点 | 14.7 |
| 能拦截回归 | v2 候选 FAIL | 14.7 |
| 能写报告 | 六个问题一屏回答 | 14.8 |
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 会把否决意见抹掉,事后无法追责与复盘。
不是一份漂亮的报告,而是一条可重复的命令:同一评估集、同一配置、同一 seed,任何人都能跑出同一结论。报告只是这条命令的截图;命令才是交付物。
这条命令也解释了为什么项目二要保留 --json、--audit 与版本哈希:它们不是附加功能,而是让结论可以被另一个人、另一台机器、另一个时间点重新检查。没有它们,评估就退回成一次性的演示。
可复现不是一句口号,而是平台唯一真正的产品。