十五个格子,谁来关
「回声」的目录里有五个共享能力: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 |
| active | 5 |
| dual-run | 3 |
| deprecated | 2 |
| retired | 5 |
| 保留 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 是否清零。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。