兜底 prompt 随主 prompt 演进而老化,六个月后主 prompt 新增字段、兜底仍输出旧字段,解析必败。兜底通常目标不同模型,需针对该模型独立测试。
你的备用 prompt 更短、更简单,针对的是更小的模型。它在生产环境运行过大约四次。每一次都是在主模型宕机期间,而至少有一次它产生的输出被你的解析器拒绝了。
备用 prompt 会腐坏有一个结构性的原因:它们只在主 prompt 被编辑时才会被编辑,而主 prompt 几乎不会被编辑,因为没有人记得备用 prompt 存在。主 prompt 在六个月的日常工作中新增了一个输出字段。备用 prompt 仍然发出它诞生时的四个字段。下游的解析器已经被更新为需要五个字段。备用路径现在注定会失败,而唯一能发现这一点的条件恰恰是主模型已经宕机的条件。
还有第二个原因,那就是备用 prompt 通常针对的是不同的模型。为大模型调校的 prompt 经常不能直接迁移——它隐性遵循的指令需要明确说明,它不需要的 few-shot 示例变得必要了,它可靠输出的 JSON 被包在了代码围栏里。这是 prompt 可移植性的主题;这里的后果是备用 prompt 必须在备用模型上评估,而不是在主模型上。
备用 prompt 可以更差。它的形状不能不同,因为消费它的代码不知道是哪个 prompt 产生的。所以首要的断言是 schema 断言,在两个测试中用的是相同的 schema。
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { primaryPrompt, fallbackPrompt } from "../prompts";
import { complete } from "../client";
const Answer = z.object({
summary: z.string().min(1),
confidence: z.enum(["low", "medium", "high"]),
sources: z.array(z.string().url()),
needs_human: z.boolean(),
});
describe.each([
["primary", primaryPrompt, "provider/model-large"],
["fallback", fallbackPrompt, "provider/model-small"],
])("%s prompt satisfies the answer contract", (_name, prompt, model) => {
it.each(goldenCases)("case %#", async (input) => {
const raw = await complete({ model, prompt, input, temperature: 0 });
const parsed = Answer.safeParse(JSON.parse(stripFence(raw)));
expect(parsed.success, parsed.error?.message).toBe(true);
});
});
把它写成 describe.each 遍历两个 prompt 就是整个技巧。一个 schema、一组用例、两个 prompt,备用 prompt 不再能悄无声息地漂移,因为同样的契约测试覆盖了它。代价是备用那一半需要调用模型,所以这个测试套件应该和你的夜间评估运行放在一起,而不是在每次提交时——这和 golden dataset 的放置逻辑相同。
这段代码里的 stripFence 辅助函数不是附带的。更小的模型更频繁地把 JSON 包在围栏块里,所以你生产代码里的解析器必须已经能处理它——如果不能,这就是你发现它的地方,而这正是正确的地方。
第二个需要证明的事情是备用路径被触发了。这是一个控制流测试,不需要模型,而且应该对你声称要处理的错误条件做穷举测试。stub 传输层然后让它经历每一种失败。
HTTP 429 — 断言主模型根据你的策略重试后才 fallback。被限流后立即 fallback 会浪费你被限流出去的更便宜的容量,并给小型模型增加一倍的负载。
HTTP 500 和 503 — 断言立即 fallback,并计数这次尝试。
超时 — 用 fake timers 驱动它,断言 fallback 在截止时间开始,而不是在传输最终放弃之后。断言总墙钟时间预算被遵守:在一个 60 秒主模型超时之后才开始执行的 fallback 已经让用户失去了等待的价值。
HTTP 400 和 422 — 断言不会 fallback。格式错误的请求对第二个模型来说同样是格式错误的,在 400 上 fallback 会把一个坏请求变成两个。
返回了有效响应但 schema 校验失败 — 决定这是否触发 fallback 并断言这个决定。两种做法都是合理的设计,未做断言的那个就像抛硬币。
断言 fallback 是用备用 prompt 调用,而不是仅仅发生了第二次调用。对主模型的重试和对不同模型的 fallback 是不同的行为,调用计数断言无法区分它们。
不要把 fallback 的触发条件设为匹配主模型的质量;它做不到,而要求它的测试会被禁用。把触发条件设为一个你愿意在事故期间服务的质量底线,用和你的主评估相同的术语表达,这样两个数字可以比较。
一个实用的形态:备用 prompt 必须在 100% 的 golden 案例上满足 schema,必须在指定比例的案例上保留必须保留的事实,而且永远不能设置触发自动操作的字段——如果存在 needs_human,降级模式可以过度使用它,但不能少于它使用它。这种不对称性正是降级路径的意义,而且是可以断言的:断言备用模型在主模型升级的案例上至少同样频繁地升级。
如果 failover 发生在网关而不是你的代码里,上面的触发测试移到一个配置检查——哪些状态码重试,哪些 failover,哪些直接透传——而契约测试保持原样编写,因为返回内容的形状仍然需要你来验证。Multigrid 的 fallback 排序是按请求的配置,这意味着触发那一半可以通过发送一个指定了故意失败的主模型的请求来练习。
两个机械性的习惯可以让备用 prompt 在两次事故之间保持活力。
能推导的就推导。如果输出 schema 是从一个定义生成并注入到两个 prompt 中的,那么新字段就不可能只出现在一个里。在测试中断言两个渲染后的 prompt 都包含 schema 中的每个字段名——这是一个在毫秒内完成的廉价字符串检查,能捕捉到上面描述的那种漂移。
一起版本化。一套版本标识符覆盖这对 prompt,这样对主 prompt 的修改可以直观地看出是对包含备用 prompt 的制品的修改。
主动练习它。一个定时任务把少量、恒定比例的流量路由到备用路径上,保持它的温度并给你一个生产信号,思路和 canary release 一样。一条每周都被练习的路径不可能落后四个字段。
无论备用 prompt 返回什么,调用代码仍然必须处理空或截断的结果:小型模型的上下文窗口更小、输出上限更低,所以适合主模型的 prompt 可能完全不适合它。这种失败算在处理空完成里。