在 LLM 应用里,Prompt 是决定输出质量的最敏感变量。同一句「请简洁回答」改成「请用不超过三句话回答」,可能让满意度上升 12 个百分点;把示例从三个减到两个,可能让格式错误率翻倍。但现实中,Prompt 往往以字符串字面量的形式硬编码在业务代码里,改一次就要发一次版,无法回滚,无法对比,更无法知道改动到底是变好还是变坏。
Prompt 版本管理与 A/B 实验要解决的正是这个问题:把 Prompt 当作可版本化、可评审、可回滚、可度量的工程资产来治理。本文给出一套从 registry 设计到显著性判定的完整落地方案。
一、Prompt 即代码
1.1 从字符串到资产
把 Prompt 当成代码,意味着它应当享受代码同等的工程待遇:有版本、有评审、有测试、有回滚、有发布流程。一个 Prompt 从提交到上线的完整生命周期应当是可追溯的,任何一次线上效果变化都能定位到具体是哪次 Prompt 变更引起的。
这要求 Prompt 与业务代码解耦。业务代码只引用一个稳定的逻辑标识(如 summarize-v3),真正的模板文本存放在独立的 registry 中。这样修改 Prompt 不需要重新构建和部署业务服务,只需发布一个新的 Prompt 版本。
1.2 版本化的最小要求
一个合格的 Prompt 版本记录至少要包含下列字段,缺一不可:
| 字段 | 含义 | 缺失后果 |
|---|---|---|
| prompt_id | 逻辑标识(如 summarize) | 无法按业务维度聚合 |
| version | 语义化版本(如 3.2.0) | 无法回滚到具体版本 |
| template | 模板文本,含变量占位符 | 无法复现 |
| variables_schema | 变量名与类型约束 | 渲染时才发现变量缺失 |
| model | 目标模型与版本 | 换模型后效果不可比 |
| params | temperature、top_p 等 | 复现结果不一致 |
| created_by | 作者与评审人 | 无法追责 |
| created_at | 时间戳 | 无法关联线上指标 |
| changelog | 变更说明 | 不知道改了什么 |
其中最容易忽略的是 model 与 params。Prompt 的效果与模型强耦合:为 gpt-4.1 调优的 Prompt 换到小模型上可能完全失效。因此版本记录必须同时锁定模型与采样参数,否则 A/B 对比的结论不成立。
1.3 与模型版本的耦合
供应商会悄悄更新模型快照。今天测出来的最优 Prompt,可能在供应商静默升级后的第二天失效。应对手段是显式锁定模型快照版本(如 gpt-4.1-2026-04-14 而不是 gpt-4.1),并在版本记录中留痕。模型切换本质上是一次影响全局的实验,应当走与 Prompt 变更相同的评测与灰度流程,相关方法见 评测与灰度发布
。
1.4 Prompt 与上下文预算
Prompt 不是越长越好。模板里塞进越多示例与规则,token 成本越高、延迟越大,而模型对超长上下文的注意力反而会稀释,出现「中间遗忘」。因此每次 Prompt 变更都应同时评估上下文预算:模板本体、动态注入的检索结果、对话历史三者之和是否超过了模型的有效注意力区间。相关取舍与压缩手段见 上下文工程 。
一个可操作的约束是给每个 Prompt 版本标注「静态模板 token 数」与「动态变量 token 上限」,并在 CI 中校验。当动态变量超过上限时,先做压缩或截断,而不是直接把超长文本丢给模型。这条约束能避免线上出现莫名其妙的超长请求。
二、Prompt Registry 设计
2.1 模板与变量 schema
Registry 的核心是一个模板引擎加一份变量契约。模板使用 {{variable}} 占位,schema 声明每个变量的类型、是否必填与取值范围。渲染前先校验,把「运行时才炸」提前到「提交时就炸」。
下面是一份 YAML 格式的 Prompt 定义:
prompt_id: summarize
version: 3.2.0
model: gpt-4.1-mini-2026-04-14
params:
temperature: 0.3
top_p: 1.0
max_tokens: 512
variables_schema:
type: object
required: [document, audience, max_sentences]
properties:
document:
type: string
minLength: 1
maxLength: 60000
audience:
type: string
enum: [general, technical, executive]
max_sentences:
type: integer
minimum: 1
maximum: 10
template: |
你是一名专业编辑。请把下面的文档总结为不超过 {{max_sentences}} 句话,
面向 {{audience}} 读者,保留关键数字与结论,不要编造原文没有的信息。
文档:
{{document}}
changelog:
- version: 3.2.0
note: 增加 audience 变量,支持面向高管的摘要风格
- version: 3.1.0
note: 显式要求保留关键数字
2.2 渲染与校验
渲染函数必须做到三件事:校验变量、转义处理、可复现。下面给出一个最小可用实现,同时演示如何把版本信息注入到调用元数据中,便于后续归因:
import re
from dataclasses import dataclass, field
from typing import Any
PLACEHOLDER = re.compile(r"\{\{\s*(\w+)\s*\}\}")
@dataclass
class PromptVersion:
prompt_id: str
version: str
template: str
model: str
params: dict[str, Any] = field(default_factory=dict)
schema: dict[str, Any] = field(default_factory=dict)
def validate(self, variables: dict[str, Any]) -> None:
spec = self.schema
required = spec.get("required", [])
missing = [k for k in required if k not in variables]
if missing:
raise ValueError(f"缺少必填变量: {missing}")
for name, rule in spec.get("properties", {}).items():
if name not in variables:
continue
value = variables[name]
if "enum" in rule and value not in rule["enum"]:
raise ValueError(f"变量 {name} 取值非法: {value}")
if rule.get("type") == "integer" and not isinstance(value, int):
raise ValueError(f"变量 {name} 必须为整数")
def render(self, variables: dict[str, Any]) -> str:
self.validate(variables)
used = set(PLACEHOLDER.findall(self.template))
unused = used - variables.keys()
if unused:
raise ValueError(f"模板引用了未提供的变量: {unused}")
return PLACEHOLDER.sub(lambda m: str(variables[m.group(1)]), self.template)
def call_metadata(self) -> dict[str, Any]:
# 注入到网关请求的 metadata,用于线上归因
return {"prompt_id": self.prompt_id, "prompt_version": self.version}
call_metadata() 返回的字段应当随每次请求上报。只有这样,线上指标(满意度、成本、延迟)才能按 Prompt 版本切分,A/B 对比才有数据来源。这一点与 模型网关
的审计打点是同一套基础设施。
2.3 存储结构
Registry 可以存在数据库里,也可以存在 Git 仓库里。两种方式的取舍如下表:
| 维度 | Git 仓库 | 数据库 |
|---|---|---|
| 评审流程 | 天然支持 PR | 需自建审批 |
| 回滚 | git revert 即可 | 需写回滚脚本 |
| 动态生效 | 需发布流程 | 可热更新 |
| 灰度控制 | 弱 | 强,可按版本分流 |
| 审计 | 完整历史 | 依赖表设计 |
| 适合规模 | 中小团队 | 大型多租户 |
务实做法是「Git 存权威版本、数据库存发布状态」:Prompt 文本在 Git 中评审合并,CI 把合并结果同步到数据库并打上版本号,运行时从数据库读取并按流量规则分流。
2.4 发布指针与热更新
Registry 需要维护一个「发布指针」:每个 prompt_id 指向当前生效的版本,以及可选的实验分流表。运行时读取指针而非硬编码版本号,就能做到不重启服务切换 Prompt。
release:
prompt_id: summarize
stable: 3.1.0 # 稳定版本,默认流量
experiment: # 实验流量
version: 3.2.0
weight: 0.20 # 20% 流量
experiment_id: summarize-v3-vs-v2
guardrails:
max_cost_increase: 0.25
max_p99_increase: 0.40
updated_at: "2026-10-04T16:00:00+08:00"
updated_by: leeting
发布指针应当有审计日志与并发保护:两人同时改指针时,后写者必须基于最新版本做乐观锁校验,否则会静默覆盖。回滚就是把这个指针指回 stable,一步到位。
三、Prompt 测试与回归
3.1 为什么 Prompt 需要测试
Prompt 的修改没有编译器兜底:改错一个词不会报错,只会在线上悄悄降低质量。因此必须用测试来兜底。测试的目标不是证明 Prompt「对」,而是证明它「没变坏」。这与代码回归测试的思路一致:锁定一组已知的输入与期望输出特征,每次修改后自动比对。
3.2 三类测试
Prompt 测试应当分三层,覆盖从语法到语义的不同风险:
| 测试类型 | 检查内容 | 运行时机 | 失败含义 |
|---|---|---|---|
| 单元测试 | 变量渲染、schema 校验、转义 | 每次提交 | 模板或契约有误 |
| 回归测试 | 黄金集上的输出特征 | 每次提交 | 效果回退 |
| 对抗测试 | 注入、越狱、边界输入 | 每日 / 发布前 | 安全风险 |
| 成本测试 | token 用量与延迟 | 每次提交 | 成本或性能劣化 |
其中回归测试最关键。黄金集(golden set)是 50 到 300 条带有期望特征的真实输入,覆盖主要场景与已知边界。每次 Prompt 变更都在黄金集上跑一遍,输出特征的通过率不得低于基线。
3.3 可运行的回归测试骨架
下面的骨架把渲染、调用、特征校验串起来,任何一项不达标就抛出异常,可直接接入 CI:
import json
from dataclasses import dataclass
@dataclass
class GoldenCase:
case_id: str
variables: dict
must_contain: list[str] # 必须出现的关键词
must_not_contain: list[str] # 不得出现的词
max_chars: int # 输出长度上限
def run_regression(prompt: PromptVersion, cases: list[GoldenCase],
call_llm, threshold: float = 0.95) -> dict:
passed, failures = 0, []
for case in cases:
text = prompt.render(case.variables)
output = call_llm(prompt.model, text, prompt.params)
ok = True
if not all(k in output for k in case.must_contain):
ok = False
if any(k in output for k in case.must_not_contain):
ok = False
if len(output) > case.max_chars:
ok = False
if ok:
passed += 1
else:
failures.append(case.case_id)
rate = passed / len(cases)
result = {"pass_rate": rate, "failures": failures, "ok": rate >= threshold}
if not result["ok"]:
raise AssertionError(f"回归未通过: {json.dumps(result, ensure_ascii=False)}")
return result
关键设计是把「期望」表达为可机检的特征(关键词、长度、格式),而不是逐字匹配。逐字匹配对 LLM 输出几乎不可能通过,会沦为形式。若确实需要语义级判断,可引入一个 LLM 裁判给输出打分,但裁判本身也要先用人工标注校准,且裁判模型的版本必须锁定。
3.4 用 LLM 做裁判的注意事项
用 LLM 当裁判(LLM-as-a-judge)能覆盖关键词测不到的语义质量,但有三个必须警惕的偏差:位置偏差(更偏爱排在前面的选项)、长度偏差(更偏爱更长的回答)、自我偏好(偏爱与自己同族的模型输出)。缓解手段是随机化选项顺序、对长度做归一、并用多个裁判投票。裁判给出的分数只能作为参考信号,最终判定的阈值仍需用人工标注的样本校准。
四、A/B 分流
4.1 稳定哈希分桶
A/B 实验最常见的错误是随机分流。如果每次请求都独立随机,同一个用户会一会儿看到 A 一会儿看到 B,体验割裂且数据被污染。正确做法是按用户标识做稳定哈希:同一个 user_id 永远落入同一个桶。
import hashlib
def bucket_of(user_id: str, experiment: str, buckets: int = 10000) -> int:
# 关键:把实验名拼进哈希,避免不同实验的相关性
key = f"{experiment}:{user_id}".encode("utf-8")
digest = hashlib.sha256(key).hexdigest()
return int(digest[:8], 16) % buckets
def assign(user_id: str, experiment: str, weights: dict[str, float]) -> str:
# weights 形如 {"A": 0.5, "B": 0.5},按累计权重切分
assert abs(sum(weights.values()) - 1.0) < 1e-9, "权重必须归一"
point = bucket_of(user_id, experiment) / 10000.0
acc = 0.0
for variant, w in weights.items():
acc += w
if point < acc:
return variant
return list(weights)[-1]
if __name__ == "__main__":
counts = {"A": 0, "B": 0}
for i in range(100000):
counts[assign(f"user-{i}", "summarize-v3-vs-v2", {"A": 0.5, "B": 0.5})] += 1
print(counts) # 期望接近 {"A": 50000, "B": 50000}
把实验名拼进哈希键是关键细节。若只用 user_id,那么凡是按用户分流的实验都会得到完全相同的分组,实验之间产生系统相关性,一旦某个实验有偏,所有实验同时有偏。
4.2 分层与互斥
当一个产品同时跑多个实验时,必须区分「互斥实验」与「正交实验」。互斥实验(如两种不同的总结风格)不能同时作用于同一用户,否则无法归因;正交实验(如总结风格与按钮颜色)可以使用不同的哈希盐,让分组相互独立。
| 实验关系 | 处理方式 | 哈希盐 |
|---|---|---|
| 互斥(同一功能两种改法) | 同一用户只进一组 | 共享 layer 名 |
| 正交(不同功能) | 分组独立 | 各自实验名 |
| 嵌套(实验内再分流) | 显式声明层级 | 实验名 + 层级名 |
4.3 分流维度的选择
分桶所用的标识决定了实验结论能推广到哪个范围。用错维度会得出无法落地的结论:
| 分流维度 | 适用场景 | 优点 | 局限 |
|---|---|---|---|
| user_id | 面向用户的体验实验 | 体验一致 | 无法覆盖未登录用户 |
| session_id | 单次会话内一致 | 无需登录 | 跨会话会漂移 |
| tenant_id | B 端多租户 | 计费口径一致 | 租户少则样本少 |
| request_id | 无状态、纯后端指标 | 样本最大化 | 体验割裂 |
| device_id | 客户端实验 | 覆盖匿名用户 | 换设备即换组 |
选择原则是:凡是影响用户体验的实验,必须按 user_id 或 tenant_id 分流;只影响后端成本或质量指标、与体验无关的实验,才可以按 request_id 分流以最大化样本。
五、统计显著性
5.1 指标选择
Prompt 实验的指标应当分层:北极星指标(如任务成功率)、体验指标(如人工评分、格式合规率)、护栏指标(成本、P99 延迟)。三者必须同时观察。只看得分不看成本,会把成本翻倍的「高分」方案推上线;只看成本不看得分,会把便宜的垃圾方案推上线。
| 指标类型 | 示例 | 期望方向 | 是否可妥协 |
|---|---|---|---|
| 北极星 | 任务成功率、人工采纳率 | 上升 | 否 |
| 体验 | 格式合规率、幻觉率 | 上升 / 下降 | 视情况 |
| 护栏 | 单请求成本、P99 延迟 | 不劣化 | 否 |
5.2 p 值与置信区间
判断两组差异是否真实,需要统计检验。对于成功率这类比例指标,用两比例 z 检验;对于成本这类连续指标,用 Welch t 检验。核心输出是 p 值与置信区间。p 值回答「若两组其实没差别,观察到这么大差异的概率有多大」,置信区间回答「真实差异的可能范围」。
import math
def two_proportion_test(n_a: int, c_a: int, n_b: int, c_b: int) -> dict:
p_a, p_b = c_a / n_a, c_b / n_b
p_pool = (c_a + c_b) / (n_a + n_b)
se_pool = math.sqrt(p_pool * (1 - p_pool) * (1 / n_a + 1 / n_b))
if se_pool == 0:
return {"lift": 0.0, "z": 0.0, "p_value": 1.0, "ci95": (0.0, 0.0)}
z = (p_b - p_a) / se_pool
# 双侧 p 值
p_value = 2 * (1 - 0.5 * (1 + math.erf(abs(z) / math.sqrt(2))))
se_diff = math.sqrt(p_a * (1 - p_a) / n_a + p_b * (1 - p_b) / n_b)
ci = ((p_b - p_a) - 1.96 * se_diff, (p_b - p_a) + 1.96 * se_diff)
return {
"p_a": p_a, "p_b": p_b,
"lift": (p_b - p_a) / p_a if p_a else float("inf"),
"z": z, "p_value": p_value, "ci95": ci,
}
def required_sample_size(p0: float, mde: float, alpha: float = 0.05,
power: float = 0.8) -> int:
# 两比例检验每组所需样本量(近似)
z_alpha = 1.96 if abs(alpha - 0.05) < 1e-9 else 2.576
z_beta = 0.84 if abs(power - 0.8) < 1e-9 else 1.28
p1 = p0 + mde
p_bar = (p0 + p1) / 2
num = (z_alpha * math.sqrt(2 * p_bar * (1 - p_bar))
+ z_beta * math.sqrt(p0 * (1 - p0) + p1 * (1 - p1))) ** 2
return math.ceil(num / (mde ** 2))
if __name__ == "__main__":
# A 组 5000 次,成功 3400;B 组 5000 次,成功 3600
r = two_proportion_test(5000, 3400, 5000, 3600)
print(f"pA={r['p_a']:.3f} pB={r['p_b']:.3f} lift={r['lift']:+.1%}")
print(f"p_value={r['p_value']:.5f} ci95=({r['ci95'][0]:+.3f}, {r['ci95'][1]:+.3f})")
print("每组所需样本:", required_sample_size(0.68, 0.02))
判定规则必须在上线前写死:只有当 p 值小于 0.05 且置信区间下界大于 0 且护栏指标不劣化时,才判定 B 组胜出。否则一律视为「无显著差异」,继续收集数据或维持现状。
5.3 最小可检测效应与样本量
样本量不足是 A/B 实验最常见的失败原因。下表给出在 80% 统计功效、5% 显著性水平下,检测不同提升幅度所需的最小样本量(每组):
| 基线成功率 | 最小可检测提升 | 每组所需样本量 | 按日均 2000 请求估计耗时 |
|---|---|---|---|
| 70% | +5% | 约 1,100 | 约 0.6 天 |
| 70% | +2% | 约 6,900 | 约 3.5 天 |
| 70% | +1% | 约 27,000 | 约 14 天 |
| 85% | +2% | 约 4,300 | 约 2.2 天 |
| 50% | +1% | 约 39,000 | 约 20 天 |
这张表揭示了一个残酷现实:越小的提升越难测出来。想验证 +1% 的改进,往往需要两周以上且流量足够。因此在低流量场景下,与其追求统计显著性,不如先用离线评测集做快速筛选,把候选缩到两三个再做在线实验,用离线的高吞吐换取在线的样本稀缺。
六、灰度发布与自动回滚
显著胜出之后,不应一步切到 100%,而应灰度放量:5% → 20% → 50% → 100%,每一步观察护栏指标,任一步劣化立即回滚。
| 阶段 | 流量 | 观察时长 | 通过条件 | 不通过动作 |
|---|---|---|---|---|
| 影子 | 0%(只记录) | 1 天 | 无异常 | 停止实验 |
| 灰度一 | 5% | 1 天 | 护栏不劣化 | 回滚 |
| 灰度二 | 20% | 2 天 | 护栏不劣化 | 回滚 |
| 灰度三 | 50% | 2 天 | 护栏不劣化 | 回滚 |
| 全量 | 100% | 持续 | 北极星不回落 | 回滚 |
自动回滚的关键是把判定条件写成可执行的规则,而不是依赖人工盯盘。例如:当 B 组成本环比上升超过 25% 或 P99 延迟上升超过 40%,且持续 15 分钟,就自动把该实验的流量权重降回 0,并把 registry 中的发布指针回退到上一个稳定版本。
下面是一个每分钟运行一次的护栏监控器,命中任意一条规则即触发回滚:
import time
from dataclasses import dataclass
@dataclass
class Guardrail:
name: str
limit: float # 相对基线的最大劣化比例
window_min: int # 需连续满足的分钟数
GUARDRAILS = [
Guardrail("cost_per_request", 0.25, 15),
Guardrail("p99_latency_ms", 0.40, 15),
Guardrail("error_rate", 0.50, 5),
]
def check_and_rollback(metrics_fn, registry, experiment_id: str,
stable_version: str) -> bool:
breached = {}
for g in GUARDRAILS:
series = metrics_fn(experiment_id, g.name, g.window_min)
if len(series) < g.window_min:
continue
baseline = series["baseline"]
worst = max(series["current"])
if baseline > 0 and (worst - baseline) / baseline > g.limit:
breached[g.name] = worst / baseline - 1
if breached:
registry.set_weight(experiment_id, 0.0)
registry.set_pointer(experiment_id, stable_version)
print(f"[rollback] {experiment_id} 触发回滚: {breached}")
return True
return False
if __name__ == "__main__":
while True:
check_and_rollback(metrics_fn=lambda *a: {"baseline": 0.02, "current": [0.03]},
registry=None, experiment_id="summarize-v3-vs-v2",
stable_version="3.1.0")
time.sleep(60)
这套机制把「判断失误」的代价从「用户持续受影响」压缩到「最多 15 分钟的劣化窗口」,是灰度发布能否安全放量的关键。
七、常见坑清单
- 同一用户跨组漂移:用随机数而非稳定哈希分流,导致同一用户在不同请求里看到不同 Prompt。必须用
hash(experiment + user_id)稳定分桶。 - 指标只看得分不看成本:B 组满意度高 2 个百分点但成本翻倍,若只看得分会上线一个不可持续的方案。护栏指标必须与北极星指标同时纳入判定。
- 并发实验互相污染:两个实验共用同一哈希盐,分组完全相关,一个实验的效应被另一个混淆。互斥实验共享 layer,正交实验各用独立实验名。
- 样本量不足就下结论:只跑了 200 个样本就宣布 B 组胜出,结论完全不可靠。上线前先用
required_sample_size估算所需样本。 - 中途偷看数据并提前停止:反复查看 p 值,一旦显著就停止,会大幅抬高假阳性率。应预先确定样本量或固定实验周期。
- Prompt 版本与模型版本未同时记录:只记 Prompt 版本,换模型后无法解释效果变化。两者必须一起锁定。
- 回滚不彻底:只回滚流量权重却忘了回滚缓存中的 Prompt 渲染结果,用户仍看到旧版本。回滚必须覆盖所有缓存层。
- 离线评测集与线上分布不一致:离线集里全是干净输入,线上全是脏输入,离线赢线上输。评测集必须持续从真实流量采样更新。
小结
Prompt 版本管理与 A/B 实验的本质,是把「改一句话」这件看似随意的事,变成有版本、有评审、有回滚、有度量的工程流程。Registry 用模板加变量 schema 保证 Prompt 可复现、可校验;稳定哈希分桶保证同一用户始终落在同一组;两比例检验与置信区间把「看起来更好」变成「统计上显著更好」;灰度放量与自动回滚保证即使判断失误也能快速止损。落地时最容易被忽视的三件事,是分桶必须稳定、护栏指标必须与北极星指标同看、样本量必须在实验前估算。做好这三点,Prompt 迭代才能从凭感觉变成凭数据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。