《互联网产品的 AI 架构·推理性能与成本工程》11.3 灰度发布与自动回滚

新模型通过离线门禁后进入 5% 灰度,错误率从 1.00% 升到 2.12%,自动回滚在第二个窗口触发。本节说明灰度比例怎样按租户粘性分桶、硬指标与软指标为什么要不同回滚条件、样本不足为什么只能 hold,以及回滚后在途请求、缓存、幂等记录与下游写入怎样保持一致。

5% 灰度里的第二小时

「回声」的新模型通过了 11.2 的离线门禁,按计划先放 1% 流量。第一个观测窗口里,treatment 只有 100 个请求、1 个错误,延迟和成本都正常;样本量低于门槛,系统返回 hold,没有继续加量,也没有宣布成功。

第二天凌晨,灰度扩到 5%。半小时后的窗口里,treatment 2500 个请求错了 53 个,错误率 2.12%;control 47500 个请求错了 475 个,错误率 1.00%。错误率绝对值和相对 control 的差值同时越过硬指标,自动回滚在 5% 这一档触发,没有再往 25% 走。

这次没有等到用户投诉,是因为回滚条件在发布前就写死了。本节讨论灰度比例、观测指标、回滚条件,以及最容易被忽略的一半:回滚之后,已经写下的数据怎么办。文中数字来自 ai-lab 的示例灰度窗口运行结果,不是生产实测。

灰度比例与粘性分桶

灰度不是随机抽请求,而是按租户分桶。同一个租户在同一个 rollout 内始终落在同一臂:

bucket = floor(hash(rolloutId, tenantId) / 2^32 × 10000)
arm    = bucket < percent × 100 ? treatment : control

分桶只依赖 rolloutId 与 tenantId,不依赖当前比例。1% 时进入 treatment 的租户,在 5%、25% 时仍然是 treatment;扩量只会让更多租户进入,不会把已经体验过新模型的租户换回旧模型。反过来,缩量时也不会把 control 租户误送进 treatment。

示例用 10000 个租户模拟 5% 分配,得到 517 个 treatment。比例不是精确的 500,因为哈希分桶有随机波动;517 仍在合理范围内。灰度系统不应该追求每个窗口都精确等于目标比例,而应该保证同一租户的臂稳定。

比例阶梯通常取 1%、5%、25%、50%、100%。每一档都要有独立的最小样本量与观测窗口,不能因为“上一档没问题”就跳过。1% 只能验证可用性与基本延迟,质量信号要到 5% 或更高才出现。

观测哪些指标

灰度窗口至少需要五组指标:

指标用途建议口径
错误率可用性网关错误类别,不含降级
P95 延迟交互体验端到端,不含排队
平均成本成本整数微元/请求
质量通过率效果抽样评测,固定判定器
样本量证据两臂各自的请求数与评测数

错误率要同时看绝对值与相对 control 的差值。只看绝对值,会在 control 本身就不稳定的时段误判;只看差值,会在两臂都变差时漏判。两个条件都满足才算硬指标通过。

质量指标需要单独抽样评测,不能直接用用户反馈代替。用户反馈有滞后与选择偏差,灰度窗口只有几十分钟,等不到足够反馈。示例的 5% 窗口每臂抽 250 条与 950 条,1% 窗口只有 100 条;低于 200 条时质量检查不启用,窗口只能 hold。

硬指标与软指标

回滚条件按指标性质分两类:

  • 硬指标:错误率绝对值、错误率相对差值。一次越界立即回滚,因为错误直接影响可用性。
  • 软指标:P95 延迟、平均成本、质量通过率。连续两个窗口越界才回滚,避免一次抖动打回全量。

示例的 5% 窗口同时触发两条硬指标:treatment 错误率 2.12% 超过 2.00% 的绝对上限,相对 control 的差值 1.12 个百分点超过 0.50 个百分点的上限。evaluateWindow 返回 hardBreach=true,evaluateRollout 不再等待第二个窗口,直接回滚。

软指标连续计数有一个细节:样本不足的窗口不能重置计数,也不能算作通过。它只返回 hold;否则一次低流量窗口会把之前的越界记录清零。

回滚之后,数据不会自动回到过去

回滚路由只需要把新流量切回 control,但已经发生的副作用不会消失:

  • 在途请求可能还在 treatment 上执行,切流后仍会写缓存、写事件、调用下游。
  • treatment 写入的缓存条目可能被 control 读到,造成模型行为混杂。
  • 同一个幂等键可能已经由 treatment 返回过结果,重算会重复扣费或重复写下游。
  • 账本与事件流已经记录了 treatment 的消耗,不能改写历史。

因此回滚计划要按“切流、排空、隔离、对账”四步走:

  1. 新流量 100% 切回 control,停止 treatment 分配。
  2. 给在途请求一个排空期限;期限后仍未结束的请求标记为未知,进入对账。
  3. 缓存键提升 epoch,而不是暴力删除。旧条目自然过期,新请求不会读到 treatment 的结果。
  4. 保留幂等记录,按 rolloutId + arm 对账账本、事件与下游写入;只补差异,不改历史。

缓存为什么用 epoch 而不是删除?批量删除会制造缓存穿透,回滚瞬间把压力打到模型;epoch 提升让旧键失效但保留数据,读不到时按正常路径重建,压力可控。

幂等记录为什么保留?因为 treatment 已经返回的结果是事实。回滚后同一个键再来,应该返回原结果或明确告知状态,而不是重新调用模型。重算可能产生第二次副作用,也可能因为模型已切回而返回不一致的内容。

实现

下面的模块做三件事:按租户粘性分桶、按窗口评估硬软指标、生成回滚计划。它不直接操作路由或缓存,只输出可被发布系统执行的判定与步骤。

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

import { createHash } from "node:crypto";
import { AiError } from "../core/errors.ts";

export interface ArmMetrics {
  requests: number; errors: number; p95LatencyMs: number;
  avgCostMicros: number; qualityPasses: number; qualitySamples: number;
}

export interface RolloutWindow {
  /** 当前灰度比例,1 到 100 的整数。 */
  stagePercent: number; startedAtMs: number;
  control: ArmMetrics; treatment: ArmMetrics;
}

export interface RollbackPolicy {
  /** treatment 错误率允许比 control 高出的绝对值。 */
  maxErrorRateDelta: number; maxErrorRate: number;
  maxLatencyRatio: number; maxCostRatio: number; maxQualityDrop: number;
  minRequestsPerArm: number; minQualitySamples: number;
  /** 软指标连续多少个窗口越界才回滚;硬指标立即回滚。 */
  consecutiveBreaches: number;
}

export const DEFAULT_ROLLBACK: RollbackPolicy = {
  maxErrorRateDelta: 0.005, maxErrorRate: 0.02,
  maxLatencyRatio: 1.15, maxCostRatio: 1.1, maxQualityDrop: 0.02,
  minRequestsPerArm: 1000, minQualitySamples: 200, consecutiveBreaches: 2,
};

export type Arm = "control" | "treatment";

/**
 * 同一个租户在同一个 rollout 内始终落在同一臂:分桶只依赖 rolloutId 与 tenantId,
 * 不依赖当前比例,所以 1% 升到 5% 时已经进入 treatment 的租户不会被换回去。
 */
export function assignArm(tenantId: string, rolloutId: string, percent: number): Arm {
  if (typeof tenantId !== "string" || tenantId.trim() === "") fail("tenantId is required");
  if (typeof rolloutId !== "string" || rolloutId.trim() === "") fail("rolloutId is required");
  if (!Number.isFinite(percent) || percent < 0 || percent > 100) fail("percent must be between 0 and 100");
  if (percent === 0) return "control";
  if (percent === 100) return "treatment";
  const digest = createHash("sha256").update(`${rolloutId}\u0000${tenantId}`, "utf8").digest();
  const bucket = Math.floor((digest.readUInt32BE(0) / 4294967296) * 10000);
  return bucket < Math.round(percent * 100) ? "treatment" : "control";
}

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

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

function validateMetrics(metrics: ArmMetrics, label: string): void {
  integer(metrics.requests, `${label}.requests`, 0);
  integer(metrics.errors, `${label}.errors`, 0);
  if (metrics.errors > metrics.requests) fail(`${label}.errors cannot exceed requests`);
  if (!Number.isFinite(metrics.p95LatencyMs) || metrics.p95LatencyMs < 0) fail(`${label}.p95LatencyMs must be >= 0`);
  integer(metrics.avgCostMicros, `${label}.avgCostMicros`, 0);
  integer(metrics.qualitySamples, `${label}.qualitySamples`, 0);
  integer(metrics.qualityPasses, `${label}.qualityPasses`, 0);
  if (metrics.qualityPasses > metrics.qualitySamples) fail(`${label}.qualityPasses cannot exceed qualitySamples`);
}

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

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

export type CheckSeverity = "hard" | "soft";

export interface RolloutCheck {
  id: string;
  passed: boolean;
  severity: CheckSeverity;
  detail: string;
}

export interface WindowEvaluation {
  stagePercent: number;
  checks: RolloutCheck[];
  hardBreach: boolean;
  softBreach: boolean;
  sufficient: boolean;
  action: "pass" | "hold" | "breach";
}

/**
 * 单个观测窗口的判定。错误率是硬指标,延迟、成本与质量是软指标:
 * 硬指标立即回滚,软指标要连续多个窗口越界才回滚,避免一次抖动打回全量。
 */
export function evaluateWindow(window: RolloutWindow, policy: RollbackPolicy = DEFAULT_ROLLBACK): WindowEvaluation {
  integer(window.stagePercent, "stagePercent", 1);
  if (window.stagePercent > 100) fail("stagePercent must be <= 100");
  integer(window.startedAtMs, "startedAtMs", 0);
  validateMetrics(window.control, "control");
  validateMetrics(window.treatment, "treatment");

  const controlError = rate(window.control.errors, window.control.requests);
  const treatmentError = rate(window.treatment.errors, window.treatment.requests);
  const controlQuality = rate(window.control.qualityPasses, window.control.qualitySamples);
  const treatmentQuality = rate(window.treatment.qualityPasses, window.treatment.qualitySamples);
  const checks: RolloutCheck[] = [];

  checks.push({ id: "error-absolute", passed: treatmentError <= policy.maxErrorRate, severity: "hard", detail: `treatment error ${(treatmentError * 100).toFixed(2)}%, limit ${(policy.maxErrorRate * 100).toFixed(2)}%` });
  checks.push({ id: "error-delta", passed: treatmentError - controlError <= policy.maxErrorRateDelta, severity: "hard", detail: `delta ${((treatmentError - controlError) * 100).toFixed(2)}pt, limit ${(policy.maxErrorRateDelta * 100).toFixed(2)}pt` });
  checks.push({ id: "latency", passed: ratio(window.treatment.p95LatencyMs, window.control.p95LatencyMs) <= policy.maxLatencyRatio, severity: "soft", detail: `p95 ratio ${ratio(window.treatment.p95LatencyMs, window.control.p95LatencyMs).toFixed(3)} <= ${policy.maxLatencyRatio}` });
  checks.push({ id: "cost", passed: ratio(window.treatment.avgCostMicros, window.control.avgCostMicros) <= policy.maxCostRatio, severity: "soft", detail: `cost ratio ${ratio(window.treatment.avgCostMicros, window.control.avgCostMicros).toFixed(3)} <= ${policy.maxCostRatio}` });

  const qualitySufficient =
    window.control.qualitySamples >= policy.minQualitySamples &&
    window.treatment.qualitySamples >= policy.minQualitySamples;
  if (qualitySufficient) {
    checks.push({ id: "quality", passed: treatmentQuality >= controlQuality - policy.maxQualityDrop, severity: "soft", detail: `quality delta ${((treatmentQuality - controlQuality) * 100).toFixed(2)}pt >= -${(policy.maxQualityDrop * 100).toFixed(2)}pt` });
  }

  const requestsSufficient =
    window.control.requests >= policy.minRequestsPerArm &&
    window.treatment.requests >= policy.minRequestsPerArm;
  const sufficient = requestsSufficient && qualitySufficient;
  const hardBreach = checks.some((check) => check.severity === "hard" && !check.passed);
  const softBreach = checks.some((check) => check.severity === "soft" && !check.passed);
  return {
    stagePercent: window.stagePercent, checks, hardBreach, softBreach, sufficient,
    action: hardBreach || softBreach ? "breach" : sufficient ? "pass" : "hold",
  };
}

export interface RolloutDecision {
  action: "continue" | "hold" | "rollback";
  stoppedAtStage: number | null;
  breachStreak: number;
  windows: WindowEvaluation[];
  reasons: string[];
}

/**
 * 按时间顺序回放观测窗口。硬指标一次越界就回滚;软指标连续越界达到阈值才回滚。
 * 样本不足的窗口只返回 hold,不用“看起来没问题”代替证据。
 */
export function evaluateRollout(
  windows: readonly RolloutWindow[],
  policy: RollbackPolicy = DEFAULT_ROLLBACK,
): RolloutDecision {
  if (windows.length === 0) fail("at least one rollout window is required");
  integer(policy.minRequestsPerArm, "minRequestsPerArm", 1);
  integer(policy.minQualitySamples, "minQualitySamples", 1);
  integer(policy.consecutiveBreaches, "consecutiveBreaches", 1);

  const evaluations: WindowEvaluation[] = [];
  const reasons: string[] = [];
  let streak = 0;
  let action: RolloutDecision["action"] = "continue";
  let stoppedAtStage: number | null = null;
  let previousStart = -1;

  for (const window of windows) {
    if (window.startedAtMs < previousStart) fail("windows must be ordered by startedAtMs");
    previousStart = window.startedAtMs;
    const evaluation = evaluateWindow(window, policy);
    evaluations.push(evaluation);
    for (const check of evaluation.checks.filter((item) => !item.passed)) {
      reasons.push(`stage ${window.stagePercent}%: ${check.id} (${check.detail})`);
    }
    if (evaluation.hardBreach) {
      action = "rollback";
      stoppedAtStage = window.stagePercent;
      break;
    }
    if (evaluation.softBreach) {
      streak += 1;
      if (streak >= policy.consecutiveBreaches) {
        action = "rollback";
        stoppedAtStage = window.stagePercent;
        break;
      }
      action = "hold";
      continue;
    }
    if (evaluation.sufficient) {
      streak = 0;
      action = "continue";
    } else {
      action = "hold";
    }
  }
  return { action, stoppedAtStage, breachStreak: streak, windows: evaluations, reasons };
}

export interface RollbackContext {
  rolloutId: string; treatmentPercent: number; rollbackAtMs: number;
  inFlightRequests: number; drainPerSecond: number; graceMs: number;
  cacheKeyPrefixes: readonly string[]; downstreamTargets: readonly string[];
  idempotencyWindowMs: number;
}

export interface RollbackPlan {
  routeTreatmentPercent: 0; drainDeadlineMs: number; cacheAction: "bump-epoch";
  cacheEpoch: string; keepIdempotencyRecords: true; idempotencyWindowMs: number;
  reconcileTargets: string[]; steps: string[];
}

/**
 * 回滚计划先切流、再排空、最后对账。缓存用 epoch 提升而不是暴力删除;
 * 幂等记录保留,避免同一个键被重算成第二次副作用。
 */
export function planRollback(context: RollbackContext): RollbackPlan {
  if (typeof context.rolloutId !== "string" || context.rolloutId.trim() === "") fail("rolloutId is required");
  if (!Number.isFinite(context.treatmentPercent) || context.treatmentPercent < 0 || context.treatmentPercent > 100) {
    fail("treatmentPercent must be between 0 and 100");
  }
  integer(context.rollbackAtMs, "rollbackAtMs", 0);
  integer(context.inFlightRequests, "inFlightRequests", 0);
  if (!Number.isFinite(context.drainPerSecond) || context.drainPerSecond <= 0) fail("drainPerSecond must be > 0");
  integer(context.graceMs, "graceMs", 0);
  integer(context.idempotencyWindowMs, "idempotencyWindowMs", 0);

  const drainMs = context.inFlightRequests === 0 ? 0 : Math.ceil((context.inFlightRequests / context.drainPerSecond) * 1000);
  const drainDeadlineMs = drainMs + context.graceMs;
  const cacheEpoch = `${context.rolloutId}:${context.rollbackAtMs}`;
  const prefixes = [...new Set(context.cacheKeyPrefixes)].sort();
  const targets = [...new Set(context.downstreamTargets)].sort();
  const steps = [
    "route 100% of new traffic back to control and stop treatment assignment",
    `drain ${context.inFlightRequests} in-flight requests within ${drainDeadlineMs}ms`,
    prefixes.length > 0 ? `bump cache epoch to ${cacheEpoch} for [${prefixes.join(", ")}]` : `bump cache epoch to ${cacheEpoch}`,
    `keep idempotency records for at least ${context.idempotencyWindowMs}ms; never recompute a served key`,
    targets.length > 0 ? `reconcile downstream targets [${targets.join(", ")}] by rolloutId and arm` : "reconcile downstream targets by rolloutId and arm",
  ];
  return {
    routeTreatmentPercent: 0, drainDeadlineMs, cacheAction: "bump-epoch", cacheEpoch,
    keepIdempotencyRecords: true, idempotencyWindowMs: context.idempotencyWindowMs,
    reconcileTargets: targets, steps,
  };
}

assignArm 只依赖 rolloutId 与 tenantId,所以扩量不会改变已进入 treatment 的租户。evaluateWindow 把错误率标为 hard,把延迟、成本、质量标为 soft;evaluateRollout 对 hard 立即回滚,对 soft 累计连续窗口。

planRollback 不删除任何数据。它输出切流比例、排空期限、缓存 epoch、幂等保留时长与对账目标,让发布系统按步骤执行。把“回滚”定义成一组可审计的动作,而不是一句“切回去”。

示例样本上的运行结果

在实验工程根目录执行:

node examples/perf/rollout.ts

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

decision=rollback stopped=5 streak=0
window stage=1 action=hold sufficient=false hard=false soft=false
window stage=5 action=breach sufficient=true hard=true soft=false
reason stage 5%: error-absolute (treatment error 2.12%, limit 2.00%)
reason stage 5%: error-delta (delta 1.12pt, limit 0.50pt)
assignment 5% treated=517/10000
rollback route=0 drain=7500ms epoch=exp-42:1762300900000
step route 100% of new traffic back to control and stop treatment assignment
step drain 1800 in-flight requests within 7500ms
step bump cache epoch to exp-42:1762300900000 for [chat:reply, chat:reply:v2]
step keep idempotency records for at least 86400000ms; never recompute a served key
step reconcile downstream targets [event-log, ledger] by rolloutId and arm

这组结果说明三个判断。第一,1% 窗口不是“通过”,而是 hold:100 个请求与 100 条质量样本都不足,系统没有足够证据加量。第二,5% 窗口的硬指标一次越界就触发回滚,stopped=5,没有继续升到 25%。第三,回滚计划给出 7500ms 的排空期限:1800 个在途请求按 300 个/秒排空需要 6000ms,再加 1500ms 宽限。5% 的目标是 500 个租户,实际分到 517 个,偏差来自哈希分桶;如果某次分到 700 个,就要检查哈希输入是否包含会变化的字段。

小结

灰度按租户粘性分桶,扩量不改变已进入 treatment 的租户。错误率是硬指标,一次越界立即回滚;延迟、成本与质量是软指标,连续两个窗口越界才回滚;样本不足只能 hold。回滚不是删数据:先切流、再排空、用 epoch 隔离缓存、保留幂等记录、按 arm 对账下游写入。

示例样本在 1% 窗口因样本不足 hold,在 5% 窗口因错误率 2.12% 与差值 1.12 个百分点触发硬回滚,排空 1800 个在途请求用了 7500ms。下一节进入规模化稳定性:当供应商限流、区域故障与流量突增同时发生时,这套灰度与回滚机制怎样不被自身压垮。

阅读导航:上一节:11.2 评测集与回归门禁 · 下一节:12.1 供应商限流与分流 。

常见错误与边界

  • 按请求随机分桶。同一用户会在两臂之间跳,体验与指标都失真。
  • 分桶包含会变化的字段。扩量时已进入 treatment 的用户被换回 control。
  • 1% 窗口就宣布质量通过。样本不足,质量检查根本没有启用。
  • 只看错误率绝对值。control 本身变差时,差值检查才能发现。
  • 硬指标也等连续两个窗口。错误已经发生,等待只会扩大影响。
  • 软指标一次越界就回滚。正常抖动会把发布反复打断。
  • 样本不足的窗口算作通过。低流量窗口会把连续越界计数清零。
  • 回滚时删除缓存。缓存穿透会把压力瞬间打回模型。
  • 回滚后重算幂等键。可能重复扣费或重复写下游。
  • 改写历史账本与事件。回滚应补差异,不应篡改已发生的记录。
  • 把示例样本结果写成生产实测。数字必须标注来源与样本量。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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