系统提示词不是普通文案,而是会改变拒答边界、工具调用和输出格式的配置资产。本文用 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 回滚部署。

这套流程不能证明新提示词一定更好,但能让行为变化有记录、有证据并且可逆,从而降低未经观察直接全量发布的风险。