首页/运维/评测与回归门

评测与回归门

问数系统的改动有个共同难题:你怎么知道这次改动没让系统变差? 凭感觉不行,跑一次端到端评测很贵。这一页讲 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;
图 1 · 只有最左边那一步花钱。录下来之后,回放与回归门都是零成本的纯文件操作——绝大多数改动不必再连模型。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

设计意图是让绝大多数改动不需要走到第三层。 第一、二层是把「已经跑过的结果」变成可反复对账的数据。

录制与回放

跑一次真实评测时,每一轮的轨迹被录下来;之后就可以离线回放打分, 不调用任何模型、不连任何网络。

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 的对照测试钉住。

replay.py:52 JUDGED_VERDICTS

回放产物落在 .trove/eval/replay.jsonl。

指标口径

指标怎么算
EX可判定条目里 MATCH 的占比
分档 EXex_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.pyKB 检索召回率 + 子结构覆盖 + 预算挤占
eval_hybrid_retrieval.pyRecall@k / MRR / nDCG@k,精排前后对比
eval_bird_retrieval.pyBIRD 表级 schema 召回 + 分路消融
tune_rrf.pyRRF 权重/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

继续读

想了解去哪
检索指标的具体含义混合检索 · 评测
失败轨迹怎么回放可观测性
另一类离线门(漂移)数据漂移治理
eval 段的配置项配置参考
本页源码锚点的基准提交:anchor-base: 7c6df87 GitHub 仓库 文档首页