《互联网产品的 AI 架构·推理性能与成本工程》11.2 评测集与回归门禁

新模型把整体通过率做到和基线一模一样,却在长中文与对抗样本两个分层明显退步。本节说明评测集怎样按能力与难度分层、抽样为什么必须确定性、阈值为什么要看 Wilson 下界与配对 bootstrap 区间,并给出一个能逐项复算的回归门禁模块与示例样本运行结果。

整体持平,为什么还要拦住这次升级

「回声」准备把私信回复切到新模型。离线评测跑了 2400 条用例,旧模型通过 1940 条,新模型也通过 1940 条,整体通过率都是 80.83%。单看这个数字,升级没有损失,可以发布。

回归门禁却给出了 fail。原因不在整体,而在分层:长中文从 80% 掉到 76%,对抗样本从 60% 掉到 52%。新模型在短中文上多对了 40 条,正好把两个分层的退步抵消掉。整体通过率是一个平均数,它把“某类用户变差”和“另一类用户变好”加在了一起。

本节讨论三件事:评测集怎样构建与抽样,阈值怎样带置信度,门禁怎样自动给出可复算的结论。文中数字来自 ai-lab 的示例评测集运行结果,不是生产实测。

评测集不是一堆请求

一个能用于门禁的评测集,至少要满足四个条件:

条件要求不满足的后果
可重放每条用例有固定输入与判定规则同一模型两次跑出不同结论
有分层键能力、语言、长度、难度至少各有一维整体平均掩盖局部退步
有难度梯度简单、常规、对抗样本都覆盖模型只在高频简单样本上好看
有版本用例、判定器与阈值一起版本化半年后无法解释分数变化

示例评测集有 2400 条,分成四个分层:短中文 1000 条、长中文 700 条、英文代码 400 条、对抗样本 300 条。分层不是按模型表现事后切的,而是在建集时就按业务场景定义好。

判定规则必须确定。能用规则判定的(格式、必含字段、引用是否存在)不要用模型打分;必须人工或模型评判的,要固定评判提示与版本,并保留抽样复核。判定器本身变了,分数变化就不能归因给模型。

分层抽样:配额、权重与确定性

全量跑 2400 条当然最好,但 CI 里每次提交都跑全量,时间和成本会阻止团队频繁验证。可行的做法是分层抽一个子集做快速门禁,发布前再跑全量。

抽样按分层配额进行,权重只影响配额,不影响指标加权:

quota_s = max(1, floor(size × weight_s / Σ weight))

示例从 2400 条抽 400 条,四个分层的配额是 132、110、79、79。分配时先保证每层至少一条,再按权重分剩余配额,并对每层的样本上限做截断,避免小分层被分到超过总体的数量。

抽样必须确定性。排序键是 SHA-256(seed + caseId),同一个 seed 在任何机器上都得到同一批用例。随机抽样会让“这次失败、下次通过”变成常态,也会让回归无法复现。

权重只用于配额,是一个容易做错的地方。如果指标也按权重加权,高权重的对抗样本可能把长中文的退步掩盖掉;门禁的分层检查必须按各层实际样本数计算,让每个分层都有独立发言权。

阈值要带置信度

点估计最容易制造假安全感。候选在 400 条样本上通过 300 条,通过率 75.0%,刚好等于阈值 75%,但这不意味着真实通过率不低于 75%。样本越小,区间越宽。

门禁用两种区间:

  • 绝对下限:候选通过率的 Wilson 下界必须不低于 minPassRate。Wilson 在小样本与极端比例下比正态近似稳。
  • 整体变化:对同一批用例的配对差值做 bootstrap,取 2.5% 分位作为最悲观下降幅度;它必须不低于 -maxPassRateDrop。

示例的完整评测集上,整体差值 0,bootstrap 区间是 [-0.79%, +0.88%],下界高于 -2%,整体检查通过。分层检查则用点估计加最小样本量:长中文 700 条,下降 4 个百分点;对抗样本 300 条,下降 8 个百分点,都超过 2 个百分点的容忍线。

分层为什么不也用区间?因为分层样本量小,区间会很宽,真实退步容易被“不显著”放过。这里的取舍是:分层检查负责发现可疑退步,完整评测集与人工复核负责确认。门禁宁可多拦一次,也不让一个分层在无人察觉的情况下变差。

六类检查与缺失结果

门禁输出六类检查:

检查失败条件作用
coverage基线或候选缺少任一用例结果防止按 0 分补
sample配对数低于最小样本量样本不足直接失败
absolute-passWilson 下界低于绝对下限防止点估计刚好过线
overall-dropbootstrap 下界低于允许下降防止整体退步
cost / latency平均成本或 P95 超过比例上限防止质量换成本换过头
stratum任一分层下降超过容忍线或样本不足防止平均掩盖局部

缺失结果不能补 0。补 0 会把“没跑”变成“失败”,也会让候选通过率凭空下降;正确做法是让 coverage 检查失败,先修评测流水线。

成本与延迟用比例而不是绝对值,是因为不同能力的基线差异很大。示例里候选成本与基线持平、P95 都是 900ms,两项通过;如果候选靠更贵的模型换质量,这两项会先拦住它。

实现

下面的模块只负责“拿同一批用例的基线结果与候选结果,算出一个可复算的结论”。评测集的构建、分层抽样与结果对齐在 src/perf/eval-sample.ts,门禁只消费对齐后的配对。

// file: src/perf/eval-gate.ts
// tier: L1
// verify: node --test test/perf/eval-gate.test.ts

import { AiError } from "../core/errors.ts";
import { alignOutcomes } from "./eval-sample.ts";
import type { AlignedPair, EvalCase, EvalOutcome } from "./eval-sample.ts";

export { alignOutcomes, stratifiedSample } from "./eval-sample.ts";
export type { EvalCase, EvalOutcome } from "./eval-sample.ts";

export interface GateThresholds {
  minPassRate: number;
  /** 允许的整体通过率下降,绝对值,例如 0.02 表示 2 个百分点。 */
  maxPassRateDrop: number;
  maxStratumDrop: number;
  maxCostIncreaseRatio: number;
  maxLatencyIncreaseRatio: number;
  minSampleSize: number;
  minStratumSize: number;
  confidence: number;
}

export const DEFAULT_GATE: GateThresholds = {
  minPassRate: 0.75, maxPassRateDrop: 0.02, maxStratumDrop: 0.02,
  maxCostIncreaseRatio: 0.1, maxLatencyIncreaseRatio: 0.15,
  minSampleSize: 300, minStratumSize: 30, confidence: 0.95,
};

export interface GateCheck { id: string; passed: boolean; detail: string }

export interface StratumDelta {
  stratum: string; cases: number; baselineRate: number;
  candidateRate: number; delta: number; sufficient: boolean;
}

export interface GateResult {
  verdict: "pass" | "fail";
  checks: GateCheck[];
  failed: string[];
  strata: StratumDelta[];
  sampleSize: number;
  baselinePassRate: number;
  candidatePassRate: number;
  delta: number;
  /** 配对 bootstrap 的置信区间;deltaLow 是最悲观的下降幅度。 */
  deltaLow: number;
  deltaHigh: number;
  confidence: number;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `eval-gate: ${message}`);
}

function ratio(value: number, field: string): void {
  if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${field} must be between 0 and 1`);
}

function integer(value: number, field: string, min: number): void {
  if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
}

function zFor(confidence: number): number {
  if (Math.abs(confidence - 0.9) < 1e-9) return 1.6448536269514722;
  if (Math.abs(confidence - 0.95) < 1e-9) return 1.959963984540054;
  if (Math.abs(confidence - 0.99) < 1e-9) return 2.5758293035489004;
  fail(`unsupported confidence ${confidence}; use 0.90, 0.95 or 0.99`);
}

/** Wilson 区间:小样本与极端比例下都比正态近似更稳。 */
export function wilsonInterval(successes: number, total: number, confidence = 0.95): { low: number; high: number } {
  integer(successes, "successes", 0);
  integer(total, "total", 1);
  if (successes > total) fail("successes cannot exceed total");
  const z = zFor(confidence);
  const p = successes / total;
  const denominator = 1 + (z * z) / total;
  const center = (p + (z * z) / (2 * total)) / denominator;
  const margin = (z * Math.sqrt((p * (1 - p)) / total + (z * z) / (4 * total * total))) / denominator;
  return { low: Math.max(0, center - margin), high: Math.min(1, center + margin) };
}

function mulberry32(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let value = state;
    value = Math.imul(value ^ (value >>> 15), value | 1);
    value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
  };
}

function nearestRank(values: readonly number[], ratioValue: number): number {
  if (values.length === 0) return 0;
  const sorted = [...values].sort((left, right) => left - right);
  const rank = Math.ceil(ratioValue * sorted.length) - 1;
  return sorted[Math.max(0, Math.min(sorted.length - 1, rank))];
}

export interface BootstrapDelta { mean: number; low: number; high: number; samples: number }

/** 配对 bootstrap:同一用例的基线/候选结果相减,再对差值重采样;固定种子保证可复算。 */
export function bootstrapDelta(pairs: readonly AlignedPair[], confidence = 0.95, iterations = 1000): BootstrapDelta {
  if (pairs.length === 0) fail("at least one aligned pair is required");
  integer(iterations, "iterations", 100);
  zFor(confidence);
  const differences = pairs.map((pair) => Number(pair.candidate.passed) - Number(pair.baseline.passed));
  const mean = differences.reduce((sum, value) => sum + value, 0) / differences.length;
  const random = mulberry32(0x9e3779b9);
  const means: number[] = [];
  for (let iteration = 0; iteration < iterations; iteration += 1) {
    let total = 0;
    for (let index = 0; index < differences.length; index += 1) {
      total += differences[Math.floor(random() * differences.length)];
    }
    means.push(total / differences.length);
  }
  const alpha = (1 - confidence) / 2;
  return { mean, low: nearestRank(means, alpha), high: nearestRank(means, 1 - alpha), samples: iterations };
}

function rate(numerator: number, denominator: number): number {
  return denominator === 0 ? 0 : numerator / denominator;
}

function average(values: readonly number[]): number {
  return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
}

function ratioOf(candidate: number, baseline: number): number {
  if (baseline === 0) return candidate === 0 ? 1 : Number.POSITIVE_INFINITY;
  return candidate / baseline;
}

function percent(value: number): string {
  return `${(value * 100).toFixed(2)}%`;
}

/**
 * 回归门禁同时看整体与分层:整体通过率持平,仍可能有一个分层明显退步。
 * 绝对下限用 Wilson 下界,整体变化用配对 bootstrap 的下界,避免“点估计刚好过线”。
 */
export function evaluateGate(
  cases: readonly EvalCase[],
  baseline: readonly EvalOutcome[],
  candidate: readonly EvalOutcome[],
  thresholds: GateThresholds = DEFAULT_GATE,
  options: { iterations?: number } = {},
): GateResult {
  if (cases.length === 0) fail("cases must not be empty");
  ratio(thresholds.minPassRate, "minPassRate");
  ratio(thresholds.maxPassRateDrop, "maxPassRateDrop");
  ratio(thresholds.maxStratumDrop, "maxStratumDrop");
  ratio(thresholds.maxCostIncreaseRatio, "maxCostIncreaseRatio");
  ratio(thresholds.maxLatencyIncreaseRatio, "maxLatencyIncreaseRatio");
  integer(thresholds.minSampleSize, "minSampleSize", 1);
  integer(thresholds.minStratumSize, "minStratumSize", 1);
  const confidence = thresholds.confidence;
  zFor(confidence);

  const alignment = alignOutcomes(cases, baseline, candidate);
  if (alignment.pairs.length === 0) fail("no aligned outcomes");
  const pairs = alignment.pairs;
  const basePasses = pairs.filter((pair) => pair.baseline.passed).length;
  const candPasses = pairs.filter((pair) => pair.candidate.passed).length;
  const baselinePassRate = rate(basePasses, pairs.length);
  const candidatePassRate = rate(candPasses, pairs.length);
  const delta = candidatePassRate - baselinePassRate;
  const interval = wilsonInterval(candPasses, pairs.length, confidence);
  const bootstrap = bootstrapDelta(pairs, confidence, options.iterations ?? 1000);

  const checks: GateCheck[] = [];
  const missing = alignment.missingBaseline.length + alignment.missingCandidate.length;
  checks.push({ id: "coverage", passed: missing === 0, detail: `missing baseline=${alignment.missingBaseline.length}, candidate=${alignment.missingCandidate.length}` });
  checks.push({ id: "sample", passed: pairs.length >= thresholds.minSampleSize, detail: `${pairs.length}/${thresholds.minSampleSize} paired cases` });
  checks.push({ id: "absolute-pass", passed: interval.low >= thresholds.minPassRate, detail: `candidate ${percent(candidatePassRate)}, Wilson low ${percent(interval.low)} >= ${percent(thresholds.minPassRate)}` });
  checks.push({ id: "overall-drop", passed: bootstrap.low >= -thresholds.maxPassRateDrop, detail: `delta ${percent(delta)}, bootstrap low ${percent(bootstrap.low)} >= ${percent(-thresholds.maxPassRateDrop)}` });

  const baseCost = average(pairs.map((pair) => pair.baseline.costMicros));
  const candCost = average(pairs.map((pair) => pair.candidate.costMicros));
  const costRatio = ratioOf(candCost, baseCost);
  checks.push({ id: "cost", passed: costRatio <= 1 + thresholds.maxCostIncreaseRatio, detail: `${candCost.toFixed(2)}/${baseCost.toFixed(2)} = ${costRatio.toFixed(3)} <= ${(1 + thresholds.maxCostIncreaseRatio).toFixed(3)}` });

  const baseP95 = nearestRank(pairs.map((pair) => pair.baseline.latencyMs), 0.95);
  const candP95 = nearestRank(pairs.map((pair) => pair.candidate.latencyMs), 0.95);
  const latencyRatio = ratioOf(candP95, baseP95);
  checks.push({ id: "latency", passed: latencyRatio <= 1 + thresholds.maxLatencyIncreaseRatio, detail: `p95 ${candP95}/${baseP95} = ${latencyRatio.toFixed(3)} <= ${(1 + thresholds.maxLatencyIncreaseRatio).toFixed(3)}` });

  const strata: StratumDelta[] = [...new Set(pairs.map((pair) => pair.stratum))].sort().map((stratum) => {
    const group = pairs.filter((pair) => pair.stratum === stratum);
    const base = rate(group.filter((pair) => pair.baseline.passed).length, group.length);
    const cand = rate(group.filter((pair) => pair.candidate.passed).length, group.length);
    return { stratum, cases: group.length, baselineRate: base, candidateRate: cand, delta: cand - base, sufficient: group.length >= thresholds.minStratumSize };
  });
  for (const item of strata) {
    checks.push({
      id: `stratum:${item.stratum}`,
      passed: item.sufficient && item.delta >= -thresholds.maxStratumDrop,
      detail: `${item.cases} cases, ${percent(item.baselineRate)} -> ${percent(item.candidateRate)}, delta ${percent(item.delta)}${item.sufficient ? "" : " (insufficient)"}`,
    });
  }

  const failed = checks.filter((check) => !check.passed).map((check) => check.id);
  return {
    verdict: failed.length === 0 ? "pass" : "fail",
    checks, failed, strata, sampleSize: pairs.length,
    baselinePassRate, candidatePassRate, delta,
    deltaLow: bootstrap.low, deltaHigh: bootstrap.high, confidence,
  };
}

wilsonInterval 与 bootstrapDelta 都单独导出,测试可以逐条核对边界。bootstrap 用固定种子的 mulberry32,同一份数据每次得到同一区间;这不是为了“更准”,而是为了 CI 里可复现。

evaluateGate 先做 coverage 与样本量检查,再算绝对下限与整体变化,最后逐层检查。检查顺序固定,failed 数组的顺序也固定,方便直接写进发布单。

示例样本上的运行结果

在实验工程根目录执行:

node examples/perf/eval-gate.ts

本机一次运行的真实输出如下:

sample requested=400 selected=400 complete=true
sample adversarial population=300 quota=79
sample code-en population=400 quota=79
sample long-zh population=700 quota=110
sample short-zh population=1000 quota=132
full n=2400 baseline=80.83% candidate=80.83% delta=0.00pt
full delta-band=[-0.79, 0.88] verdict=fail
full failed=stratum:adversarial,stratum:long-zh
full adversarial n=300 baseline=60.00% candidate=52.00% delta=-8.00pt
full code-en n=400 baseline=75.00% candidate=78.00% delta=3.00pt
full long-zh n=700 baseline=80.00% candidate=76.00% delta=-4.00pt
full short-zh n=1000 baseline=90.00% candidate=94.00% delta=4.00pt
quick n=400 baseline=79.25% candidate=78.00% delta=-1.25pt band=[-3.50, 1.00] verdict=fail
quick failed=absolute-pass,overall-drop,stratum:adversarial,stratum:long-zh

完整评测集上,整体通过率完全相同,差值 0,区间跨 0;如果只看整体,这次升级会被放行。门禁拦住它,是因为两个分层的下降超过了 2 个百分点。

400 条快速门禁则给出另一种信号:整体差值 -1.25%,bootstrap 下界 -3.50%,绝对下限与整体下降都失败。这不是“快速门禁更严格”,而是 400 条样本的噪声更大;它同时正确指出了两个分层退步,也产生了整体层面的假警报。快速门禁适合在提交时发现方向性问题,发布结论仍要回到完整评测集。

自动回归怎样接进流水线

门禁要成为发布流程的一部分,而不是一份离线报告。推荐顺序是:

  1. 提交时跑 400 条分层样本,只做快速筛查;失败则阻止合并。
  2. 合并前跑完整评测集,输出 JSON 结果与失败项。
  3. 发布前把完整结果与基线版本一起存档,作为下一次比较的输入。
  4. 任何豁免都要记录理由、影响范围与到期时间;没有到期的豁免会重新变红。

基线也要版本化。把“上一次发布的模型 + 评测集版本 + 阈值版本”作为一个整体,任何一项变化都不能直接比较。模型换了但评测集也换了,分数变化就无法归因。

小结

评测集要可重放、有分层、有难度梯度、有版本。抽样按分层配额、按哈希确定,权重只影响配额。门禁用 Wilson 下界守住绝对通过率,用配对 bootstrap 守住整体变化,再逐层检查局部退步;缺失结果直接失败,不补 0。

示例样本的结论是:整体通过率 80.83% 对 80.83%,看起来完全持平,但长中文 -4 个百分点、对抗样本 -8 个百分点,门禁必须失败。下一节把门禁通过后的模型放进真实流量:灰度比例怎样升、观测哪些指标、什么条件下自动回滚,以及回滚后缓存、幂等与下游写入怎样保持一致。

阅读导航:上一节:11.1 质量-成本-延迟的权衡面 · 下一节:11.3 灰度发布与自动回滚 。

常见错误与边界

  • 只看整体通过率。分层退步会被另一层的提升抵消。
  • 用例没有固定判定规则。同一模型两次跑出不同结论,门禁无法复现。
  • 抽样用随机数。失败样本下次可能消失,问题无法稳定复现。
  • 指标按抽样权重加权。高权重分层会掩盖低权重分层的退步。
  • 用点估计对比阈值。75.0% 对 75% 不是证据,区间下界才是。
  • 分层也用很宽的区间。小样本下真实退步会被“不显著”放过。
  • 缺失结果补 0。没跑不等于失败,应该让覆盖检查失败。
  • 基线不版本化。模型、评测集或阈值任一变化后,旧分数不可直接比较。
  • 把快速门禁的结论当发布结论。400 条样本的噪声足以制造假警报。
  • 把示例样本结果写成生产实测。数字必须标注来源与样本量。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查