你换了一个更强的模型,上线两小时后,客服工单量翻了三倍。不是模型出 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 的特殊性:你没有办法在上线前测出所有问题,所以需要让"上线"本身变得安全可逆。
核心思路:
- 流量染色给请求打标签,决定谁走新模型
- 加权路由控制 canary 比例,逐阶段递增
- 指标采集同时监控 stable 和 canary,有数据再决策
- 自动回滚异常时不等人,立刻切回 stable
Shadow → Canary 1% → 全量,这三步能省掉你无数个"上线两小时后紧急回滚"的深夜。
下次换模型,先让 1% 的流量帮你探路。