《互联网产品的 AI 架构·平台化与治理》5.2 自动化回归与门禁

门禁要回答三件事:哪些改动必须拦、样本要多少、证据留多久。本节把门禁分为提交级、发布级与模型换代级,逐级给出指标阈值、样本下限、豁免窗口与证据留存,并用可运行代码在示例样本上演示缺失即失败、硬指标不可豁免、证据包可复现的判定过程,并把结论写入治理报表。

一次「本地跑过了」的发布

内容团队提交了一个提示改写,作者在本地跑了 120 条样本,通过率从 96.25% 升到 96.40%,于是把结果贴进发布单。提交级门禁看到 120 条,低于门槛,却因为「指标都在阈值内」放行了;发布级门禁只跑了成功率和成本,漏掉了安全指标;值班同学补了一条「已知问题,先上线观察」的豁免,没有到期日。

两天后,链路 A 的发布成功率没有明显变化,但审核队列里出现了新的误放行。复盘时大家才发现:提交级门禁的样本量不足,发布级门禁缺指标被当成通过,豁免没有到期时间。三个问题指向同一件事——门禁只检查了「数好不好看」,没有检查「证据够不够」。

这一节定义三级门禁:谁触发、看哪些指标、样本要多少、什么情况可以豁免、证据留多久。它不讨论怎么调模型,只讨论怎么让一个多团队共用的能力在合并、发布、换代三个时刻都有可复核的结论。

三级门禁,三种责任

门禁不是一道闸,而是三道闸。每道闸的触发点、样本规模与责任人不同,混在一起会让团队用提交级的成本做发布级的判断。

层级触发最小样本责任方最长豁免
提交级每次合并请求200能力团队3 天
发布级能力版本发布1,000能力团队 + 平台14 天
换代级模型整体切换2,000治理委员会不允许

提交级追求快,允许只跑与改动相关的子集,但样本不能低于 200;发布级要覆盖全部指标与全部调用方场景;换代级不仅看指标,还要求评测集本身升级到能区分新旧模型的新版本。三级的样本量是下限,不是目标值;调用方越多、链路越关键,样本应该越大。

责任方也不同。提交级由能力团队自己守,平台只提供 runner 与规则;发布级需要平台复核证据包;换代级由治理委员会签字。把三代门禁都推给平台,平台会变成瓶颈;都交给能力团队,跨团队风险就没人兜底。

指标与阈值

指标一律用「基线 + 允许退化」表示,不用绝对分数。原因是绝对分数会随着评测集版本漂移,而基线可以在同一版本内复现。

指标方向提交级发布级换代级
任务成功率越高越好绝对下降 ≤ 0.5pp≤ 0.3pp≤ 0.2pp
每次交付成本越低越好相对上升 ≤ 2%≤ 2%≤ 1%
P95 延迟越低越好不检查相对上升 ≤ 5%≤ 3%
安全违规率越低越好不检查不允许上升不允许上升

基线不是全站平均值,而是链路自己的上一版。链路 A 的基线是成功率 96.25%、每次成功交付 3,496 微元、P95 3,523ms;B 是 99.58%、3,804 微元、1,738ms;C 是 95.00%、2,429 微元、793ms。发布判定要逐链路比,不能拿三条链路的合计 96.94% 去掩盖 A 的退化。

成本阈值要放在优化账里看。三能力编排把每次成功交付从 4,240 微元降到 3,786 微元(-10.71%),复用回本线是 28.47%。门禁允许 2% 的成本上升,意味着一次发布最多吃掉 75.7 微元的优化收益;连续几次「在阈值内」的发布,会把回本线推后。平台应该在发布记录里累计这个偏移,而不是每次单独判定。

延迟阈值与单步失败预算联动。13.02% 是一条链路在三次重试、目标 95% 时的单步失败上限。发布后的失败率如果逼近这个数,说明这次变更已经吃掉了容错余量;即使 P95 仍在 5% 以内,也应该暂停放量,先看失败样本。

缺证据按失败处理

门禁最容易犯的错误,是把「没测」当成「没问题」。本章的实现采用 fail closed:规则要求的指标缺失,判定为失败;样本量低于下限,判定为失败;评测集版本与发布记录不一致,同样失败。

这条规则会带来短期的摩擦,但它是多团队共享的前提。调用方无法验证提供方是否真的跑过安全指标,只能相信门禁的输出;如果缺指标可以静默通过,调用方就得自己再跑一遍,平台也就没有存在价值。

分母也必须写清楚。成功率是「成功次数 / 总请求数」,成本是「总微元 / 成功交付次数」,P95 是「按请求延迟排序后的第 95 百分位」。同一张报表里混用不同分母,会让「通过」变成文字游戏。

豁免不是免罪

豁免是门禁的必要出口,但它必须比门禁更严格。本章的默认规则如下。

约束规则
可豁免对象只限「回归」类失败
不可豁免缺指标、样本不足、安全硬指标
责任人必须写具体团队,不能写「平台」
理由不少于 20 个字符,包含残余风险
窗口提交级 3 天、发布级 14 天、换代级 0 天
证据必须引用证据包编号 EV-<LEVEL>-<8 位哈希>
并发同一层级同一指标只能有一条有效豁免

只允许豁免「回归」,是因为回归是已知的、可用时间换修复的退化;缺指标与样本不足是证据问题,豁免它们等于允许蒙眼发布。安全指标一律硬阻断,不给豁免入口,避免「先上线再补安全」成为惯例。

豁免记录要进入判定结果,包含责任人、到期日与理由。到期未修复,下一轮门禁会重新阻塞;连续两次续期需要治理委员会批准,平台不提供自动续期按钮。

证据留存

每次判定都要生成一个证据包。证据包不保存原始样本,只保存判定输入的摘要:评测集 id 与版本、各指标的基线与候选值、样本量、时间与运行 id。摘要按指标名排序后做 SHA-256,保证同样输入得到同样编号。

层级留存期典型用途
提交级30 天定位哪次合并引入了退化
发布级180 天季度复盘、调用方追溯
换代级730 天模型审计、供应商争议

留存期从判定日算起,到期后由平台归档而不是直接删除。审计接口返回已存活天数、剩余天数与是否过期;过期证据不能用于新的豁免申请,但仍可作为历史调查材料。

门禁判定模块

eval-gates.ts 把三级策略、失败判定、豁免校验与证据包写成一组纯函数。策略表是数据,不是散落在各处的 if;新增指标只需改表,不需要改判定流程。

// file: src/gov/eval-gates.ts
// tier: L2
// verify: node --test test/gov/eval-platform.test.ts

import { createHash } from "node:crypto";

/** 三级门禁:提交级拦单点改动,发布级拦能力版本,换代级拦模型整体切换。 */
export type GateLevel = "commit" | "release" | "model-swap";
export type Direction = "higher" | "lower";
export interface GateRule {
  metric: string; direction: Direction;
  /** higher 用绝对差,lower 用相对比例;两者都只允许「变差」。 */
  maxRegression: number; minSample: number; hard: boolean;
}
export interface GatePolicy { level: GateLevel; rules: readonly GateRule[] }
export const GATE_POLICIES: Record<GateLevel, GatePolicy> = {
  commit: {
    level: "commit",
    rules: [
      { metric: "task.success", direction: "higher", maxRegression: 0.005, minSample: 200, hard: false },
      { metric: "delivery.cost", direction: "lower", maxRegression: 0.02, minSample: 200, hard: false },
    ],
  },
  release: {
    level: "release",
    rules: [
      { metric: "task.success", direction: "higher", maxRegression: 0.003, minSample: 1_000, hard: false },
      { metric: "delivery.cost", direction: "lower", maxRegression: 0.02, minSample: 1_000, hard: false },
      { metric: "latency.p95", direction: "lower", maxRegression: 0.05, minSample: 1_000, hard: false },
      { metric: "safety.violation", direction: "lower", maxRegression: 0, minSample: 1_000, hard: true },
    ],
  },
  "model-swap": {
    level: "model-swap",
    rules: [
      { metric: "task.success", direction: "higher", maxRegression: 0.002, minSample: 2_000, hard: false },
      { metric: "delivery.cost", direction: "lower", maxRegression: 0.01, minSample: 2_000, hard: false },
      { metric: "latency.p95", direction: "lower", maxRegression: 0.03, minSample: 2_000, hard: false },
      { metric: "safety.violation", direction: "lower", maxRegression: 0, minSample: 2_000, hard: true },
    ],
  },
};
export interface MetricSample { metric: string; baseline: number; candidate: number; sampleSize: number }
export interface GateEvidence {
  level: GateLevel; runId: string; evalSetId: string; evalSetVersion: string;
  samples: readonly MetricSample[]; createdAt: string;
}
export type CheckCode = "pass" | "regression" | "missing-metric" | "insufficient-sample" | "exempted";
export interface GateCheckResult {
  metric: string; code: CheckCode; baseline: number; candidate: number;
  delta: number; sampleSize: number; requiredSample: number; hard: boolean;
}
export interface GateResult {
  level: GateLevel; runId: string; passed: boolean;
  checks: GateCheckResult[]; failed: string[];
}
function delta(rule: GateRule, sample: MetricSample): number {
  if (rule.direction === "higher") return sample.candidate - sample.baseline;
  return sample.baseline === 0 ? 0 : sample.candidate / sample.baseline - 1;
}
function withinTolerance(rule: GateRule, sample: MetricSample): boolean {
  const change = delta(rule, sample);
  return rule.direction === "higher" ? change >= -rule.maxRegression : change <= rule.maxRegression;
}

/** 缺指标与样本不足都按失败处理:没有证据不等于没有退化。 */
export function evaluateGate(evidence: GateEvidence, policy: GatePolicy = GATE_POLICIES[evidence.level]): GateResult {
  const checks: GateCheckResult[] = [];
  for (const rule of policy.rules) {
    const sample = evidence.samples.find((item) => item.metric === rule.metric);
    if (sample === undefined) {
      checks.push({
        metric: rule.metric, code: "missing-metric", baseline: 0, candidate: 0,
        delta: 0, sampleSize: 0, requiredSample: rule.minSample, hard: rule.hard,
      });
      continue;
    }
    const base = {
      metric: rule.metric, baseline: sample.baseline, candidate: sample.candidate,
      delta: Math.round(delta(rule, sample) * 10_000) / 10_000,
      sampleSize: sample.sampleSize, requiredSample: rule.minSample, hard: rule.hard,
    };
    if (sample.sampleSize < rule.minSample) checks.push({ ...base, code: "insufficient-sample" });
    else if (!withinTolerance(rule, sample)) checks.push({ ...base, code: "regression" });
    else checks.push({ ...base, code: "pass" });
  }
  const failed = checks.filter((item) => item.code !== "pass").map((item) => item.metric);
  return { level: evidence.level, runId: evidence.runId, passed: failed.length === 0, checks, failed };
}
export interface GateExemption {
  id: string; level: GateLevel; metric: string; owner: string; reason: string;
  createdAt: string; expiresAt: string; evidenceRef: string;
}
export interface ExemptionIssue { id: string; code: string; detail: string }
const MAX_EXEMPT_DAYS: Record<GateLevel, number> = { commit: 3, release: 14, "model-swap": 0 };
const EVIDENCE_REF = /^EV-(COMMIT|RELEASE|SWAP)-[0-9a-f]{8}$/;
const DAY = 86_400_000;
function dayDiff(from: string, to: string): number {
  return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY);
}
export function validateExemptions(
  exemptions: readonly GateExemption[],
  policy: Record<GateLevel, GatePolicy> = GATE_POLICIES,
): ExemptionIssue[] {
  const issues: ExemptionIssue[] = [];
  const active = new Set<string>();
  for (const item of exemptions) {
    const rule = policy[item.level].rules.find((entry) => entry.metric === item.metric);
    if (rule === undefined) {
      issues.push({ id: item.id, code: "unknown-metric", detail: `${item.level} 没有指标 ${item.metric}` });
      continue;
    }
    if (rule.hard) issues.push({ id: item.id, code: "hard-metric", detail: `${item.metric} 是硬指标,不允许豁免` });
    if (MAX_EXEMPT_DAYS[item.level] === 0) {
      issues.push({ id: item.id, code: "level-not-exemptible", detail: "模型换代级不允许豁免" });
    }
    if (item.owner.trim() === "") issues.push({ id: item.id, code: "missing-owner", detail: "豁免必须有责任人" });
    if ([...item.reason].length < 20) {
      issues.push({ id: item.id, code: "reason-too-short", detail: "豁免理由少于 20 个字符" });
    }
    if (!EVIDENCE_REF.test(item.evidenceRef)) {
      issues.push({ id: item.id, code: "invalid-evidence", detail: "豁免必须引用证据包编号" });
    }
    const window = dayDiff(item.createdAt, item.expiresAt);
    if (window <= 0 || window > MAX_EXEMPT_DAYS[item.level]) {
      issues.push({
        id: item.id, code: "invalid-window",
        detail: `豁免窗口 ${window} 天,${item.level} 最多 ${MAX_EXEMPT_DAYS[item.level]} 天`,
      });
    }
    const key = `${item.level}:${item.metric}`;
    if (active.has(key)) {
      issues.push({ id: item.id, code: "duplicate-active", detail: `${key} 同时存在多条有效豁免` });
    }
    active.add(key);
  }
  return issues;
}
export interface ExemptionRecord {
  id: string; level: GateLevel; metric: string; owner: string;
  expiresAt: string; applied: boolean; reason: string;
}
export interface GateDecision {
  passed: boolean; blocked: string[]; exempted: string[];
  records: ExemptionRecord[]; ignored: ExemptionRecord[];
}
function isActive(item: GateExemption, now: string): boolean {
  return item.createdAt <= now && now < item.expiresAt;
}

/** 只有「回归」可以豁免;缺指标、样本不足与硬指标都必须保持阻塞。 */
export function applyExemptions(
  result: GateResult,
  exemptions: readonly GateExemption[],
  now: string,
): GateDecision {
  const records: ExemptionRecord[] = [];
  const ignored: ExemptionRecord[] = [];
  const exempted: string[] = [];
  for (const check of result.checks) {
    if (check.code !== "regression" || check.hard) continue;
    const found = exemptions.find(
      (item) => item.level === result.level && item.metric === check.metric && isActive(item, now),
    );
    if (found === undefined) continue;
    exempted.push(check.metric);
    check.code = "exempted";
    records.push({
      id: found.id, level: found.level, metric: found.metric, owner: found.owner,
      expiresAt: found.expiresAt, applied: true, reason: found.reason,
    });
  }
  for (const item of exemptions) {
    if (records.some((record) => record.id === item.id)) continue;
    if (item.level !== result.level || !result.failed.includes(item.metric) || !isActive(item, now)) {
      ignored.push({
        id: item.id, level: item.level, metric: item.metric, owner: item.owner,
        expiresAt: item.expiresAt, applied: false, reason: "未命中失败指标、级别不符或不在有效期",
      });
    }
  }
  const blocked = result.checks
    .filter((item) => item.code !== "pass" && item.code !== "exempted")
    .map((item) => item.metric);
  return { passed: blocked.length === 0, blocked, exempted, records, ignored };
}
export interface EvidenceBundle {
  id: string; level: GateLevel; runId: string; evalSet: string; digest: string;
  createdAt: string; retainUntil: string; retentionDays: number;
}
const LEVEL_CODE: Record<GateLevel, string> = { commit: "COMMIT", release: "RELEASE", "model-swap": "SWAP" };
export const RETENTION_DAYS: Record<GateLevel, number> = { commit: 30, release: 180, "model-swap": 730 };

/** 证据包只摘要判定输入,不保存原始样本;摘要按指标名排序,保证同样输入同样编号。 */
export function buildEvidence(evidence: GateEvidence): EvidenceBundle {
  const canonical = [...evidence.samples]
    .sort((left, right) => left.metric.localeCompare(right.metric))
    .map((item) => `${item.metric}:${item.baseline}:${item.candidate}:${item.sampleSize}`)
    .join("|");
  const digest = createHash("sha256")
    .update(`${evidence.evalSetId}@${evidence.evalSetVersion}|${canonical}`)
    .digest("hex");
  const retentionDays = RETENTION_DAYS[evidence.level];
  return {
    id: `EV-${LEVEL_CODE[evidence.level]}-${digest.slice(0, 8)}`,
    level: evidence.level,
    runId: evidence.runId,
    evalSet: `${evidence.evalSetId}@${evidence.evalSetVersion}`,
    digest,
    createdAt: evidence.createdAt,
    retainUntil: new Date(Date.parse(`${evidence.createdAt}T00:00:00Z`) + retentionDays * DAY)
      .toISOString().slice(0, 10),
    retentionDays,
  };
}
export interface EvidenceAudit { id: string; ageDays: number; daysRemaining: number; expired: boolean }
export function auditEvidence(bundle: EvidenceBundle, today: string): EvidenceAudit {
  const ageDays = Math.max(0, dayDiff(bundle.createdAt, today));
  return {
    id: bundle.id,
    ageDays,
    daysRemaining: Math.max(0, bundle.retentionDays - ageDays),
    expired: ageDays > bundle.retentionDays,
  };
}
export interface ReleaseDecision {
  evidence: EvidenceBundle; gate: GateResult; decision: GateDecision;
  audit: EvidenceAudit; pass: boolean;
}

/** 一次判定同时产出结论与证据包;没有证据包的放行不算放行。 */
export function decideRelease(
  evidence: GateEvidence,
  exemptions: readonly GateExemption[],
  today: string,
): ReleaseDecision {
  const gate = evaluateGate(evidence);
  const decision = applyExemptions(gate, exemptions, today);
  const bundle = buildEvidence(evidence);
  return { evidence: bundle, gate, decision, audit: auditEvidence(bundle, today), pass: decision.passed };
}

实现里有三个刻意的选择。第一,evaluateGate 对缺失指标与样本不足都生成一条失败记录,而不是跳过;这样报表能区分「没测」与「测了但退化」。第二,豁免只修改 regression 状态,缺指标与硬指标保持阻塞。第三,证据包摘要按指标名排序,避免样本顺序不同导致同一判定得到不同编号。

示例样本上的运行结果

示例样本用链路 A 的基线构造:成功率 96.25%、每次成功交付 3,496 微元、P95 3,523ms、安全违规率 0.4%。提交级样本 500 条,发布级样本 2,000 条。

场景结果
提交级:成功率 96.30%、成本 3,510通过
提交级:成功率 94.00%失败,差值为 -2.25pp
发布级:成功率 95.00%失败,超出 0.3pp 阈值
发布级:安全违规率 0.5%失败,硬指标不可豁免
发布级:成功率回归 + 有效豁免通过,记录责任人与到期日
提交级:样本 100 条失败,样本不足
证据包编号EV-RELEASE-<8 位哈希>,同输入可复现
证据留存发布级 180 天,2026-11-05 判定,2027-05-04 到期

这些是示例样本上的运行结果,不是生产统计。真实发布的指标更多,但「缺失即失败、硬指标不可豁免、证据包可复现」这三条不变。

门禁也要算成本

按多租户配额示例,内容团队月度总额 4,000 万微元,单次摘要上限 6,200 微元。一次 1,000 条样本的发布级回归需要 620 万微元,占该团队月度的 15.5%;如果提交级每次合并都跑 200 条,20 次合并就是 2,480 万微元,超过月度份额的一半。

所以门禁不能只定义「跑多少」,还要定义「谁来付」。本章的建议是:提交级由能力团队从自己的份额扣;发布级由能力团队与调用方按调用量分摊;换代级进入平台预算。配额不足时,优先降低提交级的触发频率(例如只对改动相关的能力跑),而不是削减发布级的样本量。

多团队门禁的三条规则

第一条,门禁规则集中托管,参数允许收紧不允许放宽。调用方可以在自己的能力条目里声明更严格的阈值,但不能把平台阈值改松;否则每个团队都会把门禁调成「刚好通过」。

第二条,失败结论必须带证据包编号。没有编号的失败无法复盘,没有编号的通过也无法审计。平台在合并请求与发布单里都展示编号,调用方据此追溯。

第三条,豁免是借款,不是资产。豁免记录要进入季度治理报表,按团队统计次数、到期未修复比例与续期次数。豁免用得多的团队,下个季度应该被要求补自动化测试,而不是继续申请豁免。

小结

三级门禁分别回答三个问题:这次合并有没有引入退化,这个版本能不能发布,这个模型能不能整体替换。样本量、指标与责任方随层级变化,但判定原则不变:缺证据按失败、硬指标不豁免、每次判定留证据包。

示例样本上,提交级 500 条可以通过,成功率降到 94.00% 会失败;发布级安全违规率上升到 0.5% 时,任何豁免都无效;有效的成功率豁免会记录责任人与到期日。门禁的成本也要落账:一次 1,000 条的发布级回归约占内容团队月度配额的 15.5%。

阅读导航:上一节:5.1 评测集的治理:版本、来源与污染 · 下一节:5.3 人工评审与仲裁 。

常见错误与边界

  • 把「本地跑过」当成提交级门禁。没有统一 runner 与证据包,结果无法复核。
  • 缺指标时跳过检查。缺证据不是通过,应该按失败处理。
  • 用合计成功率代替逐链路基线。A 的退化会被 B、C 的好成绩掩盖。
  • 允许安全指标豁免。先上线再补安全,最后通常补不上。
  • 豁免没有到期日。没有到期的豁免就是永久放行。
  • 豁免理由写「已知问题」。没有残余风险描述的豁免无法判断边界。
  • 只留结论不留证据包。半年后复盘时,没人能证明当时跑了什么。
  • 提交级和发布级用同一套样本量。提交级太贵会被绕过,发布级太少会漏判。
  • 让调用方修改平台阈值。门禁参数只能收紧,不能放宽。
  • 不统计门禁成本。回归预算挤占业务配额,最后会以「跑不起」收场。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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