一次「只加了一个字段」的升级
「回声」的内容团队给 content.summarize 发了一个 2.3.1。发布说明写着「新增 traceId 便于排查」。实际上同一个提交还做了三件事:把 tenantId 改成必填、删掉 locale 的默认处理、在摘要末尾追加引用列表。三小时后,链路 A 的解析器报错,链路 B 的回复里混进 [1],链路 C 的评测集把引用标记判成格式污染。
复盘时没有人说「这个字段不该加」。争议集中在两个问题:2.3.1 这个版本号是谁定的,以及调用方为什么没有在发布前被拦住。前者是版本语义问题,后者是兼容规则问题。两者合起来,才是一个多团队共享能力的契约治理问题。
这一节不讲 REST 或 gRPC 怎么设计版本 URL。它讲的是:一个能力被三个团队调用时,提供方如何判断一次改动能不能直接上线,版本号如何反映判断结果,灰度与回退如何和多团队的责任对齐。
契约的四个面
一个 AI 能力的契约不只是一份请求 schema。它至少有四个面,缺一面就会漏掉一类变更。
| 面 | 包含什么 | 典型遗漏 |
|---|---|---|
| 输入 | 字段、类型、必填性、枚举取值、默认值 | 只检查字段名,不检查必填性 |
| 输出 | 字段、类型、枚举取值、字段顺序假设 | 只检查 schema,不检查调用方的严格解析 |
| 错误 | 错误码集合、错误语义、可重试性 | 新增错误码没有通知调用方 |
| 行为 | 语义、默认参数、格式、语气、引用规则 | 认为「schema 没变就不算变更」 |
前三个面可以靠 schema diff 工具发现,第四个面不行。content.summarize 把「纯文本摘要」改成「摘要加引用列表」,字段仍然叫 output,类型仍然是 string,schema diff 一片绿色,但所有下游的格式假设都失效了。行为变化必须由提供方显式声明,工具只能辅助。
四个面也对应四种责任。输入与输出由提供方和调用方共同确认;错误码由调用方声明处理能力;行为由提供方在评测集上证明没有退化。多团队场景下,任何一面缺少签字,版本就不应该进入共享目录。
兼容与不兼容变更清单
把常见变更列成一张表,是为了让分类可以被复核,而不是依赖发布人的手感。下表是本章采用的默认规则;团队可以在能力条目里声明更严格的规则,但不能更宽松。
| 面 | 变更 | 判定 | 理由 |
|---|---|---|---|
| 输入 | 新增可选字段 | 兼容 | 旧调用方不传也能运行 |
| 输入 | 新增必填字段 | 不兼容 | 旧调用方缺参数 |
| 输入 | 删除字段 | 不兼容 | 旧参数被忽略或拒绝 |
| 输入 | 枚举新增取值 | 兼容 | 旧调用方不会主动发送 |
| 输入 | 枚举删除取值 | 不兼容 | 旧调用方可能仍在发送 |
| 输出 | 新增可选字段 | 兼容 | 宽容解析的调用方不受影响 |
| 输出 | 删除字段 | 不兼容 | 依赖字段的调用方读到空值 |
| 输出 | 枚举新增取值 | 不兼容 | 穷举分支没有默认路径 |
| 错误 | 新增错误码 | 不兼容 | 调用方可能没有处理分支 |
| 错误 | 删除错误码 | 不兼容 | 依赖该码的补偿逻辑失效 |
| 行为 | 默认值变化 | 不兼容 | 同一请求得到不同结果 |
| 行为 | 语义变化 | 不兼容 | schema 看不出来,只能靠声明 |
有两个容易争议的格子。第一,输出新增可选字段被判为兼容,前提是调用方使用宽容解析;如果某个调用方在 CI 里做严格字段校验,提供方必须在发布说明里点名,让它先改。第二,错误码新增被判为不兼容,因为多数调用方按错误码做 switch;如果调用方声明了「未知错误统一走兜底」,可以在能力条目里把它降级为兼容,但这是一条需要评审的例外。
规则的价值不在于覆盖所有情况,而在于「未登记的变更按不兼容处理」。这条默认值把举证责任放在提供方:想证明兼容,就补一条规则和一份调用方确认;证不出来,就发主版本。
版本号只是结论
语义化版本在这里不是装饰。主版本对应不兼容变更,次版本对应兼容新增,补丁对应文档与内部实现。版本号是分类的结论,不是发布人的谈判筹码。
classifyChanges 先逐条判定,再汇总出 requiredBump。checkBump 检查声明的版本位是否覆盖要求:声明 minor 但存在不兼容变更,直接拒绝。nextContractVersion 给出应该发布的版本号。三个函数都不依赖运行时状态,测试可以逐条断言。
示例里 2.3.0 的下一版不是 2.3.1,而是 3.0.0。原因不是改动多,而是其中七条属于不兼容变更。版本号一旦写错,后面的灰度、通知、回滚都会建立在错误的前提上。
灰度不是发布,是举证
版本号正确只说明「可以发」,不说明「可以全量」。多团队共享能力的灰度要回答三个问题:哪些调用方先切、切多少、什么条件下回退。
本章采用的默认节奏是 10%→50%→100%,每档至少观察一个完整业务周期。准入指标沿用全书口径:成功率写清分母,成本用整数微元,延迟用 P95。链路 A 的基线是 96.25%,B 是 99.58%,C 是 95.00%;版本切换后的第一件事是重跑这三条基线,而不是继承旧数字。
灰度期间还要看单步失败预算。全书沿用的 13.02% 是一条链路在三次重试、目标 95% 时的单步失败上限。版本切换会改变模型行为,失败分布也会变;如果某一档的单步失败率逼近 13.02%,说明这次切换已经吃掉了链路的容错余量,应该停下来看样本,而不是继续放量。
回退的目标必须是「上一个契约版本 + 上一个能力版本」,不能只回退其中一个。3.2 会把能力版本定义成模型、提示、评测集的三元组;3.1 只需要记住一条:契约版本与行为版本要一起回。
变更分类器
versions.ts 把上面的规则写成可执行代码。前半部分是契约变更分类、版本位计算与版本号闸门;后半部分是 3.2 要用的能力版本三元组、差异与回滚计划。两段共用同一套版本语义,避免契约版本与行为版本各说各话。
// file: src/gov/versions.ts
// tier: L2
// verify: node --test test/gov/versions.test.ts
import { parseSemver } from "../orch/platform.ts";
export type ChangeSurface = "input" | "output" | "error" | "behavior";
export type ChangeKind =
| "field-add-optional"
| "field-add-required"
| "field-remove"
| "enum-add"
| "enum-remove"
| "error-add"
| "error-remove"
| "default-change"
| "semantic-change"
| "docs-only";
export interface ContractChange {
surface: ChangeSurface;
kind: ChangeKind;
field: string;
detail?: string;
}
export type Compatibility = "compatible" | "breaking";
export interface ChangeVerdict {
change: ContractChange;
compatibility: Compatibility;
reason: string;
}
const RULES: Record<string, readonly [Compatibility, string]> = {
"input:field-add-optional": ["compatible", "新增可选输入字段,旧调用方不传也能运行"],
"input:field-add-required": ["breaking", "新增必填输入字段,旧调用方会缺参数"],
"input:field-remove": ["breaking", "删除输入字段,旧调用方的参数被忽略或拒绝"],
"input:enum-add": ["compatible", "输入枚举新增取值,旧调用方不会主动发送"],
"input:enum-remove": ["breaking", "输入枚举删除取值,旧调用方可能仍在发送"],
"output:field-add-optional": ["compatible", "新增可选输出字段,宽容解析的调用方不受影响"],
"output:field-add-required": ["breaking", "输出新增必填字段,严格解析的调用方会失败"],
"output:field-remove": ["breaking", "删除输出字段,依赖该字段的调用方会读到空值"],
"output:enum-add": ["breaking", "输出枚举新增取值,穷举分支的调用方没有默认路径"],
"output:enum-remove": ["breaking", "输出枚举删除取值,调用方可能仍在匹配"],
"error:error-add": ["breaking", "新增错误码,调用方可能没有对应的处理分支"],
"error:error-remove": ["breaking", "删除错误码,依赖该码的补偿逻辑会失效"],
"behavior:default-change": ["breaking", "默认值变化会改变同一请求的结果"],
"behavior:semantic-change": ["breaking", "语义变化无法从 schema 看出来,只能靠契约声明"],
"behavior:docs-only": ["compatible", "只改文档,不改变运行时行为"],
"input:docs-only": ["compatible", "只改文档,不改变运行时行为"],
"output:docs-only": ["compatible", "只改文档,不改变运行时行为"],
"error:docs-only": ["compatible", "只改文档,不改变运行时行为"],
};
/** 未知变更按不兼容处理:治理规则宁可误报,也不让未分类变化静默上线。 */
export function classifyChange(change: ContractChange): ChangeVerdict {
const hit = RULES[`${change.surface}:${change.kind}`];
if (hit === undefined) {
return { change, compatibility: "breaking", reason: "未登记的变更类型,按不兼容处理" };
}
return { change, compatibility: hit[0], reason: hit[1] };
}
export interface CompatibilityReport {
total: number;
compatible: number;
breaking: number;
requiredBump: "major" | "minor" | "patch";
verdicts: ChangeVerdict[];
}
export function classifyChanges(changes: readonly ContractChange[]): CompatibilityReport {
const verdicts = changes.map(classifyChange);
const breaking = verdicts.filter((verdict) => verdict.compatibility === "breaking").length;
const requiredBump = breaking > 0 ? "major" : changes.length > 0 ? "minor" : "patch";
return { total: changes.length, compatible: changes.length - breaking, breaking, requiredBump, verdicts };
}
export function nextContractVersion(current: string, report: CompatibilityReport): string {
const version = parseSemver(current);
if (version === null) throw new Error(`versions: invalid current version ${current}`);
if (report.requiredBump === "major") return `${version.major + 1}.0.0`;
if (report.requiredBump === "minor") return `${version.major}.${version.minor + 1}.0`;
return current;
}
export type DeclaredBump = "major" | "minor" | "patch";
export interface BumpDecision { ok: boolean; required: DeclaredBump; declared: DeclaredBump; reason: string }
const BUMP_RANK: Record<DeclaredBump, number> = { patch: 0, minor: 1, major: 2 };
export function checkBump(report: CompatibilityReport, declared: DeclaredBump): BumpDecision {
const ok = BUMP_RANK[declared] >= BUMP_RANK[report.requiredBump];
const reason = ok
? `声明的 ${declared} 覆盖了 ${report.requiredBump} 要求`
: `${report.breaking} 个不兼容变更要求 ${report.requiredBump}`;
return { ok, required: report.requiredBump, declared, reason };
}
export interface CapabilityTriple {
model: string;
prompt: string;
evalSet: string;
}
export interface CapabilityRelease extends CapabilityTriple {
capability: string;
contractVersion: string;
}
export type TripleComponent = "model" | "prompt" | "evalSet";
export const TRIPLE_COMPONENTS: readonly TripleComponent[] = ["model", "prompt", "evalSet"];
export function releaseId(release: CapabilityRelease): string {
return `${release.capability}@${release.contractVersion}[${release.model}/${release.prompt}/${release.evalSet}]`;
}
export type TripleIssueCode =
| "empty-component"
| "floating-model"
| "unpinned-prompt"
| "unpinned-eval-set";
export interface TripleIssue { capability: string; component: TripleComponent; code: TripleIssueCode; detail: string }
const PROMPT_ID = /^p-\d{4}-\d{2}-[a-z0-9]+$/;
const EVAL_SET_ID = /^eval-\d+$/;
/** 三元组是行为的版本,不允许浮动别名;任何一项变化都必须生成新的 release。 */
export function validateRelease(release: CapabilityRelease): TripleIssue[] {
const issues: TripleIssue[] = [];
for (const component of TRIPLE_COMPONENTS) {
if (release[component].trim() === "") {
issues.push({ capability: release.capability, component, code: "empty-component", detail: `${component} is empty` });
}
}
if (/latest|\*|^auto$/i.test(release.model)) {
issues.push({ capability: release.capability, component: "model", code: "floating-model", detail: `model ${release.model} is not pinned` });
}
if (!PROMPT_ID.test(release.prompt)) {
issues.push({ capability: release.capability, component: "prompt", code: "unpinned-prompt", detail: `prompt ${release.prompt} is not a released prompt id` });
}
if (!EVAL_SET_ID.test(release.evalSet)) {
issues.push({ capability: release.capability, component: "evalSet", code: "unpinned-eval-set", detail: `evalSet ${release.evalSet} is not a released eval id` });
}
return issues;
}
export interface TripleDiff { from: string; to: string; changed: TripleComponent[]; same: boolean }
export function diffTriple(from: CapabilityRelease, to: CapabilityRelease): TripleDiff {
const changed = TRIPLE_COMPONENTS.filter((component) => from[component] !== to[component]);
return { from: releaseId(from), to: releaseId(to), changed, same: changed.length === 0 };
}
export interface RollbackPlan { from: string; to: string; changed: TripleComponent[]; atomic: boolean; steps: string[] }
const RUNTIME_COMPONENT: Record<TripleComponent, boolean> = {
model: true,
prompt: true,
evalSet: false,
};
/**
* 一个能力版本是原子的:模型与提示同时变化时不能只回滚其中一个。
* 评测集只影响判定基线,不承载线上流量。
*/
export function planRollback(from: CapabilityRelease, to: CapabilityRelease): RollbackPlan {
const diff = diffTriple(from, to);
const runtime = diff.changed.filter((component) => RUNTIME_COMPONENT[component]);
const atomic = runtime.length > 1;
const steps: string[] = [];
if (diff.same) {
steps.push("版本三元组一致,无需回滚");
} else {
if (diff.changed.includes("evalSet")) {
steps.push(`保留评测集 ${to.evalSet},重置对比基线,不切换线上流量`);
}
if (atomic) {
steps.push(`冻结 ${releaseId(to)},把模型与提示作为一个能力版本整体回切`);
steps.push(`回切到 ${from.model} + ${from.prompt},用 ${from.evalSet} 重跑回归`);
} else {
if (diff.changed.includes("model")) {
steps.push(`把模型从 ${to.model} 切回 ${from.model},提示保持 ${to.prompt}`);
}
if (diff.changed.includes("prompt")) {
steps.push(`把提示从 ${to.prompt} 切回 ${from.prompt},模型保持 ${to.model}`);
}
}
steps.push("按 10%→50%→100% 回撤灰度,逐档核对成功率与成本");
}
return { from: diff.from, to: diff.to, changed: diff.changed, atomic, steps };
}
代码里有三个刻意的选择。第一,未知变更按不兼容处理,防止新类型的改动绕过评审。第二,classifyChanges 对空变更返回 patch,表示「没有可发布的契约变化」,而不是 minor。第三,nextContractVersion 在存在不兼容变更时把 minor 与 patch 清零,保证 2.3.0 的下一主版本是 3.0.0,而不是 2.4.0 或 3.1.0。
把规则写成表而不是一串 if,是为了让评审可以逐行引用。评审记录里写「第 6 条:错误码新增按不兼容处理」,比写「我们觉得问题不大」可审计得多。
示例样本上的运行结果
示例样本包含 9 条变更:2 条兼容(输入新增可选 traceId、输出新增可选 citations),7 条不兼容(必填字段、删字段、删错误码、加错误码、默认值、语义变化等)。在示例样本上运行分类器,结果如下。
| 指标 | 结果 |
|---|---|
| 变更总数 | 9 |
| 兼容变更 | 2 |
| 不兼容变更 | 7 |
| 所需版本位 | major |
| 2.3.0 的下一版 | 3.0.0 |
| 声明 minor 的闸门 | 拒绝,理由为「7 个不兼容变更要求 major」 |
另一组只有 3 条兼容变更的样本(可选输入、可选输出、文档修订)得到 3 兼容、0 不兼容,所需版本位为 minor,2.3.0 的下一版是 2.4.0,声明 minor 可以通过。两组样本说明版本位不是按改动数量算的,而是按是否存在不兼容项算的。
这些是示例样本上的运行结果,不是生产统计。真实项目里变更条目会更多,但判定规则与汇总口径不变。
多团队共享时的三条规则
第一条,提供方分类,调用方确认。提供方最了解改动,应该先给出分类与证据;调用方最了解自己的解析假设,必须对「兼容」结论签字。平台只做规则一致性与版本号闸门,不替双方判断业务语义。
第二条,契约版本与行为版本分开。契约版本回答「形状变没变」,行为版本回答「同样的输入会不会得到不同输出」。3.2 的三元组就是行为版本。两者都变时,发布说明要同时写两个版本号;只变行为时,契约版本可以不动,但评测与灰度不能省。
第三条,迁移成本进同一笔账。三能力编排已经把每次成功交付从 4,240 微元降到 3,786 微元(-10.71%),复用回本线是 28.47%。一次不兼容升级如果让三个调用方各改一周,这笔迁移成本必须和节省的成本放在一起看;否则「兼容性」会变成提供方单方面的成本,最后没人愿意维护契约。
小结
契约版本治理的核心不是版本号格式,而是三份可复核的判断:哪些变更兼容、版本位是否足够、灰度与回退是否对齐。示例样本的 9 条变更中 7 条不兼容,所需版本位是 major,2.3.0 的下一版是 3.0.0;声明 minor 会被闸门拒绝。
四个面——输入、输出、错误、行为——缺一面就会漏掉一类变更。行为面只能靠声明与评测,不能靠 schema diff。多团队共享时,提供方分类、调用方确认、平台守闸门,三者缺一不可。
阅读导航:上一节:2.3 能力退役与迁移 · 下一节:3.2 模型与提示版本的联动 。
常见错误与边界
- 把新增必填字段当成兼容。旧调用方不会因为「字段很好用」就自动传值。
- 把输出新增字段当成绝对兼容。严格解析的调用方会在 CI 或运行时失败,必须点名确认。
- 把新增错误码当成小事。调用方的 switch 没有默认分支时,新错误会变成未处理异常。
- 只做 schema diff。默认值、语义、引用格式的变化在 schema 里看不见。
- 用补丁号发布不兼容变更。版本号一错,灰度、通知与回滚全部错位。
- 认为版本号可以谈判。声明 minor 不能改变不兼容事实,闸门应该拒绝而不是放行。
- 灰度只看总成功率。不写分母、不看单步失败预算,版本切换会悄悄吃掉容错余量。
- 回退只回契约不回行为。形状回去了,模型与提示还在新版本,问题照旧。
- 让平台替双方判断业务语义。平台守规则,提供方与调用方承担语义确认。
- 把迁移成本留给调用方。没有预算的兼容性要求,最后会被绕过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。