代码型检查 evals — 评测金字塔的确定性底座
代码型检查 evals — 评测金字塔的确定性底座
日期: 2026-06-29 阶段: Phase 1 - 产品定义×评测×可观测底座 标签: #evals #code-based-checks #ci-gate #determinism
核心问题
Day 3 把 PRD 的成功指标拆成三类 evals:代码型检查、LLM-as-judge、人工抽检。今天回答最底层、最被低估的一类——代码型检查(code-based checks / deterministic evals)。
被低估,是因为它「不性感」:没有大模型、没有语义理解,只是一堆 assert。但 2026 年的工程共识已经反过来:把 LLM-as-judge 放在评测金字塔底座是反模式(anti-pattern),会把质量门的成本顶到付不起(FutureAGI 2026)。确定性检查能在任何 judge 触发前拦掉 30%–60% 的失败。
对 AML Copilot 这种合规高风险产品,代码型检查还有第二重意义:SAR 草稿里「引用了一笔不存在的交易」「金额口径用了浮点」「类型学标签拼错」——这些是不可商量的硬错误,绝不能交给一个有方差的 judge 去「大概率发现」。能确定性断言的,就必须确定性断言。
关键内容
A. 评测金字塔:三层的成本-信号特性
把 evals 想成一个金字塔,底宽顶尖,成本沿金字塔向上复利增长,覆盖率向上收窄:
▲ 人工抽检 (top)
╱ ╲ 语义+合规判断,$$$$,零自动化
╱ ╲ 只抽样,覆盖率 1%-5%
╱─────╲
╱ judge ╲ 语义维度,$$,有方差,需校准
╱ (mid) ╲ 覆盖率 100% 但每条都烧 token
╱───────────╲
╱ 代码型检查 ╲ 结构/格式/引用/数值,$≈0,零方差
╱ (base) ╲ 覆盖率 100%,每条 <1ms
╱─────────────────╲
为什么底座必须是代码型检查?三个理由:
- 成本:一次 judge 调用是一次 LLM 推理(数百到数千 token);一次代码断言是几微秒。在 CI 里每个 PR 跑全量 golden(本项目 66 案),judge 全量跑要烧钱+耗时,代码型检查近乎免费。
- 零方差:
citedTxId ∈ caseTxIds这种断言,跑一万次结果完全一样;judge 同一输入两次评分可能不同(temperature、模型版本漂移)。CI gate 要「红就是红」,不能时红时绿。 - 失败先验:很多失败是结构性的——缺字段、引用悬空、格式错。这些用代码 100% 抓得到,没必要让 judge「读懂」才发现。Braintrust 的口径:「确定性检查处理一切能直接测量的——格式、schema 合规、必填字段,因为这些检查便宜且可预测」(Braintrust 2026)。
反直觉洞察①(金字塔倒置陷阱):直觉会想「LLM 产品当然该用 LLM 来评」,于是把 judge 当主力、代码型检查当补充。这是把金字塔倒过来——judge 在底座意味着每条 golden 都烧一次推理,质量门贵到团队舍不得跑全量,最后 gate 形同虚设。正确顺序是:代码型检查先 fail-fast,judge 只评代码管不了的语义维度。 代码能抓的失败,让 judge 去抓是双重浪费(钱 + 方差)。
B. 对 SAR / 类型学输出的确定性断言设计
AML Copilot 的输出有两类强结构对象:TypologyAssessment(类型学评估)和 SarDraft(SAR 草稿)。它们的契约写死在 src/aml/types.ts,这正是代码型检查的弹药库。把可断言项分四组:
| 检查组 | 断言内容 | 捕获的失败类 | 数据来源 |
|---|---|---|---|
| 结构完整 | sections 含 5W1H 全部小节;scores 三个类型学键齐全;topTypology ∈ {三类, null} | 漏段、枚举越界、字段缺失 | SarDraft.sections / TypologyAssessment.scores |
| 引用存在 | citedTxIds 每个 id 都能在 case.transactions 里找到 | 幻觉证据(引用不存在的交易)= 合规致命 | SarDraft.citedTxIds ⊆ caseTxIds |
| 数值口径 | 金额一律整数分(Number.isInteger);得分 ∈ [0,1];阈值=声明的 ASSESS_THRESHOLD | 浮点货币、分数越界、阈值口径漂移 | amountCents / RuleHit.score |
| 格式约束 | generatedBy 字段诚实标注来源;叙述里出现的每笔交易号都在 citedTxIds 里 | 来源标注缺失、正文引用与 citedTxIds 不一致 | SarDraft.generatedBy |
第三组「数值口径」是金融特有的硬纪律。types.ts 开头那段注释已经写明:金额一律整数分,禁止 float 货币运算(与 dsdb-lab ledger 同纪律)。代码型检查就是这条纪律的执法者——一个返回 9500.5 分的 bug,judge 未必会扣分(它看的是叙述质量),但 Number.isInteger 一行就拦死。
C. 断言套件伪代码
把上述检查组写成一个可回归的纯函数 runCodeChecks(case, assessment, sar) → CheckResult[]。每条 check 返回「检查项 id + pass/fail + 失败时的证据」,便于 CI 报告精确定位:
interface CheckResult {
id: string // 'CITE-EXIST' | 'AMT-INT' | ...
pass: boolean
failures: string[] // 失败时列出违规的具体对象(txId / 段落 / 数值)
}
function runCodeChecks(c: AmlCase, a: TypologyAssessment, sar: SarDraft): CheckResult[] {
const txIds = new Set(c.transactions.map(t => t.id))
const out: CheckResult[] = []
// 1) 引用存在性 — 幻觉证据零容忍
const dangling = sar.citedTxIds.filter(id => !txIds.has(id))
out.push({ id: 'CITE-EXIST', pass: dangling.length === 0, failures: dangling })
// 2) 5W1H 结构完整 — FinCEN 叙述必须有全部小节
const need = ['引言', '主体身份', '可疑活动', '可疑原因', '作案手法', '总结']
const missing = need.filter(k => !sar.sections.some(s => s.heading.includes(k)))
out.push({ id: 'SAR-5W1H', pass: missing.length === 0, failures: missing })
// 3) 金额整数分 — 禁止 float 货币
const floatAmts = c.transactions
.filter(t => !Number.isInteger(t.amountCents))
.map(t => `${t.id}=${t.amountCents}`)
out.push({ id: 'AMT-INT', pass: floatAmts.length === 0, failures: floatAmts })
// 4) 得分值域 — scores ∈ [0,1],阈值口径一致
const badScore = Object.entries(a.scores)
.filter(([, s]) => s < 0 || s > 1).map(([t, s]) => `${t}=${s}`)
out.push({ id: 'SCORE-RANGE', pass: badScore.length === 0, failures: badScore })
// 5) 正文-引用一致 — 叙述里提到的 Txxxx 必须在 citedTxIds
const cited = new Set(sar.citedTxIds)
const bodyRefs = sar.sections.flatMap(s => s.body.match(/T\d{4}/g) ?? [])
const orphan = bodyRefs.filter(r => !cited.has(r))
out.push({ id: 'BODY-CITE-SYNC', pass: orphan.length === 0, failures: orphan })
return out
}
注意 CITE-EXIST 和 BODY-CITE-SYNC 是两条独立断言:前者管「引用的 id 真实存在」,后者管「正文里手写的交易号没漏进 citedTxIds」。漏掉任一条,就会出现「SAR 正文说 T0042 可疑,但证据清单里没有 T0042」这种调查员复核时直接退回的硬伤。
D. 可回归性:进 CI 防退化
代码型检查的终极价值不在「单次跑通」,而在回归——每次改 sarDraft.ts / typology.ts 后,CI 自动重跑全量 golden(66 案),任一断言 fail 即阻断 merge。这把 Day 3 说的「阻断式 CI gate」落到最便宜的一层。
CI 流程(GitHub Actions,对标 Braintrust 的 eval-action 模式,2026):
PR push ──► npm run eval:checks
│ 对 66 案逐案跑 runCodeChecks
▼
汇总 CheckResult[]
│
┌─────┴─────┐
全 pass 任一 fail
│ │
✅ 放行 ❌ exit 1 + 打印失败的
检查项 id / 案件 id / 违规对象
→ 阻断 merge
关键设计:失败必须可定位。CI 不能只打印「3 个案件失败」,要打印 C017: CITE-EXIST fail, dangling=[T0099]——调查员/工程师一眼知道哪案、哪条规则、哪笔交易出错。这是把现有 src/aml/evalBaseline.ts(只输出聚合 recall/FPR)补上「逐条可归因」的缺口。
| 退化场景 | 没有代码型检查的后果 | 有 CI gate 的拦截点 |
|---|---|---|
| 改 sarDraft 引入引用 bug | 上线后调查员发现幻觉证据,信任崩塌 | CITE-EXIST fail,merge 被拦 |
| 误把金额改成浮点 | 审计时金额对不平,合规风险 | AMT-INT fail |
| 重构 typology 漏了一个段落 | SAR 缺 How,不可提交 | SAR-5W1H fail |
| 阈值常量被误改 | 召回率口径偷偷漂移 | SCORE-RANGE / 阈值断言 fail |
反直觉洞察②(满分的代码型检查 ≠ 好产品):代码型检查全绿,只证明「输出结构正确」,不证明「输出内容正确」。一份引用全部存在、5W1H 齐全、金额都是整数分的 SAR,叙述逻辑可能完全错误(把工资入账写成可疑结构化)。代码型检查是质量的必要非充分条件——它守住地板,天花板得靠 judge(Day 16)和人工(Day 18)。把「代码检查全过」当成「质量达标」是又一个口径陷阱。
设计要点/决策表
| 要点 | 决策 | 理由 |
|---|---|---|
| 哪些进代码型检查 | 凡能确定性断言的(结构/引用/数值/格式)一律下沉到代码 | 零方差、近零成本,judge 管不了也不该管 |
| 引用存在性优先级 | CITE-EXIST 设为最高优先级、零容忍 | 幻觉证据在合规场景=致命,必须代码兜死 |
| 失败粒度 | 每条 check 返回违规对象列表,非布尔 | CI 报告要可定位到 案件×规则×对象 |
| 与 evalBaseline 关系 | 新增 evalChecks.ts,与 evalBaseline.ts 并存 | baseline 测「判得对不对」,checks 测「输出合不合规」,正交 |
| 金额断言 | Number.isInteger(amountCents) 强制 | 执行 types.ts 已声明的整数分纪律 |
对本项目的落地
- 新建
src/aml/evalChecks.ts:实现 C 节runCodeChecks(case, assessment, sar),导出CheckResult类型与五条断言(CITE-EXIST/SAR-5W1H/AMT-INT/SCORE-RANGE/BODY-CITE-SYNC)。纯函数、无 IO,便于单测。 - 断言数据源:直接吃
src/aml/types.ts的AmlCase/TypologyAssessment/SarDraft,不新增数据契约;引用集来自case.transactions,5W1H 段落名来自sarDraft.ts已有的 6 个heading。 - 接入 CI:在
src/aml/__tests__/aml.test.ts旁加evalChecks.test.ts,对 66 案金标逐案跑runCodeChecks,任一 fail 即测试失败 → 阻断 merge。与现有evalBaseline(recall 1.0×3 / normal FPR 5.6%)的 CI 断言并列,构成「判得对(baseline)+ 输出合规(checks)」双闸。 - 报告格式:CI 失败时打印
案件id: 检查项id fail, failures=[...],补上evalBaseline.ts缺的逐条可归因。 - 诚实标注:
evalChecks.ts头注明确——本层只验结构/引用/数值/格式的确定性约束,不验语义质量(语义留给 Day 16 judge、合规可提交性留给 Day 18 人工)。
参考资料
- Braintrust — What is an LLM-as-a-judge? When to use it (and when to use deterministic evals):确定性检查处理「格式、schema、必填字段」,judge 只评「helpfulness/tone/grounding」等语言理解维度 (2026)
- FutureAGI — Deterministic LLM Evaluation Metrics 2026: The Eval Floor:确定性指标先抓 30%–60% 失败;judge 在金字塔底座是反模式 (2026)
- Braintrust — eval-action GitHub Action:PR 上跑 eval 套件,逐 scorer 报告 improvement/regression,阈值不达阻断 merge (2026)
- Kinde — CI/CD for Evals: Running Prompt & Agent Regression Tests in GitHub Actions (2026)
- 本仓库
src/aml/types.ts(金额整数分纪律)、src/aml/typology.ts(阈值常量)、src/aml/evalBaseline.ts(聚合 recall/FPR)(2026-06)
SOTA 检查 (2026-06-11)
- 「代码型检查是底座、judge 在上、人工在顶」的三层金字塔是 2026-06 的事实共识:Braintrust、FutureAGI、Confident AI 多家口径一致;本日 WebSearch 未见挑战该分层的方法论。
- eval 进 CI 阻断 merge 已是 table-stakes:Braintrust
eval-action、Patronus(2026-03 发布 agent 评测套件)均原生支持 PR 级 gate;本项目用普通 vitest 断言即可达到同效,无需引入外部 SaaS。 - 「确定性检查抓 30%-60% 失败」是经验数字非定律:随产品而异,AML 这类强结构输出占比可能更高(引用/数值/格式失败密集);引用时标注为「工程经验区间」。
- 过时认知警示:2024-2025 早期「全靠 LLM-judge 评一切」的做法已被淘汰——既贵又有方差;反向也要警惕「纯代码检查就够」,结构对≠内容对(反直觉洞察②)。
- 待跟踪:W3 真实跑 66 案后,统计代码型检查实际拦截的失败占比,回填本笔记的「30%-60%」是否吻合本项目分布。