给 AI 评测平台做体检:8 小时拆了 7 个连环坑
一次"benchmark 审计"意外演变成 DB 架构深度清理的实录
2026-04-09 到 2026-04-10 · 8 commits · ~2000 行净删除
TL;DR
我用的一个 AI 产品自带评测平台:51 个测试用例、14 张数据库表、12 个 Dashboard 页面、飞轮、CI —— 看上去应有尽有。
但当我打开第一个叫"文件读取"的测试,发现它的断言里一个字都没提"文件"。
那天我原本只想"好好用起来",结果剥洋葱一样一层一层挖下去,发现了 7 个互相掩盖的架构 bug。一句话核心:
"有工具无流程" 是评测平台的猝死综合症 —— 所有功能都是未拆封的新器械,第一次有人认真用就会发现螺丝没拧紧。
第 1 层:43% 的测试其实什么都没测
这是我盯着看了 5 秒才确认的事:
id: TOOL-001
name: "文件读取"
input:
prompt: "读取 package.json 文件内容"
assertions:
- type: no_error
- type: latency_below_ms: 30000
Agent 回答 "对不起我做不到":没报错 ✓ 没超时 ✓ 完美通过。
这不是个别问题。我用三问法(假阳性?假阴性?覆盖能力?)把 51 个用例过了一遍:
| 等级 | 占比 | 含义 |
|---|---|---|
| 🟢 A | 12% | 断言真的反映能力 |
| 🟡 B | 45% | 能抓严重退化 |
| 🔴 C | 43% | 只检查没崩溃 |
C 级集体出现在"工具使用"类(TOOL/COMP/CRON/SKIL)—— 这类测试本来就应该检查"Agent 调用了某工具",但没有一条用 tool_called 断言。整个项目里的"工具使用测试"其实只测了"Agent 没崩"。
修复思路:好的断言必须锚在 ground truth 上。我新建了一个 fixture 工作区,每个文件都埋了独一无二的锚点字符串:
tests/fixtures/eval-workspace/
├── package.json ← "name": "eval-fixture-project" v1.4.2
└── src/
├── index.ts ← "TODO: 添加错误处理"
├── utils.ts ← "TODO: 优化性能"
└── config.ts ← "TODO: 从环境变量读取配置"
重写后的 TOOL-001:
assertions:
- tool_called: Read # 必须调用 Read
- contains_text: "eval-fixture-project" # 必须说出独一无二的锚点
- contains_text: "1.4.2"
- file_contains: '"name":\s*"eval-fixture-project"' # Fixture 完整性
教训:如果断言不指向一个具体的、只有做对才能说出来的事实,你测的不是"做对了",是"没崩"。
第 2 层:一个 loader bug 让 2 种断言类型从未被激活
给 TOOL-001 加 file_exists 断言后,loader 吐出:
[评测] Benchmark "TOOL-001" 断言类型无效: file_exists
翻源码:
const VALID_ASSERTION_TYPES: Set<string> = new Set([
'contains_text', 'regex_match', 'tool_called',
'no_error', 'latency_below_ms', ...
// ← 缺了 file_exists, file_contains
])
断言引擎实现了这两种类型(200 行代码),类型系统也定义了,就差 loader 这个 Set 里没加。
整个项目历史上从未有任何 case 真正用过这两种断言。作者可能试过,看到 warning 以为自己拼错了,放弃了。
修复是一行代码。但它让整个"文件系统断言"子系统第一次真正可用。
教训:架构漂移是沉默的 —— 四层里修了三层,剩的一层可以静默存活任意长时间,直到有人第一次认真用它。
第 3 层:Mock 数据和测试用例早就漂移
跑 baseline,3 个 TEAM 用例失败。打开 TEAM-001 的 mock:
{
"response": "我已将调研任务分派给 teammate...\n\n## React Server Components 最新进展..."
}
而这个测试的断言在检查:
regex: "硬编码|JWT_SECRET"
regex: "MD5|哈希.*salt"
regex: "SQL.*注入"
regex: "XSS"
Mock 讲 React Server Components,断言检查 SQL 注入 —— 风马牛不相及。
这不是今天坏的 —— 几周前断言升级时留下的漂移。意味着:CI 每天都红 3 个,但没人看。
教训:机械化的告警如果没有审阅节奏配套,两周内就会变成静态噪声。修 mock 容易,建节奏难。
第 4 层:测试套件在偷偷污染生产数据库 🚨
打开生产 DB:
Unreviewed: 793
Good: 115 ← 居然有人标注过 115 条 good!
Bad: 579 ← 还有 579 条 bad!
激动地开始审阅。前几条:
id: api-test-uj6ynk3d
prompt: Test prompt for open coding API
response: Test response
过滤掉 test-* / api-test-* 前缀的行之后:
| Quality | 总数 | 真实 | 测试污染 |
|---|---|---|---|
| good | 115 | 0 | 115 |
| bad | 579 | 1 | 578 |
真实的人工标注是 0 条。所有"已标注"的都是 bun test 写进来的测试残渣。任何基于这些数据的 Dashboard 图表都是在画沙子。
继续挖根因 —— 我以为一修就完事,结果发现这是4 层独立的 bug 叠加:
4a · 测试文件没有 :memory: 隔离
// trace-db.test.ts — 没 beforeEach,没 :memory:
insertTrace(trace) // ← 默认路径 = ~/.agentzero/eval.db
4b · 模块加载时机陷阱
const DB_PATH = resolveDashboardDbPath() // ← 模块加载那一刻就冻结了
谁先加载这个文件,DB_PATH 就被永久冻结为当时解析的值。后来者设环境变量已经晚了。
4c · 单例模式的静默回退(全场最深的坑)
export function getEvalDb(dbPath?: string): EvalSqliteDb {
if (db) return db
const path = dbPath ?? getEvalDbPath() // ← 无参时回退到生产路径
db = createSqliteDb(path)
return db
}
一个经典调用序列:
- 测试 A 用
:memory:→ 跑完closeEvalDb()→ db 置空 - 测试 B 调
getEvalDb()不传参 → 悄悄跳到生产 DB
一次 close,下次 open 就静默污染生产。
4d · import hoisting 让环境变量来不及生效
process.env.AGENTZERO_EVAL_DB = ':memory:' // 意图:先设
import { app } from '../server' // 后 import
ES module 的 import hoisting 会把 import 提到最前面 —— 环境变量设置其实发生在 server 已经初始化之后。
修复:防御深度
Layer 1: 测试文件用 :memory: + beforeEach
Layer 2: run-executor 延迟解析路径
Layer 3: getEvalDb 记住 sticky path
Layer 4: 测试文件改用动态 import
任何一层失效,其他层都能兜住。修完之后,跑完整个 2344 个测试的全套,生产 DB 行数依然是 0。
教训:
- 污染问题是系统性的,不是个别的 —— 修完一层问"还有哪个路径能绕过"
- 单例模式 + 无参默认 = 危险组合 —— closed 之后的 reopen 要粘住原路径
- import hoisting + 顶层副作用 —— 需要的环境变量要么用动态 import,要么靠进程级配置
第 5 层:9 张"愿景式"表成为技术债
清理数据库时发现 14 张表里:
活跃: eval_runs, eval_results, eval_traces, trace_spans,
trace_scores, pairwise_comparisons (6 张)
死表: datasets, dataset_versions, dataset_cases,
benchmark_versions, eval_prompts, eval_scores,
scoring_configs, annotation_queues,
annotation_queue_items (9 张 · 全部 0 行)
64% 的 schema 是空壳。看 git 历史能看出清晰的模式 —— 每次迭代都照搬一个行业平台的能力:
v2 → 借鉴 Langfuse
v5 → 借鉴 BrainTrust
v8 → 借鉴 Phoenix + LangSmith + Langfuse
← 从这里开始全是画饼
每次都加表,但没有一次清理遗留。加表简单(CREATE TABLE IF NOT EXISTS 幂等安全);删表麻烦(要考虑数据、回退、测试、类型)。默认行为是"先加再说",一年后 schema 变成博物馆。
修复:schema v9 迁移删 5 张表。但这里又踩了一个坑 —— "安全删除"本身不安全。
第一版:
d.exec('DROP TABLE IF EXISTS eval_scores') // ← 我确定是空的
Codex(外部 review)立刻指出:"只对 production 确定,用户的开发副本可能有数据。"
第二版:先备份再删。
try { writeFileSync(backupPath, backup) }
catch { console.warn('备份失败,继续...') } // ← 这是自欺欺人
d.exec('DROP TABLE...')
Codex 又说:"console.warn 不是备份机制 —— 备份失败了 DROP 照跑,数据还是丢了。"
第三版:备份失败则 abort 整个迁移。
try {
writeFileSync(backupPath, backup)
} catch (err) {
throw new Error(`迁移中止:无法备份到 ${backupPath},DB 保持 v8`)
}
// 只有备份真的写成功才执行 DROP
原则:宁可让 DB 停在 v8 让用户手动介入,也不能静默删数据。
教训:
- 加表比删表容易 10 倍 —— 新表 PR 必须回答"生命周期是什么?如果半年没人用怎么删?"
- try/catch/warn/continue 是数据丢失的经典模式 —— 看到就要警觉
- Fail loud, fail early —— 保护失败要 abort,不要 log
外部 Review 的价值
修完第一版我自己跑测试,全绿。交给 Codex review,找出 4 个问题:
- 🔴 HIGH — 漏审了另一个目录下的
tests/integration/eval-dashboard.test.ts - 🟡 MEDIUM — CLI 默认
:memory:破坏了回归检测 - 🟡 MEDIUM — 迁移的备份失败处理不严
- 🟢 LOW — Triage filter 用内容启发式会误杀短 prompt
修完第二版又交给 Codex,再找出 1 个:迁移 abort 逻辑不彻底。
第三版终于过了。
为什么我自己找不到?
因为我验证修复时跑的是"我预期能过的测试"。都绿了就开心。
Codex 反过来问:"有没有我没想到的文件/顺序/路径能破坏这个?" 然后真的跑命令复现:
"I reproduced it with
bun test tests/integration/eval-dashboard.test.ts ...; the API tests then wrote against the already-open default DB"
你永远想不出自己没想到的盲点。这是外部视角不可替代的价值。
教训:大规模重构每修完一层立刻 review,不要等到"都做完了"—— 因为每一层 bug 揭示的盲点往往指向下一层 bug。
7 条可迁移的方法论
- "有工具无流程"是猝死综合症 —— 验收平台问"上次谁用过",答不出的就是死的
- 断言必须锚在 ground truth 上 —— fixture 预埋 + 精确事实
- 反鹦鹉学舌 —— 关键词只能选 reference 里有、prompt 里没有的词
- 测试污染是系统性的 —— 必须防御深度,不是单层修复
- 每加一张表都是技术债 —— PR 必须答生命周期 + 删除路径
try/catch/warn/continue是隐藏的数据丢失 —— Fail loud, fail early- 外部 review 对抗 confirmation bias —— 早做、频繁做
最后的数字
- 🗑 5 张表删除(从 14 张减到 9 张)
- ✏️ 22 个 benchmark case 重写(C 级从 43% → 6%)
- 🧹 694 条污染 trace 清零
- 🛡 5 层防御深度
- 🔍 3 轮 Codex review
- 📉 ~2000 行净删除
给维护评测平台的你:5 个诊断问题
(答案让你心虚的地方,就是你和我一样的问题)
- 上次真的有人用人工 triage 工具审阅过生产 trace 是什么时候?
- 随便打开三个 benchmark,断言能分辨"Agent 做错了"和"Agent 没崩"吗?
- 跑完整个测试套件,生产数据库的行数有没有变?
- 你 schema 里每张表都有至少一个 production code path 在写它吗?
- 你的 CI 名字叫 "Live" 但跑的是 "mock" 吗?
最后一点
这不是在批评 AgentZero 的原作者。每一个坑都来自"按正确的方向走但没走到底"—— 作者引入了行业方法论、建了完整的 schema、写了 12 个 Dashboard 页面,这是一个有品味的设计。
只是因为没有"使用者 × 时间"的压力测试,所有画好的饼都没被吃过。
一个评测平台真正的敌人不是代码质量,而是没人真的每天用它。
2026-04-10