从一次改提示没记版本说起
「回声」的私信智能回复在 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 分桶。同一用户来回跳版本,体验和复现都不可控。
- 只写模型名,不写模型快照。提供方静默升级后,提示与模型的组合已经变了。
- 模型不兼容时静默退回全量。配置错误被藏起来,事故只会更晚暴露。
- 回滚不带原因。事件日志只剩下版本号,下一次复盘仍然要从头查。
- 把灰度当成发布仪式。没有扩量条件和观测指标,灰度只是延迟了全量事故。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。