《互联网产品的 AI 架构·平台化与治理》3.2 模型与提示版本的联动

模型换代、提示改版、评测集换版如果各发各的,契约版本不变而行为已经变了。本节把三者绑成「能力版本」三元组,给出七种变化组合的联动规则、原子回滚矩阵与确定性测试,并说明提供方、调用方与平台各自该签什么字、如何用评测证据约束版本发布,以及迁移窗口内怎样保持基线可比。

契约没变,行为变了

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 → Bprompt单组件2
B → Cmodel + evalSet回模型,保留评测集3
B → Dmodel + 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% 都要按新版本重测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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