《互联网产品的 AI 架构·平台化与治理》3.1 契约版本与兼容规则

契约版本治理要回答三件事:哪些变更兼容、版本号怎么升、灰度怎么退。本节按输入、输出、错误码与行为四个面列出十类变更的兼容判定,用可运行代码计算所需版本位、拦截版本号作弊,并给出 9 项变更、7 项不兼容的示例结果、灰度举证方式与多团队发布流程。

一次「只加了一个字段」的升级

「回声」的内容团队给 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 不能改变不兼容事实,闸门应该拒绝而不是放行。
  • 灰度只看总成功率。不写分母、不看单步失败预算,版本切换会悄悄吃掉容错余量。
  • 回退只回契约不回行为。形状回去了,模型与提示还在新版本,问题照旧。
  • 让平台替双方判断业务语义。平台守规则,提供方与调用方承担语义确认。
  • 把迁移成本留给调用方。没有预算的兼容性要求,最后会被绕过。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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