文章指出系统提示词是最关键的系统配置却普遍缺乏版本控制,介绍了用TypeScript类型安全方式管理提示词版本的实践方案。
某人在某个周二的下午改了一句话的 system prompt。Diff 是一行。九秒 review。合入。
从那一刻起,你的 AI 回答在用户看来就不一样了。没有迁移,没有 feature flag,没有灰度 —— 而且在大多数代码库里,没有任何日志记录"这条回答是由哪个版本的 prompt 生成的"。
Prompt 是系统里权重最高的配置项,同时也是唯一没有任何规范管理的配置。
export const SYSTEM = `
You are a support assistant for Acme.
Be concise. Never promise refunds.
`.trim();
-Be concise. Never promise refunds.
+Be concise and friendly. Never promise refunds without checking the policy.
客服质量下降了。两周后有人发现。"这个改动是什么时候发生的?"这个问题从 git 里能回答,但"这条坏回答是改动前还是改动后生成的?"回答不了,因为存储的回答和生成它的那个字符串之间没有关联。
export type Prompt = {
readonly id: string;
readonly version: number;
readonly text: string;
};
function prompt(id: string, version: number, text: string): Prompt {
return { id, version, text: text.trim() } as const;
}
export const SUPPORT = prompt("support", 7, `
You are a support assistant for Acme.
Be concise and friendly. Never promise refunds without checking the policy.
`);
然后每次调用都记录用的是哪个:
export async function ask(p: Prompt, messages: MessageParam[]) {
const res = await client.messages.create({
model: MODEL, max_tokens: 1024, system: p.text, messages,
});
logger.info("model_call", {
promptId: p.id,
promptVersion: p.version,
promptHash: hash(p.text),
model: MODEL,
costUsd: costOf(MODEL, res.usage),
});
return res;
}
同时记录版本号和 hash。版本号供人理解;hash 用来catch那种改了文本但忘记改版本号的情况——这是最常见的错误,因为改版本号是一种纪律,冲动编辑是一种本能。
const hash = (s: string) =>
createHash("sha256").update(s).digest("hex").slice(0, 8);
比"记住要改版本号"更好的做法:让构建直接失败。
// prompts.test.ts
import { SUPPORT, TRIAGE, SUMMARISE } from "./prompts";
const LOCKED: Record<string, Record<number, string>> = {
support: { 7: "a3f19c22" },
triage: { 3: "77b0e415" },
summarise: { 12: "e0c4a8d1" },
};
it.each([SUPPORT, TRIAGE, SUMMARISE])("$id v$version is unchanged", (p) => {
const known = LOCKED[p.id]?.[p.version];
expect(known,
`${p.id} v${p.version} is not locked — add ${hash(p.text)} to LOCKED`,
).toBeDefined();
expect(hash(p.text),
`${p.id} text changed without a version bump`,
).toBe(known);
});
现在修改文本而不改版本号会得到一个红色的测试,错误信息精确地告诉你该怎么做。这个锁表成了每个曾经发布过的 prompt 版本的可读历史,在一个文件里,可以审查。
这是本文中价值最高的一件事。它只需要二十分钟,就能把一个静默的改动变成无法被静默合入的改动。

日志会滚动。如果你的 AI 输出被持久化了——大多数产品都会,比如对话历史——那就连同它是怎么产生的一起持久化:
await db.message.create({
data: {
conversationId,
role: "assistant",
content: text,
promptId: SUPPORT.id,
promptVersion: SUPPORT.version,
model: MODEL,
createdAt: new Date(),
},
});
四个额外的字段。它们回答了一个原本根本无法回答的问题:用户抱怨三周前的一条回答,你可以告诉他是哪一版 prompt、用了哪个模型写的它。
没有这些字段,对一条历史回答的溯源就只能靠猜测时间戳去对 git log,而且一旦 deploy 落后于 merge,结论就是错的。
一次 prompt 改动在 deploy 的那一刻就对 100% 的用户产生了行为影响。栈里没有任何其他东西是这样发布的。
export function promptFor(user: User): Prompt {
return flags.enabled("support-prompt-v8", user.id) ? SUPPORT_V8 : SUPPORT_V7;
}
两个版本都导出,两个都加锁,两个都记录日志。现在可以真正做对比了:
SELECT prompt_version,
count(*) AS answers,
avg(user_rating) AS rating,
avg(cost_usd) AS cost,
sum(escalated::int)::float/count(*) AS escalation_rate
FROM ai_messages
WHERE created_at > now() - interval '7 days'
GROUP BY prompt_version;
这条 SQL 是整篇文章的核心论点。没有 version 列它根本跑不了,加了它就轻而易举。
把 cost 也算进去。Prompt 改动移动成本和移动质量的概率一样高——更长的 system prompt 每次请求都要计费,而且一条让回答更详尽的指令会增加 output token。
Prompt 应该在代码库里,不应该在可以在线编辑的配置服务里。它们是代码:需要 review,需要跟着 deploy 一起回滚,需要和依赖它们输出格式的代码放在一起做 diff。
放在数据库里的 prompt 是一次没有 review、没有测试跑、也没有关联到"导致" regression 的那次 deploy 的生产变更。在第一个 incident 发生之前它听起来很方便,之后就不是了。
值得保留的例外是 kill switch——一个不需要 deploy 就能回退到上一版本的开关。那是运维控制手段,不是 prompt 创作。

以上所有内容都适用于你的工具定义,但几乎没人在那里应用它们。
export const TOOLS = {
id: "support-tools",
version: 4,
defs: [ /* ... */ ],
} as const;
模型从工具的名称和描述来选择工具。修改描述会改变工具选择——这和修改 system prompt 是同一类改动,同样地不可见。用同样的方式加版本号和日志,也把它加到锁测试里。
把 promptVersion 加到你的 model-call 日志行和存储的输出里。这是两个字段,只需要十分钟,就能把"质量在某时候下降了"变成一条可查的 SQL。
锁测试是第二件事,而它保证了第一件事不会失效。
AI That Answers 把 prompt 当作工程产物来对待——版本化管理、经过测试、谨慎发布、并且可以归因到每一条它们生成的输出。

衡量一次改动是否有帮助是第五本书的 eval 章节。全系列在 xgabriel.com/ai-in-typescript。