供应商发来一封下线邮件
「回声」的模型平台收到通知: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 日下线。
| 阶段 | 窗口 | 对客流量 | 目的 |
|---|---|---|---|
| assess | 11-09 至 11-13 | 0 | 冻结评测集、确认契约差异、准备回滚包 |
| shadow | 11-14 至 11-20 | 0 | 复制真实请求给新模型,只比对不返回 |
| canary-10 | 11-21 至 11-23 | 10% | 小流量验证真实用户路径 |
| canary-50 | 11-24 至 11-28 | 50% | 放大样本,观察尾延迟与成本 |
| full | 11-29 至 12-05 | 100% | 全量运行,旧模型只做回滚备份 |
影子阶段的流量占比是 0,但它并不是「什么都不做」。平台把请求复制一份给新模型,记录输出、延迟与成本,再与旧模型逐条比对。影子阶段能发现格式与事实错误,发现不了用户真实反馈,所以它后面还必须有一档小比例灰度。
灰度比例不是装饰。10% 阶段样本少,主要看有没有硬故障;50% 阶段样本足够,才适合判断成功率与 P95 的微小漂移。把 10% 直接跳到 100%,等于放弃一次用真实流量纠错的机会。
判定只有三种动作
decideStage 先算四个差值:成功率下降的百分点、P95 增长率、单次成本增长率、质量分绝对下降。然后按固定顺序判定。
第一,任何一项越过回滚红线,直接回滚。回滚不看样本量,因为崩塌已经发生。
第二,越过预警线但没到红线,保持当前阶段。保持不是失败,它表示「再收一批样本或先修问题」。
第三,样本量不足,也保持。指标再漂亮,800 个样本也不能支撑 50% 流量下的结论。
示例样本上的运行结果如下。
| 阶段 | 样本 | 成功率变化 | P95 变化 | 成本变化 | 质量变化 | 判定 |
|---|---|---|---|---|---|---|
| shadow | 5,000 | -0.05pp | -1.67% | +1.72% | -0.0040 | 推进到 10% |
| canary-10 | 3,200 | -0.35pp | +12.50% | +6.90% | -0.0050 | 保持 |
| canary-10 | 3,400 | -0.20pp | +5.00% | +3.02% | -0.0045 | 推进到 50% |
| canary-50 | 800 | -0.25pp | +2.50% | +3.45% | -0.0010 | 保持(样本不足) |
| canary-50 | 6,500 | -0.37pp | +2.50% | +3.45% | -0.0030 | 推进到全量 |
| full | 9,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-01 | 2% | not-due | 还有 14 天,继续迁移 |
| 12-15 | 2% | blocked | 已到下线日,但仍有流量,不能关停 |
| 12-15 | 0 | ready | 到期且无流量,可以关停 |
| 12-18 | 0 | overdue | 已过期仍未关停,进入事故流程 |
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 的矩阵。两条记录对不上,审计时无法解释谁迁了什么。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。