评测与回归门
问数系统的改动有个共同难题:你怎么知道这次改动没让系统变差? 凭感觉不行,跑一次端到端评测很贵。这一页讲 Trove 用的第三条路: 纯文件的回归门——零 LLM、零网络。
三层结构
| 层 | 件 | 成本 |
|---|---|---|
| 录制与回放 | trove/eval/replay.py | 零 |
| 指标与对比 | trove/eval/gate.py | 零(纯函数) |
| 端到端评测 | scripts/eval_bird.py | 花钱 |
flowchart LR
RUN["真实评测跑一次
scripts/eval_bird.py
连真库 + 真模型(花钱)"]:::exec --> RES["results.jsonl
逐题判定 + run_id"]:::entry
RES --> RP["离线回放
trove/eval/replay.py
零模型调用"]:::exec
RP --> REP["replay.jsonl
结构化判定 + gold 匹配"]:::entry
RES --> GATE["回归门
trove/eval/gate.py
零 LLM,纯函数"]:::gate
REP --> GATE
SC["检索 scorecard
tune_rrf / eval_*_retrieval"]:::entry --> GATE
VR["扩展面报告
trove validate --run / --impact
零 LLM"]:::entry --> GATE
GATE --> VERDICT["按方向 + 容差判定
有回归 → 退出码 1 拦下"]:::gate
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
设计意图是让绝大多数改动不需要走到第三层。 第一、二层是把「已经跑过的结果」变成可反复对账的数据。
录制与回放
跑一次真实评测时,每一轮的轨迹被录下来;之后就可以离线回放打分, 不调用任何模型、不连任何网络。
replay.py:321 score_replay replay.py:39 sql_exact_match
sql_exact_match 用的是 SQLGlot 结构级比较,不是字符串比较——
大小写、空白、别名写法的差异不该算作不同。同理,判定口径(「可判定的 verdict」
集合与完成 / 触发恢复 / 恢复成功 / 自洽 / 一次通过 / 零交付这一族谓词)是
唯一定义,被门与回放共用,避免两处口径分叉。
这不是洁癖:同一份冻结文件、两个引擎各写一遍口径时,实测出过
completion 0.9375 vs 0.9062、recovery 1.0 vs 0.1765——
门红得没有原因,排查方向也会被引到完全错误的地方。所以谓词族只有
replay.py 一份,gate.py 直接 import;
两引擎逐项相等由 tests/eval/test_gate.py 的对照测试钉住。
回放产物落在 .trove/eval/replay.jsonl。
指标口径
| 指标 | 怎么算 |
|---|---|
| EX | 可判定条目里 MATCH 的占比 |
| 分档 EX | ex_by_path:compiled / partial / llm / refused——当且仅当全部条目都带 path 才出现(半份 path 的分档是把缺数据当 llm 档);档位清单只有 replay.EX_PATH_TIERS 一份定义 |
| 编译命中率 | 带 compile_meta 的条目里 outcome 为 compiled 的占比 |
| 完成率 | 交付了答案(有 SQL 且判定非硬失败)的占比——答错也算交付,那是 EX 的事 |
| 自洽率 | 完成条目里达成共识(非平局、候选池非空)的占比;量的是过程自洽,不是对错 |
| 一次通过率 | MATCH 且从未触发恢复的占比(÷ 可判题;无可判题不发键) |
| 零交付率 | 无 SQL 且未尝试恢复的占比(更低更好)——这类题原先在恢复率的分子分母里都不可见 |
| 恢复率 | 触发过恢复的条目里以非错非报错收场的占比——触发 ≠ 成功 |
| gold 精确匹配 | 仅回放条目(有 gold_sql 的) |
| 共识率 / 置信度 | 完成条目里多候选投票达成共识的占比,与平均置信度 |
| 平均重试 | 每题平均重试次数(更低更好) |
| token 成本 | avg_tokens 与 total_tokens——只有带 token 记账的条目进分母 |
| 缓存命中率 | cache_hit_rate——命中缓存的 prompt token ÷ prompt token,只统计测量过的条目(0 命中 ≠ 未测量:报 0 进分母,无 cache 键才排除);全未测量不发键,单侧未测量进「无基线不可比」而不是当 0(更高更好,默认相对容差 20%) |
| 墙钟 | avg_elapsed_ms / total_elapsed_ms——有条目记过耗时才出现(更低更好) |
| 检索指标 | mrr / recall@k / ndcg@k / zero_recall——只在 scorecard 产物里出现 |
条目数 n 与可判定题数 n_judged 不参与判定:
它们是分母,不是成绩。只出现在一侧的指标进「无基线不可比」清单,同样不拦。
gate.py:169 metrics_from_entries gate.py:254 consensus_rate gate.py:69 n_judged gate.py:414 compare_metrics gate.py:539 render_report
EX 涨了是好事,平均 token 涨了是坏事。所以指标被分成 HIGHER_BETTER 与 LOWER_BETTER 两个集合——没有这个声明,回归判定根本无法自动化。
这是「门」能成立的前提:「变差」必须是一个可计算的方向,而不是评审时的印象。
gate.py:52 HIGHER_BETTER gate.py:78 DEFAULT_TOLERANCE
容差:绝对量或相对量
指标在小样本上本来就会抖,所以对比带容差。容差可以写成绝对量(0.02)
或相对量(后缀 -r,如 0.10-r 表示允许 10% 的相对波动)。
token 成本这类量级跨度大的指标适合相对量——「涨了 100 个 token」在 1000 的基数和 10000 的基数下意义完全不同。
分母漂移会告警,但不会拦
gate.py:481 DENOMINATOR_DRIFT_WARN
如果两次运行的样本量差得太多(默认 20%),门会警告但不判回归。 这个区分很重要:样本量变化会让所有指标失去可比性,但把它当成回归拦住提交会误伤 (比如你只是加了几道题)。告警把判断权交回给人。
门消费哪四类产物
| 产物 | 来源 | 内容 |
|---|---|---|
| results.jsonl | eval_bird.py |
逐题判定条目(含 run_id,可反查轨迹) |
| replay.jsonl | offline_eval.py replay |
回放记录 |
| scorecard json | tune_rrf.py / eval_hybrid_retrieval.py / eval_bird_retrieval.py / offline_eval.py replay --scorecard |
检索与 RRF 指标;回放那条给的是评分指标(CI 用它重算冻结基线) |
| validate 报告 json | trove validate --run --json / trove validate --impact <包|目录> --json |
扩展资产的装前试跑与影响面回放指标(顶层 metrics,走 scorecard 消费面)——技能、判定规则、预设装上之前,也过同一道门 |
gate.py:591 load_entries eval_bird.py:118 RESULTS_PATH offline_eval.py:44 --scorecard cli/commands/validate_cmds.py:138 顶层 metrics 接线 services/extensions/dryrun.py:857 metrics 段
注意 scorecard 不是门的参数,而是上游脚本的输出旗标;
门统一通过 --baseline / --current 消费它们。
这意味着改检索参数也会被门拦住,不需要跑端到端评测。
冻结基线:门要有锚点才拦得住
回归门比的是「两个时刻」,所以仓库里钉着一份可复现的基线
(eval/baseline/),三个文件各占一环:
| 文件 | 是什么 |
|---|---|
| questions.jsonl | 固定题集,每行带 qid / evidence / gold_sql |
| results.jsonl | 这批题的历史判定条目——默认的基线锚点(conf/agent.yml 的 eval.baseline_path 就指向它,所以 --baseline 不填也有锚点) |
| scorecard.json | 从 results.jsonl 算出并钉住的指标 |
eval_baseline.py check --require-full 校验这份基线还成不成立:
题集字段完整、qid 可对账、指标算得出来、没有冗余与重复判定。缺题在默认档只是告警,
--require-full 把它升级成硬问题——CI 用严格档,因为「少了几题的基线」
会让所有比例悄悄换了分母。
校验还会打印字段覆盖(tokens / elapsed_ms /
compile_meta / path / plan / matched_tables / kb_rev)。
缺字段不会让任何指标报错,只会让它算在半份样本上——
tokens 缺则成本指标只剩老条目,path 缺则分档 EX 把缺数据当 llm 档。
--require-fields tokens,elapsed_ms 把任一字段覆盖不足钉成硬问题;
冻结基线里的历史条目补不齐这些字段,所以今天默认只报告,补录之后加一行参数切严格档。
评分口径变了(改名、改定义)就要重钉快照:
uv run python scripts/offline_eval.py replay \
--input eval/baseline/results.jsonl \
--scorecard eval/baseline/scorecard.json
快照由 replay.scorecard_metrics 统一生成(CLI / CI / 测试共用同一份映射),
tests/eval/test_scorecard_pin.py 保证钉住的数字永远能从冻结产物复算出来。
改名若不同步重钉,是这个测试先红——而不是让门把两侧都算成「无基线不可比」后静默变绿
(compare_metrics 对 unpaired 只报告、不拦截)。
eval_baseline.py:39 --require-full baseline.py:166 check_integrity conf/agent.yml:235 baseline_path
怎么用
uv run python scripts/eval_gate.py --baseline <旧结果> --current <新结果>
uv run python scripts/eval_gate.py ... --tol ex=0.02 # 覆盖单项容差
uv run python scripts/eval_gate.py ... --ignore avg_tokens # 跳过某项指标
uv run python scripts/eval_gate.py ... --min-n 20 # 样本不足直接判为不可比
uv run python scripts/eval_gate.py ... --json # 机器可读,供 CI 解析
uv run python scripts/eval_gate.py ... --ci # CI 模式
| 退出码 | 含义 |
|---|---|
| 0 | 全部指标在容差内(或无回归) |
| 1 | 存在回归——把改动拦下 |
| 2 | 用法/输入错误、样本不足,或 --ci 下门未开启 |
eval_gate.py:53 --tol eval_gate.py:55 --ignore eval_gate.py:57 --min-n eval_gate.py:60 --json eval_gate.py:50 --ci
缺省参数从 conf/agent.yml 的 eval 段读,
所以本地不用每次敲一长串。挂到 CI 或提交前钩子上即可自动拦截回归。
--ci 是给自动化用的那一档:eval.gate_enabled 为
false 时它拒绝执行(退出码 2)——一个建好了却没开的门,
在流水线里会静默变绿,这比没有门更糟。
CI 里跑什么
.github/workflows/eval-gate.yml 每个 PR 都跑,五步全部零 LLM、零网络:
| 步 | 命令 | 挡什么 |
|---|---|---|
| KB 禁背题 | check_kb_anti_cheat.py | KB 可检索示例里出现 gold SQL 的拷贝(模板命中即违规,人工条目命中只告警) |
| 理解层切片 | eval_understanding.py | 「没查成」伪装成「查过了」:条目为空 / gold 全不可解析(退出码 1)、KB 缺 semantics.yml(退出码 2)。缺口与分叉不拦——它们是报告 |
| 基线完整性 | eval_baseline.py check --require-full | 基线缺题、qid 对不上、指标算不出来 |
| 零 LLM 回放 | offline_eval.py replay --input eval/baseline/results.jsonl | 用当前评分代码重算那批冻结产物 |
| 门判定 | eval_gate.py --ci --json | 钉住的 scorecard vs 现算的:评分口径漂了、基线被误改了,就红 |
最后一步成立的关键是两边来自不同时刻:一边是仓库里钉住的
eval/baseline/scorecard.json,一边是用当前评分代码从冻结结果重算出来的。
同一个文件自己跟自己比恒等 Δ=0,那是在冒充回归门。
第 1 步另外打印字段覆盖,但今天不拦:冻结基线补不齐历史条目。
--require-fields 是补录后的一行切换,不需要动代码。
但要说清楚这些步挡的不是「模型变差了」:准确率回归要真库加模型,
仍然只能手动跑 eval_bird.py。这道门挡的是「锚点坏了」——基线缺题、
评分口径漂移、冻结产物被改。理解层切片报的是「卡在哪一层」,缺口按定义是产物,
不拦。把这里的绿读成「准确率没问题」是误读。
禁背题与理解层切片两步不设开关:它们零成本,问的分别是「KB 还诚实吗」与
「理解链路卡在哪一层」,与「模型变差了吗」是两回事;
其余三步只在 eval.gate_enabled(conf/agent.yml)为
true 时跑,置 false 会打印一行 skip notice,
而不是无声跳过。
eval-gate.yml:74 check_kb_anti_cheat eval-gate.yml:108 --require-full eval-gate.yml:124 --ci eval_gate.py:50 --ci
理解层切片:准确率之外的四个面
准确率是终点数字:答对了几题,不说卡在哪一层。
scripts/eval_understanding.py 把理解链路拆成四个可独立测量的面,
零 LLM、零网络、零数据库,只读仓库里的冻结产物与
.trove/kb/<db>/semantics.yml——KB 缺语义模型直接退出码 2,
绝不给一份静默的空报告(空的 100% 看起来与「覆盖为零」一模一样)。
| 切片 | 回答什么 | 冻结基线上的读数 |
|---|---|---|
| A 语义表达上限 | gold SQL 的表/连接/列/过滤值,在语义模型里有没有落点。缺口清单 = 重建语义模型的验收表 | 350/350 项 (100%) 未声明值 17 · unknown 11 |
| B 分叉归因 | MISMATCH 的首个结构分叉组件:tables > projections > conds > groups,余下归 ordering_or_other | 13/13 可归因 tables 8 · projections 3 · conds 2 |
| C 链路归因 | schema_linking → query_sketch → compiler → gen,哪一环先偏 | 未录制(条目缺 plan / matched_tables;补录后自动生效) |
| D 一次通过率 | first_pass 与按 path 分档 EX,口径与 scorecard 同源(replay.score_replay,不另算一份) |
first_pass 0.4062 (13/32) path 覆盖 19/32 → 分档不发键 |
保守性是它唯一的正确性主张。
解析失败、限定符无法归属(派生表别名、未知前缀)一律记 unknown:
不计入分母、不进缺口清单、也不静默丢掉——静默少算一项,分母就与
「模型覆盖多少」脱钩,报告会显得比实际干净。未声明值域的过滤值只计数、不算缺口:
模型没声明值域,不等于它表达不了。缺口清单要当验收表用,虚报会让人去修不存在的问题。
归因同样与写法无关:限定符先归一(别名 → 物理表;CTE/派生表限定
→ 只比列名),tables 用谱系摘要的实际读表而不是签名里的表名——实测 0472 的 CTE
包装曾被误报成「丢 client、多 selected_client」,物理上两张表都读了。签名等价但
结果不同时报 none(分叉在结构之外),不硬塞进某个组件。
C 切片补录后才有效:没有 plan / matched_tables 就报「未录制」,
不冒充「没有偏离」。
退出码与 check_drift.py 同约定——「没查成」不许混进 0:
| 退出码 | 含义 |
|---|---|
| 0 | 报告已产出。缺口与分叉是产物,不是失败——拦截由门判定那步的 first_pass 承担 |
| 1 | 度量退化:条目为空、或 gold SQL 全部不可解析(那会产出一份空洞的 100%/0 结构) |
| 2 | 没法跑:输入文件缺失、问题集为空、KB 缺 semantics.yml |
uv run python scripts/eval_understanding.py # 人读报告(~10 行)
uv run python scripts/eval_understanding.py --json # 完整报告(逐题明细,供机器读)
eval_understanding.py:232 check_expressibility eval_understanding.py:435 _first_divergence eval_understanding.py:553 attribute_chain eval_understanding.py:643 first_pass_slice eval_understanding.py:754 main
检索侧的评测脚本
| 脚本 | 测什么 |
|---|---|
| eval_retrieval.py | KB 检索召回率 + 子结构覆盖 + 预算挤占 |
| eval_hybrid_retrieval.py | Recall@k / MRR / nDCG@k,精排前后对比 |
| eval_bird_retrieval.py | BIRD 表级 schema 召回 + 分路消融 |
| tune_rrf.py | RRF 权重/k 网格调优 |
这四个都是零 LLM 成本的,可以随便跑。参数详见 混合检索 · 评测。
端到端评测:花钱的那个
scripts/eval_bird.py 走完整反思管线、连真库、调真模型——它是计费的。仓库里对此有明确约定:没有明确指令不要启动评测运行。
日常改动应当先过回归门;只有在需要重新建立 baseline 或验证一个大改动时才跑端到端。
它的产物除了结果文件,还有一份 failures.jsonl 供
distill_lessons.py 蒸馏成教训草案(默认 pending,需人确认)。
逐题条目里带 run_id,可以据此找到那一轮的完整轨迹日志。
eval_bird.py:117 FAILURES_PATH offline_eval.py:27 DEFAULT_OUTPUT