总体准确率或胜率经常把某一语言、文档类型或工具路径上的系统性失败摊平。发布门禁不该只看平均值,而要把评测按切片聚合、给每个切片设最低通过线,并把失败样本留成可回放的证据。下面用一份可运行的 Node.js 脚本,演示如何在现有评测输出上加一层切片报告与拦截。
平均分掩盖的是哪一类失败
线上评测最容易出现的错觉是:总样本过了,系统就算可用。一百条里对了八十五条,报表很好看;可那十五条如果集中在「中文 PDF + 检索工具」或「英文表格 + 计算器」,用户碰到的就是整条路径不可用,而不是偶发抽风。
平均值有两个结构性问题。一是样本不均衡:高频切片贡献了大部分成功,低频但关键的切片被稀释。二是失败不可定位:只知道「有失败」,不知道失败是语言、文档类型,还是某条工具调用链。发布决策如果停在总分,等于默认「局部崩了也可以上线」。
更稳妥的做法是把「系统可用」改写成一组合取条件:每个事先声明的切片都达到最低通过线,否则本次发布失败。总分仍然可以看,但只作参考,不作放行依据。切片怎么切,取决于产品真实会走的路径,而不是事后从失败里临时编几个标签。
先约定切片与门禁,再谈脚本
切片是评测记录上的一组离散标签,不是模型自己生成的解释。实践里常用三维,彼此正交、取值有限:
language:如zh、endocType:如pdf、html、tabletoolPath:如none、search、calculator
一条用例必须同时带这三项(或你业务里等价的维度)。门禁看的是「单维切片」和「交叉切片」:单维能发现「所有中文都不行」;交叉能发现「只有中文 PDF 走检索时不行」。交叉切片样本很少时,不要硬套和高频切片一样的百分比——可以设最低样本量,样本不足则标记为 insufficient,单独人工看,而不是假装通过。
门禁规则建议写成配置,而不是写死在 if 里。例如:全局地板 0.8;某条高风险路径 0.9;某条样本极少的路径要求至少 5 条且通过率不低于 0.6。数字本身没有普适标准,必须按误放行成本自己定;脚本只负责把规则跑死,不负责替你发明指标。
评测脚本的输出约定尽量简单:一份 JSON,里面是 cases 数组。每条至少有 id、切片字段、passed(布尔),以及可回放的 input / expected / actual。现有 runner 只要在写结果时多带这些字段,门禁就可以后挂,不必重写整套评测。
用 Node.js 做切片聚合与拦截
下面这份脚本只依赖 Node.js 内置模块(fs、path),用 node eval-gate.js 即可运行。它会:读入评测结果、按单维与交叉切片聚合、对照门槛、把失败样本写成 JSONL,并在任一切片未达标时以非零退出码结束——这正是 CI 里「拦住发布」需要的行为。
一份可运行的门禁脚本
把下面存成 eval-gate.js,同目录再放 eval-results.json(文末有一份示例数据的写法,脚本内也内置了最小样例,无文件时会用内置数据跑通)。
const fs = require("fs");
const path = require("path");
const DEFAULT_MIN_PASS_RATE = 0.8;
const MIN_SLICE_SIZE = 3;
const SLICE_GATES = {
"language=zh": { minPassRate: 0.8, minSize: 3 },
"language=en": { minPassRate: 0.8, minSize: 3 },
"docType=pdf": { minPassRate: 0.8, minSize: 3 },
"docType=table": { minPassRate: 0.75, minSize: 3 },
"toolPath=search": { minPassRate: 0.85, minSize: 3 },
"toolPath=calculator": { minPassRate: 0.85, minSize: 3 },
"language=zh|docType=pdf|toolPath=search": { minPassRate: 0.85, minSize: 3 }
};
function loadCases() {
const file = path.join(__dirname, "eval-results.json");
if (fs.existsSync(file)) {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (!parsed.cases || !Array.isArray(parsed.cases)) {
throw new Error("eval-results.json 必须包含 cases 数组");
}
return parsed.cases;
}
return [
{ id: "c1", language: "zh", docType: "pdf", toolPath: "search", passed: true, input: "摘要这份合同", expected: "甲乙双方", actual: "甲乙双方" },
{ id: "c2", language: "zh", docType: "pdf", toolPath: "search", passed: false, input: "找出违约条款", expected: "第12条", actual: "未找到" },
{ id: "c3", language: "zh", docType: "pdf", toolPath: "search", passed: false, input: "付款日期", expected: "T+7", actual: "见附件" },
{ id: "c4", language: "en", docType: "html", toolPath: "none", passed: true, input: "Title?", expected: "Overview", actual: "Overview" },
{ id: "c5", language: "en", docType: "html", toolPath: "none", passed: true, input: "Author?", expected: "Lee", actual: "Lee" },
{ id: "c6", language: "en", docType: "table", toolPath: "calculator", passed: true, input: "sum of col A", expected: "12", actual: "12" },
{ id: "c7", language: "zh", docType: "table", toolPath: "calculator", passed: true, input: "A列合计", expected: "12", actual: "12" },
{ id: "c8", language: "zh", docType: "pdf", toolPath: "none", passed: true, input: "标题是什么", expected: "服务协议", actual: "服务协议" }
];
}
function sliceKeys(c) {
return [
`language=${c.language}`,
`docType=${c.docType}`,
`toolPath=${c.toolPath}`,
`language=${c.language}|docType=${c.docType}|toolPath=${c.toolPath}`
];
}
function aggregate(cases) {
const buckets = new Map();
for (const c of cases) {
if (typeof c.passed !== "boolean") {
throw new Error(`case ${c.id} 缺少布尔字段 passed`);
}
for (const key of sliceKeys(c)) {
if (!buckets.has(key)) buckets.set(key, { key, n: 0, passed: 0, failIds: [] });
const b = buckets.get(key);
b.n += 1;
if (c.passed) b.passed += 1;
else b.failIds.push(c.id);
}
}
return [...buckets.values()].map((b) => {
const gate = SLICE_GATES[b.key] || {
minPassRate: DEFAULT_MIN_PASS_RATE,
minSize: MIN_SLICE_SIZE
};
const passRate = b.n === 0 ? 0 : b.passed / b.n;
const insufficient = b.n < gate.minSize;
const ok = !insufficient && passRate >= gate.minPassRate;
return {
key: b.key,
n: b.n,
passed: b.passed,
passRate: Number(passRate.toFixed(4)),
minPassRate: gate.minPassRate,
minSize: gate.minSize,
insufficient,
ok,
failIds: b.failIds
};
});
}
function overall(cases) {
const n = cases.length;
const passed = cases.filter((c) => c.passed).length;
return { n, passed, passRate: n === 0 ? 0 : Number((passed / n).toFixed(4)) };
}
function writeFailures(cases) {
const lines = cases
.filter((c) => !c.passed)
.map((c) =>
JSON.stringify({
id: c.id,
language: c.language,
docType: c.docType,
toolPath: c.toolPath,
input: c.input,
expected: c.expected,
actual: c.actual
})
);
const out = path.join(__dirname, "eval-failures.jsonl");
fs.writeFileSync(out, lines.join("\n") + (lines.length ? "\n" : ""), "utf8");
return out;
}
function main() {
const cases = loadCases();
const summary = overall(cases);
const slices = aggregate(cases);
const blocked = slices.filter((s) => !s.ok);
const failFile = writeFailures(cases);
const report = {
overall: summary,
slices: slices.sort((a, b) => Number(a.ok) - Number(b.ok) || a.key.localeCompare(b.key)),
blocked: blocked.map((s) => s.key),
failureLog: failFile
};
fs.writeFileSync(
path.join(__dirname, "eval-slice-report.json"),
JSON.stringify(report, null, 2),
"utf8"
);
console.log("overall", summary);
for (const s of report.slices) {
const flag = s.insufficient ? "INSUFFICIENT" : s.ok ? "PASS" : "FAIL";
console.log(
`${flag}\t${s.key}\tn=${s.n}\tpassRate=${s.passRate}\tmin=${s.minPassRate}`
);
}
if (blocked.length > 0) {
console.error(`release blocked: ${blocked.length} slice(s) below gate`);
process.exit(1);
}
console.log("release gate passed");
}
main();
内置 8 条样例里,总分并不差,但 language=zh|docType=pdf|toolPath=search 会低于门槛,进程以退出码 1 结束。这就是「平均分好看也不能上线」在脚本里的具体含义:合取,而不是加权平均后再拍脑袋。
同目录可再放 eval-results.json,结构如下,字段需与脚本读取方式一致:
{
"runId": "local-demo",
"cases": [
{
"id": "c1",
"language": "zh",
"docType": "pdf",
"toolPath": "search",
"passed": true,
"input": "摘要这份合同",
"expected": "甲乙双方",
"actual": "甲乙双方"
}
]
}
passed 必须是评测器算出来的布尔值,不要用模型再评一次自己的输出充数。分数型指标可以另存,但门禁先用通过/失败,规则才好讲清楚。
接到现有评测输出
如果当前 runner 已经写出 JSON 或 JSONL,不要在门禁里重新调用模型。CI 里两步即可:先跑评测得到 eval-results.json,再 node eval-gate.js。GitHub Actions 等环境会把非零退出码当成 job 失败,发布任务自然停住。
切片字段要在出题时就写上,而不是失败后再人工贴标签。题一旦进集,language / docType / toolPath 视为冻结;改标签等于改题,需要重新跑该片。工具路径以「题目允许调用的工具」为准,不要用模型实际选中的工具反推——否则失败样本会把「选错工具」和「工具选对但答错」混在同一个切片里。
门槛变更走配置 diff,和代码一起审。临时放宽某切片,应在报告里能看出是门槛改了,而不是切片突然变绿。
失败样本要写成可回放证据
拦住发布只完成一半。另一半是让下一个人不用猜。脚本写出的 eval-failures.jsonl 每行一条失败,字段与当初入模的输入、期望、实际输出对齐。回放时不要改 prompt、不要换解码参数、不要换工具实现;先用同一 runner 再跑这些 id。若复现失败,才修系统;若不能复现,记成评测不稳定,而不是当成产品已修好。
JSONL 的好处是可以按切片过滤。例如只看检索路径:
const fs = require("fs");
const lines = fs.readFileSync("eval-failures.jsonl", "utf8").trim().split("\n");
const searchFails = lines
.filter(Boolean)
.map((l) => JSON.parse(l))
.filter((x) => x.toolPath === "search");
console.log(searchFails.map((x) => x.id).join("\n"));
报告里同时保留 failIds 与全文日志:ids 方便在评测集里跳转,全文方便对比 expected / actual。不要只存「模型又说了一段解释」;解释不能当证据。若输出里有工具轨迹,把轨迹一并落盘(仍是普通 JSON 字段即可),否则「切片叫 search、实际没调搜索」这类问题会被总分继续藏住。
样本不足的切片不要在门禁里静默通过。insufficient 应视为未证明可用:要么补题,要么明确排除该路径不在本次发布范围。把「没测到」写成「通过」,和把平均分当成可用,是同一类错误。
总结
总分回答的是「这批题平均怎么样」,切片门禁回答的是「我们声称支持的每条路径是否都还在线」。用 Node.js 在现有评测结果上做聚合、对照门槛、非零退出、失败落盘,并不需要新框架;关键是切片在出题时写死、门槛写成配置、失败样本可原样回放。平均分可以留在报表第一行,但放行条件应是所有声明切片达标——否则好看的数字只是把系统性失败摊薄了而已。
评论