17.2 灰度与回滚:新提示/新模型小流量
17.1 的流水线保证了「构建出来的镜像在夹具上不退化」。但夹具只有几十条用例,真实流量的分布远比它复杂。所以镜像构建成功不等于可以全量上线——正确做法是先把新版暴露给一小部分真实流量,盯住指标,没问题再逐步放量,出问题立刻撤回。这就是灰度与回滚。对 Agent 服务,它还多一层麻烦:要灰度、要回滚的东西,远不止代码。
本节把 ResearchCopilot 推进到「可安全发布」:讲清 Agent 的发布对象为什么包括提示词与模型 ID,设计按流量比例的灰度与自动回滚条件,真跑一个灰度路由器——喂两段模拟流量,一段新版正常、一段新版劣化,打印分流比例、两侧指标与「触发回滚」的真实判定。
17.2.1 可发布的东西不只是代码
普通服务的发布对象就是二进制。Agent 服务的发布对象是一个清单:
| 发布对象 | 存在哪 | 改了它的后果 |
|---|---|---|
| 服务代码 | 镜像 | 单元测试能覆盖大半 |
| 提示词 | 配置 / 模板文件 | 无测试覆盖,直接改答案质量 |
| 模型 ID | 配置 | 换了模型,行为与成本同时变 |
| 工具集 | 配置 / MCP 连接 | 少一个工具,某类问题直接答不出 |
| 检索参数 | 配置 | top-k、阈值一改,召回全变 |
关键在于后四行都不是代码,单元测试一个都测不到。你把 system 里那句「必须给出引用」删掉,pytest 照样全绿,评测夹具也可能过——因为夹具的用例就那几条,碰不到这个回归。只有真实流量能暴露它。
所以 Agent 的发布纪律是:提示词、模型 ID、工具集、检索参数的每一次改动,都要走和代码一样的灰度流程。「改一行提示词」听起来比「改一行代码」轻,实际上风险不低于甚至高于改代码——因为改代码有测试兜底,改提示词没有。把它当代码一样对待,是本节所有机制的前提。
这条纪律落到工程上有三个具体动作。其一,配置要进版本控制——提示词模板、模型 ID、检索参数不能是「运维手改的线上值」,而要作为文件进 Git、随镜像或配置中心发布。其二,配置要有版本号——回滚时能指名道姓地说「退回 prompt-v7」,而不是靠人回忆上一版长什么样。其三,配置改动要有 diff——提示词的一处小改动要在 code review 里可见,因为「加一句约束」这种改动,人眼过一遍的成本极低、漏掉它的代价极高。
一个反直觉但真实的现象:改提示词往往比改代码更容易出事。因为代码改动有类型检查、单元测试、代码审查三道关,而提示词改动通常只经过「作者自己看一眼」。越是看起来轻的改动,越容易绕过所有防线。
17.2.2 按流量比例的灰度:5% → 25% → 100%
灰度最朴素的形式是按比例分流。经典阶梯是 5% → 25% → 100%:先在 5% 的流量上观察,指标健康再放到 25%,最后全量。
分流必须是粘性的——同一个用户在一次灰度期间应该稳定落在同一侧,否则用户会一会儿看到新版、一会儿看到旧版,体验割裂,指标也会被污染。实现方式是按请求标识哈希取模,而不是每次请求掷骰子:
import hashlib
def route(rid: str, canary_pct: int) -> str:
"""同一 rid 恒定路由(哈希取模),保证灰度期间用户粘性。"""
h = int(hashlib.sha256(rid.encode()).hexdigest(), 16)
return "canary" if h % 100 < canary_pct else "stable"
哈希取模保证「同样的 rid 永远走同一侧」;canary_pct 从 5 调到 25 时,原本走新版的用户仍然走新版(单调放量),不会来回横跳。这是比随机分流更稳的做法。
阶梯的每一级该观察多久、看什么,取决于流量规模:流量大的服务,5% 就够在几分钟内积累几千个样本,可以很快往上放;流量小的服务,5% 一整天也攒不出统计意义,只能靠更长的观察窗口或干脆先上影子流量(17.2.4)。
17.2.3 自动回滚条件:成功率 / 成本 / P95
灰度不能靠人盯着看,必须自动判定。ResearchCopilot 用三个条件,任一命中就回滚:
| 条件 | 阈值示例 | 命中说明什么 |
|---|---|---|
| 成功率 | 低于基线 5 个百分点 | 新版答错了更多问题 |
| 平均成本 | 超过预算 0.0022 元/次 | 新版更费 token(提示词更长 / 模型更贵) |
| P95 延迟 | 超过 1200ms | 新版更慢,用户体感变差 |
三个条件对应 Agent 的三个核心 SLO:答得对不对(成功率)、花得值不值(成本)、快不快(延迟)。它们必须一起看——只看成功率会放过「正确率没掉但成本翻倍」的版本,只看成本会放过「省钱但答错更多」的版本。
自动回滚的判定逻辑就是「任一条件命中即回」:
def decide(stable, canary) -> list[str]:
reasons = []
if stable.n and canary.success < stable.success - 0.05:
reasons.append("成功率跌破基线")
if canary.n and canary.p95 > 1200.0:
reasons.append("P95 超阈值")
if canary.n and canary.cost > 0.0022:
reasons.append("成本超预算")
return reasons
注意判定用的基线是同期 stable 侧的指标,而不是历史固定值。因为线上流量本身会波动(大促、夜间低峰),拿历史值当基线会把「流量变化」误判成「新版劣化」。用对照组而不是历史值做基线,是灰度的关键细节。
17.2.4 影子流量:复制一份但不返回
有些改动不适合直接放量——比如换了个更贵的大模型,你想先知道「它答得好不好」再决定值不值得上。这时用影子流量:把线上请求复制一份给新版,新版的产出不返回给用户(用户始终看到 stable 的回答),只记录下来与 stable 做离线比对。
影子的最大好处是零风险:新版的任何问题都不会影响真实用户,因为它的输出根本没进用户视野。代价是它无法验证延迟与成本对用户的影响——影子请求不阻塞用户,延迟再高用户也感知不到。所以影子适合验证质量(引用对不对、工具调得准不准),不适合验证性能。两者分工:影子验质量,灰度验性能与成本。
实现上有两条纪律。其一,影子请求必须异步、可丢弃。它跑在用户请求路径之外,超时或报错都不能反过来拖慢或污染真实请求——通常做法是网关层「复制」一份请求发到影子通道,主通道正常返回、影子通道的结果只落日志。其二,影子会产生真实成本。它毕竟真的调了一次模型,所以影子流量的比例要控制(常取 1%~5%),否则「零风险」换来的是「账单翻倍」。对成本敏感的模型,可以只对抽样后的请求做影子。
影子与灰度的分工可以这样记:影子是「先看质量,不碰用户」,灰度是「小范围碰用户,看全套指标」。稳妥的发布节奏是先影子确认质量没崩,再灰度验证性能与成本,最后全量。
17.2.5 真跑:灰度路由器与两段流量
把 17.2.2 的路由与 17.2.3 的判定合成一个灰度路由器,喂两段流量:一段新版正常(行为与旧版相近),一段新版劣化(成功率掉、成本涨、延迟升)。每段按 5% → 25% → 100% 三级放量。真跑输出(Python 3.14.6,每级 2000 个请求):
===== 流量流:新版正常 =====
[ 5%] 实际分流 stable=1903 canary=97 (4.9% canary)
stable 成功率=94.3% 成本=0.0014 P95=992ms
canary 成功率=91.8% 成本=0.0014 P95=973ms
判定:健康,继续放量
[ 25%] 实际分流 stable=1527 canary=473 (23.6% canary)
stable 成功率=95.0% 成本=0.0014 P95=1000ms
canary 成功率=94.1% 成本=0.0014 P95=1012ms
判定:健康,继续放量
[100%] 实际分流 stable=0 canary=2000 (100.0% canary)
canary 成功率=94.0% 成本=0.0014 P95=1002ms
判定:健康,继续放量
===== 流量流:新版劣化 =====
[ 5%] 实际分流 stable=1888 canary=112 (5.6% canary)
stable 成功率=95.1% 成本=0.0014 P95=998ms
canary 成功率=61.6% 成本=0.0025 P95=2000ms
>>> 触发自动回滚:
- 成功率 61.6% 跌破基线 95.1% - 5%
- P95 2000ms 超阈值 1200ms
- 平均成本 0.0025 元超预算 0.0022
[ 25%] 已回滚,停止放量
[100%] 已回滚,停止放量
两段流量的对比就是灰度机制的价值证明:
- 正常版在三级放量中指标始终贴着基线(成功率 91.8% → 94.1% → 94.0%),一路健康放量到 100%。注意 5% 那级 canary 只有 97 个样本,成功率略低于基线属于小样本噪声,不该触发回滚——这也是为什么判定要设 5 个百分点的容差,而不是一有差距就回。
- 劣化版在 5% 那一级就同时踩中三个条件:成功率 61.6%(远低于基线 95.1%)、P95 2000ms、成本 0.0025 元。只用了 5% 的流量就拦住了它,剩下 95% 的用户完全没受影响。这正是「小流量先试」的全部意义——让坏版本造成的伤害正比于它的放量比例,越小越好。
- 回滚后
[25%]与[100%]两级直接停止放量,不会在坏版本上继续加码。真实系统里,这三级放量是自动推进的:脚本判定健康就自动调高权重,判定失败就自动回滚,人只在收到告警时介入。
注意 100% 那级 stable 侧样本为 0(已无旧版流量),判定逻辑里 if stable.n 这个守卫让「对照组为空」时不去做成功率比较,避免「拿 0 当基线」误判。对照组为空时的判定口径,是灰度实现里最容易漏的一处。
还有一点值得点出:分流比例是「目标」而非「结果」。输出里 5% 那级实际分到 4.9%、25% 那级分到 23.6%——哈希取模在有限样本下会有抖动,样本越小抖动越大。所以监控面板上要看的是实际分流比例,而不是你设定的那个数字;当实际比例偏离设定值太多(比如设 25% 实际只有 15%),说明哈希分布或采样出了问题,要先修分流再谈放量。
17.2.6 真跑:影子流量与回滚演练
影子流量:同一条请求同时发给两版,只返回 stable 的结果,影子结果仅记录比对:
用户看到: [stable] 关于「RAG 是什么」的回答。
影子记录: [canary] 关于「RAG 是什么」的回答,见 [doc1]。 (差异=True)
用户看到: [stable] 关于「向量检索怎么调」的回答。
影子记录: [canary] 关于「向量检索怎么调」的回答(缺引用)。 (差异=True)
影子比对完成:4 条请求,4 条与线上不同;用户 0 感知。
用户始终只看到 [stable] 的回答,影子版本即使「缺引用」也不会流到用户面前。这段比对让团队在放量之前就知道「新版会多出引用、但偶尔漏引用」,从而决定要不要放量。
回滚演练。本节反复强调一条纪律:没演练过的回滚不算回滚。回滚的代码路径如果在事故当晚才第一次运行,它大概率是坏的。所以回滚必须一键、且定期演练。回滚本身简单到只是把权重打到 0:
WEIGHT = 25 # 灰度中
print("回滚前分流:", count_split(WEIGHT)) # {'stable': 762, 'canary': 238}
WEIGHT = 0 # 一键回滚:只改这一个数字
after = count_split(WEIGHT)
print("回滚后分流:", after) # {'stable': 1000, 'canary': 0}
assert after["canary"] == 0, "回滚未生效"
真跑输出:
回滚前 weight = 25 分流: {'stable': 762, 'canary': 238}
执行回滚 weight = 0
回滚后分流: {'stable': 1000, 'canary': 0}
回滚验证通过:canary 流量为 0,100% 回到 stable
演练的关键不在「改数字」这个动作,而在验证——assert 确认回滚后 canary 流量真的归零。真实系统里这一步还要验证「已进入 canary 但未完成的请求」如何收尾(是让它跑完还是丢弃)、以及回滚后指标是否恢复到基线。回滚演练的产出不是一个数字,而是一份「回滚后系统确实恢复正常」的证据。
演练要定期做,并记录三件事:回滚耗时(从决定到流量归零用了几秒)、回滚期间的错误数(切流量那一刻有没有丢请求)、回滚后的指标曲线(多久恢复到基线)。这三项是回滚能力的真实度量——只有它们稳定在可接受范围内,才敢说「我们有回滚」。把演练当成本,而不是当形式,是团队可靠性成熟度的分水岭。
17.2.7 放量节奏:观察窗口与样本量
「5% → 25% → 100%」只说清了放多少,没说清每级停多久。停太短,样本不够,判定被噪声主导;停太长,灰度拖成持久战。判据是最小样本量,而不是固定时长。
用二项分布给个直觉:成功率 p、样本数 n 时,成功率估计的标准误约 sqrt(p(1-p)/n)。当 p=0.95、n=100 时,标准误约 2.2 个百分点——这意味着 5% 那一级只有 100 个样本时,成功率差 3 个百分点完全可能是噪声。本节 17.2.5 里 5% 那级 canary 只有 97 个样本、成功率 91.8% vs 基线 94.3%,差的 2.5 个百分点就在噪声范围内,所以判定容差设成 5 个百分点是有统计依据的,不是拍脑袋。
| 放量级 | 建议最小样本 | 观察重点 |
|---|---|---|
| 5% | 数百 | 有没有灾难性回归(报错、超时) |
| 25% | 数千 | 成功率的真实差异、成本是否超标 |
| 100% | 全量 | 长尾租户与边缘用例 |
真实做法是按最小样本量触发下一级,而不是「等半小时」。流量大的服务几分钟就攒够数百样本,可以快速放量;流量小的服务则要靠影子流量补足样本(17.2.4)。别在样本不足时做「看起来变好了」的乐观判断——那和掷硬币没有区别。
17.2.8 回滚的三层:流量、配置、数据
很多人以为回滚就是「把流量切回去」。对 Agent 服务,回滚其实有三层,漏掉任何一层都不算干净:
| 层 | 回滚动作 | 漏掉的后果 |
|---|---|---|
| 流量层 | 灰度权重打到 0 | 用户继续吃到坏版本 |
| 配置层 | 提示词 / 模型 ID 退回上一版 | 下次发布又带着坏配置上线 |
| 数据层 | 清理新版写入的记忆 / 缓存 | 切回旧版后读到新版留下的脏数据 |
最容易被漏掉的是数据层。新版可能往记忆库写了质量更差的记忆、往语义缓存塞了错误的答案对。你把流量切回旧版,旧版照样会读到这些脏数据——于是「回滚完成」之后,用户还在被坏数据影响。所以完整的回滚清单里,数据层的清理是必须项:新版写入的记忆要按 version 标记回捞并删除,缓存要按新版标识失效。
配置层同样常被忽略。如果回滚只是切流量、没把提示词配置退回上一版,那么下次发布时,「新配置 + 新代码」会再次一起上线——等于把刚踩过的坑重踩一遍。配置必须和代码一样有版本、一样能回退,这是 17.2.1「发布对象是清单」的直接推论。
17.2.9 灰度指标从哪来:接上可观测性
17.2.3 的三个回滚条件不是凭空算出来的——它们必须按版本标签分组采集。第 13 章的 OTel 埋点已经把每次 run 的成功率、成本、延迟都记了下来,灰度要做的只是给每条指标打上一个 version 标签,然后按 version=stable 与 version=canary 分别聚合:
run_success_rate{version="stable"} 0.951
run_success_rate{version="canary"} 0.616
run_cost_yuan{version="canary"} 0.0025
run_latency_p95_ms{version="canary"} 2000
这个 version 标签是灰度能「自动」回滚的全部基础设施。没有它,指标是一锅粥,你只能看到「整体成功率掉了 0.3%」,分不清是版本问题还是流量变化;有了它,canary 那一组指标单拎出来一看,问题立刻现形。所以**「给指标打版本标签」是灰度上线前必须做的一件事**,它属于第 13 章埋点的延伸,而不是第 17 章另起炉灶。
三条件的阈值也应该告警化:不只在灰度脚本里判定,还要在监控里配一条「version=canary 的成功率低于 version=stable 5 个百分点」的告警。灰度脚本负责自动回滚,监控告警负责在脚本没兜住时(比如指标延迟上报)给人发信号。两道防线叠加,才不至于漏掉。
还有一个采集上的坑:成本与延迟要按「完成的 run」聚合,而不是「发起的 run」。灰度期间 canary 若大面积超时失败,这些失败请求没有成本记录,按发起口径算会低估 canary 的成本。所以聚合口径要明确写成「按完成事件」,避免失败请求被静默排除、把劣化版本算得比实际好看。
17.2.10 验收与真实环境
本节把「发布」从「换镜像」升级成「可控的灰度 + 可验证的回滚」:
- 发布对象是清单:代码、提示词、模型 ID、工具集、检索参数,后四者无测试覆盖,必须走灰度。
- 比例灰度:哈希粘性分流,5% → 25% → 100%,用同期对照组而非历史值做基线。
- 三条件自动回滚:成功率、成本、P95,任一命中即回。
- 真跑:劣化版在 5% 就被三个条件同时拦下;影子流量用户 0 感知;回滚演练
assert验证归零。
验收清单:
- 提示词 / 模型 / 工具集 / 检索参数的改动与代码走同一条发布流程
- 分流是粘性的(哈希取模,非随机)
- 回滚条件是自动的,且基线取自同期对照组
- 回滚是一键的,且有定期演练与验证
真实环境 / 真实模型下会怎样:本节的指标全是 random 模拟的,比例与阈值是按典型值设定的。真实灰度有三个差异。第一,指标要按分位数和分层看——整体成功率没掉,可能掩盖「某一类问题成功率暴跌」,所以要按用例类型 / 租户 / 模型档位分层。第二,回滚比本节复杂得多:除了切流量,还可能要回滚数据库 schema(第 17.3 节)、清理新版写入的记忆与缓存、通知下游。第三,灰度要能「渐进」而非「全有全无」——真实系统常用按用户、按租户、按百分比多层叠加的放量策略,而不是单一权重。无论多复杂,「没演练过的回滚不算回滚」这条纪律不变。想回看质量门是怎么建的,翻 12.3 CI 质量门
;想回看可回溯的镜像版本标签,翻 17.1 流水线
。
阅读导航:上一节:17.1 流水线:lint/test/评测/构建串联 · 下一节:17.3 版本与迁移:不中断 schema 迁移演练 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。