总体准确率或胜率经常把某一语言、文档类型或工具路径上的系统性失败摊平。发布门禁不该只看平均值,而要把评测按切片聚合、给每个切片设最低通过线,并把失败样本留成可回放的证据。下面用一份可运行的 Node.js 脚本,演示如何在现有评测输出上加一层切片报告与拦截。

平均分掩盖的是哪一类失败

线上评测最容易出现的错觉是:总样本过了,系统就算可用。一百条里对了八十五条,报表很好看;可那十五条如果集中在「中文 PDF + 检索工具」或「英文表格 + 计算器」,用户碰到的就是整条路径不可用,而不是偶发抽风。

平均值有两个结构性问题。一是样本不均衡:高频切片贡献了大部分成功,低频但关键的切片被稀释。二是失败不可定位:只知道「有失败」,不知道失败是语言、文档类型,还是某条工具调用链。发布决策如果停在总分,等于默认「局部崩了也可以上线」。

更稳妥的做法是把「系统可用」改写成一组合取条件:每个事先声明的切片都达到最低通过线,否则本次发布失败。总分仍然可以看,但只作参考,不作放行依据。切片怎么切,取决于产品真实会走的路径,而不是事后从失败里临时编几个标签。

先约定切片与门禁,再谈脚本

切片是评测记录上的一组离散标签,不是模型自己生成的解释。实践里常用三维,彼此正交、取值有限:

  • language:如 zh、en
  • docType:如 pdf、html、table
  • toolPath:如 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 在现有评测结果上做聚合、对照门槛、非零退出、失败落盘,并不需要新框架;关键是切片在出题时写死、门槛写成配置、失败样本可原样回放。平均分可以留在报表第一行,但放行条件应是所有声明切片达标——否则好看的数字只是把系统性失败摊薄了而已。