Agent 灰度发布策略:单个实例先上线,逐步切流量
一、新 Agent 上线后直接崩,回退还影响了正在进行的会话
Agent 与普通微服务最大的不同:它是有状态的。一个正在执行多步任务的 Agent 实例如果被直接杀死,用户的上下文、中间结果全部丢失。这使得传统微服务的滚动更新策略在 Agent 场景下会出问题。
灰度发布(Canary Release)的核心价值不是"逐步验证新版本",而是在最小影响面下验证新 Agent 的行为,同时保证旧 Agent 的会话不受干扰。
这意味着灰度不仅仅是流量切分,还涉及会话迁移策略——正在进行中的会话不应该被迁移到新版本。
sequenceDiagram participant LB as 流量网关 participant V1 as Agent v1 (稳定) participant V2 as Agent v2 (灰度) participant SS as 会话存储 participant MON as 监控 LB->>LB: 5% 流量标记为灰度 rect rgb(200, 240, 200) Note over LB,V1: 95% 正常流量 LB->>V1: 新会话请求 V1->>SS: 创建会话 V1-->>LB: 响应 end rect rgb(255, 240, 200) Note over LB,V2: 5% 灰度流量 LB->>V2: 新会话请求(仅新会话) V2->>SS: 创建会话 V2-->>LB: 响应 V2->>MON: 上报指标 end Note over LB,SS: 已有会话的后续请求<br/>始终路由到原版本 LB->>SS: 查询会话归属 SS-->>LB: session_001 → v1 MON->>MON: 对比 v1 vs v2 指标 MON-->>LB: P99 延迟正常 / 错误率一致 Note over LB: 指标通过 → 扩大灰度比例<br/>5% → 25% → 50% → 100%二、Agent 灰度与普通微服务灰度的区别
区别一:会话亲和性(Session Affinity)
微服务灰度通常基于请求哈希路由。一次请求到 v2,下次可以到 v1——因为每个请求是独立的。
Agent 不行。一个多轮对话的会话从头到尾必须由同一个版本处理。如果第三轮请求被路由到 v2,v2 没有前两轮的上下文——对话中断。
区别二:回退策略
微服务灰度出问题时,把所有流量切回 v1 即可。Agent 灰度出问题,那些已经被路由到 v2 的会话怎么办?
我采用双写会话状态:v2 每次状态变更同时写一份到 v1 兼容格式的存储中。这样需要回退时,v1 能从存储中恢复会话——有信息损失但不至于彻底丢失。
区别三:评测指标不同
微服务灰度看延迟、错误率。Agent 还要看任务完成率(用户是否中途放弃)、工具调用正确率、用户满意度(隐式反馈如"重试"次数)。
三、基于 Redis 的会话路由实现
import asyncio import hashlib from dataclasses import dataclass from enum import Enum from typing import Optional import redis.asyncio as redis class SessionVersion(Enum): V1 = "v1" V2 = "v2" @dataclass class CanaryConfig: """灰度发布配置。 设计要点: - 比例按百分比设置,支持渐进式扩量 - 白名单优先——内部用户/测试用户始终走灰度版本 """ canary_percentage: int = 5 # 灰度百分比(0-100) white_list: list[str] = None # 始终路由到灰度的用户 ID session_timeout: int = 3600 # 会话超时时间 def __post_init__(self): if self.white_list is None: self.white_list = [] class SessionRouter: """Agent 会话路由器。 路由策略: 1. 已有会话 → 查 Redis,路由到会话当前版本 2. 新会话: a. 用户在白名单中 → v2 b. 用户 ID 哈希值 < canary_percentage → v2 c. 否则 → v1 关键:用户 ID 的哈希决定了灰度分组。 同一个用户始终在同一组——避免同一用户不同会话跳到不同版本。 """ def __init__(self, redis_url: str, config: CanaryConfig): self._redis = redis.from_url(redis_url) self._config = config async def route(self, session_id: Optional[str], user_id: str) -> SessionVersion: """决定将请求路由到哪个版本。""" # 规则1:已有会话,查看归属 if session_id: existing = await self._get_session_version(session_id) if existing: return existing # 规则2:白名单用户走灰度 if user_id in self._config.white_list: await self._create_session(session_id, SessionVersion.V2) return SessionVersion.V2 # 规则3:基于用户 ID 哈希的灰度分流 # 使用 MD5 取前 8 位做一致性哈希 # 保证同一用户始终在同一组 hash_val = int(hashlib.md5(user_id.encode()).hexdigest()[:8], 16) % 100 if hash_val < self._config.canary_percentage: version = SessionVersion.V2 else: version = SessionVersion.V1 if session_id: await self._create_session(session_id, version) return version async def update_canary_percentage(self, percentage: int) -> None: """动态调整灰度比例。 注意:调整比例不影响已有会话——它们仍然在原有版本上。 只影响新的会话创建。 """ if not 0 <= percentage <= 100: raise ValueError("percentage must be 0-100") self._config.canary_percentage = percentage # 持久化到 Redis,方便多实例同步 await self._redis.set("canary:percentage", percentage) async def rollback(self, session_ids: Optional[list[str]] = None) -> None: """回退到 v1。 session_ids 非空:只回退指定会话 session_ids 为空:全量回退 """ if session_ids: for sid in session_ids: await self._redis.setex( f"session:{sid}:version", self._config.session_timeout, SessionVersion.V1.value, ) else: self._config.canary_percentage = 0 await self._redis.set("canary:percentage", 0) async def _get_session_version(self, session_id: str) -> Optional[SessionVersion]: raw = await self._redis.get(f"session:{session_id}:version") if raw: return SessionVersion(raw.decode()) return None async def _create_session(self, session_id: str, version: SessionVersion) -> None: if session_id: await self._redis.setex( f"session:{session_id}:version", self._config.session_timeout, version.value, ) async def get_canary_stats(self) -> dict: """获取灰度统计信息。""" total = 0 v2_count = 0 cursor = 0 while True: cursor, keys = await self._redis.scan( cursor, match="session:*:version", count=100 ) total += len(keys) for key in keys: version = await self._redis.get(key) if version and version.decode() == SessionVersion.V2.value: v2_count += 1 if cursor == 0: break return { "total_sessions": total, "canary_sessions": v2_count, "canary_percentage": self._config.canary_percentage, }四、渐进式扩量与回退策略
扩量节奏
0% → 观察 v2 部署健康(15min) 0% → 内部用户全量(1 hour) → 5% 生产流量(2 hours) → 25%(4 hours) → 50%(8 hours) → 100%(下次发布窗口)每个阶段观察的指标:
- P50/P99 延迟:新旧对比差异 < 10%
- 任务完成率:不低于 v1 基准的 95%
- 工具调用成功率:无退化
- 用户 abandon rate(中途放弃率):无显著上升
快速回退
如果任何指标出现显著退化(对比差异 > 20%),立即执行回退:
- 将
canary_percentage设为 0 - v2 正在进行的会话触发优雅关闭(允许任务完成但不接收新会话)
- 通知 oncall 团队排查 v2 问题
五、总结
Agent 灰度与传统微服务灰度的根本差异在会话状态。基于用户 ID 哈希的灰度分组确保了同用户同版本,Redis 存储会话版本保证了有状态路由。灰度发布不是一步到位的开关,而是"小步快跑、持续验证"的渐进过程——每一步都有观察窗口和回退路径。