模型上线和代码上线最大的区别是:代码上线要么对要么错,模型上线只有"更好"和"更差",而且这个判断依赖流量、依赖分布、依赖你看不到的长尾。所以发布流程必须同时解决两个问题:上线前怎么建立信心,上线后怎么控制风险。前者靠离线评测,后者靠灰度与自动回滚,两者之间用同一套门禁指标连接。
离线与在线的闭环
各自能回答什么问题
| 维度 | 离线评测 | 在线评测 |
|---|---|---|
| 数据来源 | 人工标注的 golden set | 真实流量 |
| 覆盖范围 | 已知场景,可控 | 未知长尾,不可控 |
| 判断速度 | 分钟级 | 小时到天级 |
| 结论强度 | 弱,只说明"没退化" | 强,直接反映业务 |
| 主要风险 | 与真实分布偏离 | 影响真实用户 |
| 成本 | 低,可高频跑 | 高,需要流量与观测期 |
两者不是替代关系。离线的价值是快速筛掉明显退化的候选,让在线流量只用于验证真正有希望的变更。如果跳过离线直接灰度,你会把宝贵的流量浪费在明显不合格的版本上。
闭环的三个环节
完整的闭环是:候选变更进入离线评测,通过回归门禁后进入 shadow 阶段用真实流量做零风险验证,再进入 canary 阶段按比例放量,全程由同一套门禁指标判定,任一门禁不通过即自动回滚,回滚样本沉淀回 golden set 成为下一轮的回归用例。这个闭环的每一环都缺一不可,最常被省略的是最后一步,导致同一类问题反复出现。
三类变更都要走同一条流水线
模型升级、提示改动、推理引擎替换(比如从 vLLM 0.6.1 升到 0.6.3)是三种不同的变更,但风险模式相同:都可能引入质量回归、延迟变化、成本变化。因此应该共用一条流水线、共用一套门禁指标,只在门禁阈值上做差异化。把提示改动当成"配置修改"直接上线,是线上质量事故最常见的来源之一。关于提示版本化的细节,见 提示版本化与 A/B 。
离线评测
golden set 的构建
| 类型 | 规模建议 | 用途 | 更新频率 |
|---|---|---|---|
| 核心能力集 | 每维度 30 到 50 条 | 防退化,回归门禁主力 | 季度 |
| 边界集 | 80 到 150 条 | 测鲁棒性与异常输入 | 半年 |
| 对抗集 | 100 到 200 条 | 测注入与越狱 | 半年 |
| 线上坏例集 | 持续累积 | 防复发 | 每周 |
| 分布对齐集 | 300 到 500 条 | 贴近真实流量分布 | 月度 |
规模的关键不是总量,而是每个维度的最小样本量。低于 30 条时,评测结果的方差会大于变更带来的真实差异,你无法区分"随机波动"和"真实回归"。经验上,如果一个维度上的通过率变化小于 3 个百分点,且样本量小于 100,这个结论不应该作为门禁依据。
LLM-as-Judge 的可靠性
用强模型给弱模型的输出打分,是当前最实用的自动化质量评估方式,但它的可靠性有明确边界。
- 位置偏差:Judge 倾向于偏好第一个出现的答案。缓解方式是交换顺序各评一次,取平均
- 长度偏差:长答案平均得分更高,即使信息量相同。缓解方式是在评分提示里显式要求忽略长度
- 自我偏好:用同一个模型评自己的输出会偏乐观。缓解方式是 Judge 用不同家族的模型
- 分数漂移:Judge 模型本身升级会导致历史分数不可比。缓解方式是把 Judge 版本固定并记录在评测结果里
一个可用的配置是:Judge 用 GPT-4o 或 Claude 3.5 Sonnet,温度设 0,评分维度拆成正确性、完整性、格式合规三项,每项 1 到 5 分,报告三项的均值与方差。单看总分会把"格式对了但内容错了"和"内容对了但格式乱了"混为一谈。
回归门禁
离线门禁的判定逻辑应该简单且可解释,推荐三条:
- 质量分下降不超过 1.5 分(满分 5 分制)
- 任一核心维度的通过率下降不超过 3 个百分点
- 对抗集通过率不得下降,任何下降都直接拦截
第三条是硬门禁,因为安全类回归的代价远高于质量类回归。前两条是软门禁,允许在变更说明里给出理由后放行,但放行记录必须留痕。
评测的执行效率与成本
一次全量离线评测的成本经常被低估。500 条样本乘两个版本乘每个样本一次 Judge 调用,就是 1000 次 Judge 调用;如果 Judge 用的是长上下文强模型,单次成本可能到 0.05 美元,一轮就是 50 美元。按每天触发 20 次计算,月成本约 3 万美元,超过很多团队的生产推理成本。
| 策略 | 单轮成本 | 单轮耗时 | 拦截能力 | 适用阶段 |
|---|---|---|---|---|
| 全量加 Judge | 高 | 40 到 90 分钟 | 最强 | 最终候选 |
| 抽样 20% 加 Judge | 低 | 8 到 15 分钟 | 中 | 开发迭代 |
| 规则校验加小模型 Judge | 很低 | 2 到 5 分钟 | 弱 | 每次提交 |
| 只跑对抗集 | 低 | 5 到 10 分钟 | 安全向 | 硬门禁 |
推荐的分层策略是:每次提交跑规则校验与对抗集,快速拦截格式错误与安全问题;合并到发布分支时跑抽样评测;生成候选版本时跑全量评测。这样把昂贵的全量评测限制在少数几次,既控成本又保住拦截能力。
规则校验能覆盖的问题比想象中多:JSON 是否可解析、字段是否齐全、长度是否超限、是否包含禁用词、引用是否有效。这些问题用确定性代码检查,成本接近零,速度是 Judge 的几百倍,应该放在流水线最前面。
Judge 缓存与复用
同一个提示加同一个输出加同一个 Judge 版本,判定结果是确定的(温度设为 0)。因此可以对判定结果做缓存,键是提示哈希、输出哈希、Judge 版本的组合。当只改了提示的某个片段时,大量样本的判定结果可以直接命中缓存,把成本压到原来的几分之一。
在线评测
A/B 实验
A/B 是把流量随机分成对照组和实验组,用业务指标判定优劣。它回答的是"哪个更好",而不是"新的有没有坏"。做 A/B 需要三件事:稳定的分流(同一用户始终进同一组)、足够的样本量、明确的成功指标。
样本量估算可以直接用两比例检验的近似公式:每组样本量约等于 16 乘 p 乘 (1 减 p) 除以 delta 的平方,其中 p 是基线通过率,delta 是你要检测的最小差异。若基线通过率 0.85,想检测 2 个百分点的提升,每组需要约 16 乘 0.85 乘 0.15 除以 0.0004,约 5100 个样本。低于这个量级,实验结论不可信。
"""ab_size.py A/B 样本量与观测期估算,Python 3.12"""
import math
def sample_size_per_group(p: float, delta: float,
alpha: float = 0.05,
power: float = 0.80) -> int:
"""两比例检验的样本量估算
p 基线指标(如通过率)
delta 想检测的最小绝对差异
alpha 第一类错误率,默认 0.05
power 检验功效,默认 0.80
"""
z_alpha = {0.10: 1.645, 0.05: 1.960, 0.01: 2.576}[alpha]
z_beta = {0.80: 0.842, 0.90: 1.282, 0.95: 1.645}[power]
numerator = (z_alpha + z_beta) ** 2 * 2 * p * (1 - p)
return math.ceil(numerator / (delta ** 2))
def observation_hours(per_group: int, traffic_share: float, qps: float) -> float:
"""估算观测所需小时数,traffic_share 为分给实验组的流量占比"""
effective_qps = qps * traffic_share
return per_group / effective_qps / 3600
if __name__ == "__main__":
for delta in (0.01, 0.02, 0.03, 0.05):
n = sample_size_per_group(p=0.85, delta=delta)
hours = observation_hours(n, traffic_share=0.10, qps=50)
print(f"检测 {delta:.0%} 差异: 每组 {n:>6} 样本, "
f"10% 流量下需 {hours:.1f} 小时")
这个计算会暴露一个常见误区:想检测 1 个百分点的差异,每组需要 2 万个样本,10% 流量下要跑 11 小时。而业务通常等不了 11 小时。所以现实的取舍是只检测 3 个百分点以上的差异,更小的差异用离线评测去判断,不要浪费线上流量。
观测窗口与统计口径
| 口径问题 | 错误做法 | 正确做法 | 后果 |
|---|---|---|---|
| 时间对齐 | 新旧版本取不同时段 | 同一时间窗并行对比 | 把时段差异当成版本差异 |
| 用户对齐 | 按请求随机分流 | 按用户或会话稳定分流 | 同一用户跨组体验不一致 |
| 样本过滤 | 排除失败请求 | 失败请求计入分母 | 质量分虚高 |
| 极值处理 | 直接取均值 | 用 P50 与 P95 分位数 | 被长尾拖偏 |
| 多重比较 | 看 20 个指标取最好 | 预注册主指标 | 假阳性 |
| 提前停止 | 看到显著就停 | 达到预定样本量再判 | 放大假阳性 |
其中"提前停止"是最隐蔽的一个。持续偷看指标,一旦显著就宣布胜利,会让假阳性率从 5% 涨到 20% 以上。正确做法是预先声明样本量与观测时长,到点再判。
shadow 影子流量
shadow 把生产流量复制一份发给新版本,但只用旧版本的结果返回给用户。它的价值是零风险:新版本的崩溃、超时、格式错误都不会影响用户,同时你能拿到真实分布下的质量与性能数据。
shadow 的代价是资源翻倍。一个每秒 200 请求的服务,shadow 意味着额外 200 请求的算力。常用做法是只复制 10% 到 30% 的流量,并按采样保留结果用于对比。另外要注意 shadow 请求不能有副作用,涉及写操作的工具调用必须被 mock 掉,否则会污染生产数据。
canary 灰度
canary 是把小比例的真实流量切到新版本,用户能感知结果。它与 shadow 的区别是:shadow 测的是"新版本会不会崩",canary 测的是"新版本用户会不会不满"。
| 方式 | 用户感知 | 资源成本 | 能测什么 | 主要风险 |
|---|---|---|---|---|
| shadow | 无 | 高,需额外算力 | 稳定性、延迟、离线质量 | 无法测业务指标 |
| canary | 有 | 低,共享容量 | 稳定性加业务指标 | 小比例用户受影响 |
| A/B | 有 | 中,长期并行 | 业务指标与因果 | 需要长观测期 |
灰度发布流程
六阶段放量
| 阶段 | 流量比例 | 最短时长 | 观测指标 | 回滚阈值 |
|---|---|---|---|---|
| shadow | 0%,复制 10% | 2 小时 | 错误率、P95 延迟、格式合规率 | 错误率大于 3% |
| canary-1 | 1% | 30 分钟 | 上述全部加质量分 | 错误率大于 2% |
| canary-5 | 5% | 2 小时 | 加业务指标、成本 | 质量分降幅大于 2 分 |
| canary-25 | 25% | 6 小时 | 加 P99 延迟、token 分布 | P95 超过基线 1.3 倍 |
| canary-50 | 50% | 12 小时 | 全指标 | 任一硬门禁不通过 |
| full | 100% | 持续 | 全指标加长尾监控 | 回到 50% 观察 |
时长设计的原则是覆盖至少一个完整业务周期。面向企业的服务至少覆盖一个工作日,面向消费的服务至少覆盖一个晚间高峰。1% 阶段只跑 30 分钟,是因为这个阶段的目标是"快速发现崩溃",而不是"确认质量",用短时长换取快速失败。
门禁判定
门禁判定必须自动化,人工判断会引入"这次应该没问题"的乐观偏差。下面是一个可运行的判定脚本,输入是新旧版本在同一时间窗内的指标。
"""gate.py canary 门禁判定,Python 3.12"""
from dataclasses import dataclass
@dataclass
class Metrics:
"""同一时间窗内的聚合指标"""
quality_score: float # 质量分,0 到 5
p95_latency_ms: float # P95 端到端延迟
error_rate: float # 错误率,0 到 1
cost_per_request: float # 每请求成本,美元
sample_size: int # 该窗口内的请求数
@dataclass
class Gate:
"""门禁阈值,均以相对基线的容忍度表达"""
max_quality_drop: float = 1.5 # 质量分最大允许下降
max_latency_ratio: float = 1.15 # P95 最大允许倍数
max_error_rate: float = 0.01 # 错误率绝对上限
max_cost_ratio: float = 1.20 # 每请求成本最大允许倍数
min_samples: int = 2000 # 最小样本量
def evaluate(baseline: Metrics, candidate: Metrics, gate: Gate) -> dict:
checks = []
if candidate.sample_size < gate.min_samples:
return {"decision": "hold", "reason": "样本量不足",
"sample_size": candidate.sample_size}
q_drop = baseline.quality_score - candidate.quality_score
checks.append({
"name": "quality",
"ok": q_drop <= gate.max_quality_drop,
"detail": f"质量分下降 {q_drop:.2f}",
})
lat_ratio = candidate.p95_latency_ms / baseline.p95_latency_ms
checks.append({
"name": "latency",
"ok": lat_ratio <= gate.max_latency_ratio,
"detail": f"P95 倍数 {lat_ratio:.2f}",
})
checks.append({
"name": "error_rate",
"ok": candidate.error_rate <= gate.max_error_rate,
"detail": f"错误率 {candidate.error_rate:.2%}",
})
cost_ratio = candidate.cost_per_request / baseline.cost_per_request
checks.append({
"name": "cost",
"ok": cost_ratio <= gate.max_cost_ratio,
"detail": f"成本倍数 {cost_ratio:.2f}",
})
failed = [c for c in checks if not c["ok"]]
if failed:
return {"decision": "rollback", "failed": failed, "checks": checks}
return {"decision": "promote", "checks": checks}
if __name__ == "__main__":
baseline = Metrics(4.32, 1840.0, 0.004, 0.0021, 52000)
candidate = Metrics(4.05, 2210.0, 0.006, 0.0026, 54000)
result = evaluate(baseline, candidate, Gate())
print(result["decision"])
for c in result.get("checks", []):
print(f" {c['name']:<10} {'PASS' if c['ok'] else 'FAIL'} {c['detail']}")
这个例子会输出 rollback:质量分下降 0.27 在容忍范围内,P95 倍数 1.20 超过 1.15 的阈值,成本倍数 1.24 也超过 1.20。两个失败项都指向同一个原因——新版本更慢更贵,通常是上下文变长或批处理退化。
自动回滚条件
回滚必须区分"立即回滚"和"观察后回滚"两类。
立即回滚的条件是那些不需要统计显著性的硬故障:
- 错误率连续 5 分钟超过 2%
- P95 延迟超过基线的 2 倍且持续 5 分钟
- 出现新的异常类型,例如工具调用格式解析失败率突增
- 安全类拦截率异常上升,或对抗集在线采样出现越狱成功
观察后回滚的条件是需要样本量的统计判断:
- 质量分在 30 分钟窗口内下降超过 2 分
- 每请求成本上升超过 25% 且持续 1 小时
- 用户主动重试率上升超过 50%
自动回滚的实现要点是回滚动作必须幂等且快速。把流量比例切回旧版本应该是一个配置变更,而不是一次重新部署。用服务网格或网关的权重字段控制流量,能在秒级完成回滚。关于弹性与副本调整,见 Kubernetes 服务化与自动扩缩容 。
三类变更的差异化策略
| 变更类型 | 离线门禁 | shadow 必要性 | canary 起点 | 回滚优先级 |
|---|---|---|---|---|
| 模型版本升级 | 全量跑,硬门禁 | 必须 | 1% | 高 |
| 提示改动 | 全量跑,硬门禁 | 建议 | 5% | 中 |
| 推理引擎升级 | 性能集加质量集 | 必须 | 1% | 高 |
| 参数调整(温度等) | 抽样跑 | 建议 | 5% | 低 |
| 检索策略调整 | 全量跑加检索指标 | 建议 | 5% | 中 |
推理引擎升级值得特别说明:它通常不改变输出分布,但会改变延迟与吞吐的尾部行为。所以引擎升级的门禁应该以性能指标为主、质量指标为辅,并且必须做 shadow,因为引擎问题往往在低比例流量下才能暴露。
门禁阈值的动态调整
固定阈值会在服务规模变化后失效。请求量从每天 1 万涨到 100 万时,同样的错误率绝对阈值对应的容忍请求数涨了 100 倍,门禁会变得形同虚设。更稳健的做法是把阈值表达成相对于基线的倍数,并用滚动窗口的基线(比如过去 7 天的同指标)而不是固定值。同时保留绝对上限,防止基线本身已经恶化时门禁跟着放宽。
变更评审清单
上线前必须回答的问题
任何一次灰度放量之前,变更负责人都应该能回答下面这些问题。回答不了的项,就是流程里缺失的环节。
- 这次变更的目标是什么,成功指标是哪一个
- 离线评测跑了哪些集合,哪一项是硬门禁
- 是否只变更了一个维度,如果没有,怎么归因
- shadow 阶段的观测时长与样本量是多少
- 每个灰度阶段的门禁阈值分别是多少
- 自动回滚的条件是什么,回滚动作需要多久
- 回滚之后谁来分析失败样本,什么时候完成
变更记录的最小字段
| 字段 | 示例 | 用途 |
|---|---|---|
change_id | chg-20261004-017 | 唯一标识,串起评测与灰度 |
change_type | model、prompt、engine | 差异化策略 |
baseline_version | v1.8.2 / prompt v41 | 对照组定位 |
candidate_version | v1.9.0 / prompt v42 | 实验组定位 |
offline_result | quality 4.28 到 4.31 | 离线结论 |
gate_profile | strict、normal | 阈值档位 |
rollout_stage | canary-25 | 当前阶段 |
rollback_reason | latency_ratio 1.34 | 回滚归因 |
这份记录的价值在事故复盘时体现。当线上出现问题时,你能在三分钟内回答"最近七天有几次变更、每次变更了什么、当时哪些指标越过了阈值",而不是靠翻聊天记录。
发布流水线
Kubernetes canary 配置
下面的 YAML 用 Istio 的流量权重实现灰度,权重值由上面的门禁脚本输出驱动。
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: llm-gateway
namespace: inference
spec:
hosts:
- llm-gateway.inference.svc.cluster.local
http:
- match:
- headers:
x-canary:
exact: "true" # 内部白名单流量全量进新版本
route:
- destination:
host: llm-gateway
subset: v2
- route:
- destination:
host: llm-gateway
subset: v1
weight: 95 # 灰度阶段由流水线写入
- destination:
host: llm-gateway
subset: v2
weight: 5
timeout: 30s
retries:
attempts: 2
perTryTimeout: 12s
retryOn: 5xx,reset,connect-failure
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-gateway-v2
namespace: inference
labels:
app: llm-gateway
version: v2
model.prompt.version: "v42" # 提示版本随部署固化,便于归因
spec:
replicas: 2
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
containers:
- name: gateway
image: registry.internal/llm-gateway:2.4.0
env:
- name: MODEL_VERSION
value: "gpt-4o-2024-08-06"
- name: PROMPT_VERSION
value: "v42"
- name: MAX_INPUT_TOKENS
value: "8000"
resources:
requests:
cpu: "2"
memory: 4Gi
limits:
cpu: "4"
memory: 8Gi
权重值不要手改,由流水线根据门禁结果写入。每一次权重变更都要记录到变更日志,包含触发者、时间、当时的门禁快照。没有这份日志,事后复盘时你无法回答"5% 阶段到底观测了多久"。
流水线的整体形状
一条完整的流水线是:提交候选变更,触发离线评测,跑门禁,通过后部署 v2 但权重为 0,进入 shadow 对比,shadow 通过后按阶段提升权重,每阶段结束时自动判定,全部通过后固化权重为 100 并下线 v1。任一阶段失败则权重归零并把失败样本写入 golden set。整个过程除了最初的人工审批,其余都应该自动完成。
常见坑清单
离线好线上差
最常见的失败模式是离线涨了 0.3 分,线上一周后质量投诉增加。原因通常是分布偏离:golden set 里的问题太干净,真实流量里有大量错别字、多语言混用、超长上下文、以及不完整的追问。缓解方式是把线上采样样本持续注入 golden set,并让分布对齐集的更新频率保持月度。
灰度期样本不足
1% 流量跑 30 分钟,如果服务 QPS 只有 5,那么样本量只有 90 个。这个量级下任何指标变化都是噪声。门禁脚本里的 min_samples 检查就是为这种情况准备的:样本不足时应该输出 hold 而不是 promote,同时延长观测期或提高起始比例。
模型版本与提示版本耦合
模型升级和提示改动同时上线,一旦质量下降就无法归因。规则是一次只变更一个维度,如果必须同时上,就拆成两个独立的实验组,各自有对照组。同样的问题出现在"提示改了两处"的场景,应该拆成两次变更。
回滚不及时
回滚慢的常见原因有三个:流量切换需要重新部署而不是改配置、回滚需要人工审批、以及没有人盯着看板。前两个是工程问题,第三个是流程问题。解决办法是给每个灰度阶段配一个明确的"值班负责人",并让自动回滚在硬故障上不需要人工确认。
其余高频问题
- 用不同时间段的新旧版本指标做对比,把时段差异误判为版本差异
- shadow 流量带有写副作用,污染了生产数据
- Judge 模型随供应商静默升级,导致历史分数不可比
- 门禁阈值长期不变,服务规模变化后阈值早已失去意义
- 灰度通过后没有把权重固化,配置漂移导致某天流量比例莫名其妙变化
- 只在成功请求上统计质量分,失败请求被排除,质量分虚高
- 回滚后没有分析失败样本,同一问题在下一个版本再次出现
- 把 A/B 实验当成发布流程,用长期并行的方式做灰度,浪费了一半容量
- 观测窗口跨越了业务低峰期,把低负载下的良好表现当成版本优势
小结
评测与发布是一个闭环,不是两个独立流程。离线评测负责快速筛除,在线灰度负责风险控制,两者共用同一套门禁指标,失败样本回流到 golden set 形成正反馈。灰度的六阶段设计要点是:shadow 阶段零风险验证稳定性,1% 阶段快速失败,5% 与 25% 阶段验证质量与业务指标,50% 与 100% 阶段确认长尾。门禁必须自动化,硬故障立即回滚,统计类判断在足够样本量下判定。三类变更走同一条流水线,只在阈值上差异化。最后,发布流程的质量取决于最弱的一环,通常不是模型,而是样本量与观测时长。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。