《互联网产品的 AI 架构·平台化与治理》12.1 模型换代与迁移计划

模型换代不是改一个模型 ID,而是一次跨团队迁移。本节给出迁移计划的六个字段与五个阶段(评估、影子、两档灰度、全量),用确定性代码判定推进、保持与回滚,并把灰度比例、回滚红线和旧模型下线日写成可执行条件,最后说明它如何与卷四 3.2 的版本矩阵衔接。

供应商发来一封下线邮件

「回声」的模型平台收到通知:m-standard-v3 将在 2026-12-15 停止服务。五个能力、三条链路、十二个调用方都在用它。团队的直觉是「把模型 ID 换成 v4,重跑一次评测」。三周后,内容链路的摘要格式变了,客服链路的回复开始夹带英文,交易链路的 P95 涨了两成,而旧模型已经进入下线倒计时。

问题不在 v4 本身,而在换代被当成一次发布。发布关心「新版本能不能上」,换代还要关心「旧版本什么时候必须下、下之前谁迁完、迁不动时退到哪」。这三个问题不能靠上线当天的判断回答,必须提前写进一份计划。

迁移计划不是甘特图。它是一组可判定的条件:每个阶段看哪些指标、达到什么程度才能推进、越过哪条线必须回滚、旧模型在哪一天必须没有流量。本节把这些条件写成确定性代码,所有运行结果都来自示例样本,不是生产实测。

计划要写清的六个字段

字段示例缺失后的表现
能力与模型对chat.reply:m-standard-v3 → m-standard-v4不知道迁谁,也不知道迁到哪
开始日2026-11-09阶段窗口无法排期,通知没有锚点
阶段天数评估 5、影子 7、灰度 3/5、全量 7每个阶段无限期停留,或跳过影子
门禁样本、成功率、P95、成本、质量五组阈值只能凭感觉判断「看起来还行」
回滚红线成功率降 1 个百分点、质量降 0.02小抖动就回滚,或大崩塌还在观察
旧模型下线日2026-12-15供应商关停时才发现还有人调用

六个字段里,门禁与回滚红线必须分开。门禁回答「能不能继续往前」,红线回答「要不要立刻后退」。只有门禁时,团队会为了推进而解释指标;只有红线时,团队会在灰色地带一直等。两套阈值同时存在,判定才有明确的三种结果:推进、保持、回滚。

五个阶段不是五个会议

示例计划从 11 月 9 日开始,五个阶段共 27 天,旧模型在 12 月 15 日下线。

阶段窗口对客流量目的
assess11-09 至 11-130冻结评测集、确认契约差异、准备回滚包
shadow11-14 至 11-200复制真实请求给新模型,只比对不返回
canary-1011-21 至 11-2310%小流量验证真实用户路径
canary-5011-24 至 11-2850%放大样本,观察尾延迟与成本
full11-29 至 12-05100%全量运行,旧模型只做回滚备份

影子阶段的流量占比是 0,但它并不是「什么都不做」。平台把请求复制一份给新模型,记录输出、延迟与成本,再与旧模型逐条比对。影子阶段能发现格式与事实错误,发现不了用户真实反馈,所以它后面还必须有一档小比例灰度。

灰度比例不是装饰。10% 阶段样本少,主要看有没有硬故障;50% 阶段样本足够,才适合判断成功率与 P95 的微小漂移。把 10% 直接跳到 100%,等于放弃一次用真实流量纠错的机会。

判定只有三种动作

decideStage 先算四个差值:成功率下降的百分点、P95 增长率、单次成本增长率、质量分绝对下降。然后按固定顺序判定。

第一,任何一项越过回滚红线,直接回滚。回滚不看样本量,因为崩塌已经发生。

第二,越过预警线但没到红线,保持当前阶段。保持不是失败,它表示「再收一批样本或先修问题」。

第三,样本量不足,也保持。指标再漂亮,800 个样本也不能支撑 50% 流量下的结论。

示例样本上的运行结果如下。

阶段样本成功率变化P95 变化成本变化质量变化判定
shadow5,000-0.05pp-1.67%+1.72%-0.0040推进到 10%
canary-103,200-0.35pp+12.50%+6.90%-0.0050保持
canary-103,400-0.20pp+5.00%+3.02%-0.0045推进到 50%
canary-50800-0.25pp+2.50%+3.45%-0.0010保持(样本不足)
canary-506,500-0.37pp+2.50%+3.45%-0.0030推进到全量
full9,000-1.55pp+5.00%+4.31%-0.0250回滚

这张表里最值得看的是两次「保持」。第一次是 P95 增长 12.50%,超过 10% 的预警线;团队没有回滚,而是定位到新模型在长上下文上的排队,修完后 P95 增长降到 5.00%。第二次是样本只有 800,指标全部合格,但计划仍然要求保持。如果这一次放行,50% 流量下的结论就建立在 800 个样本上,后面全量的 1.55 个百分点下跌会被更晚发现。

全量阶段的失败同时触发两条红线:成功率下降 1.55 个百分点,质量分下降 0.0250。decideStage 返回回滚,流量占比归零。回滚不是「再观察一周」,因为两条红线同时被越过时,继续观察只会扩大影响面。

下线日不是公告日

旧模型下线日回答的是「哪一天之后不允许再有流量」,不是「哪一天发通知」。retirementState 同时看日期与残余流量,返回四种状态。

日期旧模型残余流量状态含义
12-012%not-due还有 14 天,继续迁移
12-152%blocked已到下线日,但仍有流量,不能关停
12-150ready到期且无流量,可以关停
12-180overdue已过期仍未关停,进入事故流程

blocked 与 overdue 的区别很重要。前者是迁移没做完,责任在调用方;后者是迁移做完了但没人执行关停,责任在平台。把两者混成一个「未完成」,季度复盘时就找不到真正的瓶颈。

实现

下面的模块只做三件事:生成阶段窗口、判定单阶段动作、判定旧模型能否下线。它不读监控,也不发通知。

// file: src/gov/migration-plan.ts
// tier: L2
// verify: node --test test/gov/evolution-gov.test.ts

/** 影子阶段复制流量但不向用户返回结果,因此对客占比仍是 0。 */
export type MigrationStage =
  | "assess" | "shadow" | "canary-10" | "canary-50" | "full" | "retired";

export const STAGE_ORDER: readonly MigrationStage[] = [
  "assess", "shadow", "canary-10", "canary-50", "full", "retired",
];

export const STAGE_TRAFFIC_SHARE: Record<MigrationStage, number> = {
  assess: 0, shadow: 0, "canary-10": 0.1, "canary-50": 0.5, full: 1, retired: 0,
};

export interface MigrationGates {
  minSamples: number;
  /** 成功率允许下降的百分点。 */
  maxSuccessDropPct: number;
  maxP95GrowthPct: number;
  maxCostGrowthPct: number;
  maxQualityDrop: number;
  /** 越过红线直接回滚,不再观察。 */
  rollbackSuccessDropPct: number;
  rollbackQualityDrop: number;
}


export const DEFAULT_GATES: MigrationGates = {
  minSamples: 2000, maxSuccessDropPct: 0.5, maxP95GrowthPct: 10, maxCostGrowthPct: 8,
  maxQualityDrop: 0.01, rollbackSuccessDropPct: 1, rollbackQualityDrop: 0.02,
};

export interface MigrationObservation {
  stage: MigrationStage; samples: number;
  successRate: number; baselineSuccessRate: number;
  p95Ms: number; baselineP95Ms: number;
  costMicrosPerCall: number; baselineCostMicrosPerCall: number;
  qualityScore: number; baselineQualityScore: number;
}

export type GateCode =
  | "insufficient-samples" | "success-drop" | "success-collapse"
  | "p95-growth" | "cost-growth" | "quality-drop" | "quality-collapse";

export interface GateFinding { code: GateCode; severity: "hold" | "rollback"; detail: string }

export interface StageDecision {
  stage: MigrationStage;
  next: MigrationStage;
  action: "advance" | "hold" | "rollback";
  findings: GateFinding[];
  /** 推进后生效的对客流量占比;回滚时为 0。 */
  trafficShare: number;
}

function rate(value: number, field: string): void {
  if (!(value >= 0 && value <= 1)) throw new Error(`migration-plan: ${field} must be within 0..1`);
}

function checkObservation(observation: MigrationObservation): void {
  if (!STAGE_ORDER.includes(observation.stage)) {
    throw new Error(`migration-plan: unknown stage ${observation.stage}`);
  }
  if (!Number.isInteger(observation.samples) || observation.samples < 0) {
    throw new Error("migration-plan: samples must be a non-negative integer");
  }
  for (const field of ["successRate", "baselineSuccessRate", "qualityScore", "baselineQualityScore"] as const) {
    rate(observation[field], field);
  }
  if (!(observation.p95Ms > 0) || !(observation.baselineP95Ms > 0)) {
    throw new Error("migration-plan: p95 must be positive");
  }
  for (const field of ["costMicrosPerCall", "baselineCostMicrosPerCall"] as const) {
    if (!Number.isInteger(observation[field]) || observation[field] < 0) {
      throw new Error(`migration-plan: ${field} must be a non-negative integer`);
    }
  }
}

/** 单阶段判定:先看红线,再看预警线,最后看样本量。 */
export function decideStage(
  observation: MigrationObservation,
  gates: MigrationGates = DEFAULT_GATES,
): StageDecision {
  checkObservation(observation);
  const successDrop = (observation.baselineSuccessRate - observation.successRate) * 100;
  const p95Growth = (observation.p95Ms - observation.baselineP95Ms) / observation.baselineP95Ms * 100;
  const costGrowth = (observation.costMicrosPerCall - observation.baselineCostMicrosPerCall)
    / observation.baselineCostMicrosPerCall * 100;
  const qualityDrop = observation.baselineQualityScore - observation.qualityScore;
  const findings: GateFinding[] = [];
  const hold = (code: GateCode, detail: string): void => {
    findings.push({ code, severity: "hold", detail });
  };
  if (successDrop >= gates.rollbackSuccessDropPct) {
    findings.push({ code: "success-collapse", severity: "rollback", detail: `成功率下降 ${successDrop.toFixed(2)} 个百分点` });
  } else if (successDrop > gates.maxSuccessDropPct) {
    hold("success-drop", `成功率下降 ${successDrop.toFixed(2)} 个百分点`);
  }
  if (qualityDrop >= gates.rollbackQualityDrop) {
    findings.push({ code: "quality-collapse", severity: "rollback", detail: `质量分下降 ${qualityDrop.toFixed(4)}` });
  } else if (qualityDrop > gates.maxQualityDrop) {
    hold("quality-drop", `质量分下降 ${qualityDrop.toFixed(4)}`);
  }
  if (p95Growth > gates.maxP95GrowthPct) hold("p95-growth", `P95 增长 ${p95Growth.toFixed(2)}%`);
  if (costGrowth > gates.maxCostGrowthPct) hold("cost-growth", `单次成本增长 ${costGrowth.toFixed(2)}%`);
  if (observation.samples < gates.minSamples) hold("insufficient-samples", `样本 ${observation.samples} 少于 ${gates.minSamples}`);

  const action = findings.some((finding) => finding.severity === "rollback")
    ? "rollback" : findings.length > 0 ? "hold" : "advance";
  const index = STAGE_ORDER.indexOf(observation.stage);
  const next = action === "advance"
    ? STAGE_ORDER[Math.min(index + 1, STAGE_ORDER.length - 1)] : observation.stage;
  const trafficShare = action === "advance" ? STAGE_TRAFFIC_SHARE[next]
    : action === "rollback" ? 0 : STAGE_TRAFFIC_SHARE[observation.stage];
  return { stage: observation.stage, next, action, findings, trafficShare };
}

export interface StageWindow { stage: MigrationStage; share: number; start: string; end: string }

export interface MigrationPlanInput {
  capability: string;
  fromModel: string;
  toModel: string;
  startDate: string;
  stageDays: Record<Exclude<MigrationStage, "retired">, number>;
  gates: MigrationGates;
  oldModelOfflineDate: string;
}

export interface MigrationPlan {
  capability: string;
  fromModel: string;
  toModel: string;
  gates: MigrationGates;
  windows: StageWindow[];
  oldModelOfflineDate: string;
}

export type PlanIssueCode =
  | "same-model" | "invalid-date" | "date-order" | "invalid-stage-days"
  | "invalid-gate" | "offline-before-full";

export interface PlanIssue { code: PlanIssueCode; detail: string }

const PLAN_STAGES: readonly Exclude<MigrationStage, "retired">[] = [
  "assess", "shadow", "canary-10", "canary-50", "full",
];

const DAY_MS = 86_400_000;

function parseDate(value: string): number | null {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
  if (match === null) return null;
  const time = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
  return new Date(time).toISOString().slice(0, 10) === value ? time : null;
}

const formatDate = (time: number): string => new Date(time).toISOString().slice(0, 10);

/** 旧模型下线日不得早于全量阶段结束,否则计划自相矛盾。 */
export function planMigration(
  input: MigrationPlanInput,
): { ok: true; plan: MigrationPlan } | { ok: false; issues: PlanIssue[] } {
  const issues: PlanIssue[] = [];
  const add = (code: PlanIssueCode, detail: string): void => { issues.push({ code, detail }); };
  if (input.fromModel.trim() === "" || input.toModel.trim() === "") {
    add("same-model", "model ids must not be empty");
  } else if (input.fromModel === input.toModel) {
    add("same-model", "from and to model are identical");
  }
  const start = parseDate(input.startDate);
  const offline = parseDate(input.oldModelOfflineDate);
  if (start === null) add("invalid-date", `startDate ${input.startDate}`);
  if (offline === null) add("invalid-date", `oldModelOfflineDate ${input.oldModelOfflineDate}`);
  for (const stage of PLAN_STAGES) {
    const days = input.stageDays[stage];
    if (!Number.isInteger(days) || days < 1) add("invalid-stage-days", `${stage} must be a positive integer`);
  }
  const gates = input.gates;
  if (!Number.isInteger(gates.minSamples) || gates.minSamples < 1) add("invalid-gate", "minSamples must be positive");
  if (!(gates.rollbackSuccessDropPct > gates.maxSuccessDropPct)) {
    add("invalid-gate", "rollback success gate must exceed hold gate");
  }
  if (!(gates.rollbackQualityDrop > gates.maxQualityDrop)) {
    add("invalid-gate", "rollback quality gate must exceed hold gate");
  }
  if (issues.length > 0 || start === null || offline === null) return { ok: false, issues };

  const windows: StageWindow[] = [];
  let cursor = start;
  for (const stage of PLAN_STAGES) {
    const end = cursor + input.stageDays[stage] * DAY_MS;
    windows.push({ stage, share: STAGE_TRAFFIC_SHARE[stage], start: formatDate(cursor), end: formatDate(end) });
    cursor = end;
  }
  if (offline < cursor) {
    add("offline-before-full", `offline ${input.oldModelOfflineDate} precedes full end ${formatDate(cursor)}`);
    return { ok: false, issues };
  }
  return {
    ok: true,
    plan: {
      capability: input.capability, fromModel: input.fromModel, toModel: input.toModel,
      gates: { ...gates }, windows, oldModelOfflineDate: input.oldModelOfflineDate,
    },
  };
}

export type RetirementState = "not-due" | "blocked" | "ready" | "overdue";

export interface RetirementDecision {
  state: RetirementState;
  daysLeft: number;
  residualShare: number;
  detail: string;
}

/** 下线日只约束时间;能否关停还要求旧模型残余流量为 0。 */
export function retirementState(
  plan: MigrationPlan, today: string, residualShare: number,
): RetirementDecision {
  const now = parseDate(today);
  const offline = parseDate(plan.oldModelOfflineDate);
  if (now === null || offline === null) throw new Error("migration-plan: invalid date");
  if (!(residualShare >= 0 && residualShare <= 1)) {
    throw new Error("migration-plan: residualShare must be within 0..1");
  }
  const daysLeft = Math.round((offline - now) / DAY_MS);
  if (now < offline) {
    return { state: "not-due", daysLeft, residualShare, detail: `距下线日还有 ${daysLeft} 天` };
  }
  if (residualShare > 0) {
    return { state: "blocked", daysLeft, residualShare,
      detail: `已到下线日,旧模型仍有 ${(residualShare * 100).toFixed(1)}% 流量` };
  }
  return now === offline
    ? { state: "ready", daysLeft: 0, residualShare, detail: "下线日到达且残余流量为 0" }
    : { state: "overdue", daysLeft, residualShare, detail: `下线日已过 ${Math.abs(daysLeft)} 天` };
}

与 3.2 版本矩阵的呼应

3.2 模型、提示与评测集的版本矩阵 用 active、dual-run、deprecated、retired 四个状态描述一个能力与模型的组合。迁移计划正好落在 dual-run 到 retired 这一段。

矩阵回答「现在有哪些格子、每个格子是什么状态」,迁移计划回答「一个格子怎么从旧状态走到新状态」。两者用同一组事实对齐:能力名、旧模型、新模型、下线日。矩阵里的 dual-run 对应影子与两档灰度,deprecated 对应全量后旧模型只做备份,retired 对应下线日之后残余流量为 0。

没有矩阵,迁移计划不知道还有哪些调用方;没有迁移计划,矩阵只能看到状态,看不到状态之间的门槛。把两者接起来,模型换代才从「一次发布」变成「一条可审计的迁移线」。

小结

迁移计划要写清六个字段:能力与模型对、开始日、阶段天数、门禁、回滚红线、旧模型下线日。五个阶段是评估、影子、10% 灰度、50% 灰度、全量;影子复制流量但不返回结果,灰度比例决定样本能支撑什么结论。

判定只有推进、保持、回滚三种动作,顺序是先红线、再预警线、最后样本量。示例中两次保持分别因为 P95 增长 12.50% 与样本只有 800;全量阶段成功率降 1.55 个百分点、质量降 0.0250,触发回滚。下线日同时受日期与残余流量约束,blocked 与 overdue 要分开记账。

阅读导航:上一节:11.3 培训、文档与知识传递 · 下一节:12.2 技术债与退出条件 。

常见错误与边界

  • 把换代当成换模型 ID。契约、提示、评测集与调用方都不会自动跟着变。
  • 跳过影子阶段。直接小流量上线,格式错误会先被真实用户看到。
  • 影子阶段当成全量验证。影子没有用户反馈,只能证明「不崩」,不能证明「更好」。
  • 门禁与回滚红线共用一套阈值。要么推进太慢,要么崩塌时还在观察。
  • 样本不足时放行。800 个样本的 0.25 个百分点差异没有统计意义。
  • 回滚只切模型。提示与采样参数不回退,回滚后的行为仍不是原基线。
  • 把下线日写成通知日。通知可以提前,关停必须有残余流量为 0 的证据。
  • 把 blocked 与 overdue 混为一谈。前者是迁移责任,后者是执行责任。
  • 计划没有 owner。阶段窗口、门禁与下线日都需要一个人对日期负责。
  • 只在本节内部对齐,不回看 3.2 的矩阵。两条记录对不上,审计时无法解释谁迁了什么。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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