模型评测与灰度发布

本文解决模型、提示、推理引擎三类变更缺乏统一发布流程的问题:建立离线评测(golden set、LLM-as-Judge、回归门禁)与在线评测(A/B、shadow、canary)闭环,给出 shadow 到 1%、5%、25%、50%、100% 的六阶段灰度流程,明确每阶段流量比例、观测时长、门禁指标与回滚阈值,并给出可运行的 Python 门禁判定脚本、canary YAML 与自动回滚条件。

模型上线和代码上线最大的区别是:代码上线要么对要么错,模型上线只有"更好"和"更差",而且这个判断依赖流量、依赖分布、依赖你看不到的长尾。所以发布流程必须同时解决两个问题:上线前怎么建立信心,上线后怎么控制风险。前者靠离线评测,后者靠灰度与自动回滚,两者之间用同一套门禁指标连接。

离线与在线的闭环

各自能回答什么问题

维度离线评测在线评测
数据来源人工标注的 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有中,长期并行业务指标与因果需要长观测期

灰度发布流程

六阶段放量

阶段流量比例最短时长观测指标回滚阈值
shadow0%,复制 10%2 小时错误率、P95 延迟、格式合规率错误率大于 3%
canary-11%30 分钟上述全部加质量分错误率大于 2%
canary-55%2 小时加业务指标、成本质量分降幅大于 2 分
canary-2525%6 小时加 P99 延迟、token 分布P95 超过基线 1.3 倍
canary-5050%12 小时全指标任一硬门禁不通过
full100%持续全指标加长尾监控回到 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_idchg-20261004-017唯一标识,串起评测与灰度
change_typemodel、prompt、engine差异化策略
baseline_versionv1.8.2 / prompt v41对照组定位
candidate_versionv1.9.0 / prompt v42实验组定位
offline_resultquality 4.28 到 4.31离线结论
gate_profilestrict、normal阈值档位
rollout_stagecanary-25当前阶段
rollback_reasonlatency_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% 阶段确认长尾。门禁必须自动化,硬故障立即回滚,统计类判断在足够样本量下判定。三类变更走同一条流水线,只在阈值上差异化。最后,发布流程的质量取决于最弱的一环,通常不是模型,而是样本量与观测时长。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLMOps」更多文章

  1. 语义缓存与 Prompt 缓存
  2. 结构化输出与函数调用
  3. 多智能体编排与工作流引擎