系统提示词不是普通文案,而是会改变拒答边界、工具调用和输出格式的配置资产。本文用 Git 管理提示词版本,通过结构化变更说明、自动检查和稳定灰度逐步发布,并根据行为指标决定放量或回滚。
为什么提示词需要发布流程
系统提示词经常以字符串常量、后台文本框或环境变量的形式存在。修改几句话看似风险有限,实际可能同时影响安全策略、工具选择、结构化输出以及模型对业务术语的理解。
例如,把“信息不足时明确拒绝”改成“尽量根据上下文回答”,可能降低拒答率,却增加无依据回答;补充一段工具说明,也可能让模型更频繁地发起工具调用。它们都属于产品行为变更,应当留下可审查、可比较、可回滚的记录。
| 管理对象 | 需要记录的内容 | 主要风险 |
|---|---|---|
| 提示词正文 | 完整文本、版本、提交记录 | 行为边界发生隐式变化 |
| 发布配置 | 候选版本、灰度比例 | 流量分配不稳定或一次性全量 |
| 变更说明 | 目标、预期差异、风险 | 评审者只能逐字猜测意图 |
| 观测指标 | 拒答、工具调用、格式错误 | 只看接口成功率,遗漏语义回归 |
提示词适合保存在 Git 仓库中,但仅有文本差异还不够。评审者需要知道“为什么改”和“哪些可观察行为应该变化”,因此发布单应当与提示词版本一起提交。
建立注册表与语义变更说明
下面使用一个 JSON 文件保存提示词注册表。生产项目也可以把正文拆成独立 Markdown 文件,再在注册表中引用路径和内容摘要。
{
"active": "support-v1",
"candidate": "support-v2",
"rollout": 10,
"versions": {
"support-v1": {
"prompt": "你是产品支持助手。仅根据用户提供的信息回答。信息不足时输出拒答,不要猜测。需要查询内部资料时调用 search_docs。除工具调用外,必须输出符合指定 JSON Schema 的内容。"
},
"support-v2": {
"prompt": "你是产品支持助手。优先根据用户信息回答。缺少关键事实时输出拒答,并说明还需要什么信息。涉及产品版本、限制或配置步骤时调用 search_docs,不要凭记忆补充。除工具调用外,必须输出符合指定 JSON Schema 的内容。"
}
},
"change": {
"intent": "降低无法继续处理但没有说明缺失信息的拒答",
"expected": [
"拒答率可能下降",
"涉及产品事实的问题更可能调用 search_docs",
"结构化输出成功率不应下降"
],
"risks": [
"工具调用量增加",
"模型可能把非关键事实误判为关键事实"
],
"rollback": "将 rollout 调整为 0"
}
}
这里的“语义差异”不是用向量距离替代人工评审。向量或文本差异只能提示变化,无法可靠判断安全边界是否扩大。更实用的做法是强制作者填写意图、预期指标、已知风险和回滚方法,再结合 git diff --word-diff 检查具体措辞。
可以增加一个无需第三方依赖的检查脚本 check.mjs:
import fs from 'node:fs';
const registry = JSON.parse(fs.readFileSync('prompts.json', 'utf8'));
const required = ['intent', 'expected', 'risks', 'rollback'];
const errors = [];
for (const key of required) {
if (!registry.change?.[key] || registry.change[key].length === 0) {
errors.push(`change.${key} is required`);
}
}
for (const id of [registry.active, registry.candidate]) {
if (!registry.versions?.[id]?.prompt?.trim()) {
errors.push(`unknown or empty prompt version: ${id}`);
}
}
if (!Number.isInteger(registry.rollout) || registry.rollout < 0 || registry.rollout > 100) {
errors.push('rollout must be an integer between 0 and 100');
}
if (errors.length) {
console.error(errors.join('\n'));
process.exit(1);
}
console.log('prompt registry is valid');
CI 至少应运行 node check.mjs,并要求提示词或发布配置变化时由熟悉业务边界的人评审。针对固定输入集的离线评测也应放在合并前,但不要把单一总分当作发布依据。
用 Node.js 实现按请求灰度
灰度分流不能直接使用 Math.random()。同一用户若在新旧提示词之间来回跳转,既影响体验,也会让指标混入版本切换噪声。下面的服务使用请求中的稳定标识计算哈希桶;相同标识在灰度比例不变时总会命中同一版本。
示例需要 Node.js 20 或更高版本,并设置 OPENAI_API_KEY。保存前面的注册表为 prompts.json,再创建 app.mjs:
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
const registry = JSON.parse(fs.readFileSync('prompts.json', 'utf8'));
const metrics = new Map();
function selectVersion(key) {
const digest = crypto.createHash('sha256').update(key).digest();
const bucket = digest.readUInt32BE(0) % 10000;
return bucket < registry.rollout * 100
? registry.candidate
: registry.active;
}
function record(version, result) {
const value = metrics.get(version) ?? {
requests: 0,
refusals: 0,
toolCalls: 0,
schemaFailures: 0
};
value.requests++;
value.refusals += Number(result.refused);
value.toolCalls += Number(result.toolCalled);
value.schemaFailures += Number(!result.schemaOk);
metrics.set(version, value);
}
function metricSnapshot() {
return Object.fromEntries([...metrics].map(([version, value]) => {
const rate = key => value.requests ? value[key] / value.requests : 0;
return [version, {
...value,
refusalRate: rate('refusals'),
toolCallRate: rate('toolCalls'),
schemaFailureRate: rate('schemaFailures')
}];
}));
}
async function runModel(version, input) {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'content-type': 'application/json'
},
body: JSON.stringify({
model: process.env.OPENAI_MODEL ?? 'gpt-4o-mini',
messages: [
{ role: 'system', content: registry.versions[version].prompt },
{ role: 'user', content: input }
],
tools: [{
type: 'function',
function: {
name: 'search_docs',
description: 'Search internal product documentation',
parameters: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
additionalProperties: false
}
}
}],
response_format: {
type: 'json_schema',
json_schema: {
name: 'support_result',
strict: true,
schema: {
type: 'object',
properties: {
decision: { type: 'string', enum: ['answer', 'refuse'] },
answer: { type: 'string' }
},
required: ['decision', 'answer'],
additionalProperties: false
}
}
}
})
});
if (!response.ok) throw new Error(`OpenAI returned ${response.status}`);
const message = (await response.json()).choices[0].message;
const toolCalled = (message.tool_calls?.length ?? 0) > 0;
let output = null;
if (!toolCalled && message.content) {
try { output = JSON.parse(message.content); } catch {}
}
const schemaOk = toolCalled || (
output && ['answer', 'refuse'].includes(output.decision) &&
typeof output.answer === 'string'
);
return {
message,
refused: message.refusal != null || output?.decision === 'refuse',
toolCalled,
schemaOk
};
}
const server = http.createServer(async (req, res) => {
res.setHeader('content-type', 'application/json; charset=utf-8');
if (req.method === 'GET' && req.url === '/metrics') {
return res.end(JSON.stringify(metricSnapshot()));
}
if (req.method !== 'POST' || req.url !== '/chat') {
res.statusCode = 404;
return res.end(JSON.stringify({ error: 'not found' }));
}
try {
let raw = '';
for await (const chunk of req) raw += chunk;
const { requestId, input } = JSON.parse(raw);
if (typeof requestId !== 'string' || typeof input !== 'string') {
throw new Error('requestId and input must be strings');
}
const version = selectVersion(requestId);
const result = await runModel(version, input);
record(version, result);
res.end(JSON.stringify({ version, message: result.message }));
} catch (error) {
res.statusCode = 400;
res.end(JSON.stringify({ error: error.message }));
}
});
server.listen(3000, () => console.log('listening on http://localhost:3000'));
运行 node check.mjs && node app.mjs,然后向 /chat 发送 {"requestId":"user-42","input":"如何配置某个版本的功能?"}。真实系统应优先使用登录用户 ID 或匿名设备 ID;不要使用每次都变化的追踪 ID,否则稳定分桶会失效。
监控差异并快速回滚
示例的 /metrics 按提示词版本分别聚合三类行为指标。拒答率反映边界变化,工具调用率反映外部依赖和成本变化,结构化输出失败率则直接影响下游解析。HTTP 错误率、延迟和模型 token 用量也应由网关统一记录,但它们不能替代行为指标。
比较新旧版本时,应使用相同时间窗口,并按业务场景进一步分组。候选版本整体正常,不代表高风险场景没有退化。发布前可以先向候选版本发送内部测试流量,再按较小比例接入真实请求,观察达到预先约定的样本量和时间窗口后再决定是否放量。
不要等异常发生后才讨论阈值。发布单应提前写明哪些变化符合预期,例如工具调用率允许上升但格式失败率不能明显恶化。阈值需要结合当前产品基线和业务容忍度制定,不能从通用文章中复制一个百分比。
发现异常时,先把 rollout 调整为 0 并重新加载配置,使后续请求全部回到活动版本。随后保留候选版本、日志和对应 Git 提交用于复盘,而不是立即覆盖文本。若提示词已经全量发布,则将上一版本重新设为活动版本;Git 回滚可以恢复仓库状态,但线上恢复速度取决于配置部署机制,因此运行时开关仍然必要。
还要注意,内存指标会在进程重启后丢失,多实例部署也无法自动汇总。示例适合说明统计口径,生产环境应把每次请求的版本、拒答标记、工具名、结构校验结果和追踪标识发送到现有日志或指标系统,同时避免记录未经处理的敏感输入。
总结
系统提示词会改变可见的产品行为,应像代码和高风险配置一样经过版本管理、评审、验证与发布。一个可落地的流程包含以下要点:
- 用 Git 保存不可变的提示词版本,发布配置只引用明确版本。
- 通过结构化发布单说明变更意图、预期指标、风险和回滚方法。
- 在 CI 中检查注册表完整性,并结合文本差异、固定评测集和人工评审。
- 使用稳定标识分桶,确保同一用户在灰度期间持续命中同一版本。
- 分版本监控拒答率、工具调用率和结构化输出失败率,不只观察接口是否成功。
- 保留可即时归零的灰度开关,让异常处理不依赖临时修改提示词或等待 Git 回滚部署。
这套流程不能证明新提示词一定更好,但能让行为变化有记录、有证据并且可逆,从而降低未经观察直接全量发布的风险。
评论