契约没变,行为变了
chat.reply 的契约版本停在 3.1.1,schema 一行没改。某个周三,提供方把底层模型从 m-4.1 换到 m-4.2,提示仍是 p-2026-10-b,评测集仍是 eval-7。发布流程一路绿灯,因为契约版本没动、接口测试全过。
上线后链路 B 的回复开始变长,客服团队的话术模板被撑爆;链路 C 的下单确认里多了一句模型自己的推测。成功率没有立刻掉,P95 先涨了 180ms,一周后成本才在账单上显形。
这次事故暴露了一个空洞:契约版本只覆盖「形状」,不覆盖「行为」。模型、提示、评测集任何一项变化,都可能让同样的输入得到不同的输出,但三者没有一个被纳入版本。多团队共享能力时,这个空洞会被调用方放大成信任问题。
能力版本是三元组
本章把「能力版本」定义成三个固定组件的组合:模型、提示、评测集。三者共同决定一次调用的行为,缺一项都无法复现。
| 组件 | 作用 | 浮动后会怎样 |
|---|---|---|
| 模型 | 决定能力上限与失败分布 | 供应商悄悄升级别名,行为漂移无法归因 |
| 提示 | 决定任务指令、格式与语气 | 改版没有版本号,评测无法对应到具体提示 |
| 评测集 | 决定「好」的判定标准 | 换版后分数变了,分不清是能力变好还是题目变易 |
三元组用 能力名@契约版本[模型/提示/评测集] 表示。例如 content.summarize@3.1.1[m-4.1/p-2026-10-a/eval-7]。这个字符串不是给用户看的,而是给目录、回放包、评审记录和账单共用的主键。
契约版本与能力版本是两层。契约版本回答「输入输出形状变没变」,能力版本回答「同样的形状会不会得到不同结果」。一次发布可以只动其中一层,也可以两层同时动;发布说明必须写清动的是哪一层。
三者如何联动
联动规则可以压成一张表。表的每一行是一种变化组合,最后一列是必须做的动作,不是建议。
| 变化 | 契约版本 | 能力版本 | 必须做的动作 |
|---|---|---|---|
| 只换模型 | 通常不变 | 新三元组 | 用同一评测集重跑,对比成功率、成本、P95 |
| 只改提示 | 视输出格式而定 | 新三元组 | 重跑评测,检查格式与语义漂移 |
| 只换评测集 | 不变 | 新三元组 | 标记基线断点,不切换线上流量 |
| 模型 + 提示 | 可能不变 | 新三元组 | 整体回归,按原子版本回滚 |
| 模型 + 评测集 | 通常不变 | 新三元组 | 换模型并重置基线,评测集保留 |
| 提示 + 评测集 | 视输出格式而定 | 新三元组 | 换提示并重置基线 |
| 三者都换 | 可能不变 | 新三元组 | 视为一次能力换代,走完整迁移 |
表里最容易被忽略的是「只换评测集」。它不改变线上行为,所以很多人认为不用发版;但评测集是判定的尺子,尺子换了,前后分数不可比。正确做法是发一个新能力版本,线上流量不动,评测报告标注基线断点。
另一个容易出错的是「模型 + 评测集」。模型换了,旧评测集可能仍然有效,只是分数不可直接比较;这时应保留评测集、重置基线,而不是把评测集也换掉。两个变量一起换,出了问题无法归因。
回滚矩阵
回滚不是「切回上一个版本」一句话。三元组的组件不同,回滚粒度也不同。
| 变化的组件 | 回滚粒度 | 原因 |
|---|---|---|
| 仅提示 | 只回提示 | 模型与评测集未变,单变量可回 |
| 仅模型 | 只回模型 | 提示与评测集未变,单变量可回 |
| 仅评测集 | 不回滚流量 | 评测集不承载线上请求,只重置基线 |
| 模型 + 提示 | 原子回滚 | 两者共同定义行为,不能只回一个 |
| 模型 + 评测集 | 回模型,保留评测集 | 评测集是尺子,保留它才能比较 |
| 提示 + 评测集 | 回提示,保留评测集 | 同上 |
| 三者都换 | 原子回滚 | 已是能力换代,按整体回切 |
planRollback 把这张表写成确定性逻辑:运行时组件是模型与提示,评测集不承载流量;运行时组件超过一个时 atomic=true。示例中 B→C 只换了模型与评测集,回滚计划是「回模型、保留 eval-8」,不是原子回滚;B→D 换了模型、提示与评测集,计划是「整体回切」。
用测试固定联动语义
联动规则最怕「这次特殊」。下面的测试文件把七种组合、边界与调用方扫描都固定成断言;任何人想改语义,必须先改测试,评审就能看到差异。
// file: test/gov/versions.test.ts
// tier: L2
// verify: node --test test/gov/versions.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import {
checkBump,
classifyChanges,
diffTriple,
nextContractVersion,
planRollback,
validateRelease,
} from "../../src/gov/versions.ts";
import type { CapabilityRelease, ContractChange } from "../../src/gov/versions.ts";
import {
migrationChecklist,
planConvergence,
planDeprecation,
scanCallers,
summarizeMatrix,
windowPhase,
} from "../../src/gov/version-matrix.ts";
import type { CallerUsage, MatrixCell, VersionMatrix } from "../../src/gov/version-matrix.ts";
const contractChanges: ContractChange[] = [
{ surface: "input", kind: "field-add-optional", field: "traceId" },
{ surface: "input", kind: "field-add-required", field: "tenantId" },
{ surface: "input", kind: "field-remove", field: "locale" },
{ surface: "output", kind: "field-add-optional", field: "citations" },
{ surface: "output", kind: "field-remove", field: "debugRaw" },
{ surface: "error", kind: "error-add", field: "rate_limited" },
{ surface: "error", kind: "error-remove", field: "timeout" },
{ surface: "behavior", kind: "default-change", field: "temperature 0.2 -> 0.0" },
{ surface: "behavior", kind: "semantic-change", field: "summary includes citations" },
];
const releaseA: CapabilityRelease = {
capability: "content.summarize",
contractVersion: "3.1.1",
model: "m-4.1",
prompt: "p-2026-10-a",
evalSet: "eval-7",
};
const releaseB: CapabilityRelease = { ...releaseA, prompt: "p-2026-10-b" };
const releaseC: CapabilityRelease = { ...releaseB, model: "m-4.2", evalSet: "eval-8" };
const releaseD: CapabilityRelease = { ...releaseB, model: "m-4.2", prompt: "p-2026-11-a", evalSet: "eval-8" };
test("contract changes split into compatible and breaking", () => {
const report = classifyChanges(contractChanges);
assert.equal(report.total, 9);
assert.equal(report.compatible, 2);
assert.equal(report.breaking, 7);
assert.equal(report.requiredBump, "major");
assert.equal(nextContractVersion("2.3.0", report), "3.0.0");
assert.equal(checkBump(report, "minor").ok, false);
assert.equal(checkBump(report, "major").ok, true);
});
test("compatible additions only need a minor bump", () => {
const report = classifyChanges([
{ surface: "input", kind: "field-add-optional", field: "traceId" },
{ surface: "output", kind: "field-add-optional", field: "citations" },
{ surface: "behavior", kind: "docs-only", field: "examples" },
]);
assert.equal(report.compatible, 3);
assert.equal(report.breaking, 0);
assert.equal(report.requiredBump, "minor");
assert.equal(nextContractVersion("2.3.0", report), "2.4.0");
assert.equal(checkBump(report, "minor").ok, true);
});
test("unknown change kinds are breaking", () => {
const unknown = { surface: "input", kind: "rename-field", field: "query" } as unknown as ContractChange;
const report = classifyChanges([unknown]);
assert.equal(report.breaking, 1);
assert.match(report.verdicts[0].reason, /未登记/);
});
test("release triples must be pinned", () => {
assert.deepEqual(validateRelease(releaseA), []);
const issues = validateRelease({ ...releaseA, model: "latest", prompt: "prod", evalSet: "eval-x" });
assert.deepEqual(issues.map((issue) => issue.code), [
"floating-model",
"unpinned-prompt",
"unpinned-eval-set",
]);
});
test("prompt-only change rolls back the prompt only", () => {
const diff = diffTriple(releaseA, releaseB);
assert.deepEqual(diff.changed, ["prompt"]);
const plan = planRollback(releaseA, releaseB);
assert.equal(plan.atomic, false);
assert.match(plan.steps.join(" "), /把提示/);
assert.doesNotMatch(plan.steps.join(" "), /把模型/);
});
test("model and prompt change roll back atomically", () => {
const plan = planRollback(releaseB, releaseD);
assert.deepEqual(plan.changed, ["model", "prompt", "evalSet"]);
assert.equal(plan.atomic, true);
assert.match(plan.steps.join(" "), /整体回切/);
assert.match(plan.steps.join(" "), /保留评测集 eval-8/);
});
test("identical triples produce a no-op rollback", () => {
const plan = planRollback(releaseA, { ...releaseA });
assert.deepEqual(plan.changed, []);
assert.equal(plan.atomic, false);
assert.equal(plan.steps.length, 1);
});
test("rollback matrix classifies all eight change combinations", () => {
const counts = { noop: 0, single: 0, multi: 0 };
for (const model of ["m-4.1", "m-4.2"]) {
for (const prompt of ["p-2026-10-a", "p-2026-10-b"]) {
for (const evalSet of ["eval-7", "eval-8"]) {
const plan = planRollback(releaseA, { ...releaseA, model, prompt, evalSet });
if (plan.changed.length === 0) counts.noop += 1;
else if (plan.changed.length === 1) counts.single += 1;
else counts.multi += 1;
const runtime = plan.changed.filter((component) => component !== "evalSet").length;
assert.equal(plan.atomic, runtime > 1);
}
}
}
assert.deepEqual(counts, { noop: 1, single: 3, multi: 4 });
});
function buildMatrix(): VersionMatrix {
const capabilities = [
"content.summarize",
"moderation.classify",
"chat.reply",
"search.rerank",
"support.assist",
];
const cells: MatrixCell[] = [];
for (const capability of capabilities) {
cells.push({ capability, model: "m-4.0", status: "retired" });
}
const m41: MatrixCell["status"][] = ["active", "active", "dual-run", "deprecated", "deprecated"];
const m42: MatrixCell["status"][] = ["active", "active", "active", "dual-run", "dual-run"];
capabilities.forEach((capability, index) => {
cells.push({ capability, model: "m-4.1", status: m41[index] });
cells.push({ capability, model: "m-4.2", status: m42[index] });
});
return { cells };
}
test("matrix summary counts every support status", () => {
const summary = summarizeMatrix(buildMatrix());
assert.equal(summary.total, 15);
assert.equal(summary.capabilities, 5);
assert.equal(summary.models, 3);
assert.equal(summary.active, 5);
assert.equal(summary["dual-run"], 3);
assert.equal(summary.deprecated, 2);
assert.equal(summary.retired, 5);
});
test("convergence assigns every closed cell an action", () => {
const matrix = buildMatrix();
const narrow = planConvergence(matrix, ["m-4.2"]);
assert.equal(narrow.targetCells, 5);
assert.equal(narrow.closedCells, 10);
assert.equal(narrow.migrate.length, 3);
assert.equal(narrow.forceOff.length, 2);
assert.equal(narrow.alreadyRetired.length, 5);
const wide = planConvergence(matrix, ["m-4.2", "m-4.1"]);
assert.equal(wide.targetCells, 10);
assert.equal(wide.closedCells, 5);
assert.equal(wide.migrate.length, 0);
});
test("deprecation schedule is deterministic and phase boundaries are explicit", () => {
const schedule = planDeprecation("2026-11-05", { announceDays: 14, dualRunDays: 30, forceOffDays: 7 });
assert.deepEqual(schedule, {
announce: "2026-11-05",
dualRunStart: "2026-11-19",
forceOffStart: "2026-12-19",
offline: "2026-12-26",
totalDays: 51,
});
assert.equal(windowPhase("2026-11-10", schedule), "announced");
assert.equal(windowPhase("2026-11-19", schedule), "dual-running");
assert.equal(windowPhase("2026-12-01", schedule), "dual-running");
assert.equal(windowPhase("2026-12-19", schedule), "force-off");
assert.equal(windowPhase("2026-12-26", schedule), "offline");
});
const usages: CallerUsage[] = [
{ team: "team-content", capability: "content.summarize", version: "3.1.1", lastSeenDaysAgo: 2 },
{ team: "team-content", capability: "content.summarize", version: "3.1.1", lastSeenDaysAgo: 5 },
{ team: "team-content", capability: "content.summarize", version: "2.3.0", lastSeenDaysAgo: 10 },
{ team: "team-content", capability: "content.summarize", version: "2.3.0", lastSeenDaysAgo: 45 },
{ team: "team-support", capability: "content.summarize", version: "3.1.1", lastSeenDaysAgo: 1 },
{ team: "team-support", capability: "content.summarize", version: "3.1.1", lastSeenDaysAgo: 3 },
{ team: "team-support", capability: "content.summarize", version: "3.0.0", lastSeenDaysAgo: 12 },
{ team: "team-trade", capability: "content.summarize", version: "3.1.1", lastSeenDaysAgo: 4 },
];
test("caller scan separates behind callers from silent ones", () => {
const scan = scanCallers(usages, "content.summarize", "3.1.1");
assert.equal(scan.total, 8);
assert.equal(scan.onTarget, 5);
assert.equal(scan.behind, 3);
assert.equal(scan.stale, 1);
assert.deepEqual(scan.behindTeams, ["team-content", "team-support"]);
assert.equal(scan.readyToForceOff, false);
const schedule = planDeprecation("2026-11-05", { announceDays: 14, dualRunDays: 30, forceOffDays: 7 });
const checklist = migrationChecklist(scan, schedule);
assert.equal(checklist.length, 4);
assert.match(checklist.join(" "), /3 个调用方/);
assert.match(checklist.join(" "), /1 个静默调用方/);
});
test("caller scan allows force-off only when nobody is behind", () => {
const clean = scanCallers(
usages.filter((usage) => usage.version === "3.1.1"),
"content.summarize",
"3.1.1",
);
assert.equal(clean.onTarget, 5);
assert.equal(clean.behind, 0);
assert.equal(clean.readyToForceOff, true);
const schedule = planDeprecation("2026-11-05", { announceDays: 14, dualRunDays: 30, forceOffDays: 7 });
assert.equal(migrationChecklist(clean, schedule).length, 3);
});
测试里有几组值得单独说明。第一组把 9 条契约变更分成 2 兼容、7 不兼容,并要求 major;第二组验证只有兼容变更时 minor 足够。第三组把三元组的所有 8 种组合跑一遍,得到 1 个无变化、3 个单组件、4 个多组件,并逐条检查 atomic 是否等于「运行时组件超过一个」。
第四组是矩阵与弃用窗口,属于 3.3 的内容,放在同一测试文件里是为了让版本语义只有一处事实来源。共享能力最怕两套规则:目录一套、发布一套,最后谁也说不清哪个版本是合法的。
示例样本上的运行结果
示例样本取 content.summarize 的三个能力版本:A 是 m-4.1/p-2026-10-a/eval-7,B 是只改提示的 m-4.1/p-2026-10-b/eval-7,C 是换模型与评测集的 m-4.2/p-2026-10-b/eval-8,D 是三者都换的 m-4.2/p-2026-11-a/eval-8。在示例样本上运行,结果如下。
| 场景 | 变化组件 | 回滚粒度 | 计划步骤数 |
|---|---|---|---|
| A → B | prompt | 单组件 | 2 |
| B → C | model + evalSet | 回模型,保留评测集 | 3 |
| B → D | model + prompt + evalSet | 原子回滚 | 4 |
| A → A | 无 | 无操作 | 1 |
8 种组合的统计是 1 个无变化、3 个单组件、4 个多组件。这个分布不是业务结论,而是回滚矩阵的完备性检查:只要三元组只有三个组件,所有组合都必须能被归类,不能出现「未定义」。
版本切换还要回到全书口径。三能力编排的每次成功交付从 4,240 微元降到 3,786 微元(-10.71%),复用回本线 28.47%;这些收益是按旧三元组测的。换模型后如果成本或延迟变化,必须重跑,而不是继续引用旧数字。单步失败预算 13.02% 同理:模型换代可能改变失败分布,灰度期间要重新核对该预算。
多团队各签什么字
提供方签「三元组与评测证据」。一次能力版本发布,必须附带模型、提示、评测集的精确版本,以及在该评测集上的成功率、成本、P95。没有这三项,调用方无法判断能不能跟。
调用方签「行为接受度」。同一个能力版本,链路 A 可能只关心格式,链路 B 关心语气,链路 C 关心事实性。调用方要声明自己接受哪一组指标,以及不能接受的退化上限。默认继承提供方的全局阈值,是最容易埋雷的做法。
平台签「版本一致性」。目录里的能力版本、回放包里的三元组、账单上的计费口径必须是同一个字符串。三者不一致时,平台应该拒绝发布,而不是事后对账。
三份签字合起来,才把「模型换代」从提供方的内部动作,变成多团队可预期的一次能力版本变更。
小结
契约版本管形状,能力版本管行为。能力版本是模型、提示、评测集的三元组,三者任一变化都要发新版本;只换评测集不切流量,但要标记基线断点。七种变化组合对应七种联动动作,回滚粒度由「是否承载线上流量」决定。
示例样本的 8 种组合里,1 个无变化、3 个单组件、4 个多组件回滚;A→B 只回提示,B→C 回模型并保留评测集,B→D 原子回切。提供方、调用方、平台分别签三元组证据、行为接受度与版本一致性,三者缺一不可。
阅读导航:上一节:3.1 契约版本与兼容规则 · 下一节:3.3 版本矩阵与弃用窗口 。
常见错误与边界
- 只给契约发版,不给行为发版。shape 没变,输出已经变了。
- 模型用浮动别名。别名一升级,行为漂移无法归因,也回不到原版本。
- 提示没有版本号。评测分数对不上具体提示,复盘只能靠记忆。
- 评测集换版不标基线断点。分数变了,分不清是能力变好还是题目变易。
- 模型与评测集一起换。两个变量同时动,出问题无法定位。
- 模型与提示同时换却只回一个。行为由两者共同定义,必须原子回滚。
- 回滚评测集。评测集不承载线上流量,回滚它没有意义,只会丢掉对比基线。
- 一次能力版本发布不带评测证据。调用方只能凭感觉跟或不跟。
- 三家签字流于形式。提供方、调用方、平台签的必须是不同维度的责任。
- 把旧三元组的成本当成新三元组的成本。4,240→3,786 与 28.47% 都要按新版本重测。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。