在AI模型Provider迁移的数周内,业务迭代会导致live端prompt与备用端悄然分化,迁移验证实为新旧版本不对等对比。解决方案是消除第二份副本,从源头阻止漂移发生。
一次持续六周的迁移,意味着六周的 prompt 编辑会同时落在两个地方。没人盯着的那份会悄悄漂移,直到切换当天才会被发现。解决办法不是靠纪律约束,而是把那第二份副本彻底去掉。
提供商迁移的通常做法是:复制 prompt 文件,在新提供商上调整直到行为符合预期,然后同时保留两份直到切换那天。在第一天这是可行的,因为两份文件除了调整的部分之外完全相同。但一旦日常产品工作恢复,事情就变得不再美好了。
迁移期间的 prompt 编辑并非罕见事件。一张支持工单揭示模型错误处理了退款,于是有人添加了一个条款。法务审查添加了免责声明。工具描述被收紧,因为模型一直调用错误的工具。每一处修改都是对当前线上文件的两行改动,而这些改动中的每一处都悄悄缺席于那个没有上线的文件。六周之后,两份 prompt 已经相差十几处没人记录过的编辑,而你切换前运行的「等价」A/B 测试,实际上是在拿当前 prompt 和一个六周前的版本做比较。新提供商看起来比实际表现更差,而你无法判断这种差距有多少是模型本身的问题。
第二个问题更严重也更隐蔽:有人确实记得要同步,于是把编辑应用到了两份文件上。他们把编辑正确地应用到了线上版本,而对另一份只是近似地应用,因为两份文件结构不同,那段文字所在的位置也不一样。现在这种分歧对 diff 来说已经不可见了,因为两份文件都变了。
这个模式已经在每个国际化应用中使用过:把语义保存一份,按目标分别渲染。规范形式不是一段字符串,而是一个结构,用来保存真正与提供商无关的 prompt 部分:
角色和任务指令——那段说明助手是什么、做什么用的 prose。这几乎总是可以逐字移植的。
约束条件和拒绝规则——同样是 prose,同样可移植,也是最可能在迁移中期被编辑的部分,因为合规相关的改动都落在这里。
少样本示例以结构化的对话轮次呈现,而不是一段文本块。一个用户轮次和一个助手轮次,各自带有角色,这样渲染器就可以把它们放到提供商需要的位置。
工具定义包含名称、描述和一个 JSON Schema 对象。Schema 本身是可移植的;它外面的包装不是,而包装是渲染器的问题。
采样意图表达为一个决策而非一个数字:deterministic、balanced、creative。原因在下一节。
其他所有内容——系统文本放在哪里、工具包装长什么样、输出格式字段叫什么、token 上限叫什么以及它是否是必填的——都属于渲染器的范畴。
渲染器接收规范结构,然后为单个提供商生成请求体。写两个渲染器是半天的工作,而且这样做是值得的,而不是用模板,因为差异是结构性的而非表面性的。以下是那些会咬人的差异:
系统文本的位置不一样
OpenAI 的 Chat Completions API 将其作为消息数组中一条带有 role 的消息来传递——历史上是 system,库已经处理了更新的 developer 角色。Anthropic 的 Messages API 将其作为独立于 messages 数组之外的顶层 system 参数,而那里的 messages 数组只接受 user 和 assistant。OpenAI 的 Responses API 将其作为 instructions。一个规范字段,三个不同的目的地。
工具包装在相同的 Schema 周围有所不同
Chat Completions 嵌套了定义:type: "function" 的数组条目,包含一个 function 对象,持有名称、描述和参数。Anthropic 的 Messages API 将名称、描述和 input_schema 平铺在 tool 对象上。你放进 parameters 或 input_schema 的 JSON Schema 是同一份文档。在规范形式中将其保存为一个对象,让每个渲染器自行包装。
采样范围不是同一个范围
这就是为什么规范形式存储意图而非浮点数的原因。OpenAI 文档记录 temperature 范围是 0 到 2;Anthropic 文档记录范围是 0 到 1。一个规范的 0.7 在一个提供商上是轻度设置,在另一个上则是中等偏暖的设置,直接复制数字过去是一种看起来什么都没变但却改变了行为的变化。在每个渲染器中将 balanced 映射为一个数字,一次完成,并附上注释说明来自哪个文档化的范围。
token 上限的名称不同,而且不一定是可选的
Anthropic 的 Messages API 要求每个请求都有 max_tokens。Chat Completions 将其上限视为可选的,有模型相关的默认值,而 OpenAI 的推理模型拒绝 max_tokens,转而使用 max_completion_tokens。Responses API 称之为 max_output_tokens。一个规范的「回答预算」在每个地方渲染为不同的键,而其中一个地方如果省略了就会直接导致请求失败。
参数名称和可接受的范围是供应商的表面接口,会变动。把渲染器中的映射表当作在升级 SDK 时需要重新检查的东西,在阅读变更日志时关注那些与你相关的条目。
有了规范形式,迁移中期的退款条款就是对一个文件的单次编辑。你仍然需要证明这个编辑在两个渲染器中都存活了下来,因为渲染器 bug 现在会同时出现在两个产品中。这个证明就是每个提供商的快照测试:渲染规范 prompt,序列化请求体,与已提交的文件做比较。
# tests/render_snapshot_test.py
from prompts import CANONICAL
from render import render_openai, render_anthropic
def test_openai_snapshot(snapshot):
body = render_openai(CANONICAL["support_agent"], version=7)
assert body == snapshot("support_agent.openai.json")
def test_anthropic_snapshot(snapshot):
body = render_anthropic(CANONICAL["support_agent"], version=7)
assert body == snapshot("support_agent.anthropic.json")
快照不是为了断言 prompt 是好的而存在的。它们的存在是为了让一行 prose 编辑在 pull request 中产生两个文件的 diff,两个 diff 都包含相同的新句子,审查者可以一眼看出这个改动到达了两个提供商。当一个快照变了而另一个没变时,说明渲染器吞掉了什么东西。
对规范条目进行版本控制,而不是对渲染后的输出进行版本控制。一个 prompt 版本标识符会进入请求元数据和日志,它让你在切换之后能够回答「这个糟糕的回答是哪个 prompt 生成的」,那时两个提供商会出现在同一个日志流中。这与你的日志字段迁移需要跨迁移保持稳定是同一个标识符。
拿出现在线上的那个 prompt,用手工拆成上述五个规范部分。现在不要试图让它变得通用——先做一个 prompt。
为当前所在的提供商写渲染器。断言其输出与你今天发送的请求体字节完全一致。在那个断言通过之前,你已经改变了生产行为而声称没有。
单独部署那个渲染器,不要在任何地方引入第二个提供商。这是危险的一步,而且它应该是这个版本中唯一的东西。
写第二个渲染器。用同一个规范条目运行它,在发送到任何地方之前先读一读它生成的 JSON。大部分映射错误在这个时候就是可见的。
提交两个快照,并把两个文件 diff 的预期加入到迁移期间的审查清单中。
删除旧的 prompt 文件。如果它还在,就会有人去编辑它。这不是偏执;文件的可编辑性就是这套模式存在所要消除的整个失败模式。
切换之后,删除你不再使用的渲染器——或者故意保留它,作为让回滚变成配置变更而非代码回退的东西。
渲染器层的形态是相同的,无论它运行在你的服务内部还是前面。如果你运行多个都发送 prompt 的服务,网关是让映射只写一次而不是每个代码库都写一次的地方——Multigrid 跨提供商规范化请求形态,这让你各服务只持有规范形式。无论哪种方式,规范形式的设计权仍然在你手里;这是本页不会消失的那部分内容。
Migrating a Prompt Versioning System Between Providers
Building a Migration Runbook for a Provider Cutover
Why the Same Prompt Behaves Differently on Every Model