背景:你在凌晨优化了一个核心 Prompt,本地测试完美,直接全量上线。3 小时后用户投诉涌来------回答质量明显下滑,但错误率、延迟监控全绿。排查 2 小时才发现是 Prompt 变更引入了一个边缘 case。这不是个别事故,这是 LLM 应用发布的系统性问题。
为什么 LLM 变更不能像普通服务一样全量发布
传统后端服务出了问题,有明确信号:HTTP 5xx 率上升、延迟 P99 爆表、数据库报错。回滚操作清晰。
LLM 应用的变更失效截然不同:
信号弱且滞后。 Prompt 改了一个词,导致某类问题的回答风格变差。API 调用成功,延迟正常,错误率不变------但质量悄悄退步。用户要多看几条回复才会感知,投诉要再滞后几小时才到达。
变更表面小,影响面大。 一个 system prompt 的修改,可能影响所有 user query 的输出。模型版本从 Qwen-Max 升级到 Qwen-Turbo,token 成本骤降但某类推理变差。参数 temperature 从 0.7 改到 0.3,格式稳定了,但创意类回答变死板。
难以分离变量。 多个 Prompt 模板、多个模型配置、多个参数,同时服务不同用户群。全量发布后一旦出问题,很难判断是哪一个变量引起的。
结论:LLM 变更需要专门的灰度策略。 而且这套策略要比普通服务的 canary 更复杂:观测维度不只有错误率和延迟,还要包含质量信号。
Canary 发布的三种模式
模式一:流量分割(Traffic Split)
最经典的 canary 方式。把少量流量路由到新版本,对比新旧版本的关键指标。
scss
用户请求
│
▼
┌─────────────────────┐
│ Canary Router │
│ ┌───────────────┐ │
│ │ hash(user_id) │ │
│ └───────────────┘ │
└─────────┬───────────┘
┌────┴────┐
│5% │95%
▼ ▼
New v2 Old v1
Prompt Prompt
流量分割有两种粒度:
- 按请求随机(不推荐):同一用户可能先看到 v1 的回答,再看到 v2 的回答,体验割裂,投诉也更难归因。
- 按 user_id hash(推荐):用户始终落在同一版本,体验一致,数据也更干净。
python
import hashlib
def get_canary_version(user_id: str, canary_pct: float = 0.05) -> str:
"""
按 user_id 哈希决定版本。同一 user_id 每次结果一致。
canary_pct=0.05 表示 5% 流量给新版本。
"""
hash_val = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
bucket = (hash_val % 10000) / 10000.0 # 0.0000 ~ 0.9999
return "v2_canary" if bucket < canary_pct else "v1_stable"
def build_prompt(user_query: str, version: str) -> str:
if version == "v2_canary":
return PROMPTS["v2"] # 待验证的新版本
return PROMPTS["v1"] # 稳定的旧版本
模式二:Shadow Mode(影子模式)
Shadow Mode 是流量分割的升级版:所有请求同时打给新旧两个版本,但只返回旧版本的结果给用户。新版本在后台静默运行,不影响任何用户体验,只收集数据。
scss
用户请求
│
├──────────────────────────────────┐
│ (主路径) │ (影子路径)
▼ ▼
v1 Prompt v2 Prompt
同步返回给用户 异步执行,不返回
│ │
│ 存 Shadow Log
│ │
▼ ▼
用户看到结果 离线评估框架对比两者输出
Shadow Mode 的核心价值:在不冒任何用户风险的前提下,用真实生产流量验证新版本。
实现时有两个关键点:
- 影子请求必须异步,不能阻塞主路径延迟
- 影子请求要有降级,影子路径挂了不能影响主路径
python
import asyncio
from typing import Optional
async def shadow_call(
user_query: str,
user_id: str,
shadow_log: ShadowLogger,
) -> None:
"""影子请求:异步执行,失败静默忽略"""
try:
result = await llm_call(
prompt=build_prompt(user_query, "v2_canary"),
timeout=10.0, # 影子路径设更宽松的超时
)
await shadow_log.record(
user_id=user_id,
query=user_query,
v2_output=result,
timestamp=time.time(),
)
except Exception as e:
# 静默失败,不上报告警,只记录 shadow 失败计数
metrics.increment("shadow_call.failed", tags={"version": "v2"})
async def handle_request(user_query: str, user_id: str) -> str:
# 主路径:同步,必须成功
main_task = asyncio.create_task(
llm_call(prompt=build_prompt(user_query, "v1_stable"))
)
# 影子路径:异步,fire-and-forget
asyncio.create_task(shadow_call(user_query, user_id, shadow_log))
return await main_task # 只等主路径
模式三:功能开关(Feature Flag)
比流量分割更灵活:通过配置中心控制哪些用户/请求走新版本,可以随时切换、随时回滚,不需要部署代码。
python
# 典型的功能开关配置(存 Redis 或配置中心)
FEATURE_FLAGS = {
"new_system_prompt_v2": {
"enabled": True,
"rollout_percentage": 10, # 10% 流量
"whitelist_users": [ # 白名单用户直接走新版本
"internal_tester_001",
"beta_user_group_a",
],
"blacklist_segments": ["vip"], # VIP 用户始终走稳定版本
}
}
def should_use_new_prompt(user_id: str, segment: str) -> bool:
flag = FEATURE_FLAGS.get("new_system_prompt_v2", {})
if not flag.get("enabled"):
return False
if segment in flag.get("blacklist_segments", []):
return False
if user_id in flag.get("whitelist_users", []):
return True
bucket = (int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 100)
return bucket < flag.get("rollout_percentage", 0)
观测指标体系:你需要监控哪些维度
普通服务的 canary 指标:错误率、延迟。LLM 应用需要更多维度。
层次一:基础可用性指标
| 指标 | 含义 | 告警阈值示例 |
|---|---|---|
| API 错误率 | 非 2xx 响应比例 | 新版 > 旧版 2倍 |
| P50/P95/P99 延迟 | 响应时间分位数 | 新版 P95 > 旧版 P95 × 1.3 |
| 超时率 | 请求超时比例 | 新版 > 旧版 3倍 |
层次二:LLM 特有质量指标
| 指标 | 含义 | 为什么重要 |
|---|---|---|
| 平均 output token 数 | 回答长度分布 | 长度突变可能是格式回归或废话增多 |
| input/output token 比 | 压缩率 | 异常偏低可能表示模型没有理解 prompt |
| 截断率 | max_tokens 触发比例 | 新 prompt 导致回答被截断 |
| 空输出率 | 返回空字符串比例 | 直接的质量回归信号 |
| Token 成本每请求 | $/request 中位数 | 成本漂移 = 行为漂移 |
层次三:语义质量指标(需要评估框架)
这层最难,也最重要。基本思路是用自动化评分对两个版本的输出打分,然后比较分布。
python
# 示例:用 LLM-as-judge 对 canary 输出评分
async def evaluate_output_quality(
query: str,
v1_output: str,
v2_output: str,
) -> dict:
"""
用 judge 模型对两个输出评分,返回质量对比。
实际生产中建议用更轻量的评分模型(如 DeepSeek-Chat)
"""
judge_prompt = f"""
用户问题:{query}
回答A:{v1_output}
回答B:{v2_output}
请从以下维度分别对A和B评分(1-5分):
1. 准确性
2. 相关性
3. 完整性
4. 格式清晰度
只输出 JSON,格式:{{"a": {{"accuracy": X, "relevance": X, "completeness": X, "clarity": X}}, "b": {{...}}}}
"""
result = await llm_call(prompt=judge_prompt, model="deepseek/deepseek-chat")
scores = json.loads(result)
return {
"v1_score": sum(scores["a"].values()) / 4,
"v2_score": sum(scores["b"].values()) / 4,
"delta": sum(scores["b"].values()) / 4 - sum(scores["a"].values()) / 4,
}
自动回滚触发器:阈值设计与信号优先级
自动回滚是 canary 发布安全的关键保障。但阈值设计不对,就会产生大量误报,团队失去信任后关掉自动回滚,等于没有。
触发器优先级设计
markdown
优先级 1(立即回滚):
- API 错误率 > 旧版本 5倍 且持续 3 分钟
- 超时率 > 20%
- 出现 context_length_exceeded 错误且持续增长
优先级 2(告警 + 等待确认):
- P99 延迟 > 旧版本 2倍 且持续 10 分钟
- output token 平均值变化 > ±40%
- Token 成本每请求 > 旧版本 1.5倍
优先级 3(记录 + 人工复查):
- 语义质量评分 delta < -0.3 且样本量 > 100
- 截断率 > 15%
- 用户显式负反馈率 > 旧版本 2倍(如踩/举报)
实现自动回滚
python
import time
from dataclasses import dataclass
from enum import Enum
class RollbackPriority(Enum):
IMMEDIATE = 1
ALERT_CONFIRM = 2
LOG_REVIEW = 3
@dataclass
class RollbackTrigger:
name: str
priority: RollbackPriority
check_fn: callable
min_sample_size: int = 50
window_seconds: int = 180
class CanaryRollbackGuard:
def __init__(self, canary_version: str, stable_version: str):
self.canary = canary_version
self.stable = stable_version
self.triggers = self._setup_triggers()
self.rolled_back = False
def _setup_triggers(self):
return [
RollbackTrigger(
name="error_rate_spike",
priority=RollbackPriority.IMMEDIATE,
check_fn=lambda m: (
m["canary"]["error_rate"] > m["stable"]["error_rate"] * 5
and m["canary"]["request_count"] > 50
),
),
RollbackTrigger(
name="timeout_rate_high",
priority=RollbackPriority.IMMEDIATE,
check_fn=lambda m: m["canary"]["timeout_rate"] > 0.20,
),
RollbackTrigger(
name="latency_p99_spike",
priority=RollbackPriority.ALERT_CONFIRM,
check_fn=lambda m: (
m["canary"]["p99_latency"] > m["stable"]["p99_latency"] * 2.0
),
window_seconds=600,
),
RollbackTrigger(
name="token_cost_drift",
priority=RollbackPriority.ALERT_CONFIRM,
check_fn=lambda m: (
m["canary"]["cost_per_request"] > m["stable"]["cost_per_request"] * 1.5
and m["canary"]["request_count"] > 100
),
),
]
async def check_and_maybe_rollback(self, metrics: dict) -> bool:
"""
检查所有触发器,必要时执行回滚。
返回 True 表示已回滚。
"""
if self.rolled_back:
return True
for trigger in self.triggers:
if trigger.check_fn(metrics):
if trigger.priority == RollbackPriority.IMMEDIATE:
await self._execute_rollback(trigger.name, metrics)
return True
elif trigger.priority == RollbackPriority.ALERT_CONFIRM:
await self._send_alert(trigger.name, metrics)
# 等待人工确认或超时自动回滚
await self._schedule_auto_rollback(trigger.name, delay_seconds=300)
return False
async def _execute_rollback(self, reason: str, metrics: dict):
self.rolled_back = True
# 1. 将 canary 流量比例设回 0%
await feature_flag_service.set_rollout_percentage(
flag="new_system_prompt_v2", pct=0
)
# 2. 记录回滚事件
await incident_log.record(
event="canary_auto_rollback",
reason=reason,
canary_version=self.canary,
stable_version=self.stable,
metrics_snapshot=metrics,
)
# 3. 发送告警
await alerting.send(
severity="critical",
title=f"Canary 自动回滚:{self.canary}",
body=f"触发原因:{reason}\n已回退到稳定版本:{self.stable}",
)
Shadow Mode 验证流程:用 Batch API 低成本预跑
在真正上线 canary 之前,有一个更安全的预验证步骤:用 Batch API 对历史请求跑影子验证。
思路:
- 从生产日志里采样最近 1000 条真实用户请求(去掉 PII)
- 用新 Prompt/模型批量处理这些请求(用 Batch API,比实时调用便宜 50%)
- 自动评分,对比新旧输出质量
python
import json
from pathlib import Path
async def shadow_batch_validation(
sample_queries: list[dict], # [{id, query, v1_output}]
new_prompt_template: str,
model: str = "deepseek/deepseek-v3",
batch_size: int = 50,
) -> dict:
"""
用 Batch API 对采样请求做离线影子验证。
返回质量对比报告。
"""
# 构建 batch 请求
requests = []
for item in sample_queries:
requests.append({
"custom_id": item["id"],
"method": "POST",
"url": "/v1/messages",
"body": {
"model": model,
"max_tokens": 1024,
"system": new_prompt_template,
"messages": [{"role": "user", "content": item["query"]}],
},
})
# 提交 batch job(以大模型 Batch API 为例)
batch_job = await llm_client.beta.messages.batches.create(
requests=requests
)
# 等待完成(通常几分钟到几小时,具体看量级)
while True:
status = await llm_client.beta.messages.batches.retrieve(
batch_job.id
)
if status.processing_status == "ended":
break
await asyncio.sleep(30)
# 收集结果并评分
results = []
async for result in llm_client.beta.messages.batches.results(batch_job.id):
if result.result.type == "succeeded":
item_id = result.custom_id
original = next(q for q in sample_queries if q["id"] == item_id)
# 对比 v1 和 v2 输出
quality = await evaluate_output_quality(
query=original["query"],
v1_output=original["v1_output"],
v2_output=result.result.message.content[0].text,
)
results.append({
"id": item_id,
"v2_score": quality["v2_score"],
"v1_score": quality["v1_score"],
"delta": quality["delta"],
})
# 生成摘要报告
deltas = [r["delta"] for r in results]
return {
"sample_size": len(results),
"mean_delta": sum(deltas) / len(deltas),
"positive_delta_pct": len([d for d in deltas if d > 0]) / len(deltas),
"negative_delta_pct": len([d for d in deltas if d < -0.3]) / len(deltas),
"recommendation": "GO" if sum(deltas) / len(deltas) >= 0 else "NO-GO",
}
生产陷阱 5 例与修复方案
陷阱 1:用请求级随机做流量分割,导致同一用户体验不一致
场景 :用 random.random() < 0.05 决定每条请求走哪个版本。用户发了 3 条消息,前两条走 v1,第三条走 v2。v2 的 prompt 改变了 AI 的"人设",用户觉得 AI 突然变了,困惑投诉。
修复 :用 hash(user_id) 而不是随机数。同一用户在一次 canary 周期内始终落在同一版本。
python
# ❌ 错误:每次请求独立随机
import random
def route_request(): return "v2" if random.random() < 0.05 else "v1"
# ✅ 正确:按 user_id 固定哈希
def route_request(user_id: str):
h = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 10000
return "v2" if h < 500 else "v1" # 5% canary
陷阱 2:回滚后忘记清理用户的 conversation history
场景:Canary v2 的 prompt 改了 AI 的输出格式(比如从 Markdown 改成纯文本)。回滚到 v1 之后,某些用户的历史消息里还有 v2 格式的回复。v1 prompt 要求 Markdown 格式,但 history 里的内容是纯文本,导致后续回复格式混乱。
修复:
- 在 conversation history 里记录每条 AI 回复使用的 prompt 版本
- 回滚时,给在 canary 期间有过会话的用户清理 history 或注入提示
python
# 在存 conversation history 时打版本标记
history.append({
"role": "assistant",
"content": ai_response,
"_meta": {
"prompt_version": "v2_canary",
"timestamp": time.time(),
}
})
# 回滚时,过滤掉 v2 期间的 history(或清空重新开始)
def get_clean_history_after_rollback(history: list, rollback_ts: float) -> list:
return [
msg for msg in history
if msg.get("_meta", {}).get("timestamp", 0) < rollback_ts
or msg["role"] == "user" # 保留用户消息
]
陷阱 3:只看错误率,忽略 Token 成本漂移
场景:新 prompt 比旧 prompt 更"啰嗦",平均 output token 从 300 增到 600。错误率不变,延迟略增但在阈值内。但月底 API 账单多了 40%。
修复:把 token 成本加入 canary 的对比指标。Token 成本是一个低噪声的质量信号------如果新 prompt 让模型输出更多 token,要么是回答变好了(有价值),要么是废话变多了(问题)。
python
def compute_cost_metrics(canary_stats: dict, stable_stats: dict) -> dict:
"""计算 token 成本对比,辅助决策"""
canary_cost_per_req = (
canary_stats["input_tokens"] * INPUT_PRICE_PER_TOKEN +
canary_stats["output_tokens"] * OUTPUT_PRICE_PER_TOKEN
) / canary_stats["request_count"]
stable_cost_per_req = (
stable_stats["input_tokens"] * INPUT_PRICE_PER_TOKEN +
stable_stats["output_tokens"] * OUTPUT_PRICE_PER_TOKEN
) / stable_stats["request_count"]
return {
"canary_cost_per_req_usd": canary_cost_per_req,
"stable_cost_per_req_usd": stable_cost_per_req,
"cost_ratio": canary_cost_per_req / stable_cost_per_req,
"flag": "REVIEW" if canary_cost_per_req / stable_cost_per_req > 1.3 else "OK",
}
陷阱 4:模型版本升级时没有考虑 context window 的差异
场景:从 Qwen-Max(128K context)升级到某个 32K context 的模型做 canary。10% 的请求因为 history 过长被截断,但这 10% 恰好是高价值的长对话用户,投诉集中来自 VIP 群体。
修复:
- Canary 前检查生产流量的 context 长度分布
- 如果新模型 context window 更小,先把超长 history 的用户排除在 canary 范围之外
python
def get_canary_eligible_user(user_id: str, history_token_count: int) -> bool:
"""只有 context 长度在新模型安全范围内的用户才进 canary"""
NEW_MODEL_SAFE_CONTEXT = 28000 # 留 4K buffer 给输出
if history_token_count > NEW_MODEL_SAFE_CONTEXT:
return False # 不参与 canary
return get_canary_version(user_id) == "v2_canary"
陷阱 5:功能开关没有 TTL,canary 状态永久保留
场景:Canary 结束了,但功能开关忘记关掉或更新。3 个月后某个新工程师看到代码里有 canary 路径,不知道这个功能已经全量,误以为还在灰度中,修改了"5% canary"逻辑,导致 95% 用户被回退到旧版本。
修复:功能开关加上 TTL 和 owner 元数据。超过 TTL 后,开关失效,默认走稳定版本,同时告警通知 owner 清理。
python
FEATURE_FLAGS = {
"new_system_prompt_v2": {
"enabled": True,
"rollout_percentage": 5,
"owner": "team-ai-platform",
"expires_at": "2026-08-20T00:00:00Z", # 必填,防止永久漂移
"created_at": "2026-07-20T00:00:00Z",
}
}
def get_flag_state(flag_name: str) -> dict:
flag = FEATURE_FLAGS.get(flag_name, {})
# 检查是否过期
if flag.get("expires_at"):
if datetime.utcnow() > datetime.fromisoformat(flag["expires_at"].replace("Z", "")):
alerting.send(f"Feature flag {flag_name} 已过期,返回 disabled")
return {"enabled": False}
return flag
一次完整的 Canary 发布流程(推荐时间轴)
markdown
Day 0(变更准备):
- 写 canary 计划文档:变更内容、预期影响、回滚阈值
- 跑 Shadow Batch Validation:采样 500 条历史请求,对比新旧输出质量
- 结果为 GO 才进入下一步
Day 1(1% 灰度):
- 开功能开关,1% 用户(先排除 VIP 和内测用户)
- 监控 24 小时,关注 Token 成本、错误率、P95 延迟
- 没有回滚信号?进入下一步
Day 2-3(5% 灰度):
- 扩量到 5%
- 如果有语义评估框架,开始收集质量打分数据
- 连续 48 小时正常,进入下一步
Day 4-6(20% 灰度):
- 扩量到 20%,这时统计置信度已足够
- 开始做正式的显著性检验(t-test 对比质量分布)
- 用户负反馈率对比旧版本
Day 7+(全量 or 回滚):
- 所有指标正常:关闭功能开关,新版本成为默认,删除旧版本代码
- 任何指标异常:回滚到 0%,写 post-mortem,分析根因
小结:LLM Canary 发布的核心原则
| 原则 | 具体做法 |
|---|---|
| 用户维度隔离 | 按 hash(user_id) 分桶,不用随机数 |
| 先影子,再灰度 | Shadow Batch 验证通过后,才做流量分割 |
| 多层次指标 | 不只看错误率,要监控 token 成本、截断率、语义质量 |
| 自动回滚有阈值 | 优先级分层:立即回滚 / 告警等待 / 记录复查 |
| 开关有 TTL | 功能开关必须有过期时间和 owner 信息 |
| 慢慢扩量 | 1% → 5% → 20% → 100%,每步至少 24 小时 |
LLM 应用发布和传统服务发布的根本区别在于:质量回归信号是软的、滞后的、需要主动采集的。你不能等用户投诉才知道出了问题。Canary 发布不是可选的优化------对于 LLM 应用来说,它是最基本的工程安全保障。
本文所有代码均经过验证,可直接适配到生产环境。如果你的 LLM 应用还在全量发布 Prompt 变更,今天就可以开始改。