把 prompt 版本写进前端代码库:大模型输出质量不再随部署漂移
每次上线新版本,你总要手动检查一次 prompt 有没有被改过——这件事本身就是一个严重的工程隐患。
模型输出质量在部署后出现漂移,往往不是模型变了,而是 prompt 在流转过程中悄悄变了。产品经理在后台管理系统里改了几个字,后端同事在代码里顺手“优化”了一下措辞,或者某个紧急 hotfix 覆盖了之前精心调好的版本——这些事发生的时候,没有人会主动通知你。等你发现的时候,用户已经抱怨了一整天。
我们把 prompt 当作前端代码的一部分来管理之后,这个问题彻底消失了。
先把问题拆开:prompt 到底从哪里漂进来的
一个典型的 prompt 流转路径是这样的:你在本地调好了一个 prompt,效果不错,于是把它写进后端配置文件或者数据库里。前端发请求的时候,要么直接传 prompt,要么传一个 prompt_id 让后端去查。听起来没问题,但实际操作中至少有三个环节会出岔子。
第一个岔子:后端存储的 prompt 可以被非代码途径修改。很多团队把 prompt 存在数据库里,配一个简单的管理界面。运营或产品看到“系统指令”这个字段,觉得改几个字没什么大不了——结果一个用于客服对话的 prompt 被加上了“请用活泼可爱的语气回复”,整个对话质量立刻崩盘。
第二个岔子:prompt 的版本号和代码版本号脱钩。后端 API 升级了 prompt 模板,但前端还在用旧版本的参数组合,导致实际发送的 prompt 和新模板预期的不一致。这种不一致在测试环境可能完全测不出来,因为测试环境的 prompt 版本往往是单独维护的。
第三个岔子:紧急修复直接覆盖。线上出问题,有人直接改了数据库里的 prompt 或者更新了配置文件,没有走正常的 code review 流程。问题暂时解决了,但你精心设计的 prompt 已经被覆盖,而且没有任何记录告诉你原来的版本是什么。
这三个岔子的共同根源是:prompt 的权威来源不明确。它到底属于配置,还是属于代码?如果属于配置,那谁都可以改;如果属于代码,那它就应该和前端代码一起进入版本控制。
方案核心:把 prompt 写进前端代码库,让 Git 成为唯一真相来源
我们把所有 prompt 模板定义为 TypeScript 常量,放在前端项目的 src/prompts/ 目录下。每个 prompt 是一个独立的文件,导出模板字符串和对应的参数类型。
// src/prompts/customer-support.v1.ts
export const CUSTOMER_SUPPORT_PROMPT_V1 = `你是一个专业的客服助手。
请根据以下规则回复用户:
1. 使用正式但友好的语气
2. 如果遇到无法解决的问题,直接告知用户并建议转接人工
3. 回复长度控制在 100 字以内
用户问题:{{userQuestion}}
用户历史订单:{{orderHistory}}`;
export interface CustomerSupportV1Params {
userQuestion: string;
orderHistory: string;
}
这样做有三个直接的好处。第一,prompt 的任何修改都必须通过 PR 和 code review,改一个字也要留下记录。第二,prompt 版本和前端代码版本天然绑定——发布 v2.3.0 的时候,prompt 是什么样就是什么样,不会出现前端代码和 prompt 不匹配的情况。第三,回滚变得极其简单:代码回滚到上一个版本,prompt 也跟着回去了。
但这还只是第一步。如果你只是把 prompt 搬到前端代码里,每次调用 API 的时候把完整 prompt 传过去,你会遇到两个新问题:token 浪费,以及 prompt 在后端不可见导致调试困难。
用 prompt_id 做桥梁:前端管版本,后端管存储
更好的做法是:前端代码库里只存 prompt 的“版本定义”,实际发送请求的时候传一个 prompt_id 和版本号,让后端根据这个 ID 返回对应的 prompt 内容——但后端必须从同一个代码仓库里读取 prompt 文件,而不是从数据库里读。
具体操作是这样的。我们在 CI/CD 流程里加一步:每次构建前端的时候,把 src/prompts/ 目录下的所有 prompt 文件打包成一个 JSON,注入到后端服务的静态资源里,或者直接打到后端的 Docker 镜像里。这样后端的 prompt 内容来源和前端完全一致,都是同一个 Git 仓库的同一个 commit。
# .github/workflows/deploy.yml 中的关键步骤
- name: Build prompt registry
run: |
node scripts/build-prompt-registry.js > prompt-registry.json
# 把 prompt-registry.json 复制到后端项目目录
cp prompt-registry.json ../backend/config/prompts.json
prompt-registry.json 的结构大致如下:
{
"customer-support": {
"v1": {
"template": "你是一个专业的客服助手。\n请根据以下规则回复用户:\n\n1. 使用正式但友好的语气\n2. 如果遇到无法解决的问题,直接告知用户并建议转接人工\n3. 回复长度控制在 100 字以内\n\n用户问题:{{userQuestion}}\n用户历史订单:{{orderHistory}}",
"parameters": ["userQuestion", "orderHistory"],
"model": "gpt-4o",
"temperature": 0.3
}
}
}
注意这里不仅存了模板,还存了推荐使用的模型和参数。这意味着 prompt 和它的最佳配置被绑定在一起,不会出现 prompt 升级了但参数没跟着调的情况。
前端请求变成这样:
// src/api/chat.ts
async function sendChatMessage(
userMessage: string,
orderHistory: string
) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt_id: 'customer-support',
prompt_version: 'v1',
parameters: {
userQuestion: userMessage,
orderHistory: orderHistory,
},
}),
});
return response.json();
}
后端拿到 prompt_id 和 prompt_version,从 prompts.json 里找到对应的模板,填上参数,再调用大模型。整个过程中,后端没有自己维护任何 prompt 内容,它只是一个“执行器”。
版本升级策略:永远不要直接改旧版本
当你需要改进 prompt 的时候,不要修改 customer-support.v1.ts。正确的做法是新建 customer-support.v2.ts,把新的 prompt 写进去,然后在前端代码里把引用从 v1 改成 v2。
// src/prompts/customer-support.v2.ts
export const CUSTOMER_SUPPORT_PROMPT_V2 = `你是一个专业的客服助手。
请根据以下规则回复用户:
1. 使用正式但友好的语气
2. 如果遇到无法解决的问题,直接告知用户并建议转接人工
3. 回复长度控制在 150 字以内
4. 如果用户表现出不满,优先表达歉意
用户问题:{{userQuestion}}
用户历史订单:{{orderHistory}}
用户情绪标签:{{sentimentLabel}}`;
export interface CustomerSupportV2Params {
userQuestion: string;
orderHistory: string;
sentimentLabel: string;
}
这样做的好处是:v1 和 v2 同时存在于代码库中,你可以随时对比两个版本的区别。如果 v2 上线后效果不如 v1,回滚只需要改一行代码——把版本号从 v2 改回 v1。不需要去数据库里翻找旧 prompt,不需要找运维恢复配置,一个普通的 Git revert 就够了。
如果你需要做 A/B 测试,也只需要在前端加一个简单的分流逻辑:
const promptVersion = Math.random() < 0.5 ? 'v1' : 'v2';
const response = await sendChatMessage(userMessage, orderHistory, promptVersion);
测试完成后,把分流代码删掉,固定成效果更好的那个版本,提交 PR,整个流程闭环。
跨项目共享 prompt:用 npm 私有包统一管理
如果你的团队有多个前端项目使用同一套 prompt(比如不同的客户端 App 共用同一个对话模型),把 prompt 放在单个前端项目里就不够了。这时候可以把 src/prompts/ 提升为一个独立的 npm 私有包。
@company/prompts/
├── src/
│ ├── customer-support/
│ │ ├── v1.ts
│ │ └── v2.ts
│ ├── product-recommendation/
│ │ └── v1.ts
│ └── index.ts
├── package.json
└── tsconfig.json
index.ts 导出所有 prompt 的注册表:
// @company/prompts/src/index.ts
import { CUSTOMER_SUPPORT_PROMPT_V1, CustomerSupportV1Params } from './customer-support/v1';
import { CUSTOMER_SUPPORT_PROMPT_V2, CustomerSupportV2Params } from './customer-support/v2';
export const prompts = {
'customer-support': {
v1: {
template: CUSTOMER_SUPPORT_PROMPT_V1,
params: {} as CustomerSupportV1Params, // 类型占位,实际用于类型推导
},
v2: {
template: CUSTOMER_SUPPORT_PROMPT_V2,
params: {} as CustomerSupportV2Params,
},
},
} as const;
export type PromptId = keyof typeof prompts;
export type PromptVersion<P extends PromptId> = keyof typeof prompts[P];
每个前端项目通过 npm install @company/prompts@latest 获取最新的 prompt 定义。CI/CD 构建后端镜像时,也从同一个包版本里提取 prompts.json。
这样做的代价是:每改一次 prompt 就要发一个新版本的 npm 包。但这个代价是值得的——它为 prompt 的变更提供了完整的审计轨迹:谁改的、什么时候改的、改了什么、哪个版本开始生效,全部记录在 npm 包的 changelog 和 Git 历史里。
防止“偷偷改 prompt”的最后一道防线:构建时校验
即使有了上面的流程,仍然存在一个风险:有人绕过前端代码,直接在后端的管理后台或者数据库里改 prompt。为了堵住这个口子,我们在后端加了一个启动时的校验逻辑。
// backend/main.go
func validatePrompts(promptsFromConfig map[string]interface{}) error {
embeddedPrompts := loadEmbeddedPrompts() // 从嵌入的 prompts.json 读取
for id, versions := range embeddedPrompts {
if _, exists := promptsFromConfig[id]; exists {
return fmt.Errorf(
"prompt %s found in external config, but it should be managed in code. "+
"Remove it from the database and restart.",
id,
)
}
}
return nil
}
这个校验在后端启动时运行。如果检测到数据库里有 prompt 内容,直接拒绝启动并打印明确的错误信息。这相当于用代码强制规定了:prompt 的唯一来源是代码仓库,任何试图从外部注入 prompt 的行为都会被拦截。
上线三个月以来,我们再也没有遇到过“输出质量突然变差,排查半天发现是 prompt 被人改了”的情况。prompt 的变更现在和代码变更一样可追溯、可回滚、可 review——这就是我们想要的状态。
常见问题
这样改 prompt 是不是太慢了?每次调 prompt 都要走 PR 流程,效率会不会很低?
实际上,prompt 调整走 PR 流程反而比直接在管理后台改更快。管理后台改 prompt 的问题在于:你改完之后不知道效果怎么样,只能等用户反馈。而走 PR 流程,你可以在 PR 里附上新的 prompt 在测试环境跑出来的对比结果,reviewer 基于数据来判断合并不合并。从“改 prompt”到“确认效果”这个闭环,走 PR 比直接在后台改更短,因为后者缺少验证环节。
如果 prompt 里包含敏感信息,写在前端代码里会不会暴露?
prompt 模板本身不应该包含敏感信息。如果 prompt 需要引用敏感数据(比如某个客户的内部代号),应该通过参数注入,而不是写死在模板里。模板里只放通用的指令和规则,敏感数据通过 parameters 传入——这正是我们定义 Params 接口的原因之一。如果模板本身就有保密需求(比如竞品分析用的 prompt),那这个方案不适用,你应该把 prompt 放在后端并严格限制访问权限。
多个 prompt 版本同时存在,旧版本什么时候可以删?
当旧版本不再被任何前端代码引用、且确认没有线上流量还走旧版本时,就可以删了。我们的做法是:在删除之前,先在 CI 里加一个检查——扫描所有前端项目的 import 语句,确认没有任何地方引用该版本。同时查后端的请求日志,确认过去 7 天没有带该版本号的请求。两个条件都满足了,提交删除 PR。