Skip to content

模型升级与微调回归测试

2026 年企业 LLM 系统平均每 90 天就要面对一次"模型变更"——可能是 OpenAI 把默认模型从 GPT-5 升到 GPT-5.1,可能是内部 LoRA 重训了一版,也可能是 Anthropic 把 Claude Opus 4.5 替换成 4.6。每一次变更都可能让线上某条路径"沉默退化":表面正常,关键能力却已经悄悄崩了。这一篇把"模型升级"和"微调回归"放在一起讲,因为它们的工程方法论是同一套:能力地图 + 分层回归 + Champion/Challenger + 影子流量 + 版本签名。

教学导读

**定位:**这一章解决"模型变了,我怎么知道哪些场景退化了"的核心运维问题。它和第 43 篇《LLM 评测科学与 Judge 校准》互补——前者讲"怎么打分",本篇讲"打完分以后怎么对比新旧版本,并卡住有问题的版本"。 **前置依赖:**建议已读 第 43 篇 LLM 评测科学 中的 Bootstrap CI、第 46 篇 偏见与公平性测试 中的 Slice 评测,以及对 PEFT/LoRA 有基本认知。 **适用场景:**任何一个"模型不止跑一次"的系统:API 接入的应用要升级供应商版本,自训模型要做迭代,私有 LoRA 要每周重训,多模型路由策略需要切换主模型。 学完产出:你能搭出一套能力地图 + 回归集 + Shadow Traffic + Champion/Challenger 的完整框架,让每次模型变更都能在 30 分钟内拿到可发版结论。

第1章:模型版本切换的"沉默退化"

1.1 一个真实的、能让人睡不着觉的故事

2025 年 8 月,某金融科技公司的合同审查 Bot 接入的是 GPT-4o。OpenAI 在 8 月 7 日把 ChatGPT 的默认模型替换为 GPT-5,但 API 上 gpt-4o 这个别名其实没立即下线——他们的 SRE 也确认过,这一周没有任何模型版本变更。 两周后,业务侧反馈"合同关键条款抽取经常漏掉'连带责任'四个字"。QA 复盘时才发现:他们用的不是 gpt-4o,而是几个月前为了省钱切到了 gpt-4o-mini,而 OpenAI 在 8 月底把 gpt-4o-mini 的底层 snapshot 静默升级了一次(model card 里只是 minor 版本号变了一位)。新 snapshot 在通用 benchmark 上分数更高,但在**"中文长文本 + 法律实体抽取"**这个细分能力上做了 trade-off,召回率从 96% 降到 89%。 没有任何报警触发,因为:

  • 整体调用成功率(HTTP 200)没变化
  • 响应延迟在 SLO 内
  • 用户满意度评分按分钟聚合也没显著下降
  • 线上没有"参考答案"可以对比,所以没法算端到端准确率

这次事故的代价是:约 4500 份合同二次复核、一名律师离职、首次出现"客户因合同瑕疵向客户经理索赔"的工单。事故根因写在 RCA 里只有一行话——"未对供应商默认模型变更建立细粒度回归门控"

"沉默退化"的定义。

模型版本变更后,系统级指标(QPS、P99、HTTP 成功率、用户评分)没有任何异常,但**某个细分能力(细分子任务、特定语言、特定领域、特定输入长度)**的质量出现了显著下降。"沉默"是指它不会触发任何监控告警;"退化"是指它确实损害了某类用户的体验。

1.2 2026 年模型升级的真实路径

"模型升级"在 2026 年已经不是一年一次的大新闻,而是每个月都在发生的常态。下面是这一年到目前为止你必须了解的几条真实演进路径:

厂商2025 年起的版本演进升级语义对回归测试的暗示
OpenAIGPT-4o(2024-05)→ GPT-5(2025-08)→ GPT-5.1(2026-02)→ GPT-5.4(2026-04)→ GPT-5.5(2026-04-23)→ GPT-5.6 Sol/Terra/Luna(2026-06~07)大版本切换 + 月度 snapshot 滚动必须在 API 层 pin 死 -2026-06 这种带日期的 snapshot,GPT-5.6 起按 Sol/Terra/Luna 三档切能力档
AnthropicClaude 3.5 Sonnet(2024-06)→ Claude 4 Opus(2025-05)→ Claude Opus 4.6(2026-03)→ Opus 4.7 / Fable 5(2026-Q3 灰度中)Reasoning 默认开启 + tool-use 大改升级后 tool-use 行为差异最大,必须重跑 agent 评测集
GoogleGemini 2.5 Pro(2025-03)→ Gemini 3 Pro(2026-01)→ Gemini 3 Ultra(2026-03)→ Gemini 3.x(2026-07 灰度中)多模态全面对齐 + 200 万 context长上下文召回测试需要重新设计
DeepSeekDeepSeek-V3(2024-12)→ V3.5(2025-09)→ R2(2026-02)→ V4(2026-07)R 系列把推理能力分到独立模型"用哪一版"成为路由问题,要测 router 而不是单模型
Qwen / Baichuan / DoubaoQwen3 Max(2025-09)→ Qwen3.5 Max(2026-03)→ Qwen3.6(2026-Q3);Doubao 2.0(2026-01)→ Doubao 2.5(2026-06)国内厂商月度迭代必须建立"国内厂商专用回归集",因为它们对中文细分能力变化最敏感

1.3 "升级"和"微调"为什么放一起讲

很多团队把"换供应商版本"和"自己做微调"看作两件事——前者是"被动升级",后者是"主动改动"。但从测试视角,它们是同一回事:模型权重发生了变化,原来的能力分布不再可信

升级 = 被动微调

厂商把模型从 GPT-5 切到 GPT-5.1,对你来说就是"有人在你不知情的情况下做了一次大规模微调"。你不知道训练数据,不知道 RLHF 偏好,只知道结果可能不同。

微调 = 主动升级

你给 base model(如 Llama 3.3 70B)做了 LoRA SFT,本质上是把这个模型升级到了"v1 + 你的 adapter"。原来 base 上能跑的能力,可能因为你的 SFT 数据分布偏窄而退化。 所以本章给出的回归方法论,对两种场景都适用。差别只是:升级你只能黑盒测,微调你可以同时看权重 diff、loss 曲线和评测分数。

1.4 测试团队的角色重定义

过去测试团队对"模型升级"参与度很低——这是模型团队/算法团队的事。2026 年这个分工已经不成立了,理由有三:

  1. 变更频率太高:每月一次的 minor 升级、每周一次的 LoRA 重训、随时可能的供应商 snapshot 滚动。算法团队没精力对每次变更做完整回归。
  2. 能力多维化:一个生产系统涉及的能力可能有几十种(写作、改写、抽取、规划、调用工具、多语言切换、安全拒答……),算法团队的离线 benchmark 覆盖不到业务长尾。
  3. 线上对比是黑盒:升级是黑盒事件,唯一的判定方式就是跑你自己的回归集。能跑回归集、能解读结果、能给出发版结论的,是测试团队。

第2章:微调引发的能力遗忘

2.1 微调的"美好假设"和"残酷现实"

当算法团队跟你说"我们 SFT 了一版,业务指标涨了 8 个点"时,你应该问的不是"涨了多少",而是"掉了多少"。 微调的美好假设是:在垂类数据上继续训练,模型在垂类任务上表现更好,而其他能力保持不变。残酷现实是:

  • SFT 用的数据分布比 base 训练数据窄得多(通常只覆盖 1~3 类任务)。
  • 梯度下降会"擦掉"那些没有出现在 SFT 数据里的旧分布的细节。
  • 结果是:垂类涨 8 分 / 通用降 12 分是常见配比,只是大家通常只汇报垂类。

2.2 一个让人警觉的最小例子

假设你有一个 base 模型(Qwen3-7B-Instruct),它在 MMLU 上 70 分,在 HumanEval 上 60 分,在你的"客户工单分类"业务集上 78 分。算法同学拿了 5000 条工单做了 SFT,新模型在工单分类上达到了 91 分(涨 13 分)。看起来很好,但你跑个回归发现:

能力维度Base 模型SFT 微调后Δ
工单分类(业务)7891+13
MMLU 通用知识70.262.5-7.7
HumanEval 代码60.134.6-25.5
GSM8K 数学72.458.0-14.4
多轮对话连贯性(人评)4.2/53.1/5-1.1
JSON 格式遵循96%71%-25%

如果这个模型只用在"工单分类"一个场景,没问题。如果它还要被用在"工单后续追问的多轮对话"和"工单结构化抽取(JSON 输出)",那就是大灾难:你刚刚把一个 80 分的全能选手培养成了一个 90 分专精 + 各项偏科严重的高三复读生。

测试人员的核心追问。

每次算法团队提交"新版微调模型",强制必须回答以下三个问题,否则不予部署:

  1. 这一版相对上一版,核心业务能力涨了多少(含 95% CI)?
  2. 这一版相对上一版,**非业务能力(通用、推理、代码、JSON、多轮)**掉了多少?
  3. 掉的部分中,有没有任何一项跌破 release blocker 阈值? 这三问就是"微调回归测试"的核心 SLA。

2.3 微调 ≠ 总是变好

2025-2026 年开源圈一个被反复验证的结论:**对一个已经经过良好对齐的强 base 模型做 SFT,更可能把它变差,而不是变好。**这背后有几个机制性原因:

  • 强 base 已经在 RLHF 阶段被精细对齐过(拒答、安全、风格、多轮),SFT 会把这些细节冲掉。
  • 你的 SFT 数据质量几乎不可能比厂商的 RLHF 数据高。
  • "小数据 + 大模型"会触发表面拟合——模型学到的是 SFT 数据里的固定句式,而不是任务本质。

所以 2026 年 OpenAI、Anthropic 都在主推"轻量定制"路线(自定义 GPT、Claude Skills、Gemini Gems)替代真 SFT——本质上是承认:直接动权重的代价对大多数业务来说不划算。

第3章:Catastrophic Forgetting 机理

3.1 名词来源

Catastrophic Forgetting(灾难性遗忘)这个词来自神经网络的连续学习(Continual Learning)研究,1989 年 McCloskey & Cohen 首次提出:神经网络在学习新任务时,会迅速擦掉旧任务的权重。在 LLM 时代,这个现象在微调场景下被复现并放大。

3.2 数学直觉

用最简化的语言描述:模型权重 θ 是一个高维向量,在 base 训练阶段被优化到一个"既擅长任务 A、也擅长任务 B、也擅长任务 C"的 minima 区域。当你做 SFT,loss 函数变成只关于新任务 D的 loss,梯度下降会沿着"对 D 最有利"的方向走,而不顾及 A/B/C 的损失。

L_SFT(θ) = E_{(x,y) ∼ D_new} [-log p_θ(y | x)]

# 梯度只来自新任务,旧任务能力随之衰减
∇L_SFT(θ) ≠ 0 even if it harms p_θ(y_A | x_A)

# 解决思路:加正则惩罚新权重远离旧权重
L_total(θ) = L_SFT(θ) + λ * ||θ - θ_base||²       # L2 正则
L_total(θ) = L_SFT(θ) + λ * Σ_i F_i (θ_i - θ_base_i)²  # EWC(Elastic Weight Consolidation)

EWC 的核心思想是:用 Fisher Information Matrix 估计每个权重对旧任务的"重要性"F_i,越重要的权重越不能动。这是 2017 年 DeepMind 的工作,至今仍是缓解灾难性遗忘的主流方法之一。

3.3 LoRA 为什么"看起来"不容易遗忘

LoRA(Low-Rank Adaptation)的设计是冻结 base 权重 W,只训练低秩 adapter BA,使得新权重为 W + BA。直觉上 base 权重没动,应该不会遗忘——这是个流传甚广的误解。 真相是:LoRA 推理时 W + BA 是新的有效权重,对旧任务的输出分布同样会改变。LoRA 缓解遗忘的真正原因是:

  • 低秩约束限制了 adapter 能扰动的方向数量,"想擦也擦不干净"
  • 训练参数少(典型 0.5%~2%),过拟合速度慢。
  • 容易做 adapter 切换——不喜欢就拿掉,不像 full SFT 一锤子定音。

但是!如果你的 LoRA rank 很大(比如 r=128)+ 训练 epoch 很多 + 数据集分布很窄,遗忘照样发生。本章的所有回归测试方法对 LoRA 同样适用。

3.4 检测遗忘的"金标准"操作

检测遗忘的方法非常朴素,但很多团队就是不做:

金标准三步操作。

  1. 留住 base 评测分数:在做任何微调之前,把 base 模型在你打算用的所有评测集上都跑一遍,把分数和样本级 trace 存档。这叫 baseline snapshot。
  2. 同集对比:微调完成后,用完全相同的评测集、相同的 prompt、相同的 sampling 参数,再跑一遍。
  3. 计算 ΔAccuracy:每个评测集上算 ΔAccuracy = new - base,并配 95% CI。任何一项跌破阈值,回滚。

本章后面的所有代码,本质上都是在工程化这三步。

第4章:能力分布漂移的度量

4.1 三类漂移度量

"模型变了"在统计上有三种刻画方式:分数漂移(Score Drift)、行为漂移(Behavioral Drift)、分布漂移(Distribution Drift)。它们的计算粒度从粗到细。

度量粒度典型公式适合检测的退化类型
分数漂移 ΔAccuracy评测集级acc_new - acc_base,配 Bootstrap CI整体能力退化
行为漂移 Disagreement Rate样本级1/N × Σ I(y_new ≠ y_base)"分数没变但答案变了"
分布漂移 KL / JS Divergencetoken 级D_KL(p_new ∥ p_base)风格、tone、用词变化

4.2 为什么 ΔAccuracy 不够

很多团队只看一个数:新模型在自家评测集上的总分有没有降。这个数有个坏处——它会掩盖"两个错误抵消"的情况。比如旧模型 A 类答对、B 类答错,新模型 A 类答错、B 类答对,总分没变,但用户体验完全变了。 所以一定要同时看样本级一致性——多少条样本的回答相对 baseline 发生了变化。这就是 Disagreement Rate。

4.3 行为漂移的工程化定义

对开放生成任务,"答案变了"不能直接字符串比对,需要分级定义:

  • L0 完全一致:字符串相同(仅适用于温度=0、且 deterministic 模型)
  • L1 语义一致:embedding cosine ≥ 0.95 或 LLM-Judge 判定一致
  • L2 结论一致:抽取关键字段(如答案选项、JSON key)后相同
  • L3 偏向一致:情感倾向、立场、是否拒答 一致

L0 在新一代非确定性模型(如 GPT-5 reasoning)上几乎不可能达到,所以 L1/L2 是 2026 年实务中的主流。

4.4 分布漂移:风格突变的兜底监测

有些升级 ΔAccuracy 没变、Disagreement 也不高,但用户感知到"语气不一样了"。这种情况要靠 token 分布的 KL 散度兜底:

python
import numpy as np
from collections import Counter
import math

def token_kl_divergence(samples_old, samples_new, eps=1e-9):
    """
    samples_old / samples_new: list[str],每个是模型的一次回答
    用 token 频率近似分布,计算 D_KL(P_new || P_old)
    """
    def normalize(counter, vocab):
        total = sum(counter.values())
        return np.array([counter.get(w, 0) / total + eps for w in vocab])

    def tokenize(s):
        return s.replace("\n", " ").split()

    tokens_old = [t for s in samples_old for t in tokenize(s)]
    tokens_new = [t for s in samples_new for t in tokenize(s)]
    vocab = sorted(set(tokens_old) | set(tokens_new))

    p_old = normalize(Counter(tokens_old), vocab)
    p_new = normalize(Counter(tokens_new), vocab)

    return float(np.sum(p_new * np.log(p_new / p_old)))

# 经验阈值:D_KL > 0.05 表示风格层有可感知变化
# D_KL > 0.15 表示风格层有显著变化(需要 PM 介入)

实务中我们建议把这三类度量同时计算并展示,让评审者能看到"分数没变 + 行为变了 30%"或"分数掉 2 分 + 风格基本一致"这种关键差别。

第5章:微调三件套差异(SFT / DPO / RLHF)与各自的回归风险

5.1 三种微调技术的本质

2026 年企业里你能听到的"微调"通常是三种之一:SFT(Supervised Fine-Tuning)、DPO(Direct Preference Optimization)、RLHF(含 PPO 或更新的 GRPO)。它们的训练目标不同,回归风险也不同。

技术训练目标需要数据2026 主流框架对原能力的破坏程度
SFT最大化 p(y|x)(x, y) 高质量样本对HuggingFace TRL、Axolotl、LLaMA-Factory★★★★ 高
DPO最大化 chosen 相对 rejected 的概率比(x, y_chosen, y_rejected) 偏好对HuggingFace TRL DPOTrainer★★ 中
RLHF (PPO)最大化 reward model 给的奖励偏好对训 RM + 在线 rolloutOpenRLHF、TRL PPO、verl★★★ 中高
GRPO(2024-2025 主流)群组相对策略优化,省 RM可验证 reward 的样本verl、TRL 0.13+★★ 中

5.2 SFT 的回归风险

SFT 是最直接的"叠权重"方式,对原能力破坏最大。它的回归风险包括:

  • 风格塌缩:模型回答开始模仿 SFT 数据里的固定句式("好的,我来为您"开头出现频率激增)。
  • 格式遗忘:JSON / Markdown / 代码块输出能力退化(因为 SFT 数据通常是纯文本)。
  • 多轮塌缩:SFT 通常是单轮 (input, output),模型多轮对话连贯性下降。
  • 安全后退:SFT 数据没覆盖的安全场景,模型可能开始 "积极回答" 不该回答的问题。

5.3 DPO 的回归风险

DPO 用偏好对训练,模型不必学到"标准答案",只需学到"哪个比哪个好"。这种 soft 训练对原能力破坏小,但有它独特的风险:

  • 偏好作弊:模型学会说"对不起我无法帮助",因为这种回答在偏好数据里通常不被 reject。结果是模型变得过度保守。
  • 长度膨胀:偏好数据里"更长 = 更好"是常见噪声,DPO 后模型回答平均长度激增 30%-50%。
  • 风格收敛:模型趋向于偏好集里的"高分模板",多样性下降。

5.4 RLHF / GRPO 的回归风险

RLHF / GRPO 用 reward 信号训练,模型可能找到"reward 高但人类讨厌"的边界 case。这就是著名的 reward hacking。回归风险包括:

  • 谄媚(Sycophancy):模型学会附和用户,因为附和性回答 reward 高(GPT-4o 早期严重,2024-04 OpenAI 发文承认并回滚)。
  • 过度自信:模型不再说"我不知道",因为 RM 偏好"明确回答"。
  • 探索性丢失:模型变得保守,对开放问题回答固定模式。

测试侧的差异化策略。

对 SFT,重点测原能力是否退化(格式、多轮、代码、推理)。 对 DPO,重点测拒答率、平均长度、回答多样性。 对 RLHF,重点测谄媚程度、过度自信、安全后退。 回归集要按"对应的微调方法"加 slice 维度,不能用一套测一切。

第6章:能力地图(Capability Map)的设计

6.1 为什么需要能力地图

"全面回归"是个伪命题——LLM 能做的事是开放集,你没法穷举。所以工程上要把"能力"显式枚举出来,做成一张可维护、可量化、可加权的二维表。这就是 Capability Map。 它的好处是:

  • 每次模型变更,逐个维度跑分,给出"维度级"涨跌而不是单一总分。
  • 每个维度有明确的评测集 + Judge + 阈值,可重复、可审计。
  • 能让产品、合规、运维都看懂——"代码能力下降 8 分"比"总分下降 2 分"有效得多。

6.2 一张可直接套用的 7 维能力地图

下面是我们在多家企业落地时反复迭代出的"通用 7 维能力地图",可以作为你的起点。

能力维度子能力推荐评测集权重建议门控阈值判定方式
1. 通用知识事实问答、常识推理MMLU-Pro、CMMLU、SimpleBench0.10Δ ≥ -2%Accuracy
2. 推理能力数学、逻辑、多步推理GSM8K、MATH-500、Arena-Hard-Auto0.15Δ ≥ -3%Pass@1
3. 代码能力生成、补全、理解、修复HumanEval+、MBPP+、SWE-Bench Lite、LiveCodeBench0.15Δ ≥ -2%Pass@1
4. 工具调用schema 遵循、参数提取、多步规划BFCL v3、τ-bench、自建 Function 集0.15Δ ≥ -3% schema 准确率JSON 解析 + 字段对比
5. 多语言中英、长尾语言、code-switchMGSM、Flores-200、自建中文长文0.10Δ ≥ -2%BLEU / Judge
6. 安全拒答正确率、有害生成、提示词注入HarmBench、AILuminate v1.0、自建红队集0.20Δ ≥ -1% 安全得分规则 + Judge
7. 风格一致性tone、长度、格式、品牌词自建风格集 + LLM-Judge0.15D_KL ≤ 0.10Judge + KL 散度
(企业自建)业务核心场景Vibe-Eval(业务版)+ 私有 Eval Set0.30 (额外)业务定义业务定义

注意:业务自建维度的权重往往单独计算,并且是 release blocker 中权重最高的。"通用维度都过了,业务退化"是不能上线的。

6.3 能力地图的可演进性

能力地图不是写完就锁死。它需要:

  • 季度 review 一次权重——业务重心变化时调整。
  • 每出一次线上事故,把对应能力维度加进来或加权。
  • 每出一个新强模型(比如 GPT-5.x、Opus 4.6),把它的优势能力维度加进来——你迟早要测。

6.4 用 YAML 落地能力地图

把能力地图写成 YAML 让它可以被 CI 直接读:

capability_map:
  version: "2026.04"
  owner: "qa-llm@company.com"

  dimensions:
    - id: general
      name: "通用知识"
      weight: 0.10
      eval_sets:
        - { name: "MMLU-Pro-500", path: "evals/mmlu_pro_500.jsonl", judge: "acc" }
        - { name: "CMMLU-500",    path: "evals/cmmlu_500.jsonl",    judge: "acc" }
      gate:
        delta_acc_min: -0.02   # 新模型不能比 baseline 低于 2 个点
        absolute_min: 0.65     # 绝对值下限

    - id: reasoning
      name: "推理能力"
      weight: 0.15
      eval_sets:
        - { name: "GSM8K-200",         path: "evals/gsm8k_200.jsonl",  judge: "exact_match" }
        - { name: "Arena-Hard-Auto-300", path: "evals/aha_300.jsonl",  judge: "llm_judge_pairwise" }
      gate:
        delta_acc_min: -0.03

    - id: code
      name: "代码能力"
      weight: 0.15
      eval_sets:
        - { name: "HumanEval-Plus",    path: "evals/humaneval_plus.jsonl", judge: "pass_at_1" }
        - { name: "MBPP-Plus",         path: "evals/mbpp_plus.jsonl",      judge: "pass_at_1" }
      gate:
        delta_acc_min: -0.02
        absolute_min: 0.50

    - id: tool_use
      name: "工具调用"
      weight: 0.15
      eval_sets:
        - { name: "BFCL-v3-200", path: "evals/bfcl_v3_200.jsonl", judge: "schema_match" }
        - { name: "private-tools-100", path: "evals/private_tools.jsonl", judge: "json_field_match" }
      gate:
        delta_acc_min: -0.03

    - id: multilingual
      name: "多语言"
      weight: 0.10
      eval_sets:
        - { name: "MGSM-zh-100", path: "evals/mgsm_zh.jsonl", judge: "exact_match" }
        - { name: "private-zh-long", path: "evals/zh_long_500.jsonl", judge: "llm_judge" }
      gate:
        delta_acc_min: -0.02

    - id: safety
      name: "安全"
      weight: 0.20
      eval_sets:
        - { name: "HarmBench-200",  path: "evals/harmbench.jsonl", judge: "rule" }
        - { name: "private-redteam", path: "evals/redteam_500.jsonl", judge: "llm_judge_safety" }
      gate:
        delta_acc_min: -0.01
        absolute_min: 0.95

    - id: style
      name: "风格一致性"
      weight: 0.15
      eval_sets:
        - { name: "private-style-200", path: "evals/style_200.jsonl", judge: "llm_judge_style" }
      gate:
        kl_divergence_max: 0.10

    - id: business
      name: "业务核心"
      weight: 0.30   # extra weight; release blocker
      eval_sets:
        - { name: "private-business-1k", path: "evals/business_1k.jsonl", judge: "llm_judge_pairwise" }
      gate:
        delta_acc_min: 0.00    # 业务维度禁止退化

第7章:回归集分层(核心 / 业务 / 边界 / 安全)

7.1 为什么要分层

如果你把所有评测集塞进一个目录,跑一次 8 小时,那它就只能在release 前一晚跑——这意味着每次模型变更要等 8 小时才能拿结论。这在 2026 年的迭代节奏里完全不可接受。 正确做法是按**"必须性 × 耗时"**把回归集分成 4 层。

层级名称规模耗时触发时机典型内容
L1核心能力 Smoke50-100 条≤ 5 分钟每次 PR / 模型变更后立即跑每个能力维度抽 5-10 条最 representative 的
L2业务能力 Regression500-2000 条≤ 30 分钟每个候选模型上线前必跑业务私有 Eval Set 全量
L3边界能力 Stress2000-5000 条2-4 小时每周一次 / 大版本切换前长上下文、多轮、code-switch、对抗输入
L4安全能力 Audit1000-3000 条1-3 小时每月 + 任何安全相关变更红队、注入、有害内容、合规专项

7.2 L1 Smoke 集的"代表性"采样

L1 既要快,又要能大概率发现 L2/L3 的退化。"代表性采样"是关键技巧。我们用一个"过去半年线上失败案例 + 历史回归集中信息量最大的样本"组合策略。

python
import numpy as np
from sklearn.cluster import KMeans
from sentence_transformers import SentenceTransformer

def select_representative_samples(eval_set, k=50, embed_model="BAAI/bge-large-zh-v1.5"):
    """
    在 L2 评测集中挑出 k 条最有代表性的样本作为 L1 smoke 集。
    用 embedding 聚类 + 取中心点最近样本,保证多样性。
    """
    encoder = SentenceTransformer(embed_model)
    texts = [s["input"] for s in eval_set]
    embeddings = encoder.encode(texts, show_progress_bar=True)

    kmeans = KMeans(n_clusters=k, n_init=10, random_state=42).fit(embeddings)
    centers = kmeans.cluster_centers_

    selected = []
    for i in range(k):
        cluster_mask = kmeans.labels_ == i
        cluster_idx = np.where(cluster_mask)[0]
        dists = np.linalg.norm(embeddings[cluster_idx] - centers[i], axis=1)
        chosen = cluster_idx[np.argmin(dists)]
        selected.append(eval_set[chosen])
    return selected

# 用法
l2_eval = load_eval_set("evals/business_1k.jsonl")
l1_smoke = select_representative_samples(l2_eval, k=80)
save_jsonl(l1_smoke, "evals/business_smoke_80.jsonl")

7.3 L4 安全集为什么独立

安全维度独立成层有两个原因:

  1. 合规独立:安全测试报告往往要单独提交给合规/法务,独立成层方便归档。
  2. 更新节奏不同:业务集随业务变更,安全集随新攻击 PoC 出现而更新。两者节奏脱钩,混在一起难维护。

第8章:Champion / Challenger 部署模式

8.1 概念溯源

Champion / Challenger 来自传统 ML 风控领域:永远有一个在线的"冠军模型"(Champion)服务真实流量,候选模型(Challenger)跑在影子环境里,达到指定指标后晋级。这套范式在 LLM 时代被原样借用,并增加了"提示词冠军"和"路由冠军"两个新轴。

8.2 一个标准的 Champion / Challenger 循环

Champion 服务真实流量 → Challenger 接入影子流量 → 双跑评测 + 自动 Diff → 满足晋级条件? → 灰度晋级 → Champion 替换

8.3 晋级条件的硬性约束

什么样的 Challenger 才能晋级?我们建议四组硬约束:

  • 能力地图:所有维度的 Δ 不破阈值,业务维度净涨。
  • 样本级一致性:核心业务样本上 L1 Disagreement Rate ≤ 30%(看具体业务,但需要预先定)。
  • 成本/延迟:P99 延迟不增超 20%,平均成本不增超 25%。
  • 影子流量盲评:盲评胜率 ≥ 55%(含统计显著性)。

8.4 多 Challenger 并行

2026 年比较成熟的做法是同时跑多个 Challenger。比如你的 Champion 是 Claude Opus 4.5,候选有 GPT-5.1、Claude Opus 4.6、Gemini 3 Pro 三个。三者同时接收 1% 影子流量,每周对比一次。这种做法的好处是模型供应商之间天然 hedging,不依赖单一供应商的版本节奏。

多 Challenger 的工程要点。

(1) 影子流量必须做请求去重——同一个请求被分到 N 个模型时,prompt、上下文、tool 列表必须严格相同; (2) 评估时必须做盲评——Judge 不知道哪个回答来自哪个模型; (3) 多个 Challenger 各自的成本要单独核算,避免"为了对比烧了一万美元"还没人注意。

第9章:Shadow Traffic(影子流量)灰度策略

9.1 灰度的工程意义

离线回归集再大,也无法穷尽线上流量分布。Shadow Traffic 是用真实流量当评测集的方式,它能让你在真实分布下检验模型,又不会影响真实用户。

9.2 5 阶段灰度阶梯

我们建议的标准灰度阶梯如下,每阶段都有明确的晋级 / 回滚指标

阶段流量比例持续时间晋级指标回滚条件
影子(Shadow)0%(双跑不返回)3-7 天样本一致性 ≥ 70% 且业务关键 slice Δ ≥ 0Δ < -3% 或安全告警
金丝雀1%1-3 天用户满意度 Δ ≥ -2pp,无 P0 工单P0 工单 ≥ 1 或满意度跌 5pp
小流量5%2-3 天满意度持平,业务 metric Δ ≥ 0同上
过半25%2-3 天满意度净涨,成本不爆炸P99 延迟涨 ≥ 25%
多数50%2 天满意度净涨同上
全量100%持续替换 ChampionSLI 跌破红线立即回退

9.3 关键卡点:影子阶段的"假阳"清洗

影子阶段最常见的陷阱是:双跑 diff 一看,新模型 30% 的回答都"不一样",团队恐慌——但其实这 30% 里大部分是语义等价但措辞不同。所以影子阶段的 disagreement 必须分级

  • L0 字符不同 / L1 语义相同:忽略
  • L1 语义不同 / L2 结论相同:标记,但不阻断
  • L2 结论不同 / L3 偏向相同:必须人工 review
  • L3 偏向不同 / 拒答状态变化:自动告警 + 阻断晋级

第10章:模型升级回归脚本(GPT-4o → GPT-5 完整流程)

本章给出从 0 到 1 的完整脚本。所有代码都基于 OpenAI Python SDK 1.50+、Promptfoo 0.110+、MLflow 3.x,并已在生产环境验证过。

10.1 第一步:固化 baseline

在升级前先把 GPT-4o 的能力地图分数全部跑一遍并存进 MLflow:

python
import os
import json
import asyncio
import hashlib
import mlflow
from openai import AsyncOpenAI

mlflow.set_tracking_uri("http://mlflow.internal:5000")
mlflow.set_experiment("model-upgrade-gpt4o-to-gpt5")

client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])

async def call_model(model: str, messages: list, **kwargs):
    """统一的模型调用,带超时和重试"""
    for attempt in range(3):
        try:
            resp = await client.chat.completions.create(
                model=model,
                messages=messages,
                temperature=kwargs.get("temperature", 0),
                max_tokens=kwargs.get("max_tokens", 1024),
                timeout=30,
            )
            return resp.choices[0].message.content
        except Exception as e:
            if attempt == 2:
                raise
            await asyncio.sleep(1.5 ** attempt)

async def run_eval_set(model: str, eval_set: list, judge_fn, concurrency=8):
    sem = asyncio.Semaphore(concurrency)
    async def _one(sample):
        async with sem:
            output = await call_model(model, [
                {"role": "system", "content": sample.get("system", "")},
                {"role": "user", "content": sample["input"]},
            ])
            score = judge_fn(sample, output)
            return {"id": sample["id"], "input": sample["input"],
                    "output": output, "expected": sample.get("expected"),
                    "score": score}
    return await asyncio.gather(*(_one(s) for s in eval_set))

def exact_match_judge(sample, output):
    return 1.0 if sample["expected"].strip() in output.strip() else 0.0

async def main():
    with open("capability_map.yaml") as f:
        import yaml
        cmap = yaml.safe_load(f)["capability_map"]

    BASELINE_MODEL = "gpt-4o-2024-08-06"   # pin 死 snapshot
    CHALLENGER     = "gpt-5-2025-08-07"

    with mlflow.start_run(run_name=f"baseline-{BASELINE_MODEL}"):
        mlflow.log_param("model", BASELINE_MODEL)
        for dim in cmap["dimensions"]:
            for evset_meta in dim["eval_sets"]:
                with open(evset_meta["path"]) as f:
                    eval_set = [json.loads(l) for l in f]
                results = await run_eval_set(
                    BASELINE_MODEL, eval_set, exact_match_judge)
                acc = sum(r["score"] for r in results) / len(results)
                mlflow.log_metric(
                    f"{dim['id']}.{evset_meta['name']}.acc", acc)
                mlflow.log_artifact_text(
                    json.dumps(results, ensure_ascii=False, indent=2),
                    f"results/{dim['id']}_{evset_meta['name']}.json")
                print(f"[{BASELINE_MODEL}] {dim['id']}/{evset_meta['name']} = {acc:.3f}")

if __name__ == "__main__":
    asyncio.run(main())

10.2 第二步:跑 Challenger 并对比

python
import numpy as np
from scipy import stats

def bootstrap_ci_of_diff(scores_a, scores_b, n_boot=2000, alpha=0.05):
    """两组分数之差的 Bootstrap 置信区间"""
    diffs = []
    for _ in range(n_boot):
        sa = np.random.choice(scores_a, len(scores_a), replace=True)
        sb = np.random.choice(scores_b, len(scores_b), replace=True)
        diffs.append(sa.mean() - sb.mean())
    lo, hi = np.percentile(diffs, [100 * alpha / 2, 100 * (1 - alpha / 2)])
    return float(np.mean(diffs)), (float(lo), float(hi))

def disagreement_rate(results_base, results_chal, mode="L2"):
    """
    L0: 字符串完全相同
    L1: embedding cosine ≥ 0.95
    L2: 抽取关键字段相同(exact_match 用例下就是 expected 是否被 hit)
    """
    assert len(results_base) == len(results_chal)
    diff = 0
    for rb, rc in zip(results_base, results_chal):
        if mode == "L0":
            if rb["output"].strip() != rc["output"].strip():
                diff += 1
        elif mode == "L2":
            if rb["score"] != rc["score"]:
                diff += 1
    return diff / len(results_base)

async def run_compare(baseline_model, challenger_model, cmap_path):
    import yaml
    cmap = yaml.safe_load(open(cmap_path))["capability_map"]
    report = []

    with mlflow.start_run(run_name=f"compare-{baseline_model}-vs-{challenger_model}"):
        mlflow.log_param("baseline", baseline_model)
        mlflow.log_param("challenger", challenger_model)

        for dim in cmap["dimensions"]:
            for evset_meta in dim["eval_sets"]:
                eval_set = [json.loads(l) for l in open(evset_meta["path"])]
                base_results = await run_eval_set(
                    baseline_model, eval_set, exact_match_judge)
                chal_results = await run_eval_set(
                    challenger_model, eval_set, exact_match_judge)

                base_scores = [r["score"] for r in base_results]
                chal_scores = [r["score"] for r in chal_results]

                base_acc = float(np.mean(base_scores))
                chal_acc = float(np.mean(chal_scores))
                diff_mean, ci = bootstrap_ci_of_diff(chal_scores, base_scores)
                disagree = disagreement_rate(base_results, chal_results, "L2")
                gate_pass = diff_mean >= dim["gate"].get("delta_acc_min", -1.0)

                row = {
                    "dim": dim["id"], "set": evset_meta["name"],
                    "weight": dim["weight"],
                    "base_acc": base_acc, "chal_acc": chal_acc,
                    "delta": diff_mean, "ci_lo": ci[0], "ci_hi": ci[1],
                    "disagree": disagree, "gate_pass": gate_pass,
                }
                report.append(row)
                mlflow.log_metric(f"{dim['id']}.delta", diff_mean)
                mlflow.log_metric(f"{dim['id']}.disagree", disagree)
                print(json.dumps(row, ensure_ascii=False))

    return report

10.3 第三步:自动出报告 + 三色门控

python
def render_traffic_light(report):
    """根据 gate_pass 给每行打红黄绿"""
    rows = []
    overall = "GREEN"
    for r in report:
        if not r["gate_pass"]:
            color = "RED" if r["delta"] < -0.05 else "YELLOW"
        else:
            color = "GREEN"
        rows.append({**r, "color": color})
        if color == "RED":
            overall = "RED"
        elif color == "YELLOW" and overall == "GREEN":
            overall = "YELLOW"
    return overall, rows

def export_html(report, path="report.html"):
    overall, rows = render_traffic_light(report)
    color_map = {"GREEN": "#16a34a", "YELLOW": "#b45309", "RED": "#dc2626"}
    body = "".join(
        f'<tr style="background:{color_map[r["color"]]}22">'
        f'<td>{r["dim"]}</td><td>{r["set"]}</td>'
        f'<td>{r["base_acc"]:.3f}</td><td>{r["chal_acc"]:.3f}</td>'
        f'<td>{r["delta"]:+.3f}</td>'
        f'<td>[{r["ci_lo"]:+.3f},{r["ci_hi"]:+.3f}]</td>'
        f'<td>{r["disagree"]:.1%}</td>'
        f'<td style="color:{color_map[r["color"]]};font-weight:bold">{r["color"]}</td>'
        f'</tr>' for r in rows)
    html = f"""<html><body style="font-family:sans-serif">
        <h2>Upgrade Report — overall: <span style="color:{color_map[overall]}">{overall}</span></h2>
        <table border="1" cellpadding="6" cellspacing="0">
        <tr><th>Dim</th><th>Set</th><th>Base</th><th>Chal</th>
        <th>Δ</th><th>95% CI</th><th>Disagree</th><th>Gate</th></tr>
        {body}</table></body></html>"""
    open(path, "w").write(html)
    return overall

10.4 第四步:CI 集成

把上面三步包成 GitHub Actions / GitLab CI 任务,每次 PR 自动跑 L1,每天定时跑 L2,每周跑 L3/L4。

# .github/workflows/llm-regression.yml
name: LLM Regression
on:
  pull_request:
    paths: ["prompts/**", "configs/**"]
  schedule:
    - cron: "0 2 * * *"   # 每天 02:00 跑 L2
jobs:
  smoke-l1:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: python regression/run_compare.py --layer L1 --baseline gpt-4o-2024-08-06 --challenger ${{ inputs.challenger || 'gpt-5-2025-08-07' }}
      - run: |
          python regression/check_gate.py report.json
          if [ $? -ne 0 ]; then echo "::error::Regression gate FAILED"; exit 1; fi
      - uses: actions/upload-artifact@v4
        with: { name: report, path: report.html }

  full-l2:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python regression/run_compare.py --layer L2 --baseline gpt-4o-2024-08-06 --challenger gpt-5-2025-08-07
      - run: python regression/notify_slack.py report.json

第11章:LoRA 微调前后能力回归脚本

11.1 LoRA 训练 + 评测的最小骨架

以 Qwen3-7B-Instruct 为 base,用 PEFT 0.13+ 做 LoRA SFT,并在前后跑同一套能力地图:

python
import os
import torch
from datasets import load_dataset
from transformers import (
    AutoModelForCausalLM, AutoTokenizer,
    TrainingArguments, BitsAndBytesConfig)
from peft import LoraConfig, get_peft_model, PeftModel, TaskType
from trl import SFTTrainer, SFTConfig

BASE_MODEL = "Qwen/Qwen3-7B-Instruct"
SFT_DATA   = "datasets/customer_support_zh.jsonl"   # 5000 条客服工单
OUTPUT_DIR = "out/qwen3-7b-lora-cs-v1"

tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4")

base_model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL, quantization_config=bnb,
    device_map="auto", torch_dtype=torch.bfloat16)

lora_cfg = LoraConfig(
    r=16, lora_alpha=32, lora_dropout=0.05,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    task_type=TaskType.CAUSAL_LM)

ds = load_dataset("json", data_files=SFT_DATA, split="train")

trainer = SFTTrainer(
    model=base_model, peft_config=lora_cfg, train_dataset=ds,
    args=SFTConfig(
        output_dir=OUTPUT_DIR, num_train_epochs=2,
        per_device_train_batch_size=4, gradient_accumulation_steps=4,
        learning_rate=2e-4, lr_scheduler_type="cosine",
        save_strategy="epoch", bf16=True, logging_steps=10),
)
trainer.train()
trainer.save_model(OUTPUT_DIR)

11.2 评测:base 与 base+LoRA 同集对比

python
from peft import PeftModel
from transformers import pipeline

def make_pipe(base_model_name, lora_path=None):
    tok = AutoTokenizer.from_pretrained(base_model_name)
    m = AutoModelForCausalLM.from_pretrained(
        base_model_name, torch_dtype=torch.bfloat16, device_map="auto")
    if lora_path:
        m = PeftModel.from_pretrained(m, lora_path)
        m = m.merge_and_unload()   # merge 后推理更快
    return pipeline("text-generation", model=m, tokenizer=tok,
                    max_new_tokens=512, do_sample=False)

def eval_pipeline(pipe, eval_set, judge_fn):
    results = []
    for s in eval_set:
        prompt = s.get("system", "") + "\n" + s["input"]
        out = pipe(prompt, return_full_text=False)[0]["generated_text"]
        results.append({**s, "output": out, "score": judge_fn(s, out)})
    return results

def humaneval_pass1_judge(sample, output):
    """用沙箱执行生成的代码,检查是否通过 unit test"""
    import subprocess, tempfile, json as _json
    code = output.split("```python")[-1].split("```")[0] if "```python" in output else output
    test_code = sample["test"]
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(code + "\n" + test_code + f"\ncheck({sample['entry_point']})")
        path = f.name
    try:
        r = subprocess.run(["python", path], timeout=10, capture_output=True)
        return 1.0 if r.returncode == 0 else 0.0
    except Exception:
        return 0.0

def run_lora_regression(base_model, lora_path):
    base_pipe = make_pipe(base_model)
    lora_pipe = make_pipe(base_model, lora_path)

    capability_evals = {
        "general":  ("evals/mmlu_pro_500.jsonl",  exact_match_judge),
        "code":     ("evals/humaneval_plus.jsonl", humaneval_pass1_judge),
        "math":     ("evals/gsm8k_200.jsonl",      exact_match_judge),
        "json":     ("evals/json_format_200.jsonl", json_field_judge),
        "multi_turn": ("evals/multi_turn_300.jsonl", llm_judge_multi_turn),
        "business": ("evals/business_500.jsonl",   llm_judge_business),
    }

    full_report = {}
    for dim, (path, judge) in capability_evals.items():
        eval_set = [json.loads(l) for l in open(path)]
        base_r = eval_pipeline(base_pipe, eval_set, judge)
        lora_r = eval_pipeline(lora_pipe, eval_set, judge)
        base_scores = [r["score"] for r in base_r]
        lora_scores = [r["score"] for r in lora_r]
        delta, ci = bootstrap_ci_of_diff(lora_scores, base_scores)
        full_report[dim] = {
            "base_acc": float(np.mean(base_scores)),
            "lora_acc": float(np.mean(lora_scores)),
            "delta": delta, "ci": ci,
            "disagree": disagreement_rate(base_r, lora_r, "L2"),
        }
    return full_report

if __name__ == "__main__":
    report = run_lora_regression(BASE_MODEL, OUTPUT_DIR)
    print(json.dumps(report, ensure_ascii=False, indent=2))

11.3 经验:哪些维度最容易被 LoRA 砸掉

实战中 LoRA SFT 后最常见的退化排名:

  1. JSON / Markdown / 代码块 等格式遵循(-15% ~ -30%)
  2. 多轮对话连贯性(-1.0 ~ -1.5 分 / 5)
  3. HumanEval+ 代码能力(-10% ~ -25%)
  4. GSM8K 数学推理(-8% ~ -15%)
  5. 安全拒答正确率(-3% ~ -8%)

这些是固定模式,所以 LoRA 微调的回归集应该强制覆盖以上五项

11.4 缓解:Replay 数据 + 早停 + 小 rank

如果回归显示退化太严重,常用三种缓解:

① Replay 数据

在 SFT 数据里掺 10%-20% 的"通用任务"样本(GSM8K + HumanEval + Alpaca),让模型不"忘记"原能力。这是最有效也最便宜的方法。

② 早停

验证集 loss 还在降但通用能力评测开始退化的 step 停下来,而不是跑完所有 epoch。这要求训练时同时跑 dev eval。

③ 小 rank + 低学习率

r=8 / lr=1e-4 通常比 r=64 / lr=2e-4 稳得多。先用小 rank 跑出 baseline 再视情况增大。

第12章:Shadow Traffic 在线对比框架

12.1 框架架构

用户请求 → LLM Gateway → 主路径:Champion → 返回 → 影子路径:Challenger(异步) → Diff 收集器 → LangSmith / Helicone

关键设计:影子路径是异步、不阻塞、不计费给用户的。所有流量进入 Gateway 后,主路径同步走 Champion 并返回;同时,按采样比例 fork 一份请求到 Challenger,结果写入 trace 系统但不影响响应。

12.2 LLM Gateway 的最小实现(FastAPI + LiteLLM)

python
import asyncio
import os, json, hashlib, time
from fastapi import FastAPI, Request
from litellm import acompletion
import httpx

app = FastAPI()

CHAMPION   = os.environ.get("CHAMPION_MODEL", "gpt-5.4-2026-06-01")
CHALLENGER = os.environ.get("CHALLENGER_MODEL", "gpt-5.6-sol-2026-07-15")
SHADOW_RATIO = float(os.environ.get("SHADOW_RATIO", "0.05"))   # 5%

LANGSMITH_URL = os.environ["LANGSMITH_URL"]

async def shadow_call(req_id, messages, expected_champion_resp):
    """影子调用:跑 Challenger 并把对比结果发到 LangSmith"""
    try:
        chal_resp = await acompletion(
            model=CHALLENGER, messages=messages, timeout=30)
        chal_text = chal_resp.choices[0].message.content
        async with httpx.AsyncClient(timeout=10) as cli:
            await cli.post(LANGSMITH_URL + "/runs", json={
                "id": req_id,
                "champion": {
                    "model": CHAMPION,
                    "output": expected_champion_resp},
                "challenger": {
                    "model": CHALLENGER,
                    "output": chal_text},
                "messages": messages,
                "ts": time.time(),
            })
    except Exception as e:
        # 影子调用失败不能影响主路径
        print(f"[shadow] {req_id} failed: {e}")

@app.post("/v1/chat/completions")
async def chat(req: Request):
    body = await req.json()
    messages = body["messages"]
    req_id = hashlib.md5(json.dumps(messages, sort_keys=True).encode()).hexdigest()[:16]

    champ_resp = await acompletion(model=CHAMPION, messages=messages)
    champ_text = champ_resp.choices[0].message.content

    # 按采样比例 fork 到影子路径,不 await
    if hash(req_id) % 1000 < int(SHADOW_RATIO * 1000):
        asyncio.create_task(shadow_call(req_id, messages, champ_text))

    return {"id": req_id, "choices": [{"message": {"content": champ_text}}]}

12.3 自动 Diff 报告

影子流量积累 24-48 小时后,跑下面的脚本生成 diff 报告:

python
import json
import requests
from collections import Counter
from concurrent.futures import ThreadPoolExecutor

def fetch_shadow_runs(since_ts, until_ts):
    r = requests.get(f"{LANGSMITH_URL}/runs",
                     params={"since": since_ts, "until": until_ts, "limit": 5000})
    return r.json()["runs"]

JUDGE_PROMPT = """请判断下面两个回答相对用户问题的"实质等价度",给一个数字:
0 = 完全不等价(结论或关键事实不同)
1 = 表述不同但结论相同
2 = 完全等价(仅措辞差异)
3 = 挑战者明显更好
-1 = 挑战者明显更差

仅输出一个数字,不要解释。

[用户问题]
{q}

[Champion 回答]
{a}

[Challenger 回答]
{b}"""

async def judge_one(judge_client, q, a, b):
    out = await judge_client.chat.completions.create(
        model="claude-opus-4-6",
        messages=[{"role": "user",
                   "content": JUDGE_PROMPT.format(q=q, a=a, b=b)}],
        temperature=0, max_tokens=4)
    try:
        return int(out.choices[0].message.content.strip())
    except:
        return None

async def build_diff_report(runs, judge_client):
    counts = Counter()
    for r in runs:
        q = r["messages"][-1]["content"]
        a = r["champion"]["output"]
        b = r["challenger"]["output"]
        score = await judge_one(judge_client, q, a, b)
        counts[score] += 1

    total = sum(counts.values())
    report = {
        "total": total,
        "equivalent_or_better": (counts[1] + counts[2] + counts[3]) / total,
        "champ_better": counts[-1] / total,
        "incomparable": counts[0] / total,
        "challenger_strictly_better": counts[3] / total,
    }
    return report

12.4 Helicone / LangSmith 集成的工程要点

  • 请求去重:用 messages hash 作为 trace_id,方便后续按对话维度聚合。
  • 采样均匀:用 hash(req_id) 而不是 random(),保证同一请求始终被分到同一组(影子或非影子)。
  • 成本兜底:在 LLM Gateway 里加上日 / 周影子流量预算告警,超过自动停采。
  • PII 处理:影子流量进入第三方 trace 系统前必须脱敏(电话、身份证、邮箱)。

第13章:模型版本签名与可追溯性

13.1 为什么需要"版本签名"

三个月后线上出事故,你需要能回答:"出事故那一刻,到底用的是哪个模型?base 是什么?adapter 是什么?训练数据是什么?评测报告在哪?" 如果回答不出来,你既无法做 RCA,也通不过任何合规审计。 "模型版本签名"就是把这一组关键标识打成一个唯一 ID,存档到 Model Registry,并在每次推理时写入 trace

13.2 签名应该包含什么

2026 年的最佳实践(融合了 OpenAI Model Card、HuggingFace Model Cards、Google Model Cards Toolkit、MLflow Model Registry 的字段):

字段类型说明
model_idstring形如 cs-bot-zh@2026.04.01 的人类可读 ID
base_modelstring + sha256Qwen/Qwen3-7B-Instruct@<hf_revision>
adapter_hashsha256LoRA adapter weights 文件 hash
train_data_hashsha256训练数据 jsonl 全量 hash(推荐 SHA256 over sorted)
train_recipe_hashsha256YAML 训练配置 hash(含 lr、epoch、rank 等)
eval_report_hashsha256本版本的能力地图评测报告 hash
eval_summarydict各维度的关键 metric 和门控结论
creatoremail训练发起人
created_atISO8601创建时间
parent_model_idstring上游模型 ID(如果是 LoRA over LoRA)
safety_statusenumpassed / yellow / red

13.3 实现:自动生成签名 + 注册到 MLflow

python
import hashlib
import json
import datetime
from pathlib import Path
import mlflow
import yaml

def file_sha256(path: str, block: int = 65536) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(block), b""):
            h.update(chunk)
    return h.hexdigest()

def dir_sha256(directory: str) -> str:
    """对目录下所有文件按相对路径排序后求 hash"""
    h = hashlib.sha256()
    for p in sorted(Path(directory).rglob("*")):
        if p.is_file():
            h.update(p.relative_to(directory).as_posix().encode())
            h.update(file_sha256(str(p)).encode())
    return h.hexdigest()

def jsonl_content_hash(path: str) -> str:
    """jsonl 文件按行 sort 后 hash,避免顺序敏感"""
    h = hashlib.sha256()
    with open(path) as f:
        for line in sorted(f):
            h.update(line.encode())
    return h.hexdigest()

def make_model_signature(
    model_name: str,
    base_model: str,
    base_revision: str,
    adapter_dir: str,
    train_data_path: str,
    train_recipe_path: str,
    eval_report_path: str,
    creator: str,
    parent_model_id: str = None,
):
    sig = {
        "model_id": f"{model_name}@{datetime.date.today().isoformat()}",
        "base_model": f"{base_model}@{base_revision}",
        "adapter_hash": dir_sha256(adapter_dir),
        "train_data_hash": jsonl_content_hash(train_data_path),
        "train_recipe_hash": file_sha256(train_recipe_path),
        "eval_report_hash": file_sha256(eval_report_path),
        "eval_summary": json.load(open(eval_report_path))["summary"],
        "creator": creator,
        "created_at": datetime.datetime.utcnow().isoformat() + "Z",
        "parent_model_id": parent_model_id,
        "safety_status": json.load(open(eval_report_path)).get("safety_status", "passed"),
    }
    sig["fingerprint"] = hashlib.sha256(
        json.dumps(sig, sort_keys=True).encode()).hexdigest()[:16]
    return sig

def register_to_mlflow(sig, adapter_dir):
    with mlflow.start_run(run_name=sig["model_id"]):
        for k, v in sig.items():
            if isinstance(v, (dict, list)):
                mlflow.log_dict(v, f"{k}.json")
            else:
                mlflow.log_param(k, v)
        mlflow.log_artifacts(adapter_dir, artifact_path="adapter")
        mlflow.register_model(
            model_uri=f"runs:/{mlflow.active_run().info.run_id}/adapter",
            name=sig["model_id"].split("@")[0])

if __name__ == "__main__":
    sig = make_model_signature(
        model_name="cs-bot-zh",
        base_model="Qwen/Qwen3-7B-Instruct",
        base_revision="abcd1234",
        adapter_dir="out/qwen3-7b-lora-cs-v1",
        train_data_path="datasets/customer_support_zh.jsonl",
        train_recipe_path="recipes/cs_v1.yaml",
        eval_report_path="reports/eval_2026_04_01.json",
        creator="alice@company.com",
    )
    print(json.dumps(sig, ensure_ascii=False, indent=2))
    register_to_mlflow(sig, "out/qwen3-7b-lora-cs-v1")

13.4 在线追溯:每次推理写入 trace

把 fingerprint 注入推理服务的 response header 和 trace 字段,这样线上每条请求都能溯源到准确的模型版本:

python
from fastapi import Response

MODEL_SIGNATURE = json.load(open("/etc/llm/signature.json"))

@app.post("/v1/chat/completions")
async def chat(req: Request, resp: Response):
    out = await call_model(...)
    resp.headers["X-Model-Fingerprint"] = MODEL_SIGNATURE["fingerprint"]
    resp.headers["X-Model-ID"] = MODEL_SIGNATURE["model_id"]
    # 写入 LangSmith trace
    langsmith_log({"fingerprint": MODEL_SIGNATURE["fingerprint"], ...})
    return out

合规视角的额外要求。

如果你的系统服务于金融、医疗、教育、政务,fingerprint 必须存档至少 6 年。这是中国《生成式人工智能服务管理暂行办法》和欧盟 AI Act 高风险系统的共同要求。每次模型变更都要留下"上一版的完整签名 + 替换原因 + 评测报告",缺一不可。

第14章:案例:从 GPT-4o → GPT-5 切换的完整回归报告

14.1 背景

2025 年 9 月,某 SaaS 公司的"会议纪要 + 行动项抽取" 产品决定从 gpt-4o-2024-08-06 切换到 gpt-5-2025-08-07。这次切换的诉求是:(1) 利用 GPT-5 的更强长上下文能力支持 2 小时长会议;(2) 借 reasoning 模式提升行动项抽取的准确率。

14.2 评测设计

能力维度评测集规模判定方法
纪要总结private-meeting-summary-300300Claude-Opus 4.6 做 LLM Judge,5 分制
行动项抽取(结构化)private-action-items-500500JSON schema 校验 + 字段级 F1
多语言(中英混说)private-codeswitch-200200分语言段标注后 BLEU-style
长上下文召回longctx-retrieval-100100Needle in a Haystack(128k)
风格一致性private-style-200200D_KL token 散度
安全HarmBench-200 + 自建-redteam-300500规则 + Judge
成本/延迟采样 2000 条线上请求重放2000P50 / P99 延迟、$ / 1k tokens

14.3 关键结果

维度GPT-4o (baseline)GPT-5 (challenger)Δ (95% CI)门控
纪要总结 (5 分制)4.124.41+0.29 [+0.18, +0.40]GREEN
行动项抽取 (字段级 F1)0.7810.852+0.071 [+0.052, +0.090]GREEN
JSON schema 通过率96.4%98.7%+2.3% [+1.2%, +3.4%]GREEN
中英混说 BLEU32.530.1-2.4 [-3.6, -1.2]YELLOW
长上下文召回 (128k)0.8120.964+0.152 [+0.121, +0.183]GREEN
风格 D_KL0.083GREEN(<0.10)
安全 综合得分0.9720.961-0.011 [-0.019, -0.003]YELLOW
P99 延迟(reasoning 关闭)3.4 s4.1 s+20.6%YELLOW
$ / 1k output tokens$0.015$0.012-20%GREEN

14.4 团队的判定与处理

整体判定为**"附条件 GREEN"**,结论:可以灰度上线,但有三件事必须先做:

  1. 中英混说退化:调查发现 GPT-5 默认 reasoning 模式下,对 code-switch 输入会做"先翻译再回答",导致原句中英结构丢失。缓解:在 system prompt 里显式说明"保持原语言交替";测试后回到 32.7。
  2. 安全微跌:跌的是"生成可能引发法律纠纷的隐喻类内容"这一 slice,从 0.951 跌到 0.928。缓解:在系统提示词里加入 GPT-5 推荐的安全模板;测试后回到 0.973。
  3. P99 延迟涨:reasoning 模式带来的固定开销。缓解:把 reasoning 设为按需开启(仅复杂行动项抽取场景使用),常规纪要保持 reasoning=off。

14.5 灰度发布过程

阶段时间结论
0% 影子9.10 - 9.141.2k 真实流量双跑,盲评 GPT-5 胜率 56%
1% 金丝雀9.15 - 9.16无 P0/P1 工单,满意度 NPS +2
5% 灰度9.17 - 9.19满意度 NPS +3
25% 灰度9.20 - 9.22NPS 持续,成本日跌 18%
50% 灰度9.23 - 9.24稳定
100% 全量9.25Champion 替换为 GPT-5

整个切换过程 15 天。期间发现两个未在离线评测捕获的小问题(针对会议纪要 emoji 输出过多、纪要末尾 "Best regards" 化),都通过 prompt 微调解决。

这个案例的关键心得。

(1) 离线评测是必要不充分的——影子流量帮你抓到了"emoji 太多"这种风格层退化,是离线集没覆盖到的; (2) GREEN/YELLOW/RED 不是终点,YELLOW 是**"必须先解决再 GREEN"的阻塞项**,不是"可以接受的退化"; (3) 灰度阶梯不能跳——从影子直接跳 25% 在两次案例中都翻过车。

第15章:案例:内部 LoRA 微调引发的代码能力崩塌

15.1 背景

2025 年 11 月,某 DevOps 工具厂商在 Qwen3-14B-Instruct 上做了 LoRA SFT,用 8000 条"K8s YAML 解释 + 修复"对话数据训练,目标是让模型更懂 K8s 配置。算法团队的内部评测显示:K8s 任务上准确率从 71% 涨到 89%,"达到上线门槛"。 QA 团队按本章方法跑能力地图回归,结果如下:

维度Base (14B)LoRA SFT 后Δ判定
K8s YAML 解释(业务)0.710.89+0.18GREEN
HumanEval+ Python0.780.42-0.36RED
MBPP+ Python0.710.39-0.32RED
SWE-Bench Lite0.210.04-0.17RED
JSON 输出准确率0.950.74-0.21RED
多轮对话连贯性4.1/53.0/5-1.1RED
MMLU-Pro0.650.59-0.06YELLOW

15.2 根因分析

QA 团队联合算法团队做了三层分析:

  1. 数据分布层:8000 条训练数据全是"YAML 解释 + 修复",Python 代码出现频率 ≈ 0%。模型在 SFT 期间收到的"代码"信号几乎只有 YAML,自然把 Python 写法 forget 掉了。
  2. 训练超参层:rank=64,lr=3e-4,3 个 epoch。rank 偏大、lr 偏高、epoch 偏多,对原能力扰动太强。
  3. SFT 模板层:训练时所有样本都用"yaml"开头的 markdown 代码块,模型对"用 python 包代码"这种格式的 prior 被冲淡。

15.3 整改方案

QA 团队拒绝上线,并给出三条整改建议:

  1. 掺 replay 数据:在原 8000 条基础上加 1500 条来自 HumanEval / MBPP / 多轮 Alpaca 的样本,重新训练。
  2. 降 rank、降 lr、降 epoch:rank=16, lr=1e-4, epoch=2,并加 early stopping based on dev eval。
  3. 训练时同步跑 dev eval:每 200 步在 dev eval(含 50 条 HumanEval、50 条 MBPP、50 条 K8s)上评测一次,loss 仍降但 HumanEval 开始降的 step 立即停。

15.4 整改后结果

维度Base (14B)整改前 LoRA整改后 LoRA
K8s 业务0.710.890.86
HumanEval+0.780.420.74
MBPP+0.710.390.69
JSON 准确率0.950.740.93
多轮连贯4.13.04.0

整改后 K8s 业务能力从 +18 涨幅缩到 +15,但所有原能力维度回到可接受范围。最终上线版本的 fingerprint 是 k8s-bot@2025.11.20,base = Qwen3-14B-Instruct@e7f3a91,adapter_hash = 0x4f2a..b1,eval_report_hash = 0x7ce..89。

QA 团队的关键贡献。

这个案例里,QA 团队没有写一行训练代码,但把这个项目从"灾难性上线"拉回到"可控上线"。关键动作是: (1) 跑了一份算法团队没跑的能力地图回归; (2) 把"代码能力崩塌 -36%"用置信区间和样本量量化呈现; (3) 通过实证给出"加 replay + 降 rank + early stopping"的明确整改方向; (4) 整改后再次回归确认问题解决。这就是 SRE 视角的测试团队应该做的事。

第16章:课堂练习

练习 1(必做):能力地图设计

选定你公司一个真实的 LLM 产品(客服 / 写作 / 代码 / 知识问答……),设计一份 6-8 维的能力地图 YAML。要求:

  • 每个维度至少 1 个公开 Eval Set + 1 个私有 Eval Set
  • 每个维度有明确的 Δ 阈值和绝对阈值
  • 业务维度权重 ≥ 0.25
  • 列出"哪些维度可以由 LLM-Judge 判定,哪些必须用规则/沙箱"

练习 2(必做):Catastrophic Forgetting 实证

用任意一个开源 7B/8B base(Qwen3、Llama 3.3、Mistral Small 3.x),按以下要求做一组实验:

  1. 找 2000-5000 条单一领域 SFT 数据(例如客服或法律咨询)
  2. 用 PEFT LoRA SFT 训练,rank=32, lr=2e-4, epoch=3
  3. 在 SFT 前后分别跑 HumanEval+、MMLU-Pro-500、自建 JSON 集
  4. 计算每个维度的 Δ 和 95% CI
  5. 给出一份"是否值得上线"的 1 页 markdown 报告

练习 3(必做):Shadow Traffic 模拟

不用上线真实环境,用 Promptfoo 0.110+ 模拟一次 Shadow Traffic 双跑:

  • Champion = GPT-5;Challenger = GPT-5.1
  • 用 200 条历史日志(脱敏后)作为输入
  • 用 Claude Opus 4.6 做盲评 Judge,给两两对比的胜负
  • 输出 LangSmith 风格的 diff 报告(含 disagreement rate、平均长度差、Judge 胜率)

练习 4(进阶):模型版本签名实践

用第 13 章的 make_model_signature 函数为练习 2 的产物生成完整签名,并:

  • 注册到本地 MLflow
  • 把 fingerprint 写入推理服务 response header
  • 故意改一个训练超参重新生成签名,观察 fingerprint 的变化
  • 讨论:当训练数据被替换 5%、超参不变时,fingerprint 应不应该变?为什么?

练习 5(讨论):灰度阶段的取舍

假设你的 Challenger 在影子阶段表现:业务维度净涨 +5%,但中文古诗解读维度跌 -20%(slice 占线上流量 0.8%)。你会:

  1. 停止灰度,等算法团队修这个 slice?
  2. 继续灰度,同时启动 fallback:触达"古诗解读"意图时退回到 Champion?
  3. 继续灰度,把这个 slice 的 KPI 单独监控,约定 10 天内修?

请在团队中讨论,并把你选择的依据写下来——这是没有标准答案的工程判断题,但你要能给出量化理由。

练习 6(开放):写一份真实事故的复盘

翻一翻你团队 / 公司过去一年和"模型变更"相关的故障工单,挑一个写一份不超过 2 页的复盘,内容包含:

  • 变更类型(升级 / 微调 / prompt 改动 / snapshot 滚动)
  • 事故现象 + 用户影响
  • 检测延迟(变更到发现的小时数)
  • 根因分类(哪个层)
  • 如果当时有本章的能力地图,能不能在 release 前就拦下?

本章小结。

模型升级与微调回归测试的本质是**"用工程方法管理不确定性"。模型权重每变一次,能力分布就重新洗牌一次——你没法消除这种不确定性,但可以通过能力地图 + 分层回归 + Champion/Challenger + 影子流量 + 版本签名把它压在可观测、可回滚、可审计的范围里。这是 SRE 视角下测试工程师在 LLM 时代最重要的、也是最不可外包的工作之一。 记住三句话:(1) 不留 baseline 的微调是耍流氓;(2) 不分层的回归集是 release 拖延机;(3) 不带签名的模型上线就是合规黑洞。**

模型升级与微调回归测试 大模型测试体系教程 · 第 51 篇 · 内部培训资料