Prompt 变更的三道闸门:CI、离线评测与灰度回滚
一次 Prompt 改动上线后,客服机器人的转人工率从 18% 涨到 31%,三天后才有人定位到元凶——因为在这三天里,没有任何一个环节会因为“回答变差了”而失败。模型版本切换更隐蔽:接口返回 200,延迟没抖,token 数没变,只有答案质量悄悄滑坡。
这类事故的根因是缺变更管理,缺指标本身并不构成主因。Prompt、模型版本、检索参数在多数团队里被当作“配置”对待,不走代码评审、不跑回归、不带版本号,出了事连回滚到哪一版都说不清。下面按 CI、离线评测、灰度放量三道闸门,讲清每一道该挡什么、怎么挡。
一、先把“行为”定义成可版本化的变更单元
一个 AI 功能的行为由四类东西共同决定,改动任何一类都应视为一次变更:
- Prompt 模板文本,包括内嵌的 few-shot 示例;
- 模型与版本,包括第三方模型快照名或自建推理服务的权重版本;
- 生成参数与工具 schema,temperature、max_tokens、function 定义;
- 上下文装配逻辑,检索 top-k、重排模型、历史轮次裁剪策略。
这四类合起来算一个「行为版本」。构建时产出 manifest,参与发布,并写进每条线上 trace:
{
"behavior_id": "b-20240612-7f3a",
"prompt_digest": "sha256:9c1e...",
"model": {"name": "internal-llm", "version": "v3.2.1"},
"params": {"temperature": 0.2, "max_tokens": 1024},
"retriever": {"index": "kb-v7", "top_k": 5, "rerank": "rerank-v2"},
"evalset_version": "holdout-2024Q2"
}
有了 behavior_id,归因方式就变了:出问题不再是“昨天好像改过 prompt”,而是按 behavior_id 分桶对比成功率、转人工率、平均轮次。回滚也不是去 Git 里翻哪个 commit 动了模板,而是把线上 manifest 指针切回上一个 behavior_id。
三道闸门的分工要在一开始就定死,否则后面对每一个新需求都会变成逐次拍脑袋决策:
| 闸门 | 触发时机 | 时长预算 | 主要挡什么 | 失败动作 |
|---|---|---|---|---|
| CI 静态检查 | 每次 PR | < 5 min | 模板错误、schema 破坏、token 超预算、评测集污染 | 阻断合并 |
| 离线评测 | 合入 release 分支 | 20–60 min | 能力倒退、格式退化、成本/延迟超标 | 阻断发布 |
| 灰度放量 | 发布后 | 小时到天 | 线上分布漂移、业务指标劣化 | 自动回滚 |
二、CI 闸门:只放确定性检查
CI 阶段最常见的错误是把语义评测塞进来。全量跑一遍要 40 分钟、几十美元,且大模型输出有波动,今天红明天绿。工程师很快学会加 skip 标记绕过它,闸门就名存实亡。
CI 应该只做确定性断言,5 分钟内跑完:
- 模板渲染:所有占位符都能取到值,渲染后没有残留
{{; - 输出契约:要求 JSON 时,用固定输入校验 schema,字段齐全、类型正确、枚举值合法;
- 工具签名:function schema 与实现侧的函数签名一致,必填参数没少;
- token 预算:system prompt 加 few-shot 的静态部分不超过上限,超了直接拒绝(这类膨胀是成本失控的主因);
- 禁用内容:PII 正则、承诺性话术、竞品名黑名单;
- 污染检测:prompt 文本与评测集样本的 8-gram 重叠率,超过阈值告警;
- 变更说明:prompt 的 diff 必须附带离线评测报告链接才能合入。
def test_prompt_render_no_missing_var():
out = render(PROMPT_V3, {"user_query": "退货运费谁出", "context": "..."})
assert "{{" not in out
def test_json_contract_on_smoke_set():
for case in SMOKE_CASES: # 30~80 条人工确认过的必过用例
resp = call_model(PROMPT_V3, case.input)
obj = json.loads(resp) # 必须可解析
assert set(obj) >= {"answer", "confidence", "citations"}
assert 0 <= obj["confidence"] <= 1
冒烟集控制在 30 到 80 条,来源是历史事故和“绝对不能错”的核心场景。这些用例只做宽松断言(关键词必须出现、不能出现某类表述),不做语义打分,保证 CI 的稳定性。
三、离线闸门:评测集是回归基线
离线评测解决的是“能力有没有退”,它要做的是给出足以支撑发布决策的对比,并不是为了打一个分。评测集按用途分层,别混成一锅:
| 层 | 规模 | 来源 | 用途 | 更新频率 |
|---|---|---|---|---|
| 黄金集 | 200–500 | 业务专家标注 | 发布门禁主判据 | 季度评审 |
| 边界集 | 100–300 | 历史 badcase | 防旧问题复发 | 每次事故后追加 |
| 对抗集 | 100–200 | 改写、注入、越权构造 | 鲁棒性 | 月度 |
| 回流集 | 持续增长 | 线上抽样 + 标注 | 跟随真实分布 | 每周 |
判定规则用两层,缺一不可:
绝对阈值兜底。黄金集任务成功率 ≥ 92%,引用准确率 ≥ 95%,格式合规率 ≥ 99%。
相对不倒退做主判据。新旧两个 behavior_id 在同一评测集、同一 judge、同一采样参数下对比,允许 −1pp 以内的容忍带,超出即阻断。注意这里必须算样本量——小评测集只能检出大退化。粗略估算每组所需样本:
n ≈ 16 × p(1-p) / δ²
p = 0.9 时,要检出 2pp 的下降,每组需要约 3600 条
所以一个 300 条的黄金集,只对 5pp 以上的退化有统计意义。用它卡“不许崩”,而不是卡“涨没涨”。
分项必须单独设阈。总分涨了但引用准确率崩了,同样是事故。报告里至少拆成正确性、格式、引用、拒答、P95 延迟、单次成本六项。
LLM-as-judge 要用,但必须先校准:双 judge 交叉打分,用 200 条人工标注校准,Cohen's kappa ≥ 0.6 才允许投入门禁;judge 模型不能与被测模型同版本(自偏好会让它给自己打高分);评测时 A/B 顺序交替,消除位置偏见。
{
"behavior_id": "b-20240612-7f3a",
"baseline": "b-20240601-2c91",
"evalset": "holdout-2024Q2",
"metrics": {
"task_success": {"new": 0.913, "base": 0.927, "delta_pp": -1.4, "ci95": [-2.6, -0.2]},
"citation_precision":{"new": 0.961, "base": 0.944, "delta_pp": 1.7},
"p95_latency_ms": {"new": 2140, "base": 1890},
"cost_per_call": {"new": 0.0041, "base": 0.0039}
},
"gate": {"result": "block",
"reason": "task_success -1.4pp 超出容忍带 -1.0pp;p95 上涨 13%"}
}
四、让评测集不被过拟合
这是整套体系里最容易崩塌的一环。Prompt 调优天然朝着评测集收敛:三个人调两周,黄金集从 88% 刷到 96%,上线只涨 1pp——涨的是记忆,不是能力。等你发现,这套评测集已经不能作为发布依据了。
防过拟合靠结构,不靠自觉。
三集分离,可见性不同。 调优集(dev)随便看、随便调。验收集(test)只返回聚合指标和分项区间,不返回逐条明细——看不到具体哪条挂了,就没法对单条做定向修补。留存集(holdout)由平台方保管,工程师不可见,只在发布前由流水线跑一次。把这三者混在一起,等于取消了所有防线。
给评测集记录被查看次数。 一个评测集的寿命是被消耗的,每用它做一次决策,就消耗一次。超过阈值(比如 20 次)就退役换新。这条规则逼着团队攒样本,而不是反复榨干同一份。
反抄答案。 检测 prompt 里的 few-shot 示例与评测样本的文本相似度,8-gram 重叠率超过 1% 告警。最典型的作弊是把验收集的问法原样塞进 prompt 当示例。
影子集变异。 对 holdout 做同义改写、实体替换、截断、加噪,生成 2 到 3 份变体。真实的能力提升会在所有变体上同步上涨;靠记忆的提升在改写版上会明显掉下来——差异本身就说明问题是用哪种方式解决的。
线上回流与老化。 每周从生产 trace 分层抽样 200 到 500 条,标注后入池;同时按难度衰减下线已被稳定通过的旧样本。只加不减,评测集会越来越简单,通过率虚高。
难度分布监控。 某集通过率从 85% 突然跳到 97%,第一反应应该是查泄漏和重复,而不是庆祝。
| 健康度指标 | 健康区间 | 异常含义 |
|---|---|---|
| 通过率 | 70%–90% | > 95% 说明太简单或被污染 |
| 近义样本重复率 | < 5% | 偏高则有效样本量虚高 |
| 难度分布熵 | 稳定 | 下降说明样本向简单端漂移 |
| 平均被查看次数 | < 20 | 超过则该集已过拟合 |
| 线上回流样本占比 | > 20% | 偏低则分布跟不上真实流量 |
五、灰度闸门:在线确定性观测与自动回滚
离线评测覆盖不到长尾分布,灰度是最后一道、也是唯一能看到真实流量的闸门。
先跑影子模式。 新版本只记录不返回,用同一批线上真实请求对比新旧输出,人工抽检 100 条。这一步能抓到离线集完全没覆盖的场景,成本不高,收益最大。
放量阶梯固定下来,不要每次临时定:
| 阶段 | 流量 | 最短观察 | 主要看什么 |
|---|---|---|---|
| 内部白名单 | 固定用户 | 1 天 | 崩溃、格式错误、明显胡说 |
| 1% | 1% | 2 小时 | 错误率、guardrail 触发 |
| 5% | 5% | 1 天 | 代理指标(追问率、会话轮次) |
| 20% | 20% | 2 天 | 业务指标趋势 |
| 50% → 100% | 全量 | — | 稳定观察 |
分流按 user_id 哈希取模,并做会话粘性。同一个用户在同一会话里不能被切到两个版本,否则对话上下文和行为风格打架,用户感知到的是“这机器人精神分裂”。
要认清小流量下业务指标看不出来。 1% 流量、日活 10 万,一天只有 1000 次调用,检出 2pp 差异需要几千样本。所以早期阶段看的是代理指标和 guardrail,不是最终转化率。
guardrail 必须能自动回滚,且回滚动作是切 manifest 指针,不是重新构建部署:
GUARDRAILS = [
# (指标, 观察窗口, 触发条件, 动作)
("format_error_rate", "10m", lambda n, b: n > b * 2 + 0.005, "rollback"),
("handoff_rate", "30m", lambda n, b: n - b > 0.02, "rollback"),
("p95_latency_ms", "10m", lambda n, b: n > b * 1.3, "rollback"),
("negative_feedback", "1h", lambda n, b: n - b > 0.01, "alert"),
]
在线质量抽样。 每天从新版本 trace 抽 200 条跑 judge 打分,与基线版本同期样本对齐比较。这是灰度阶段最直接的质量信号,也是发现“指标没崩但答得越来越敷衍”的唯一手段。
落地建议
- 先做归因再做评测。 给每个 AI 功能建 behavior manifest 并写进 trace,把 prompt_digest、model_version、检索配置都带上。没有 behavior_id,后面所有对比都是在猜。
- CI 只放确定性检查,5 分钟跑完。 超过这个时长就砍用例;语义评测一律挪到离线阶段,否则闸门会被绕过。
- 评测集三集分离,验收集只返回聚合指标。 留存集由平台保管、工程师不可见,发布前由流水线跑一次。
- 给每个评测集记“被查看次数”,超过 20 次退役换新,每周从线上回流补充。 同时监控通过率和难度分布,通过率突跳先查污染。
- 灰度必配自动回滚 guardrail,回滚实现为切 manifest 指针。 从影子模式开始,按 1%→5%→20%→50% 推进,早期只看代理指标,别指望小流量看出转化率。
- 点赞
- 收藏
- 关注作者
评论(0)