《互联网产品的 AI 架构·平台化与治理》3.3 版本矩阵与弃用窗口

五个能力乘三个模型代际就是十五个格子,再加上提示与评测集,矩阵会迅速失控。本节给出 active、dual-run、deprecated、retired 四态与收敛动作,用可运行代码计算 15 格到 5 格的关闭清单,并把公告、双跑、强制下线与调用方扫描写成可判定的窗口流程。

十五个格子,谁来关

「回声」的目录里有五个共享能力:content.summarize、moderation.classify、chat.reply、search.rerank、support.assist。每个能力支持三个模型代际 m-4.0、m-4.1、m-4.2,矩阵里就有 5 × 3 = 15 个格子。

半年后,m-4.0 早已退役但目录里还留着条目;m-4.1 上仍有两条链路在跑;m-4.2 已经开始接新调用方。评测平台每个月要跑 15 组回归,运维要维护 15 套告警,账单上同一个能力出现三种单价。没有人能回答一个简单问题:如果下个月关掉 m-4.0,会影响哪些调用方?

版本矩阵要解决的不是「怎么支持更多版本」,而是「怎么关掉旧版本」。支持是默认动作,关闭才需要治理。

矩阵从哪来

矩阵的每一格是「能力 × 模型代际」,状态描述这一格还能不能接流量。提示与评测集不单独占一维,而是作为能力版本的一部分挂在格子里;否则维度会从 N×M 变成 N×M×K,没人能维护。

四个状态对应四种治理动作:

状态含义允许的动作
active默认承载新流量接新调用方、扩容、参加常规回归
dual-run新旧并行,按比例分流只接迁移中的调用方,必须同时跑两套
deprecated停止新接入,存量继续只减不增,按窗口迁移
retired不再提供服务拒绝新请求,只保留审计记录

状态不是标签,而是准入规则。active 可以接新调用方,dual-run 不能;deprecated 的调用方数量只能下降;retired 的格子必须从默认路由里消失。把这些写进目录校验,矩阵才不是一张展示图。

收敛策略

收敛的目标不是把所有旧格子里的人都赶走,而是给每个旧格子一个明确动作。planConvergence 把动作分成三类:迁移、强制下线、已退役。

动作适用状态责任
迁移active / dual-run 且不在保留代际调用方改配置,提供方提供对照
强制下线deprecated 且不在保留代际平台按窗口执行,提供方兜底
已退役retired只保留审计,不再分配资源

收敛的第一个决定是「保留几个代际」。本章建议同时保留不超过两个:当前代际加一个过渡代际。保留三个以上,评测、告警、账单的维护成本会超过迁移收益。示例里保留 m-4.2 一个代际,15 格收敛到 5 格,关闭 10 格;如果保留 m-4.1 与 m-4.2,则收敛到 10 格,关闭 5 格。

第二个决定是「先迁谁」。优先级不是按调用方大小,而是按依赖深度:被三条链路共用的能力先迁,单链路能力后迁。因为前者一改,三条链路的基线都要重跑;拖到最后会变成关键路径上的阻塞项。

第三个决定是「什么时候停新接入」。deprecated 状态一设,目录就应该拒绝新调用方注册;否则一边迁移一边新增,窗口永远不会结束。

窗口不是日历

弃用窗口由三段组成:公告、双跑、强制下线。每一段都有明确的进入条件与退出证据,不是到点自动切换。

阶段默认长度进入条件退出证据
公告14 天目标能力版本发布并冻结所有调用方收到通知并确认
双跑30 天调用方完成配置切换双跑期指标不低于旧版本
强制下线7 天落后调用方为零或已确认静默下线后无新失败、审计可查

示例策略是 14 + 30 + 7 = 51 天。从 2026-11-05 开始,双跑从 11-19 开始,强制下线从 12-19 开始,12-26 完全下线。planDeprecation 用 UTC 日期计算,windowPhase 在边界日给出确定阶段:11-19 当天算双跑,12-19 当天算强制下线,12-26 当天算已下线。

窗口长度可以按能力调整,但三段不能省。只公告不双跑,调用方没有验证机会;只双跑不强制,旧版本会永久存在;没有公告直接下线,等于制造事故。

调用方扫描

窗口能不能推进,取决于一张调用方清单。扫描要回答四个数:总数、已在目标版本、落后、静默。

scanCallers 按能力过滤调用方,逐条比较版本;落后且超过 30 天没有调用的,单独标为 stale。静默不等于已迁移,也可能是调用方已经下线但没注销配置。两种情况处理方式不同:前者要催迁移,后者要确认后清理。

示例样本有 8 条调用记录,5 条已在 3.1.1,3 条落后,其中 1 条静默超过 30 天。落后调用方分布在 team-content 与 team-support 两个团队。readyToForceOff 为 false,因为还有 3 条落后记录;迁移清单有四步:公告、迁移 3 个调用方、确认 1 个静默调用方、双跑截止与下线日期。

矩阵与窗口的计算

version-matrix.ts 把状态统计、收敛动作、窗口日期与调用方扫描写成纯函数。所有输入都是显式数据,不读系统时钟,测试可以固定到某一天。

// file: src/gov/version-matrix.ts
// tier: L2
// verify: node --test test/gov/versions.test.ts

import { compareSemver, parseSemver } from "../orch/platform.ts";

export type SupportStatus = "active" | "dual-run" | "deprecated" | "retired";

export interface MatrixCell { capability: string; model: string; status: SupportStatus }

export interface VersionMatrix { cells: readonly MatrixCell[] }

export interface MatrixSummary {
  total: number;
  capabilities: number;
  models: number;
  active: number;
  "dual-run": number;
  deprecated: number;
  retired: number;
}

export function summarizeMatrix(matrix: VersionMatrix): MatrixSummary {
  const summary: MatrixSummary = {
    total: matrix.cells.length,
    capabilities: new Set(matrix.cells.map((cell) => cell.capability)).size,
    models: new Set(matrix.cells.map((cell) => cell.model)).size,
    active: 0,
    "dual-run": 0,
    deprecated: 0,
    retired: 0,
  };
  for (const cell of matrix.cells) summary[cell.status] += 1;
  return summary;
}

export interface ConvergencePlan {
  keepModels: string[]; capabilities: string[]; totalCells: number; targetCells: number;
  closedCells: number; migrate: MatrixCell[]; forceOff: MatrixCell[]; alreadyRetired: MatrixCell[];
}

/** 收敛不是把旧格子删掉,而是把每个旧格子分配到一个动作:迁移、强制下线或已退役。 */
export function planConvergence(matrix: VersionMatrix, keepModels: readonly string[]): ConvergencePlan {
  const keep = [...new Set(keepModels)].sort();
  const keepSet = new Set(keep);
  const capabilities = [...new Set(matrix.cells.map((cell) => cell.capability))].sort();
  const migrate = matrix.cells.filter(
    (cell) => !keepSet.has(cell.model) && (cell.status === "active" || cell.status === "dual-run"),
  );
  const forceOff = matrix.cells.filter(
    (cell) => !keepSet.has(cell.model) && cell.status === "deprecated",
  );
  const alreadyRetired = matrix.cells.filter((cell) => cell.status === "retired");
  const totalCells = matrix.cells.length;
  const targetCells = capabilities.length * keep.length;
  return { keepModels: keep, capabilities, totalCells, targetCells, closedCells: totalCells - targetCells, migrate, forceOff, alreadyRetired };
}

export interface DeprecationPolicy { announceDays: number; dualRunDays: number; forceOffDays: number }

export interface DeprecationSchedule {
  announce: string; dualRunStart: string; forceOffStart: string; offline: string; totalDays: number;
}

const DAY_MS = 86_400_000;

function parseDate(value: string): number {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
  if (match === null) throw new Error(`version-matrix: invalid date ${value}`);
  return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}

function formatDate(value: number): string {
  return new Date(value).toISOString().slice(0, 10);
}

export function planDeprecation(startDate: string, policy: DeprecationPolicy): DeprecationSchedule {
  for (const [name, days] of Object.entries(policy)) {
    if (!Number.isInteger(days) || days < 0) {
      throw new Error(`version-matrix: invalid ${name} ${days}`);
    }
  }
  const start = parseDate(startDate);
  const dualRunStart = start + policy.announceDays * DAY_MS;
  const forceOffStart = dualRunStart + policy.dualRunDays * DAY_MS;
  const offline = forceOffStart + policy.forceOffDays * DAY_MS;
  return {
    announce: formatDate(start), dualRunStart: formatDate(dualRunStart),
    forceOffStart: formatDate(forceOffStart), offline: formatDate(offline),
    totalDays: Math.round((offline - start) / DAY_MS),
  };
}

export type WindowPhase = "announced" | "dual-running" | "force-off" | "offline";

export function windowPhase(date: string, schedule: DeprecationSchedule): WindowPhase {
  const at = parseDate(date);
  if (at < parseDate(schedule.dualRunStart)) return "announced";
  if (at < parseDate(schedule.forceOffStart)) return "dual-running";
  if (at < parseDate(schedule.offline)) return "force-off";
  return "offline";
}

export interface CallerUsage { team: string; capability: string; version: string; lastSeenDaysAgo: number }

export type CallerState = "on-target" | "behind" | "stale";

export interface CallerRow { team: string; version: string; lastSeenDaysAgo: number; state: CallerState }

export interface CallerScan {
  capability: string; target: string; total: number; onTarget: number; behind: number;
  stale: number; behindTeams: string[]; readyToForceOff: boolean; rows: CallerRow[];
}

/** 落后版本按调用方逐条判定;静默超过窗口的调用方单独标出,避免误判为已迁移。 */
export function scanCallers(
  usages: readonly CallerUsage[],
  capability: string,
  targetVersion: string,
  staleDays = 30,
): CallerScan {
  if (parseSemver(targetVersion) === null) {
    throw new Error(`version-matrix: invalid target ${targetVersion}`);
  }
  const rows: CallerRow[] = [];
  for (const usage of usages) {
    if (usage.capability !== capability) continue;
    if (parseSemver(usage.version) === null) {
      throw new Error(`version-matrix: invalid version ${usage.version}`);
    }
    const behind = compareSemver(usage.version, targetVersion) < 0;
    const state: CallerState = behind ? (usage.lastSeenDaysAgo > staleDays ? "stale" : "behind") : "on-target";
    rows.push({ team: usage.team, version: usage.version, lastSeenDaysAgo: usage.lastSeenDaysAgo, state });
  }
  rows.sort((left, right) => {
    if (left.team !== right.team) return left.team < right.team ? -1 : 1;
    return compareSemver(right.version, left.version);
  });
  const behind = rows.filter((row) => row.state !== "on-target").length;
  const stale = rows.filter((row) => row.state === "stale").length;
  const behindTeams = [...new Set(rows.filter((row) => row.state !== "on-target").map((row) => row.team))].sort();
  return {
    capability, target: targetVersion, total: rows.length, onTarget: rows.length - behind,
    behind, stale, behindTeams, readyToForceOff: behind === 0, rows,
  };
}

export function migrationChecklist(scan: CallerScan, schedule: DeprecationSchedule): string[] {
  const steps = [`公告 ${schedule.announce}:目标版本 ${scan.target}`];
  if (scan.behind > 0) {
    steps.push(`迁移 ${scan.behind} 个调用方:${scan.behindTeams.join("、")}`);
  } else {
    steps.push("没有落后调用方,可以按计划强制下线");
  }
  if (scan.stale > 0) {
    steps.push(`确认 ${scan.stale} 个静默调用方,避免误判为已迁移`);
  }
  steps.push(`双跑截止 ${schedule.forceOffStart},下线 ${schedule.offline}`);
  return steps;
}

代码里有两个治理判断值得说明。第一,closedCells 等于总格数减去「能力数 × 保留代际数」,它衡量的是矩阵收敛的规模,不是迁移完成度;迁移完成度要看 migrate 与 forceOff 是否清零。第二,windowPhase 的边界是左闭右开:进入双跑当天算双跑,进入强制下线当天算强制下线。边界模糊会让「到底还能不能用」变成扯皮。

示例样本上的运行结果

示例矩阵是 5 个能力 × 3 个模型代际,共 15 格。m-4.0 全部 retired;m-4.1 有 2 个 active、1 个 dual-run、2 个 deprecated;m-4.2 有 3 个 active、2 个 dual-run。在示例样本上运行,结果如下。

指标结果
总格数15
能力数5
模型代际数3
active5
dual-run3
deprecated2
retired5
保留 m-4.2 后的目标格数5
关闭格数10
需迁移3
需强制下线2
已退役5

窗口示例从 2026-11-05 开始,总长 51 天,12-26 完全下线。调用方扫描的 8 条记录里 5 条在目标版本、3 条落后、1 条静默,因此不能强制下线。

这些是示例样本上的运行结果,不是生产统计。真实矩阵的能力数与代际数会更多,但四态、三类动作与三段窗口的口径不变。

成本与基线要按格算

每多保留一个代际,就多一份评测、告警与运维成本。三能力编排把每次成功交付从 4,240 微元降到 3,786 微元(-10.71%),复用回本线是 28.47%;如果为了兼容旧格子在三条链路上各保留一套双跑,这部分收益会被运维成本吃掉。

基线也要按格看。链路 A 的 96.25%、B 的 99.58%、C 的 95.00% 是在特定能力版本上测的,不能直接套到新格子。矩阵里每个 active 或 dual-run 格子都应该有自己的一组基线;没有基线的格子不允许接新流量。

失败预算同样按格核对。全书沿用的 13.02% 单步失败上限,在模型换代后可能变化;双跑期如果某个格子的失败率逼近这条线,应该停止放量,而不是用「旧版本也这样」解释。

多团队共享时的执行规则

第一条,矩阵由平台维护,状态由提供方申请。平台保证同一能力在同一代际只有一个状态;提供方提交迁移计划与下线证据。调用方不能自己把格子标成 retired。

第二条,公告必须点名到团队。广播式通知等于没通知;扫描清单里的每个落后团队都要有确认记录。静默调用方要单独确认,避免把「已下线」误判为「已迁移」。

第三条,强制下线需要证据链。证据包括:落后调用方为零、静默调用方已确认、双跑期指标不低于旧版本、下线后无新失败。四条缺一条,窗口顺延,而不是硬切。

第四条,异常要留出口。确实无法在窗口内迁移的调用方,可以申请一次有期限的例外;例外要写明原因、替代方案与复查日期,并且不能阻止其他格子关闭。例外不是永久豁免。

小结

版本矩阵治理的核心是「关闭」而不是「支持」。四个状态对应四种准入动作,三类收敛动作覆盖每个旧格子;示例的 15 格在保留 m-4.2 后收敛到 5 格,关闭 10 格,其中 3 格需迁移、2 格需强制下线、5 格已退役。

弃用窗口由公告、双跑、强制下线三段组成,示例总长 51 天;调用方扫描给出 8 条记录、5 条在目标版本、3 条落后、1 条静默,因此不能强制下线。矩阵管规模,窗口管节奏,扫描管证据,三者合起来才是可执行的版本治理。

阅读导航:上一节:3.2 模型与提示版本的联动 · 下一节:4.1 三层配额:总额、份额、单次 。

常见错误与边界

  • 把矩阵做成展示图。状态不进入准入规则,格子再多也没有约束力。
  • 同时保留三个以上代际。评测、告警与账单成本会超过迁移收益。
  • deprecated 状态还允许新接入。一边迁移一边新增,窗口永远结束不了。
  • 只公告不双跑。调用方没有验证机会,下线时会集中爆发。
  • 只双跑不强制。旧版本会永久存在,矩阵只增不减。
  • 把静默调用方当成已迁移。它可能只是配置没注销,下线后才暴露。
  • 强制下线不看证据。落后调用方还在,硬切就是制造事故。
  • 用旧格子的基线评估新格子。模型、提示、评测集都变了,数字不可比。
  • 例外没有期限。一次例外会变成永久豁免,窗口失去意义。
  • 只算关闭格数,不算迁移完成度。10 格关闭不等于 10 格迁移完成,要看 migrate 与 forceOff 是否清零。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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