《互联网产品的 AI 架构·模型接入与能力边界》6.3 提示版本化与发布

「回声」一次直接改线上提示、没有版本记录,效果变差后无法回滚,只能凭记忆重写。本节把提示发布做成不可变版本、变更记录、确定性灰度与回滚,并要求提示与模型快照精确绑定,配可运行的 TypeScript 注册表与完整测试,并把发布审计写进事件日志。

从一次改提示没记版本说起

「回声」的私信智能回复在 6.1、6.2 之后有了模板、变量和上下文预算,但发布方式仍然原始:提示文本放在一个配置文件里,谁要改就改,改完提交、部署、生效。

某天客服反馈,模型开始频繁使用「亲」这个称呼,和产品设定的语气不符。值班同学打开配置文件,把称呼规则从「使用用户昵称」改成「不要使用亲」,保存发布。当天看起来好了。

第二天,另一批用户反馈回复变得生硬,连正常寒暄都省掉了。团队想把前一天的改动退回去,才发现三个问题:

  • 配置文件没有版本号,前一天改之前的原文没有人留存。
  • 提交记录里只有一句「fix tone」,看不出改的是哪条规则、为什么改。
  • 没有人记得这一版提示是在哪个模型快照上验证的,换模型之后效果是否仍然成立也没有依据。

最后是从一台旧机器的部署备份里找回了上一版。这次靠运气,下次未必。

这个事故说明,提示的发布不能停留在「改文件」。它需要版本号、变更记录、灰度、回滚,以及和模型版本的绑定。

版本号在提示上意味着什么

提示不是普通文案,版本号也不该只是递增的整数。本书采用三段式语义,含义如下。

变更类型版本位例子是否必须重新评测
输出契约或角色改变major从「草稿」改成「直接发送」是,全量重评
新增变量、规则或上下文类别minor增加「禁用话题」变量是,至少跑相关切片
措辞修正、错别字、标点patch把「请」改成「麻烦」建议跑冒烟集

语义化的目的不是形式主义,而是让评审者看到版本号就知道风险量级。看到 major,默认要重新评估;看到 patch,可以只跑冒烟。比版本号更重要的是不可变:同一个版本号对应的内容永远不能改,发现错别字也要发新版本。原地覆盖会让「线上是 1.2.0」这句话失去意义,因为不同时间读到的 1.2.0 可能是两份内容。

发布状态机

发布动作可以收敛成四个:登记、提升、灰度、回滚。

动作前置条件效果典型失败
登记版本号递增、变更记录非空追加一个不可变版本重复版本号、倒序版本
提升版本已登记且不低于当前全量设为全量版本提升旧版本
灰度版本比当前全量新、比例在 0..100设置灰度指针灰度旧版本、比例越界
回滚有灰度或存在上一版先撤灰度,再退全量只有一个版本时回滚

灰度分桶必须确定性

灰度最怕的不是比例不准,而是同一个用户在两次请求里落到不同版本。用户会看到前后不一致的回复,客服也无法复现问题。

「回声」的做法是:用 promptId + bucketKey 做 FNV-1a 哈希,对 100 取模,得到 0..99 的桶号;桶号小于灰度比例就走新版本。bucketKey 用租户 ID 加用户 ID,不能用请求 ID,也不能用时间戳。

分桶只解决「谁先看到」,不解决「看到后是否更好」。比例从 1% 扩到 10% 之前,必须看指标:通过率、人工抽检、投诉率、成本。指标没看就扩量,等于把灰度当成了发布仪式。

与模型快照绑定

提示和模型是联合产物。同一段提示,在模型 A 上通过率 0.86,在模型 B 上可能只有 0.71。原因可能是指令遵循风格不同,也可能是输出格式偏好不同。

所以发布记录里不能只写模型名,要写模型快照引用,例如 mock-small@2026-10。解析时做精确匹配:绑定里没有这个快照,就直接失败。

为什么失败而不是退回全量?因为退回全量会把「提示与模型不兼容」这个配置错误藏起来。表面上看请求成功了,实际上用的是一份没有在该模型上评估过的组合,事故只会更晚出现。

实现:一个最小注册表

下面的实现把登记、提升、灰度、解析、回滚和事件记录放在一个模块里。它不依赖数据库,状态用 Map 表示,方便测试,也方便替换成持久化实现。

// file: src/prompt/registry.ts
// tier: L1
// verify: node --test test/prompt/registry.test.ts

import { AiError } from "../core/errors.ts";

export interface PromptRelease {
  promptId: string;
  version: string;
  owner: string;
  changelog: string;
  body: string;
  /** 允许使用的模型引用,形如 "mock-small@2026-10",必须精确匹配。 */
  compatibleModels: string[];
  createdAt: number;
}

export interface Canary {
  version: string;
  percent: number;
}

export type RegistryEventKind = "register" | "promote" | "canary" | "rollback";

export interface RegistryEvent {
  kind: RegistryEventKind;
  promptId: string;
  version: string;
  detail: string;
}

export interface PromptRegistry {
  releases: Map<string, PromptRelease[]>;
  active: Map<string, string>;
  canary: Map<string, Canary>;
  events: RegistryEvent[];
}

export interface ResolveContext {
  promptId: string;
  /** 模型快照引用,例如 "mock-small@2026-10";不接受只有模型名。 */
  modelRef: string;
  /** 稳定分桶键,建议用 tenantId + userId;同一用户始终落同一侧。 */
  bucketKey: string;
}

export interface Resolution {
  promptId: string;
  version: string;
  body: string;
  bucket: number;
  canary: boolean;
}

const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;

function fail(message: string): never {
  throw new AiError("invalid-request", `prompt-registry: ${message}`);
}

export function createRegistry(): PromptRegistry {
  return { releases: new Map(), active: new Map(), canary: new Map(), events: [] };
}

export function compareVersions(left: string, right: string): number {
  const a = left.split(".").map(Number);
  const b = right.split(".").map(Number);
  for (let index = 0; index < 3; index += 1) {
    if (a[index] !== b[index]) return a[index] - b[index];
  }
  return 0;
}

/** FNV-1a 32 位取模,保证同一分桶键在任何进程里落到同一侧。 */
export function bucketOf(key: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < key.length; index += 1) {
    hash ^= key.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash % 100;
}

function historyOf(registry: PromptRegistry, promptId: string): PromptRelease[] {
  const releases = registry.releases.get(promptId);
  if (!releases || releases.length === 0) fail(`unknown prompt "${promptId}"`);
  return releases;
}

function releaseOf(releases: PromptRelease[], version: string): PromptRelease {
  const release = releases.find((item) => item.version === version);
  if (!release) fail(`unknown version "${version}"`);
  return release;
}

/** 版本内容不可变;同一 prompt 只能按版本号递增追加。 */
export function registerRelease(registry: PromptRegistry, release: PromptRelease): void {
  if (
    release.promptId.trim() === "" ||
    release.owner.trim() === "" ||
    release.changelog.trim() === ""
  ) {
    fail("promptId, owner and changelog are required");
  }
  if (!SEMVER.test(release.version)) fail(`invalid version "${release.version}"`);
  if (release.body.trim() === "") fail("body is required");
  if (release.compatibleModels.length === 0) fail("at least one compatible model is required");
  if (new Set(release.compatibleModels).size !== release.compatibleModels.length) {
    fail("compatibleModels contains duplicates");
  }
  if (!Number.isInteger(release.createdAt) || release.createdAt < 0) {
    fail("createdAt must be a non-negative integer");
  }

  const releases = registry.releases.get(release.promptId) ?? [];
  if (releases.some((item) => item.version === release.version)) {
    fail(`version ${release.version} already exists and is immutable`);
  }
  const latest = releases[releases.length - 1];
  if (latest && compareVersions(release.version, latest.version) <= 0) {
    fail(`version ${release.version} must be greater than ${latest.version}`);
  }

  releases.push(release);
  registry.releases.set(release.promptId, releases);
  if (!registry.active.has(release.promptId)) registry.active.set(release.promptId, release.version);
  registry.events.push({
    kind: "register",
    promptId: release.promptId,
    version: release.version,
    detail: release.changelog,
  });
}

/** 把已评估的版本提升为全量;如果它正在灰度,同时结束灰度。 */
export function promoteRelease(registry: PromptRegistry, promptId: string, version: string): void {
  const releases = historyOf(registry, promptId);
  const release = releaseOf(releases, version);
  const active = registry.active.get(promptId);
  if (!active) fail(`prompt "${promptId}" has no active version`);
  if (compareVersions(release.version, active) < 0) {
    fail(`cannot promote older version ${release.version} over ${active}`);
  }
  registry.active.set(promptId, version);
  const canary = registry.canary.get(promptId);
  if (canary && canary.version === version) registry.canary.delete(promptId);
  registry.events.push({ kind: "promote", promptId, version, detail: `active ${active} -> ${version}` });
}

/** 灰度只允许指向比当前全量更新的版本,避免“灰度旧版本”这种无效发布。 */
export function startCanary(
  registry: PromptRegistry,
  promptId: string,
  version: string,
  percent: number,
): void {
  const releases = historyOf(registry, promptId);
  const active = registry.active.get(promptId);
  if (!active) fail(`prompt "${promptId}" has no active version`);
  const release = releaseOf(releases, version);
  if (!Number.isInteger(percent) || percent < 0 || percent > 100) {
    fail("canary percent must be an integer in 0..100");
  }
  if (compareVersions(release.version, active) <= 0) {
    fail("canary version must be newer than active version");
  }
  registry.canary.set(promptId, { version, percent });
  registry.events.push({ kind: "canary", promptId, version, detail: `${percent}%` });
}

/**
 * 解析一次请求实际使用的版本。灰度命中后仍需检查模型绑定:
 * 不兼容就失败,而不是静默退回全量,否则会把错误的组合藏起来。
 */
export function resolveRelease(registry: PromptRegistry, context: ResolveContext): Resolution {
  const releases = historyOf(registry, context.promptId);
  const active = registry.active.get(context.promptId);
  if (!active) fail(`prompt "${context.promptId}" has no active version`);

  const bucket = bucketOf(`${context.promptId}:${context.bucketKey}`);
  const canary = registry.canary.get(context.promptId);
  const useCanary = canary !== undefined && bucket < canary.percent;
  const version = useCanary && canary ? canary.version : active;
  const release = releaseOf(releases, version);

  if (!release.compatibleModels.includes(context.modelRef)) {
    fail(`prompt ${context.promptId}@${version} is not bound to model ${context.modelRef}`);
  }

  return {
    promptId: context.promptId,
    version: release.version,
    body: release.body,
    bucket,
    canary: useCanary,
  };
}

/**
 * 先撤灰度,再退全量。每次回滚都必须带原因,事件日志就是发布记录。
 */
export function rollback(registry: PromptRegistry, promptId: string, reason: string): PromptRelease {
  if (reason.trim() === "") fail("rollback reason is required");
  const releases = historyOf(registry, promptId);
  const active = registry.active.get(promptId);
  if (!active) fail(`prompt "${promptId}" has no active version`);

  const canary = registry.canary.get(promptId);
  if (canary) {
    registry.canary.delete(promptId);
    const release = releaseOf(releases, active);
    registry.events.push({
      kind: "rollback",
      promptId,
      version: active,
      detail: `canary ${canary.version} removed: ${reason}`,
    });
    return release;
  }

  const index = releases.findIndex((item) => item.version === active);
  if (index <= 0) fail("no previous version to roll back to");
  const previous = releases[index - 1];
  registry.active.set(promptId, previous.version);
  registry.events.push({ kind: "rollback", promptId, version: previous.version, detail: reason });
  return previous;
}

export function listVersions(registry: PromptRegistry, promptId: string): string[] {
  return (registry.releases.get(promptId) ?? []).map((item) => item.version);
}

几个设计点值得展开。

第一,registerRelease 拒绝重复版本号和倒序版本。版本只能追加,不能覆盖。这条规则让「不可变」从口头约定变成运行时约束。

第二,resolveRelease 先分桶再检查模型绑定。只有真正被选中的版本才需要兼容当前模型;未命中的灰度版本不参与这次请求,也就不该因为它不兼容而失败。

第三,rollback 先撤灰度,再退全量。两次操作分开,事件日志能看出是「撤灰度」还是「退版本」。回滚必须带原因,events 只追加不修改,发布审计因此可以还原。

测试与真实输出

注册表的逻辑必须是确定性的:版本比较、分桶、灰度命中、回滚顺序,都要能用断言固定。完整测试放在 test/prompt/registry.test.ts。

在实验工程根目录执行:

node --test test/prompt/registry.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 版本比较按数值而不是字典序 (0.569875ms)
✔ 首版自动成为全量,后续版本只追加不替换 (0.657333ms)
✔ 同一版本号不可覆盖,版本号必须递增 (0.309416ms)
✔ 变更记录为空的版本不能被登记 (0.084459ms)
✔ 模型绑定精确匹配,未评估的组合直接失败 (0.174958ms)
✔ 灰度 100% 命中新版本,0% 始终走全量 (0.118167ms)
✔ 灰度分桶对同一用户稳定,且落在 0..99 (0.089625ms)
✔ 回滚先撤灰度,再退回上一全量 (0.157167ms)
✔ 只有一个版本时无法回滚 (0.120958ms)
✔ 登记、提升、灰度与回滚都会留下事件记录 (0.1325ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 143.527125

一次回滚演练

用一个假设时间线把四个动作串起来。所有时间与比例都是案例假设值。

  • 10 月 21 日,chat.reply@1.0.0 是全量。
  • 10 月 23 日,1.1.0 通过评测,被提升为全量。事件记录里有一条 promote。
  • 10 月 25 日,1.2.0 登记并开始 20% 灰度。事件记录里有一条 canary。
  • 10 月 26 日,灰度组的投诉率上升。值班同学执行回滚,系统先撤掉灰度指针,请求全部回到 1.1.0。
  • 10 月 27 日,全量指标继续变差,确认问题来自 1.1.0 本身,再次回滚到 1.0.0。

两次回滚在事件日志里分别是「移除 1.2.0 灰度」和「全量 1.1.0 退到 1.0.0」,责任人和原因都留了记录。如果没有这套结构,第二次回滚很可能被误操作成「把 1.2.0 直接设为全量」,问题会变得更难定位。

发布检查清单

检查项通过标准
版本语义版本位与变更类型匹配,没有被覆盖的同号版本
变更记录写清改了什么、为什么改、影响哪些场景
评测结果相关切片通过率不低于上一版,红线项没有退化
模型绑定写明模型快照,且评测就是在该快照上跑的
灰度比例从低比例开始,有明确的扩量条件
回滚目标明确上一版版本号,确认它仍可用
观测指标通过率、投诉率、成本、延迟都有对照

这张表不能保证发布一定正确,但能保证出错时退得回来。

小结

提示版本化不是为了多一道流程,而是为了让「线上是哪一版、为什么改、出问题退到哪」这三个问题有确定答案。

不可变版本、变更记录、确定性灰度、模型快照绑定,四件事缺一不可。少了任何一件,回滚都会退化成凭记忆重写;而凭记忆重写出来的版本,通常既不是原来那一版,也没有经过原来的评测。

阅读导航:上一节:6.2 上下文预算与裁剪 · 下一节:7.1 结构化输出的契约与校验 。

常见错误与边界

  • 直接改线上配置文件,不留旧版本。回滚时只能凭记忆重写,越改越偏。
  • 用时间戳或提交号代替版本号。它们能标识内容,但不能表达变更风险量级。
  • 允许原地覆盖同一版本号。「线上是 1.2.0」这句话会因此失去意义。
  • 灰度用随机数或请求 ID 分桶。同一用户来回跳版本,体验和复现都不可控。
  • 只写模型名,不写模型快照。提供方静默升级后,提示与模型的组合已经变了。
  • 模型不兼容时静默退回全量。配置错误被藏起来,事故只会更晚暴露。
  • 回滚不带原因。事件日志只剩下版本号,下一次复盘仍然要从头查。
  • 把灰度当成发布仪式。没有扩量条件和观测指标,灰度只是延迟了全量事故。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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