换模型也要灰度:10% → 50% → 100%,每一档只看三个指标

0 阅读5分钟

十月中旬,我们要把合同摘要服务的主模型从 M1 换成 M2。M2 便宜 40%,离线评测准确率还高了 1.5 个点。

换模型这件事,团队之前做过一次,方式是周五晚上直接改配置。周一早上法务部来了三个人,说摘要里的金额单位全错了——M2 在某类合同上会把「万元」写成「元」。那次回滚花了半天,返工了两百多份摘要。

这一次,我们按发布普通服务的方式做了灰度:10%、50%、100% 三档,每档跑够时间、看够指标再往下走。前后九天,期间发现并修掉了两个离线评测没测出来的问题。

一、为什么模型切换必须灰度

离线评测再全,也只是一个固定的测试集。线上有三样东西是测试集没有的:

  1. 真实的输入分布。 我们的测试集 600 份合同,线上一周就有 4,000 多份,类型更杂。
  2. 真实的负载。 新模型在高并发下的延迟、限流表现,离线测不出来。
  3. 下游的反应。 摘要格式变一点点,下游的解析脚本、人的阅读习惯都可能出问题。

二、路由怎么做

最简单的做法:按请求 ID 的哈希分流,保证同一个请求每次都落到同一个模型。

import os, hashlib
from openai import OpenAI

client = OpenAI(base_url="https://api.example.com/v1",  # 换成你自己的 OpenAI 兼容地址
                api_key=os.environ["API_KEY"])
CANARY = int(os.environ.get("CANARY_PERCENT", "0"))  # 0 / 10 / 50 / 100

def pick_model(doc_id: str) -> str:
    bucket = int(hashlib.md5(doc_id.encode()).hexdigest(), 16) % 100
    return "model-m2" if bucket < CANARY else "model-m1"

def summarize(doc_id: str, text: str) -> dict:
    model = pick_model(doc_id)
    r = client.chat.completions.create(model=model, temperature=0,
                                       messages=[{"role": "system", "content": PROMPT},
                                                 {"role": "user", "content": text}])
    log_metrics(doc_id, model, r.usage)   # 每条请求记下模型名,后面按模型拆指标
    return {"model": model, "summary": r.choices[0].message.content}

两个要点:

  • 灰度比例放在环境变量里,改比例不用发版,回滚只需要把它改回 0。
  • 每条结果都记下是哪个模型生成的。 不记的话,出了问题你没法知道是哪边的锅。

三、每一档只看三个指标

指标不是越多越好。我们每一档只盯三个,任何一个越线就停:

指标怎么算越线条件
质量:人工抽检通过率每天随机抽 30 份,两个人独立判断比 M1 低 3 个点以上
延迟:首字 p95按模型分别统计超过 M1 的 1.3 倍
下游:解析失败率下游脚本解析摘要的失败比例超过 0.5%

注意第三个。模型切换最容易出问题的地方,往往不在模型本身,在下游。

四、九天里发生了什么

图_1 第_1–3_天:M1_vs_M2_首字延迟_p50_p95_p99.png

第 1–3 天:10%

指标M1M2
抽检通过率93%94%
首字 p50 / p95 / p990.9 / 2.1 / 3.8 秒0.8 / 1.9 / 4.6 秒
解析失败率0.1%24%

第三个指标越线了。查下来是 M2 输出的日期格式不一样:M1 写「2026年10月15日」,M2 写「2026-10-15」。下游脚本只认前一种,凡是摘要里带日期字段的都解析失败——大约四份里有一份。

这就是那种离线评测不可能测出来的问题——评测只看内容对不对,不看格式和以前一不一样。

修法:在提示词里明确日期格式,同时让下游脚本两种都认。第三天修完,解析失败率回到 0.1%。

第 4–6 天:50%

量上来之后,第二个问题出现了:M2 的 p99 在下午高峰会冲到 7 秒以上,p95 也到了 2.9 秒,超过 M1 的 1.3 倍。原因是我们这个账户在 M2 上的并发额度比 M1 低,高峰期排队了。

修法:申请提高 M2 的并发额度,同时在路由里加一条——M2 返回 429 时自动降级到 M1。第六天额度生效,p95 回到 2.0 秒。

第 7–9 天:100%

三个指标全部正常,M1 降为降级备用。

图_2 日期格式问题:灰度_vs_直接全量的影响面.png

五、算一下灰度值不值

灰度九天,比直接切多花了大约 1.5 个人天的盯盘时间。

换来的是:两个问题都在只影响 10% 和 50% 流量的时候被发现。解析失败的那个问题,三天 10% 流量里一共有 41 份合同中招(约 171 份 × 24%);如果直接全量切,三天会是约 1,710 份 × 24% ≈ 410 份。

上次直接切换返工了两百多份。这次 41 份,而且都是下游脚本自动重试修好的,法务部没有感知。

切换完成后,月度模型费用从 3,300 元降到 2,000 元,降了 39%。

六、几条可以直接用的

  1. 灰度比例做成配置,不做成代码。 回滚要能在一分钟内完成。
  2. 每条结果记模型名。 这是事后拆指标的前提。
  3. 指标只看三个:质量、延迟、下游。 下游最容易被忽略,也最容易出事。
  4. 延迟看 p95 和 p99,别看平均。 这次 M2 的 p50 比 M1 还快,问题全在尾部。
  5. 新模型的限流额度要提前确认。 它和老模型的额度往往是分开算的。
  6. 留一条自动降级路径。 新模型出问题时自动回到老模型,比人工回滚快得多。

这次能做灰度,前提是两个模型能用同一套代码调。我们走的是词元云(tokenfreeai.com)的 OpenAI 兼容地址,两个模型只差一个 model 字段,路由逻辑就是上面那十几行。如果两个模型来自不同厂商、SDK 不一样,灰度会麻烦很多——这也是我们后来坚持所有模型都走兼容接口的原因。