《互联网产品的 AI 架构·平台化与治理》2.3 能力退役与迁移

退役不是删除目录条目,而是一次跨团队迁移项目。本节把退役拆成公告、双跑、下线三个阶段,定义 90 天公告期、30 天双跑窗口与强制下线的四项判据,用确定性状态机校验日期顺序、迁移工具、调用方迁移率与残余流量,并给出示例样本上 5 个调用方从 100% 迁移到 1.1% 残余的判定结果。

退役不是从目录删一行

能力下线最常见的做法,是在目录里把状态改成 deprecated,然后在某个周五关掉旧版本。第二天,内容链路的摘要节点开始报 404,客服链路的回复缺了引用字段,交易链路的评测集整批判错。

问题不在关闭动作,而在关闭之前没有一条可执行的迁移流程。退役要回答四个问题:谁会受影响、替代版本是什么、每个调用方什么时候迁完、什么条件下才允许强制下线。缺任何一个,退役都会从计划变成事故。

2.1 的条目要求填写 retireAt,但一个日期本身不构成流程。本节把日期扩展成状态机:公告、双跑、下线三个阶段,每个阶段有准入条件与退出证据;下线不是按日期自动发生,而是四项判据同时成立。

四个阶段与准入条件

阶段含义允许的动作进入下一阶段的条件
active正常服务发布补丁与次版本公告日期到达
announced已公告,仍全量服务只修安全与迁移阻塞问题双跑开始日期到达,且迁移工具就绪
dual-run新旧并行,旧版本只接迁移流量修复差异、回滚、逐批切流下线日期到达,且四项下线判据全满足
retired不再接收流量只保留文档与回放无

状态只能向前。不能从 active 直接跳到 dual-run,也不能在双跑未完成时标记 retired。每一次跃迁都需要证据,而不是一句「已经通知过了」。

公告期与双跑窗口是两个独立约束。公告期从 announcedAt 到 dualRunStart,保证调用方有时间排期;双跑窗口从 dualRunStart 到 retireAt,保证新旧版本有足够时间对比。示例策略取 90 天与 30 天,两个数字都可以按能力风险调整,但必须写进策略而不是每次临时决定。

公告期要交付什么

公告不是发一条群消息。一个可执行的公告至少包含五样东西:

  1. 依赖清单:哪些能力版本、哪些调用方、每个调用方的负责人。
  2. 替代方案:新版本号、契约差异、默认档位变化、计费单位是否变化。
  3. 迁移工具:命令、输入、输出、失败时的回滚方式。
  4. 时间表:公告日、双跑开始日、下线日,以及每个调用方的建议迁移窗口。
  5. 反馈渠道:迁移阻塞往哪里报,谁在规定时间内答复。

没有依赖清单,公告只能发给「知道的团队」;没有替代方案,调用方无法评估工作量;没有迁移工具,每个团队会自己写一版,差异在双跑时才暴露;没有时间表,退役日期会变成一次没有截止日的协商。

示例计划从 2026-06-01 公告到 2026-09-01 开始双跑,共 92 天;从双跑到达 2026-12-01 下线,共 91 天。两个窗口都满足 90 天与 30 天的策略下限。日期是日历日,不是时刻,比较时不需要时区换算。

迁移工具要输出什么

迁移工具不是一段「帮你改配置」的脚本,而是一份可回放的迁移记录。它至少要输出六类信息:

输出用途
依赖清单证明没有漏掉调用方,作为公告的附件
契约差异字段新增、删除、类型变化、默认档位变化
改写结果每个调用方的新版本范围与覆盖参数
回归样本迁移前后同一批请求的输入与期望输出
回滚包迁移失败时恢复旧配置的最小步骤
状态回写把每个调用方的迁移比例写回退役计划

迁移工具必须幂等。同一个调用方重复执行,不应产生第二份配置或第二个工单。它还必须能在 dry-run 模式下只输出差异,让调用方先看再改。

工具的输出要能被退役状态机消费。计划里的 migratedRatio 不是人工填写的百分比,而是工具按调用量回写的实测比例;hasOwnerApproval 来自工单系统,不是「他说可以了」。

强制下线的四项判据

日期到达只是允许下线的必要条件,不是充分条件。以下四项必须同时成立:

  • 到期:today >= retireAt。
  • 调用方迁移:每个调用方的迁移比例不低于 99.9%,按调用方数量计算迁移率。
  • 残余流量:旧版本残余调用占比不超过 0.5%,按调用量加权,而不是按调用方数量平均。
  • 负责人确认:每个调用方的负责人都留下书面确认。

此外,迁移工具不能为空。没有工具的双跑,等于让每个调用方手工试错。

四项判据里最容易作弊的是残余流量。五个调用方里有四个迁移率 100%,一个迁移率 98.2%,按调用方数量算迁移率是 80%,但按调用量加权,如果这个调用方占 60% 的流量,残余占比会远高于 0.5%。所以指标必须同时看「几个调用方迁完」和「多少流量迁完」,两者不能互相替代。

强制下线不是「到点就关」,而是「四项判据都满足才关」。如果判据不满足,正确动作是延期并记录原因,而不是关掉之后再说。

退役还要有业务理由。卷三的三能力编排把成本从 4,240 微元降到 3,786 微元,下降 10.71%,复用回本线是 28.47%;迁移后的新版本如果让复用率跌破这条线,退役就只是把成本从旧版本搬到新版本。收益核算必须沿用同一套整数微元口径,不能用「新版本更先进」代替。

把退役写成状态机

catalog-retirement.ts 提供四组能力:计划校验、迁移指标、强制下线判定与状态推进。计划校验检查日期顺序、公告与双跑窗口、迁移工具、调用方唯一性与比例范围;指标按调用量加权;状态推进只允许向前。

// file: src/gov/catalog-retirement.ts
// tier: L2
// verify: node --test test/gov/catalog.test.ts

import { isIsoDate } from "./catalog.ts";

export type RetirementStage = "active" | "announced" | "dual-run" | "retired";

export interface MigrationEntry {
  consumer: string;
  monthlyCalls: number;
  /** Share of this consumer's calls already on the replacement, 0..1. */
  migratedRatio: number;
  hasOwnerApproval: boolean;
}

export interface RetirementPlan {
  capability: string;
  version: string;
  stage: RetirementStage;
  announcedAt: string;
  dualRunStart: string;
  retireAt: string;
  migrationTool: string;
  consumers: readonly MigrationEntry[];
}

export interface RetirementPolicy {
  minNoticeDays: number;
  minDualRunDays: number;
  /** Maximum share of old-version traffic allowed at shutdown, 0..1. */
  residualShareLimit: number;
}

export const DEFAULT_RETIREMENT_POLICY: RetirementPolicy = {
  minNoticeDays: 90,
  minDualRunDays: 30,
  residualShareLimit: 0.005,
};

export type RetirementPlanIssueCode =
  | "bad-date"
  | "date-order"
  | "notice-too-short"
  | "dual-run-too-short"
  | "missing-tool"
  | "no-consumers"
  | "duplicate-consumer"
  | "invalid-calls"
  | "invalid-ratio"
  | "invalid-stage";

export interface RetirementPlanIssue {
  field: string;
  code: RetirementPlanIssueCode;
  detail: string;
}

const STAGES: readonly RetirementStage[] = ["active", "announced", "dual-run", "retired"];

export function daysBetween(from: string, to: string): number | null {
  if (!isIsoDate(from) || !isIsoDate(to)) return null;
  return (Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000;
}

export function validateRetirementPlan(
  plan: RetirementPlan,
  policy: RetirementPolicy = DEFAULT_RETIREMENT_POLICY,
): RetirementPlanIssue[] {
  const issues: RetirementPlanIssue[] = [];
  const add = (field: string, code: RetirementPlanIssueCode, detail: string): void => {
    issues.push({ field, code, detail });
  };
  if (!STAGES.includes(plan.stage)) add("stage", "invalid-stage", plan.stage);
  const dates = [plan.announcedAt, plan.dualRunStart, plan.retireAt];
  if (dates.some((date) => !isIsoDate(date))) {
    add("dates", "bad-date", dates.join(", "));
  } else if (plan.announcedAt >= plan.dualRunStart || plan.dualRunStart >= plan.retireAt) {
    add("dates", "date-order", "announcedAt < dualRunStart < retireAt is required");
  } else {
    const notice = daysBetween(plan.announcedAt, plan.dualRunStart)!;
    if (notice < policy.minNoticeDays) {
      add("dualRunStart", "notice-too-short", `${notice} < ${policy.minNoticeDays} days`);
    }
    const dual = daysBetween(plan.dualRunStart, plan.retireAt)!;
    if (dual < policy.minDualRunDays) {
      add("retireAt", "dual-run-too-short", `${dual} < ${policy.minDualRunDays} days`);
    }
  }
  if ((plan.stage === "dual-run" || plan.stage === "retired") && plan.migrationTool.trim() === "") {
    add("migrationTool", "missing-tool", "dual-run needs a migration tool");
  }
  if (plan.consumers.length === 0 && plan.stage !== "active") {
    add("consumers", "no-consumers", "no consumer can be migrated");
  }
  const seen = new Set<string>();
  for (const consumer of plan.consumers) {
    if (seen.has(consumer.consumer)) {
      add("consumers", "duplicate-consumer", consumer.consumer);
    }
    seen.add(consumer.consumer);
    if (!Number.isInteger(consumer.monthlyCalls) || consumer.monthlyCalls <= 0) {
      add("monthlyCalls", "invalid-calls", `${consumer.consumer}: ${consumer.monthlyCalls}`);
    }
    if (!(consumer.migratedRatio >= 0 && consumer.migratedRatio <= 1)) {
      add("migratedRatio", "invalid-ratio", `${consumer.consumer}: ${consumer.migratedRatio}`);
    }
  }
  return issues;
}

export interface RetirementMetrics {
  totalConsumers: number;
  migratedConsumers: number;
  migrationRatePct: number;
  residualSharePct: number;
  noticeDays: number;
  dualRunDays: number;
  daysUntilRetire: number;
}

const round1 = (value: number): number => Math.round(value * 10) / 10;

/** Weighted by calls so a tiny but stubborn consumer cannot dominate the view. */
export function retirementMetrics(
  plan: RetirementPlan,
  today: string = plan.retireAt,
): RetirementMetrics {
  const totalCalls = plan.consumers.reduce((sum, item) => sum + item.monthlyCalls, 0);
  const residual = plan.consumers.reduce(
    (sum, item) => sum + item.monthlyCalls * (1 - item.migratedRatio), 0,
  );
  const migrated = plan.consumers.filter((item) => item.migratedRatio >= 0.999).length;
  return {
    totalConsumers: plan.consumers.length,
    migratedConsumers: migrated,
    migrationRatePct: plan.consumers.length === 0
      ? 0 : round1((migrated / plan.consumers.length) * 100),
    residualSharePct: totalCalls === 0 ? 0 : round1((residual / totalCalls) * 100),
    noticeDays: daysBetween(plan.announcedAt, plan.dualRunStart) ?? 0,
    dualRunDays: daysBetween(plan.dualRunStart, plan.retireAt) ?? 0,
    daysUntilRetire: daysBetween(today, plan.retireAt) ?? 0,
  };
}

export type RetirementBlocker =
  | "invalid-plan"
  | "already-retired"
  | "notice-not-started"
  | "dual-run-not-started"
  | "missing-tool"
  | "no-consumers"
  | "not-due"
  | "unmigrated-consumers"
  | "residual-traffic"
  | "missing-approval";

export interface ForceOffDecision {
  allowed: boolean;
  blockers: RetirementBlocker[];
  metrics: RetirementMetrics;
}

/** Hard gate for the final switch: all four conditions must hold. */
export function evaluateForceOff(
  plan: RetirementPlan,
  today: string,
  policy: RetirementPolicy = DEFAULT_RETIREMENT_POLICY,
): ForceOffDecision {
  const blockers: RetirementBlocker[] = [];
  const metrics = retirementMetrics(plan);
  if (plan.migrationTool.trim() === "") blockers.push("missing-tool");
  if (plan.consumers.length === 0) blockers.push("no-consumers");
  if (!isIsoDate(today) || today < plan.retireAt) blockers.push("not-due");
  if (metrics.migratedConsumers < metrics.totalConsumers) blockers.push("unmigrated-consumers");
  if (metrics.residualSharePct / 100 > policy.residualShareLimit) blockers.push("residual-traffic");
  if (plan.consumers.some((item) => !item.hasOwnerApproval)) blockers.push("missing-approval");
  return { allowed: blockers.length === 0, blockers, metrics };
}

export interface RetirementDecision {
  ok: boolean;
  from: RetirementStage;
  to: RetirementStage;
  blockers: RetirementBlocker[];
  issues: RetirementPlanIssue[];
  metrics: RetirementMetrics;
}

/** Forward-only state machine: active -> announced -> dual-run -> retired. */
export function nextStage(
  plan: RetirementPlan,
  today: string,
  policy: RetirementPolicy = DEFAULT_RETIREMENT_POLICY,
): RetirementDecision {
  const issues = validateRetirementPlan(plan, policy);
  const metrics = retirementMetrics(plan);
  if (issues.length > 0) {
    return {
      ok: false, from: plan.stage, to: plan.stage, blockers: ["invalid-plan"], issues, metrics,
    };
  }
  if (plan.stage === "retired") {
    return {
      ok: false, from: plan.stage, to: plan.stage, blockers: ["already-retired"], issues, metrics,
    };
  }
  if (plan.stage === "active") {
    const reached = isIsoDate(today) && today >= plan.announcedAt;
    return {
      ok: reached, from: "active", to: reached ? "announced" : "active",
      blockers: reached ? [] : ["notice-not-started"], issues, metrics,
    };
  }
  if (plan.stage === "announced") {
    if (!isIsoDate(today) || today < plan.dualRunStart) {
      return {
        ok: false, from: "announced", to: "announced",
        blockers: ["dual-run-not-started"], issues, metrics,
      };
    }
    if (plan.migrationTool.trim() === "") {
      return {
        ok: false, from: "announced", to: "announced", blockers: ["missing-tool"], issues, metrics,
      };
    }
    return { ok: true, from: "announced", to: "dual-run", blockers: [], issues, metrics };
  }
  const gate = evaluateForceOff(plan, today, policy);
  return {
    ok: gate.allowed,
    from: "dual-run",
    to: gate.allowed ? "retired" : "dual-run",
    blockers: gate.blockers,
    issues,
    metrics,
  };
}

代码里有三个刻意选择。第一,公告期用 dualRunStart - announcedAt 计算,而不是 retireAt - announcedAt;公告期的目的是给调用方排期,排期的终点是双跑开始,不是下线。第二,残余流量按调用量加权,避免一个小调用方拖住整体判断,也避免一个大调用方被平均掉。第三,状态推进先校验计划,计划不合法时直接返回 invalid-plan,不会在半路发现日期顺序错误。

示例样本上的运行结果

示例计划退役 content.summarize@2.3.0,替代版本为 3.1.0。五个调用方分别是内容、客服、交易、搜索与运维团队,月调用量 12 万、4 万、2 万、1 万、1 万。以下是示例样本上的运行结果。

场景结果
计划校验0 个问题;公告 92 天、双跑 91 天
2026-10-15 推进停在 dual-run,阻塞项 not-due;5/5 已迁移,迁移率 100%,残余 0.0%,距下线 47 天
2026-12-02 推进进入 retired,无阻塞项
一个调用方迁移 98.2%4/5 已迁移,迁移率 80%,残余 1.1%,阻塞项为未迁移、残余流量、缺负责人确认
公告期只有 31 天1 个 notice-too-short;双跑仍有 91 天,距下线 108 天

第一行说明计划本身合法,不代表可以下线。第二行说明日期未到,状态机不会因为「看起来都迁完了」就提前关闭。第四行说明三项阻塞可以同时出现,修复顺序应该是先迁流量、再补确认,而不是先改日期。

第五行的公告期只有 31 天,低于 90 天下限。注意双跑窗口仍然是 91 天,所以只报一个 notice-too-short,不会连带报双跑太短。校验按窗口分别判断,团队能准确知道该改哪个日期。

迁移期要看的指标

退役计划不是一张甘特图,它需要一组能在周报上更新的指标。

指标口径示例值
调用方迁移率已迁调用方数 ÷ 总调用方数100% 或 80%
残余流量占比旧版本调用量 ÷ 总调用量,按调用量加权0.0% 或 1.1%
公告天数双跑开始日 − 公告日92 天
双跑天数下线日 − 双跑开始日91 天
距下线天数下线日 − 当天47 天
双跑差异率新旧版本同一批样本的结果差异比例示例值 0.8%
回滚次数双跑期内回滚到旧版本的次数示例值 0 次

前五个指标由状态机直接计算,后两个需要双跑平台回写。差异率不是越低越好,而是要解释差异来源:契约字段变化、模型档位变化、提示版本变化,三类差异的处理方式不同。回滚次数也不是越少越好,零回滚可能意味着没人敢试。

迁移期的周报要把「指标」与「阻塞」分开。指标回答进度,阻塞回答为什么停。把两者混在一张红黄绿灯表里,团队只知道红,不知道先修什么。

小结

退役是一次跨团队迁移项目,不是目录里的一次状态修改。它需要公告期、双跑窗口、迁移工具与四项下线判据:到期、调用方迁移率、残余流量、负责人确认。

示例样本上,合法计划公告 92 天、双跑 91 天;2026-10-15 停在 dual-run,距下线 47 天;2026-12-02 满足全部判据进入 retired;一个调用方迁移 98.2% 时,迁移率 80%、残余 1.1%,三项阻塞同时出现。数字不是重点,重点是每个数字都有明确分母,且状态机只向前走。

阅读导航:上一节:2.2 提供方与调用方的契约 · 下一节:3.1 契约版本与兼容规则 。

常见错误与边界

  • 把退役当成改一个状态。没有迁移计划,关闭当天就是事故。
  • 公告只发群消息。没有依赖清单与负责人,漏掉的调用方不会自己出现。
  • 公告期从公告日算到下线日。排期的终点是双跑开始,不是下线。
  • 双跑窗口太短。没有足够样本对比,差异率没有统计意义。
  • 迁移工具不幂等。重复执行会产生第二份配置或第二个工单。
  • 迁移比例人工填写。没有调用量回写,比例只是一句乐观估计。
  • 只看调用方数量,不看调用量。一个大调用方可以被四个小调用方平均掉。
  • 残余流量按调用方平均。正确口径是按调用量加权。
  • 到点就强制下线。日期只是必要条件,四项判据必须同时成立。
  • 判据不满足就改日期。延期要记录原因与新的阻塞项,而不是静默推迟。
  • 双跑差异率不做归因。契约、档位、提示三类差异混在一起,无法修复。
  • 退役后删除回放与文档。审计与事故复盘仍然需要旧版本的证据。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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