做 AI Agent 工程的人,迟早会撞上同一个麻烦:一个新版本到底能不能上?我今年大半时间都在打磨一套 Agent Harness,最头疼的不是 agent 逻辑本身,而是怎么给这玩意做灰度发布。AI Agent Harness Engineering 的灰度发布策略,说穿了就是两张牌:A/B 测试和流量染色。这两张牌帮我拦下了至少五次差点酿成事故的变更,也让我摸清了 Agent 发布和普通后端服务发布的本质区别。这篇文章把里面能直接用的方法、参数和坑都摊开讲,你照着搭一遍,至少不会在灰度第一步就翻车。
这篇文章适合两类人看:一类是正在做 Agent 基础设施、被"新版本不敢直接上"困扰的开发者;另一类是已经在用传统灰度工具,但发现它们对 LLM 场景不太管用的团队。我会从为什么需要灰度、灰度的锚点放在哪、A/B 怎么测、流量怎么染色、回滚怎么做这五块展开,尽量把每一个决策背后的原因也讲清楚。
1. 先聊聊为什么 Agent 的发布值得单独搞一套灰度体系
1.1 传统服务灰度的是确定性,Agent 灰度的是飘忽不定
传统后端服务做灰度,本质上是给一段确定性的代码切流量。代码在给定输入下的输出基本可预期,你灰度的是部署风险、配置风险、依赖风险。但 Agent 不一样,Agent 的行为由"模型权重 + prompt 模板 + 工具配置 + 记忆上下文 + 护栏规则"共同决定,任何一个环节动了,输出分布都会变,而且这种变化没法提前用单元测试穷举。
我举一个真实场景。有个天气查询 Agent,工具列表里有"查天气"和"查空气质量"两个工具。某次升级只是调整了工具描述的顺序,把"查空气质量"放到了前面。结果模型在用户问"明天适合跑步吗"时,不再走"先查天气再查空气质量"的链路,而是只查了一个工具就开始推理。行为本身没有报错,任务结果看起来也像模像样,但信息完整度明显下降。这种"看起来没坏、实际上偏了"的问题,只有在真实流量分布里才暴露得出来。
另一个麻烦在于 LLM 的非确定性。传统服务 A/B 跑出来的指标差异,主要来自代码路径不同;Agent A/B 跑出来的差异,还会叠加采样随机性、温度参数、上下文长度变化。同一份 prompt,同一批输入,跑两次可能给出不同工具调用序列。所以灰度期间你要对比的,不是一个"稳定值",而是两个"分布"。这也是为什么很多团队在 Agent 灰度上特别容易误判:看到一点波动就以为是版本问题,或者反过来,看到指标平稳就放心了,结果一全量就出事。
1.2 传统灰度发布与 Agent 灰度发布的核心差异
我整理过一个对照表,每次给新同学讲 Agent 灰度时都会直接甩出来:
| 对比维度 | 传统服务灰度 | Agent 灰度 |
|---|---|---|
| 变更单元 | 代码、配置、依赖 | prompt、模型版本、工具描述、记忆策略、护栏规则 |
| 行为可预测性 | 高,给定输入基本有确定输出 | 低,真实输入分布下输出呈长尾 |
| 主要监控指标 | 错误率、延迟、CPU、内存 | 成功率、工具调用质量、护栏触发率、成本、用户干预率 |
| 评估方式 | 单元测试、集成测试、性能压测 | 离线评测集 + LLM as judge + 在线 A/B |
| 回滚代价 | 通常较低,重启/回包即可 | 高,涉及会话状态、记忆兼容、工具副作用 |
| 灰度最小单位 | 请求、实例、机房 | 会话、用户,且需要稳定的分流 key |
这张表看着简单,实际每一条背后都是坑。比如"回滚代价高"这条,传统服务回滚就是切回旧版本,但 Agent 长会话里,用户和模型已经来回聊了好几轮,上下文里的记忆结构是用新版本格式写的,你强行把运行版本切回旧版,旧版可能根本解析不了新版写入的记忆片段,照样出错。所以 Agent 灰度不能只考虑"切换",还要考虑"切换后老会话怎么处理"。
2. 灰度放在哪里做:Harness 层才是真正的锚点
2.1 我理解的 Harness 是什么
很多做 LLM 应用的人第一次听到 Agent Harness 会有点懵,这个词在不同团队里叫法也不一样:有的叫 Agent 运行时,有的叫编排层,有的叫控制套件。我倾向于把它理解成"包在模型外面的一层工程化控制面"。它负责上下文组装、工具调度、护栏执行、记忆读写、追踪埋点、版本路由,这些脏活累活如果不抽出来,最后都会以 if-else 的形式散落在业务代码里。
Harness 层的位置大致是:业务请求先打到 Harness,它决定用哪一套 Agent 配置(prompt、模型、工具、参数),然后执行这个配置生成工具调用序列,最后把结果返回给业务。模型本身只是 harness 里被调用的一个组件。这个分层最大的好处是:版本变更的承载点集中在 Harness 的配置体系里,而不是散落在模型层或者业务层。
做灰度发布时,Harness 层是天然的锚点。你可以在不改业务代码的前提下,给一套 Agent 配置定义版本号、部署组、灰度比例,甚至同一套配置内部还可以做 prompt 级的多变量实验。模型层做不到这一点,因为模型层只负责出 token,不知道上下文是什么、工具怎么接、护栏要不要触发;业务层也做不到,因为业务代码一旦和具体的 prompt 结构耦合,你就没法独立发布 Agent 逻辑。
2.2 分流点应该长在 Harness 的哪个位置
我踩过一次坑:一开始把分流逻辑直接写在业务网关里,根据用户 ID 哈希决定走新 Agent 还是旧 Agent。当时看起来没问题,后来业务方想在某个渠道单独跑实验,发现改分流规则要从业务网关改起,代码上线要先走业务发布的流程,完全不灵活。后来我把分流收敛到 Harness 的请求入口,所有 Agent 流量都必须经过同一个 variant resolver,灰度能力才算真正长对地方。
具体的流程是这样:请求进入 Harness 后,先在 variant resolver 里读流量上下文(租户、会话 ID、来源渠道、染色标记),接着根据灰度配置决定本次会话应该走到哪个 agent 变体。这里的 agent 变体不是指某个模型实例,而是一整套配置快照,包括 prompt 模板、模型路由规则、工具启用列表、温度参数、护栏等级。配置快照的管理可以像发布二进制一样做版本化和回滚,prompt 也是代码,配置也是代码。
我常年在 Harness 里维护一份类似下面的变体配置:
variants: stable: prompt_version: "prompt_agent_v2_0" model: provider: internal-llm model_name: "agent-v2" temperature: 0.3 tools: ["search_tool", "weather_tool", "calc_tool"] guardrails: "standard" memory_schema: "schema_v2" canary: prompt_version: "prompt_agent_v2_1" model: provider: internal-llm model_name: "agent-v2" temperature: 0.2 tools: ["search_tool", "weather_tool", "calc_tool", "news_tool"] guardrails: "strict" memory_schema: "schema_v3" traffic: canary_percent: 5这套配置里,stable 和 canary 的模型一样,但 prompt、工具、护栏、记忆 schema 都不同,这正是 Agent 灰度最常见的形态:一次变更往往同时动了好几个变量。如果不用这种配置快照管理,你会很难定位线上某个问题到底是 prompt 引起的,还是工具列表引起的,还是模型行为漂移引起的。
2.3 确定性分流:同一个会话不能来回切
灰度切流最忌讳的就是"抖动"。用户在一次会话里,第一轮走旧版,第二轮突然被切到新版,体验和评估都会乱。所以分流 key 的选择非常关键,我不用 IP、不用设备 ID,而是用租户 ID + 会话 ID 拼出来的稳定字符串做哈希。
分流逻辑我用 Python 写过一版,讲意思想表达清楚就够了:
import hashlib def select_variant(context, canary_percent=5): key = f"{context.tenant_id}:{context.session_id}" digest = int(hashlib.sha256(key.encode()).hexdigest(), 16) bucket = digest % 1000 if bucket < canary_percent * 10: return "canary" return "stable"这里把比例放大到千分位,是想支持 0.1%、0.5% 这种极低流量。早期我只用百分位,最小只能切 1%,对一些高成本 Agent 场景来说 1% 的流量已经足够造成几千块的额外成本,所以千分位更实用。还有一点,这个 hash 要基于一个在整个会话生命周期内不变的 key,一旦用户开了会话,不管灰度比例怎么调整,他在本次会话里始终走同一个变体。
3. A/B 测试落地:分组、指标与评估闭环
3.1 分组粒度:以会话为最小单位
A/B 测试的分组粒度,决定了实验时长和结论可信度。Agent 场景下我强烈建议以会话(session)为最小分析单元,而不是以请求(request)为单元。原因很简单:一次用户请求可能引发模型多次内部推理和工具调用,如果按 request 计数,一个会话里出了问题会被重复计数,最终算出来的显著性完全是虚胖。
以用户为粒度会更"干净",但实验速度会慢很多。比如一个用户一天可能只产生一到两个会话,你至少要积累几千个用户才能达到显著水平。以租户为粒度更慢,一般只在大型企业客户灰度时才用。我的实践中,toB 场景按租户隔离、toC 场景按用户隔离、内部和产品验证场景按会话隔离,三者的优先级排开,没有放之四海皆准的方案。
分组时还要注意"同一租户的多个用户可能共享一个环境"。之前我遇到过一个情况,某企业客户的所有员工共用一套账号体系,会话 ID 才是真正唯一的。如果按租户 ID 做 hash 分流,这个租户会整个被分到一个桶里,A/B 就没法在租户内部做了。所以设计分流 key 时,要去看目标系统里真正的隔离边界是什么,而不是想当然。
3.2 核心指标:任务成功率之外还要看什么
Agent A/B 的指标设计比传统服务复杂,因为"成功"本身是模糊的。一个客服 Agent 帮用户查到了订单,但语气冷淡、流程绕了三个来回,算成功还是失败?我通常会搭一套指标分层:
| 指标层 | 具体指标 | 用途 |
|---|---|---|
| 业务层 | 任务完成率、用户最终反馈、工单转化率 | 判断新版本是否带来真实业务价值 |
| 行为层 | 工具调用次数、平均推理步数、用户中断率、重问次数 | 判断 Agent 行为是否更高效 |
| 质量层 | 护栏触发率、有害输出率、关键字段错误率 | 判断新版本是否引入了安全/合规风险 |
| 成本层 | 总 token 消耗、单会话成本、外部 API 调用量 | 判断新版本是否值得用额外成本换效果 |
这四个层缺一不可。我见过不少团队只看任务完成率和 token 成本,结果新版本确实便宜了,但护栏触发率翻了三倍,问题在实验报告里被淹没了。反过来,有些团队只盯质量指标,忽略了成本,上了新版本之后单会话成本直接翻倍,业务上根本扛不住。
另外,LLM 场景里"延迟"的度量也要重新定义。传统服务的延迟是单次请求的响应时间,Agent 的"延迟"是用户感受到的整个交互时间,包括模型多轮推理、工具调用往返。灰度期间我会分别统计两个指标:单次模型调用的 TTFT 和整个会话的完成时长。新版如果只是 TTFT 变快,但推理步数变多,用户感受反而更差。
3.3 离线评估与在线 A/B 的衔接
灰度发布不能只靠在线 A/B,也不能只靠离线评测,两者必须形成先后链路。我先在离线评测集上把新版本跑一遍,通过后才允许进入灰度流量。离线评测集要有代表性,不能只放几个 happy path 样例,要加入失败样例、长尾 query、多轮对话场景。这一关能拦掉大部分明显回归。
但离线评估有个陷阱:评测集是静态的,模型和 prompt 很容易被"背"下来。我见过一个团队,连续几个版本都在同一套评测集上刷高分,结果上线后真实用户表现一次比一次差。原因是评测集太小、太固定,模型其实已经过拟合到了评测集里的表达模式。所以我每年会花不少精力做评测集定期扩充,灰度期间还会从真实流量里抽样回流到评测集,保证它跟得上线上变化。
在线 A/B 和离线评估之间还要有一个"影子评估"过渡带。具体做法是,新版本完全不接管流量,而是把真实请求同时发给新版 Agent,但新版的结果只进日志、不触达用户。影子模式可以跑一两天,把新版在真实输入上的输出去离线评一遍,看看和稳定版有多少偏离。这一步能提前发现很大的问题,而且零用户风险。
3.4 最少需要多少样本:先算清楚再放量
很多团队灰度到一半发现"看不出差异",本质上是样本量不够。Agent 场景比传统 A/B 更吃样本,因为会话级分析单元天然比请求级少很多。我会在灰度前先做一轮粗略的功效计算,公式不复杂:
[ n \approx \frac{16 \times p \times (1-p)}{(\delta)^2} ]
其中 p 是基线指标(比如任务成功率 0.7),δ 是你期望检测到的最小提升(比如 0.05)。算下来 n 大概 2688 个会话。也就是说,如果你要看到 5 个百分点的成功率提升,每组至少要积累接近 2700 个有效会话,而不是 2700 次请求。
实际做实验时还要预留余量,因为日志缺失、无效会话、护栏拦截会把一部分样本剔掉。我一般按 1.3 倍来定目标,也就是每组要攒到 3500 个干净会话。灰度比例 10% 的话,如果每天能产生一万个会话,两组大约需要三天;如果灰度比例只有 1%,就要等上一个月。所以实验设计阶段就要把比例和时间窗一起规划好,不然很容易出现"数据攒够了但业务已经等不起"的尴尬。
4. 流量染色:让每一笔 Agent 调用都有可追踪的身份
4.1 染色标记本身是怎么打的
流量染色这个词听起来玄乎,本质就是给流经 Harness 的请求打上一个肉眼可查、系统可读的标记,让全链路日志、指标、追踪都能按这个标记聚合。我习惯把染色标记直接放在请求头和内部上下文的 attribute 里,用x-agent-stain这个头来承载,取值类似g=canary,v=2.4.0-rc1,t=a。g 表示实验组,v 表示版本快照,t 表示染色来源。
染色来源通常有三种。第一种是业务方主动指定,比如内部测试团队、某个重点客户要求全量进入灰度环境,直接在请求头携带标记;第二种是 Harness 的 variant resolver 根据灰度比例自动给会话打标,这个标记和分流逻辑共用同一个哈希结果;第三种是实验平台侧下发,常见做法是通过配置中心给满足一定条件的流量统一染色。三种来源最终都会归一到同一套标记规范,否则后面看板聚合时会疯掉。
标记要放在两个地方:请求上下文和日志链路。请求上下文里的标记用于 Harness 内部做决策时读取;日志和追踪里的标记用于离线分析和监控。我踩过的一个坑是,只在上层请求日志里打了标记,Agent 内部的工具调用日志没有继承,结果某次排查"新版 Agent 为什么频繁调用某外部接口"时,根本筛不出是哪笔流量调的。后来我把染色标记作为公共 attribute 注入到 Harness 生成的所有 span 和日志 entry 里,才算把这问题根治。
4.2 染色标记与全链路追踪的配合
Agent 的一次请求在 Harness 内部会拆成很多段:入口解析、prompt 组装、模型调用、工具调用、护栏判定、记忆读写。每一段都应该有自己的 span,同时这些 span 共享同一个 trace_id,也要共享同一个染色标记。这样才能回答一类关键问题:"新版 Agent 在调用天气工具时,上下文长度是否暴涨?"
具体做法是,在创建根 span 时就从请求里解析染色标记,然后作为 span attribute 写进去。下游所有子 span 通过 context propagation 自动继承。查问题时,直接在追踪系统里过滤stain.group = "canary",再叠加span.name = "tool_call",就能看到新版 Agent 每笔工具调用的耗时、参数、返回结果。没有这层染色,你只能把全量日志拉下来慢慢翻,效率不是一个量级。
我在日志字段里会约定一组固定的键:stain_group、stain_version、stain_source、trace_id、session_id。前三个来自染色标记,后两个来自链路追踪。查询时优先组合 session_id 和 trace_id 定位到单次会话,再用 stain_group 做聚合对比。这套键位设计不复杂,但非常实用,几乎覆盖了 Agent 灰度期间所有的问题排查场景。
4.3 染色数据的清洗与隔离:防止实验污染
流量染色不只是给观测用的,它还是数据隔离的工具。Agent 灰度期间最容易出的一种事故是:实验组的行为数据写进了共享的长期记忆服务,然后对照组也读到了这些记忆,结果两组的表现互相污染,A/B 结论完全失真。我遇到过一次,实验组新版 Agent 给某企业客户写入了一条错误的业务标签,对照组后续会话读到后,也跟着出错。最后查了整整一天才定位到是记忆写入没有带隔离键。
所以我的原则是:只要数据会回流到 Agent 的上下文(记忆、缓存、意图识别模型),就必须以"租户 + 会话 + 染色组"为键做隔离。比如记忆服务的存储 key 设计成mem:{tenant_id}:{session_id}:{stain_group},这样实验组写入的记忆永远不会被对照组读到,对照组也不会污染实验组。
另外,灰度流量不能被当成普通训练语料。在离线数据清洗时,我会把stain_group不等于 stable 的样本单独归档,绝不能直接混入下一版模型的微调集合。原因很简单,灰度版本本身可能有缺陷,把有缺陷的 agent 行为当成标准答案去训练,等于给模型喂毒。染色标记在这一环节再次发挥作用:数据管道按染色标记分区,不合格的实验组样本可以整段剔除。
5. 灰度发布的标准流程、回滚细节与现实中的坑
5.1 我常用的灰度发布流程
Agent 灰度不能一上来就切线上流量,我一般会卡五道闸门。
第一道闸门是离线评测,在静态评测集上跑完回归测试、影子模式跑完真实输入评估,结果达标才继续。第二道闸门是内部灰度,把比例压在 1% 以内,流量来源限定为内部员工和测试租户,主要看功能是否可用、链路是否报错。第三道闸门是小流量灰度,比例 5% 到 10%,开始接真实用户,这时候我会重点盯质量层和行为层指标,比如护栏触发率、用户中断率。
第四道闸门是半量灰度,比例 25% 到 50%,看业务层指标是否稳定,比如任务完成率、单会话成本。如果走到这一步都没问题,才进第五道闸门全量。每一道闸门我都要看当天统计的指标是否达到预设标准,标准没达到就暂停放量,宁可多等一天,也不要"冲过去再说"。
有一个原则我在团队里反复强调:灰度比例不能只增不减。如果 25% 阶段出现了明显异常,按流程要立刻回到 5% 或直接归零,而不是"反正就差一点了,再放放看"。Agent 的异常往往是滞后的,因为一个会话可能持续好几分钟,等到你在监控上看到问题时,已经有一大批用户在错误路径上跑完了。
5.2 回滚不是关开关,而是要处理残留会话
传统服务回滚只需要把新版本下线,让流量回到旧版本。Agent 场景多了一个麻烦:灰度过的新版本可能已经写入了长期记忆、已经触发过外部工具副作用、已经改变了用户会话的上下文结构。我遇到过必须回滚的场景:新版本 Agent 在工具调用参数解析上有个 bug,导致大量订单查询失败。当时我把分流配置切回 stable,新会话立即恢复正常,但那些已经用新版本跑了一半的会话,重启后上下文里仍带着旧格式的结构化记忆。
为了处理这种残留,我会在 Harness 里维护一个"会话版本映射表",记录每个活跃会话当前实际使用的 agent 版本。回滚时不只是把分流比例改成 0,还要对流量映射表中处于 canary 的会话做两类操作:一类是会话内容无关紧要的,直接终结并引导用户重新发起;另一类是业务上重要的,把会话上下文迁移到兼容的稳定版记忆格式。后者的成本不低,但对比让用户在一个错误上下文里继续对话,这点成本完全可以接受。
自动回滚条件也要提前配好,不能等人发现。我一般设两条硬规则:一是灰度组任务成功率连续 15 分钟低于稳定组 10% 以上;二是灰度组护栏触发率超过稳定组 3 倍。触发后由 Harness 自动把分流比例降到 0,同时告警推送给我。这套自动止血机制我是在一次晚上十点的事故里定下的。当时新版本在少数长尾 query 上反复触发违规回复,如果等人发现再手动处理,影响面会再扩大一倍。
5.3 踩过的几个典型坑
第一个坑是 LLM judge 的顺位偏置。早期我用同一个大模型给新旧两个版本的输出打分,prompt 里固定先展示 canary 的输出,后展示 stable 的输出。结果连续两轮实验 canary 的评分都偏低,后来发现是 judge 对后出现的文本更挑剔,跟版本好坏没关系。修复方案很简单:把两个版本的呈现顺序随机打乱,或者用双向对比取多数。这个小细节直接影响了灰度版本的取舍判断。
第二个坑是实验组记忆污染对照组,这个我在上一节讲隔离时提过。它的变体还有缓存污染:实验组新版 Agent 把某个问法的答案写进了共享缓存,对照组命中缓存后共用结果,等于对照组也在隐形地跑新版逻辑。后来我把缓存 key 也加上了染色组标识,才彻底切断这条污染路径。
第三个坑是外部供应商限流。灰度期间 canary 流量一旦触发外部 API 的限流策略,影响的可能不只是 canary 组,连稳定组共用的同一供应商配额也会被拖累。我处理的方式是给 canary 流量单独配置一份限流额度,同时为外部 API 调用设置更短的超时时间。Novel 的地方在于,稳定组和 canary 组需要独立统计配额,不能在同一个共享计数器里扣。
第四个坑是样本统计的"会话长度偏差"。新版 Agent 如果比旧版更容易让用户问更多问题,那么新版组的会话普遍更长,长会话天然更容易出问题,最终表现出来的成功率可能被会话长度结构夸大到失真。分析的时候如果不按会话长度分层,很容易得出"新版成功率低"的错误结论。我现在做 Agent A/B 分析时,默认都会把会话轮数作为协变量放进去看分层结果。
第五个坑是长尾会话的归属问题。灰度期间用户可能开了会话放在那里,等 20 分钟后再回来继续聊,这时流量比例已经变了。如果按"当前版本"去归类会话,统计口径就会混乱。我的做法是:会话一旦创建就固定版本归属,无论后续流量比例怎么调整,该会话的染色标记和版本归属都不再变化。这跟之前说的"同一个会话不能来回切"是同一件事,只是从统计分析角度又强调了一遍。
最后再分享一个小技巧。灰度发布的时候,我会额外打印一条结构化日志,内容包含:会话 ID、染色组、版本号、分流 key 的哈希桶、决策原因。这条日志平时看着多余,但一旦出现线上纠纷,它是定位"这笔流量到底是被哪个决策逻辑分到哪个版本"的唯一铁证。流量染色这件事,如果你不能证明流量去了哪里,那它基本等于没做。