《AI Agent 工程实战》17.2 灰度与回滚:新提示/新模型小流量

Agent 服务的发布对象不只是代码,提示词、模型 ID、工具集、检索参数都是可发布的配置,改提示词的风险不低于改代码。本节设计按流量比例的灰度与自动回滚条件,真跑一个灰度路由器:新版正常时放量、新版劣化时按成功率/成本/P95 三个条件触发一键回滚,并演示影子流量。

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 迁移演练 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距