LLM 应用的 Canary 发布工程实践:模型升级不停服的灰度切流、回滚与流量染色

8 阅读10分钟

你换了一个更强的模型,上线两小时后,客服工单量翻了三倍。不是模型出 bug,是它的输出风格稍微变了——日期格式不一样了,拒答的边界场景变多了,回复长度缩了 20%。你回滚,两天工作白费。

这不是极端案例,这是每个换过生产模型的团队都经历过的标准故事。

为什么 LLM 升级比代码发布难得多

传统软件发布的风险是可测的:单元测试、集成测试、压测——你能在上线前把大多数问题暴露出来。LLM 升级不行,原因有三:

1. 非确定性无法消除

即使把 temperature 设为 0,LLM API 的输出也不是确定性的。原因是 GPU 浮点运算不满足严格结合律,并行推理时不同 batch size 会引入不同的舍入误差。有研究记录到同一 prompt 在多次调用间准确率差异高达 15%。这意味着:你没有办法写一个可靠的 unit test 来验证"模型行为没变"。

2. 小变化,大爆炸

模型版本号 bump、fine-tuning 数据更新、甚至 system prompt 的一处措辞调整,都可能带来跑分漂亮但实际行为很不一样的结果:新模型在评测集上高了 3 分,但对某类模糊问题的处理风格变了,下游 parser 因为日期格式不同崩掉了,或者某个你依赖的"隐式宽容行为"消失了。这些问题只有真实流量才能暴露。

3. 反馈延迟

一个 500 错误一秒内就能触发告警。一个质量变差的 LLM 回答,可能要几小时后才以用户投诉、下游 pipeline 失败、或工单量上升的形式出现。这个延迟窗口需要一套隔离机制:新模型先跑,我先观察,有问题立刻切回去。

这就是 Canary 发布要解决的问题。


架构全景:LLM Canary 发布的四层组件

┌─────────────────────────────────────────────────────┐
│                   客户端请求                         │
└───────────────────────┬─────────────────────────────┘
                        │
             ┌──────────▼──────────┐
             │   流量染色层         │  ← 给请求打标签(canary/stable)
             │  Traffic Tagger     │
             └──────────┬──────────┘
                        │
             ┌──────────▼──────────┐
             │   加权路由层         │  ← 按权重分流到不同模型
             │  Weighted Router    │    (1%5%20%100%)
             └────┬──────────┬─────┘
                  │          │
        ┌─────────▼──┐  ┌────▼──────────┐
        │ Stable 模型 │  │  Canary 模型  │
        │ (DeepSeek) │  │ (Qwen-Max)    │
        └─────────┬──┘  └────┬──────────┘
                  │          │
             ┌────▼──────────▼────┐
             │   指标采集层        │  ← 延迟、错误率、质量分
             │  Metrics Collector │
             └──────────┬─────────┘
                        │
             ┌──────────▼──────────┐
             │   自动回滚控制器    │  ← 超阈值 → 自动切回 stable
             │  Rollback Controller│
             └─────────────────────┘

四层各有职责,缺一不可:

  • 流量染色:决定哪个请求走 canary 路径
  • 加权路由:控制 canary 流量百分比
  • 指标采集:实时监控 canary vs stable 的行为差异
  • 自动回滚:异常时不等人工介入,直接切回

第一步:流量染色(Traffic Tagging)

流量染色的核心是:在请求进入 LLM Gateway 时,给它打上一个标签,标识"这个请求应该走 canary 模型还是 stable 模型"。

染色策略

基于 user_id hash(最常用)

import hashlib

def get_model_variant(user_id: str, canary_percent: int, experiment_id: str) -> str:
    """
    基于 user_id 稳定分桶:同一个用户在同一实验里永远走同一个模型。
    canary_percent: 0-100
    """
    key = f"{experiment_id}:{user_id}"
    hash_val = int(hashlib.md5(key.encode()).hexdigest(), 16)
    bucket = hash_val % 100
    
    if bucket < canary_percent:
        return "canary"
    return "stable"

# 示例:5% 流量走 canary
variant = get_model_variant(
    user_id="user_12345",
    canary_percent=5,
    experiment_id="deepseek-v3-upgrade-v1"
)

为什么要加 experiment_id:同一个 user_id 在不同实验里应该能落到不同的桶。不加盐的话,所有实验都给同一批用户当小白鼠。

基于请求属性(适合精细控制)

def tag_request(request: dict, canary_config: dict) -> str:
    """
    支持多种染色策略,按优先级匹配。
    """
    # 强制染色:内部测试账号永远走 canary
    if request.get("account_type") == "internal":
        return "canary"
    
    # 地域染色:先在某个地区灰度
    if request.get("region") in canary_config.get("pilot_regions", []):
        return "canary"
    
    # 会话类型:只对新会话灰度,不打断存量会话
    if request.get("session_type") == "new" and request.get("feature_flags", {}).get("llm_canary"):
        user_id = request["user_id"]
        canary_pct = canary_config.get("canary_percent", 0)
        return get_model_variant(user_id, canary_pct, canary_config["experiment_id"])
    
    return "stable"

把染色结果写进请求头

async def canary_tagger_middleware(request, call_next):
    variant = tag_request(request.state.context, canary_config)
    request.state.model_variant = variant
    request.headers["X-Model-Variant"] = variant
    request.headers["X-Experiment-Id"] = canary_config["experiment_id"]
    response = await call_next(request)
    return response

第二步:加权路由

有了染色标签,路由层按标签决定调哪个模型。

方案 A:在 LLM Gateway 层做加权路由

# LiteLLM Router 配置示例(canary 实验期间)
router_config = {
    "model_list": [
        {
            "model_name": "chat-stable",
            "litellm_params": {
                "model": "deepseek/deepseek-chat",
                "api_key": os.environ["DEEPSEEK_API_KEY"]
            }
        },
        {
            "model_name": "chat-canary", 
            "litellm_params": {
                "model": "qwen/qwen-max",  # 待灰度的新模型
                "api_key": os.environ["DASHSCOPE_API_KEY"]
            }
        }
    ]
}

router = Router(model_list=router_config["model_list"])

async def route_request(messages: list, variant: str, **kwargs):
    model = "chat-canary" if variant == "canary" else "chat-stable"
    response = await router.acompletion(model=model, messages=messages, **kwargs)
    return response

方案 B:Nginx upstream 加权(适合自托管模型)

upstream llm_stable {
    server stable-llm-server:8000 weight=95;
}

upstream llm_canary {
    server canary-llm-server:8001 weight=5;
}

map $http_x_model_variant $upstream_target {
    "canary"  llm_canary;
    default   llm_stable;
}

server {
    location /v1/chat/completions {
        proxy_pass http://$upstream_target;
        proxy_set_header X-Model-Variant $http_x_model_variant;
    }
}

Canary 比例递增策略

ROLLOUT_STAGES = [
    {"stage": 1, "canary_percent": 1,   "hold_minutes": 30,  "description": "内部用户预热"},
    {"stage": 2, "canary_percent": 5,   "hold_minutes": 60,  "description": "小流量验证"},
    {"stage": 3, "canary_percent": 20,  "hold_minutes": 120, "description": "规模验证"},
    {"stage": 4, "canary_percent": 50,  "hold_minutes": 240, "description": "半量运行"},
    {"stage": 5, "canary_percent": 100, "hold_minutes": 0,   "description": "全量切换"},
]

class CanaryRolloutController:
    def __init__(self, experiment_id: str, redis_client):
        self.experiment_id = experiment_id
        self.redis = redis_client
    
    def advance_stage(self) -> dict:
        current = self.get_current_percent()
        next_stage = next(
            (s for s in ROLLOUT_STAGES if s["canary_percent"] > current), None
        )
        if next_stage:
            self.redis.set(f"canary:{self.experiment_id}:percent", next_stage["canary_percent"])
            return next_stage
        return {"stage": "completed", "canary_percent": 100}
    
    def rollback(self, reason: str):
        self.redis.set(f"canary:{self.experiment_id}:percent", 0)
        self.redis.set(f"canary:{self.experiment_id}:rollback_reason", reason)

第三步:指标采集与对比

需要采集哪些指标

指标维度具体指标说明
延迟p50/p95/p99 TTFT、总延迟新模型不一定更快
错误error_rate、timeout_rate、refuse_rate拒答率变化影响用户体验
成本token_usage per request新模型可能更贵
输出质量LLM-as-judge 评分、长度分布、格式合规率最难量化但最重要
业务指标用户追问率、会话完成率、下游 parse 成功率最终 ground truth

Prometheus 指标采集

from prometheus_client import Histogram, Counter
import time

llm_latency = Histogram(
    'llm_request_latency_seconds',
    'LLM request latency',
    ['model_variant', 'model_name', 'endpoint'],
    buckets=[0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0]
)

llm_errors = Counter(
    'llm_request_errors_total',
    'LLM request errors',
    ['model_variant', 'model_name', 'error_type']
)

llm_token_usage = Histogram(
    'llm_token_usage',
    'LLM token usage per request',
    ['model_variant', 'model_name', 'token_type'],
    buckets=[100, 500, 1000, 2000, 4000, 8000, 16000]
)

class CanaryMetricsCollector:
    async def record_request(self, variant, model, start_time, response=None, error=None):
        duration = time.time() - start_time
        llm_latency.labels(model_variant=variant, model_name=model, endpoint="chat").observe(duration)
        
        if error:
            llm_errors.labels(model_variant=variant, model_name=model, error_type=type(error).__name__).inc()
            return
        
        if response and hasattr(response, 'usage'):
            llm_token_usage.labels(model_variant=variant, model_name=model, token_type="prompt").observe(response.usage.prompt_tokens)
            llm_token_usage.labels(model_variant=variant, model_name=model, token_type="completion").observe(response.usage.completion_tokens)

质量自动评分(LLM-as-judge)

async def quality_judge(prompt, stable_response, canary_response, judge_model="deepseek-chat") -> dict:
    """
    让裁判模型比较两个响应的质量。
    只在采样请求上运行(如 1% 流量),避免额外成本过高。
    """
    judge_prompt = f"""你是一个客观的回答质量评估员。

用户问题:{prompt}
回答 A:{stable_response}
回答 B:{canary_response}

评估维度:准确性、完整性、格式、简洁性。
以 JSON 格式返回:{{"winner": "A"|"B"|"tie", "scores": {{"A":1-10,"B":1-10}}, "reasoning":"..."}}"""
    
    result = await llm_client.chat.completions.create(
        model=judge_model,
        messages=[{"role": "user", "content": judge_prompt}],
        response_format={"type": "json_object"}
    )
    return json.loads(result.choices[0].message.content)

第四步:自动回滚

手动回滚太慢——发现问题、开会讨论、找人操作,可能已经过去了 30 分钟。生产级 Canary 必须有自动回滚。

@dataclass
class RollbackThresholds:
    error_rate_relative_increase: float = 0.5   # canary 误差率超 stable 50%
    p95_latency_max_seconds: float = 30.0
    p95_latency_relative_increase: float = 0.3
    min_sample_size: int = 100                   # 避免小样本误判
    refuse_rate_max: float = 0.05

class AutoRollbackController:
    async def check_and_rollback(self, experiment_id: str) -> Optional[str]:
        stable = await self.metrics.get_recent_metrics("stable", minutes=10)
        canary = await self.metrics.get_recent_metrics("canary", minutes=10)
        
        if canary["sample_count"] < self.thresholds.min_sample_size:
            return None  # 样本不足,不触发判断
        
        reasons = []
        
        if stable["error_rate"] > 0:
            increase = (canary["error_rate"] - stable["error_rate"]) / stable["error_rate"]
            if increase > self.thresholds.error_rate_relative_increase:
                reasons.append(f"error_rate_spike: {increase:.1%}")
        
        if canary["p95_latency"] > self.thresholds.p95_latency_max_seconds:
            reasons.append(f"p95_latency_exceeded: {canary['p95_latency']:.1f}s")
        
        if canary.get("refuse_rate", 0) > self.thresholds.refuse_rate_max:
            reasons.append(f"refuse_rate_high: {canary['refuse_rate']:.3f}")
        
        if reasons:
            reason_str = "; ".join(reasons)
            self.rollout.rollback(reason_str)
            await self.alert.send(f"🚨 LLM Canary 自动回滚\n实验: {experiment_id}\n原因: {reason_str}")
            return reason_str
        
        return None
    
    async def start_monitoring_loop(self, experiment_id: str, check_interval_seconds: int = 60):
        while True:
            if await self.check_and_rollback(experiment_id):
                break
            await asyncio.sleep(check_interval_seconds)

Shadow Mode:比 Canary 更保守的前置步骤

在 Canary(真实用户承受风险)之前,还有一个更安全的阶段:Shadow Mode。

Shadow Mode 的做法是:每个请求同时发给 stable 和 canary,但只把 stable 的结果返回给用户,canary 的结果只用于内部对比,用户完全无感知。

async def shadow_mode_call(messages, stable_model, shadow_model, shadow_sample_rate=0.1):
    # stable 调用必须完成,给用户返回
    stable_response = await router.acompletion(model=stable_model, messages=messages)
    
    # shadow 调用采样,不阻塞用户响应
    if random.random() < shadow_sample_rate:
        asyncio.create_task(_run_shadow_and_compare(messages, shadow_model, stable_response))
    
    return stable_response

async def _run_shadow_and_compare(messages, shadow_model, stable_response):
    try:
        shadow_response = await router.acompletion(model=shadow_model, messages=messages)
        comparison = {
            "timestamp": datetime.utcnow().isoformat(),
            "stable_tokens": stable_response.usage.total_tokens,
            "shadow_tokens": shadow_response.usage.total_tokens,
            "stable_length": len(stable_response.choices[0].message.content),
            "shadow_length": len(shadow_response.choices[0].message.content),
        }
        await metrics_db.insert("shadow_comparisons", comparison)
    except Exception as e:
        logger.warning(f"Shadow call failed: {e}")

升级路径:Shadow Mode → Canary 1% → Canary 5% → ... → 全量。每个阶段有数据支撑,每个阶段可以立刻回头。


生产中的实际踩坑

坑 1:存量会话被切换了

场景:用户第 1 轮对话走了 stable,第 2 轮请求恰好被分到 canary。同一个会话前后换模型,上下文处理方式不同,表现诡异。

解决:基于 session_id 染色,而不是 user_id。同一个 session 里模型不变。

# 用 session_id 保证会话内模型一致性
def get_model_variant(session_id: str, canary_percent: int, experiment_id: str) -> str:
    key = f"{experiment_id}:{session_id}"
    hash_val = int(hashlib.md5(key.encode()).hexdigest(), 16)
    return "canary" if (hash_val % 100) < canary_percent else "stable"

坑 2:小样本误差率"虚高"

Canary 只有 1-5% 流量时,50 个请求里碰到 5 个超时,误差率显示 10%(而 stable 是 2%),看起来触发了回滚阈值,其实只是小样本抖动。

解决:设 min_sample_size=100+,样本不够不触发自动回滚。

坑 3:回滚快但没留证据

回滚时只记了"误差率高了",不知道是哪类请求触发的,下次升级还踩同一个坑。

解决:回滚时写完整快照到文件,包含 canary_percent_at_rollback、各指标数值和时间戳,方便事后分析。

坑 4:新模型成本暴增无人预警

切换到新模型后,输出 token 价格高了 30%。因为 Canary 阶段只有 5% 流量,看起来成本没变化,全量后账单才暴增。

解决:在 Canary 阶段就做成本外推计算:"如果这是 100% 流量,月成本是多少",提前预警。


Grafana Dashboard 关键查询

# p95 延迟对比(stable vs canary)
histogram_quantile(0.95, 
  sum(rate(llm_request_latency_seconds_bucket[5m])) by (le, model_variant)
)

# 误差率对比
sum(rate(llm_request_errors_total[5m])) by (model_variant)
/
sum(rate(llm_request_total[5m])) by (model_variant)

# 每请求 token 用量对比
sum(rate(llm_token_usage_sum[5m])) by (model_variant, token_type)
/
sum(rate(llm_token_usage_count[5m])) by (model_variant, token_type)

完整发布 checklist

发布前

  • 确认新模型版本号、API 端点、定价差异
  • staging 环境 shadow 对比,记录质量差异
  • 设置回滚阈值(误差率、延迟、质量分)
  • 配置告警渠道(钉钉 / 企微 / PagerDuty)

Canary 阶段(1% → 5% → 20%)

  • 每阶段等待足够样本量(≥1000 请求)
  • 误差率相对变化 ≤20% 为绿灯
  • p95 延迟相对变化 ≤15% 为绿灯
  • 成本外推对比通过

全量切换

  • 所有指标绿灯持续 ≥2 小时
  • 通知业务方(可能有 UI 依赖输出格式的地方)
  • 保留 stable 端点 72 小时作为回滚通道

小结

LLM Canary 发布不是过度工程,而是承认了 LLM 的特殊性:你没有办法在上线前测出所有问题,所以需要让"上线"本身变得安全可逆。

核心思路:

  1. 流量染色给请求打标签,决定谁走新模型
  2. 加权路由控制 canary 比例,逐阶段递增
  3. 指标采集同时监控 stable 和 canary,有数据再决策
  4. 自动回滚异常时不等人,立刻切回 stable

Shadow → Canary 1% → 全量,这三步能省掉你无数个"上线两小时后紧急回滚"的深夜。

下次换模型,先让 1% 的流量帮你探路。