模型升级与微调回归测试
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 年起的版本演进 | 升级语义 | 对回归测试的暗示 |
|---|---|---|---|
| OpenAI | GPT-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 三档切能力档 |
| Anthropic | Claude 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 评测集 |
| Gemini 2.5 Pro(2025-03)→ Gemini 3 Pro(2026-01)→ Gemini 3 Ultra(2026-03)→ Gemini 3.x(2026-07 灰度中) | 多模态全面对齐 + 200 万 context | 长上下文召回测试需要重新设计 | |
| DeepSeek | DeepSeek-V3(2024-12)→ V3.5(2025-09)→ R2(2026-02)→ V4(2026-07) | R 系列把推理能力分到独立模型 | "用哪一版"成为路由问题,要测 router 而不是单模型 |
| Qwen / Baichuan / Doubao | Qwen3 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 年这个分工已经不成立了,理由有三:
- 变更频率太高:每月一次的 minor 升级、每周一次的 LoRA 重训、随时可能的供应商 snapshot 滚动。算法团队没精力对每次变更做完整回归。
- 能力多维化:一个生产系统涉及的能力可能有几十种(写作、改写、抽取、规划、调用工具、多语言切换、安全拒答……),算法团队的离线 benchmark 覆盖不到业务长尾。
- 线上对比是黑盒:升级是黑盒事件,唯一的判定方式就是跑你自己的回归集。能跑回归集、能解读结果、能给出发版结论的,是测试团队。
第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 微调后 | Δ |
|---|---|---|---|
| 工单分类(业务) | 78 | 91 | +13 |
| MMLU 通用知识 | 70.2 | 62.5 | -7.7 |
| HumanEval 代码 | 60.1 | 34.6 | -25.5 |
| GSM8K 数学 | 72.4 | 58.0 | -14.4 |
| 多轮对话连贯性(人评) | 4.2/5 | 3.1/5 | -1.1 |
| JSON 格式遵循 | 96% | 71% | -25% |
如果这个模型只用在"工单分类"一个场景,没问题。如果它还要被用在"工单后续追问的多轮对话"和"工单结构化抽取(JSON 输出)",那就是大灾难:你刚刚把一个 80 分的全能选手培养成了一个 90 分专精 + 各项偏科严重的高三复读生。
测试人员的核心追问。
每次算法团队提交"新版微调模型",强制必须回答以下三个问题,否则不予部署:
- 这一版相对上一版,核心业务能力涨了多少(含 95% CI)?
- 这一版相对上一版,**非业务能力(通用、推理、代码、JSON、多轮)**掉了多少?
- 掉的部分中,有没有任何一项跌破 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 检测遗忘的"金标准"操作
检测遗忘的方法非常朴素,但很多团队就是不做:
金标准三步操作。
- 留住 base 评测分数:在做任何微调之前,把 base 模型在你打算用的所有评测集上都跑一遍,把分数和样本级 trace 存档。这叫 baseline snapshot。
- 同集对比:微调完成后,用完全相同的评测集、相同的 prompt、相同的 sampling 参数,再跑一遍。
- 计算 Δ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 Divergence | token 级 | 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 散度兜底:
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 + 在线 rollout | OpenRLHF、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、SimpleBench | 0.10 | Δ ≥ -2% | Accuracy |
| 2. 推理能力 | 数学、逻辑、多步推理 | GSM8K、MATH-500、Arena-Hard-Auto | 0.15 | Δ ≥ -3% | Pass@1 |
| 3. 代码能力 | 生成、补全、理解、修复 | HumanEval+、MBPP+、SWE-Bench Lite、LiveCodeBench | 0.15 | Δ ≥ -2% | Pass@1 |
| 4. 工具调用 | schema 遵循、参数提取、多步规划 | BFCL v3、τ-bench、自建 Function 集 | 0.15 | Δ ≥ -3% schema 准确率 | JSON 解析 + 字段对比 |
| 5. 多语言 | 中英、长尾语言、code-switch | MGSM、Flores-200、自建中文长文 | 0.10 | Δ ≥ -2% | BLEU / Judge |
| 6. 安全 | 拒答正确率、有害生成、提示词注入 | HarmBench、AILuminate v1.0、自建红队集 | 0.20 | Δ ≥ -1% 安全得分 | 规则 + Judge |
| 7. 风格一致性 | tone、长度、格式、品牌词 | 自建风格集 + LLM-Judge | 0.15 | D_KL ≤ 0.10 | Judge + KL 散度 |
| (企业自建) | 业务核心场景 | Vibe-Eval(业务版)+ 私有 Eval Set | 0.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 | 核心能力 Smoke | 50-100 条 | ≤ 5 分钟 | 每次 PR / 模型变更后立即跑 | 每个能力维度抽 5-10 条最 representative 的 |
| L2 | 业务能力 Regression | 500-2000 条 | ≤ 30 分钟 | 每个候选模型上线前必跑 | 业务私有 Eval Set 全量 |
| L3 | 边界能力 Stress | 2000-5000 条 | 2-4 小时 | 每周一次 / 大版本切换前 | 长上下文、多轮、code-switch、对抗输入 |
| L4 | 安全能力 Audit | 1000-3000 条 | 1-3 小时 | 每月 + 任何安全相关变更 | 红队、注入、有害内容、合规专项 |
7.2 L1 Smoke 集的"代表性"采样
L1 既要快,又要能大概率发现 L2/L3 的退化。"代表性采样"是关键技巧。我们用一个"过去半年线上失败案例 + 历史回归集中信息量最大的样本"组合策略。
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 安全集为什么独立
安全维度独立成层有两个原因:
- 合规独立:安全测试报告往往要单独提交给合规/法务,独立成层方便归档。
- 更新节奏不同:业务集随业务变更,安全集随新攻击 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% | 持续 | 替换 Champion | SLI 跌破红线立即回退 |
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:
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 并对比
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 report10.3 第三步:自动出报告 + 三色门控
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 overall10.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,并在前后跑同一套能力地图:
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 同集对比
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 后最常见的退化排名:
- JSON / Markdown / 代码块 等格式遵循(-15% ~ -30%)
- 多轮对话连贯性(-1.0 ~ -1.5 分 / 5)
- HumanEval+ 代码能力(-10% ~ -25%)
- GSM8K 数学推理(-8% ~ -15%)
- 安全拒答正确率(-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)
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 报告:
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 report12.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_id | string | 形如 cs-bot-zh@2026.04.01 的人类可读 ID |
| base_model | string + sha256 | Qwen/Qwen3-7B-Instruct@<hf_revision> |
| adapter_hash | sha256 | LoRA adapter weights 文件 hash |
| train_data_hash | sha256 | 训练数据 jsonl 全量 hash(推荐 SHA256 over sorted) |
| train_recipe_hash | sha256 | YAML 训练配置 hash(含 lr、epoch、rank 等) |
| eval_report_hash | sha256 | 本版本的能力地图评测报告 hash |
| eval_summary | dict | 各维度的关键 metric 和门控结论 |
| creator | 训练发起人 | |
| created_at | ISO8601 | 创建时间 |
| parent_model_id | string | 上游模型 ID(如果是 LoRA over LoRA) |
| safety_status | enum | passed / yellow / red |
13.3 实现:自动生成签名 + 注册到 MLflow
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 字段,这样线上每条请求都能溯源到准确的模型版本:
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-300 | 300 | Claude-Opus 4.6 做 LLM Judge,5 分制 |
| 行动项抽取(结构化) | private-action-items-500 | 500 | JSON schema 校验 + 字段级 F1 |
| 多语言(中英混说) | private-codeswitch-200 | 200 | 分语言段标注后 BLEU-style |
| 长上下文召回 | longctx-retrieval-100 | 100 | Needle in a Haystack(128k) |
| 风格一致性 | private-style-200 | 200 | D_KL token 散度 |
| 安全 | HarmBench-200 + 自建-redteam-300 | 500 | 规则 + Judge |
| 成本/延迟 | 采样 2000 条线上请求重放 | 2000 | P50 / P99 延迟、$ / 1k tokens |
14.3 关键结果
| 维度 | GPT-4o (baseline) | GPT-5 (challenger) | Δ (95% CI) | 门控 |
|---|---|---|---|---|
| 纪要总结 (5 分制) | 4.12 | 4.41 | +0.29 [+0.18, +0.40] | GREEN |
| 行动项抽取 (字段级 F1) | 0.781 | 0.852 | +0.071 [+0.052, +0.090] | GREEN |
| JSON schema 通过率 | 96.4% | 98.7% | +2.3% [+1.2%, +3.4%] | GREEN |
| 中英混说 BLEU | 32.5 | 30.1 | -2.4 [-3.6, -1.2] | YELLOW |
| 长上下文召回 (128k) | 0.812 | 0.964 | +0.152 [+0.121, +0.183] | GREEN |
| 风格 D_KL | — | 0.083 | — | GREEN(<0.10) |
| 安全 综合得分 | 0.972 | 0.961 | -0.011 [-0.019, -0.003] | YELLOW |
| P99 延迟(reasoning 关闭) | 3.4 s | 4.1 s | +20.6% | YELLOW |
| $ / 1k output tokens | $0.015 | $0.012 | -20% | GREEN |
14.4 团队的判定与处理
整体判定为**"附条件 GREEN"**,结论:可以灰度上线,但有三件事必须先做:
- 中英混说退化:调查发现 GPT-5 默认 reasoning 模式下,对 code-switch 输入会做"先翻译再回答",导致原句中英结构丢失。缓解:在 system prompt 里显式说明"保持原语言交替";测试后回到 32.7。
- 安全微跌:跌的是"生成可能引发法律纠纷的隐喻类内容"这一 slice,从 0.951 跌到 0.928。缓解:在系统提示词里加入 GPT-5 推荐的安全模板;测试后回到 0.973。
- P99 延迟涨:reasoning 模式带来的固定开销。缓解:把 reasoning 设为按需开启(仅复杂行动项抽取场景使用),常规纪要保持 reasoning=off。
14.5 灰度发布过程
| 阶段 | 时间 | 结论 |
|---|---|---|
| 0% 影子 | 9.10 - 9.14 | 1.2k 真实流量双跑,盲评 GPT-5 胜率 56% |
| 1% 金丝雀 | 9.15 - 9.16 | 无 P0/P1 工单,满意度 NPS +2 |
| 5% 灰度 | 9.17 - 9.19 | 满意度 NPS +3 |
| 25% 灰度 | 9.20 - 9.22 | NPS 持续,成本日跌 18% |
| 50% 灰度 | 9.23 - 9.24 | 稳定 |
| 100% 全量 | 9.25 | Champion 替换为 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.71 | 0.89 | +0.18 | GREEN |
| HumanEval+ Python | 0.78 | 0.42 | -0.36 | RED |
| MBPP+ Python | 0.71 | 0.39 | -0.32 | RED |
| SWE-Bench Lite | 0.21 | 0.04 | -0.17 | RED |
| JSON 输出准确率 | 0.95 | 0.74 | -0.21 | RED |
| 多轮对话连贯性 | 4.1/5 | 3.0/5 | -1.1 | RED |
| MMLU-Pro | 0.65 | 0.59 | -0.06 | YELLOW |
15.2 根因分析
QA 团队联合算法团队做了三层分析:
- 数据分布层:8000 条训练数据全是"YAML 解释 + 修复",Python 代码出现频率 ≈ 0%。模型在 SFT 期间收到的"代码"信号几乎只有 YAML,自然把 Python 写法 forget 掉了。
- 训练超参层:rank=64,lr=3e-4,3 个 epoch。rank 偏大、lr 偏高、epoch 偏多,对原能力扰动太强。
- SFT 模板层:训练时所有样本都用"
yaml"开头的 markdown 代码块,模型对"用python 包代码"这种格式的 prior 被冲淡。
15.3 整改方案
QA 团队拒绝上线,并给出三条整改建议:
- 掺 replay 数据:在原 8000 条基础上加 1500 条来自 HumanEval / MBPP / 多轮 Alpaca 的样本,重新训练。
- 降 rank、降 lr、降 epoch:rank=16, lr=1e-4, epoch=2,并加 early stopping based on dev eval。
- 训练时同步跑 dev eval:每 200 步在 dev eval(含 50 条 HumanEval、50 条 MBPP、50 条 K8s)上评测一次,loss 仍降但 HumanEval 开始降的 step 立即停。
15.4 整改后结果
| 维度 | Base (14B) | 整改前 LoRA | 整改后 LoRA |
|---|---|---|---|
| K8s 业务 | 0.71 | 0.89 | 0.86 |
| HumanEval+ | 0.78 | 0.42 | 0.74 |
| MBPP+ | 0.71 | 0.39 | 0.69 |
| JSON 准确率 | 0.95 | 0.74 | 0.93 |
| 多轮连贯 | 4.1 | 3.0 | 4.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),按以下要求做一组实验:
- 找 2000-5000 条单一领域 SFT 数据(例如客服或法律咨询)
- 用 PEFT LoRA SFT 训练,rank=32, lr=2e-4, epoch=3
- 在 SFT 前后分别跑 HumanEval+、MMLU-Pro-500、自建 JSON 集
- 计算每个维度的 Δ 和 95% CI
- 给出一份"是否值得上线"的 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%)。你会:
- 停止灰度,等算法团队修这个 slice?
- 继续灰度,同时启动 fallback:触达"古诗解读"意图时退回到 Champion?
- 继续灰度,把这个 slice 的 KPI 单独监控,约定 10 天内修?
请在团队中讨论,并把你选择的依据写下来——这是没有标准答案的工程判断题,但你要能给出量化理由。
练习 6(开放):写一份真实事故的复盘
翻一翻你团队 / 公司过去一年和"模型变更"相关的故障工单,挑一个写一份不超过 2 页的复盘,内容包含:
- 变更类型(升级 / 微调 / prompt 改动 / snapshot 滚动)
- 事故现象 + 用户影响
- 检测延迟(变更到发现的小时数)
- 根因分类(哪个层)
- 如果当时有本章的能力地图,能不能在 release 前就拦下?
本章小结。
模型升级与微调回归测试的本质是**"用工程方法管理不确定性"。模型权重每变一次,能力分布就重新洗牌一次——你没法消除这种不确定性,但可以通过能力地图 + 分层回归 + Champion/Challenger + 影子流量 + 版本签名把它压在可观测、可回滚、可审计的范围里。这是 SRE 视角下测试工程师在 LLM 时代最重要的、也是最不可外包的工作之一。 记住三句话:(1) 不留 baseline 的微调是耍流氓;(2) 不分层的回归集是 release 拖延机;(3) 不带签名的模型上线就是合规黑洞。**
模型升级与微调回归测试 大模型测试体系教程 · 第 51 篇 · 内部培训资料