整体持平,为什么还要拦住这次升级
「回声」准备把私信回复切到新模型。离线评测跑了 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-pass | Wilson 下界低于绝对下限 | 防止点估计刚好过线 |
| overall-drop | bootstrap 下界低于允许下降 | 防止整体退步 |
| 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 条样本的噪声更大;它同时正确指出了两个分层退步,也产生了整体层面的假警报。快速门禁适合在提交时发现方向性问题,发布结论仍要回到完整评测集。
自动回归怎样接进流水线
门禁要成为发布流程的一部分,而不是一份离线报告。推荐顺序是:
- 提交时跑 400 条分层样本,只做快速筛查;失败则阻止合并。
- 合并前跑完整评测集,输出 JSON 结果与失败项。
- 发布前把完整结果与基线版本一起存档,作为下一次比较的输入。
- 任何豁免都要记录理由、影响范围与到期时间;没有到期的豁免会重新变红。
基线也要版本化。把“上一次发布的模型 + 评测集版本 + 阈值版本”作为一个整体,任何一项变化都不能直接比较。模型换了但评测集也换了,分数变化就无法归因。
小结
评测集要可重放、有分层、有难度梯度、有版本。抽样按分层配额、按哈希确定,权重只影响配额。门禁用 Wilson 下界守住绝对通过率,用配对 bootstrap 守住整体变化,再逐层检查局部退步;缺失结果直接失败,不补 0。
示例样本的结论是:整体通过率 80.83% 对 80.83%,看起来完全持平,但长中文 -4 个百分点、对抗样本 -8 个百分点,门禁必须失败。下一节把门禁通过后的模型放进真实流量:灰度比例怎样升、观测哪些指标、什么条件下自动回滚,以及回滚后缓存、幂等与下游写入怎样保持一致。
阅读导航:上一节:11.1 质量-成本-延迟的权衡面 · 下一节:11.3 灰度发布与自动回滚 。
常见错误与边界
- 只看整体通过率。分层退步会被另一层的提升抵消。
- 用例没有固定判定规则。同一模型两次跑出不同结论,门禁无法复现。
- 抽样用随机数。失败样本下次可能消失,问题无法稳定复现。
- 指标按抽样权重加权。高权重分层会掩盖低权重分层的退步。
- 用点估计对比阈值。75.0% 对 75% 不是证据,区间下界才是。
- 分层也用很宽的区间。小样本下真实退步会被“不显著”放过。
- 缺失结果补 0。没跑不等于失败,应该让覆盖检查失败。
- 基线不版本化。模型、评测集或阈值任一变化后,旧分数不可直接比较。
- 把快速门禁的结论当发布结论。400 条样本的噪声足以制造假警报。
- 把示例样本结果写成生产实测。数字必须标注来源与样本量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。