Day30 | LoRA 合并实战——合并完模型没坏,但悄悄替你扣了 3 分
2026/7/18 20:15:39 网站建设 项目流程

苦猿的大模型日记 · Day30 · LoRA 合并实战-帮普通人把AI学进简历系列

前言:两次评测,一个 87.3,一个 82.1

上周我在整理一个 LoRA 微调项目的验收报告,翻到两行数字愣住了。

合并前,用 peft 加载 adapter 跑评测集,综合得分87.3

合并后,执行完merge_and_unload(),同一套评测集再跑一遍,综合得分82.1

中间掉了5.2 分

我第一反应是代码写错了——重新跑,分数没变。我又找了几条模型输出人工看,一句一句读下来,模型说话完全正常,没有乱码,没有重复,没有答非所问。就是评测分数,实打实地掉了。

这才是最麻烦的地方。要是模型合并完直接胡言乱语,你三秒就能发现,回滚重来就行。可它现在看起来一切正常,你甚至可能会把这 5 分的差距归咎于"评测集有波动"——直到某天线上效果被投诉,你才想起来查这一步

今天这篇,就拆一拆合并这两行代码背后,到底藏着哪些容易被忽略的假设。


PART 01:先搞懂——"合并"这两个字,合的是什么

LoRA 的核心公式很简单:

W' = W + (alpha / r) * B @ A

W是 base 模型冻结的原始权重,AB是训练出来的两个小矩阵,alphar是缩放系数和秩。

训练时,推理走的是"双路径":输入先过一遍冻结的W,再单独过一遍B @ A这条旁路,两边算完的结果加起来才是最终输出。W全程没动,adapter 是"挂"在旁边的。

合并时,你把(alpha/r) * B @ A这坨东西真正加进W里,生成一个新的、单一的权重矩阵。合并完之后,AB可以直接扔掉——模型看起来就是个普普通通的完整模型,推理路径变成"单路径"。

理论上,这两种算法路径,输出应该完全一致。这是合并操作的数学基础,也是几乎所有教程讲到这一步就结束的原因——公式写完了,代码两行,收工。

但"理论上一致"和"工程上一致",中间隔着不少东西。


PART 02:三个真实会让合并"悄悄掉点"的病因

我把自己踩过和在 issue 区翻到的案例,归了归类,基本逃不出这三种。

病因 1:精度没对齐,加法悄悄丢了尾数

LoRA 训练时,AB两个矩阵通常用 bf16 或 fp16 存。但很多流程里,base 权重W加载进来的精度可能不完全一致——尤其是从 QLoRA(NF4 量化)训练完之后直接合并,情况更复杂。

merge_and_unload()本质是做一次加法。如果加法发生在低精度上,或者中间有一次隐式的类型转换,每个权重值的误差可能只有小数点后几位,但这个误差会被逐层放大,尤其在需要精细数值判断的任务上——比如多选题里几个选项概率很接近、或者长链推理需要保持数值稳定——误差就开始体现在分数上。

写代码的时候,养成一个习惯:合并前后手动 assert 一下 dtype。

from peft import PeftModel from transformers import AutoModelForCausalLM import torch base = AutoModelForCausalLM.from_pretrained( "Qwen/Qwen3-8B", torch_dtype=torch.bfloat16, # 显式指定,别让它自动推断 ) model = PeftModel.from_pretrained(base, "./lora-adapter") # 合并前检查:adapter 和 base 精度是否一致 for name, param in model.named_parameters(): if "lora_A" in name or "lora_B" in name: assert param.dtype == torch.bfloat16, f"{name} 精度不对: {param.dtype}" merged = model.merge_and_unload() merged.save_pretrained("./qwen3-8b-merged", safe_serialization=True)

症状:模型说话完全正常,但涉及精细判断的任务(选择题、数值推理)分数下滑。验证方法:合并前后固定同一个输入,对比 logits 的分布,而不是只看生成文本。

病因 2:scaling 系数算错,幅度对不上方向

LoRA 真正生效的增量是(alpha/r) * B @ A,不是单纯的B @ A。这个缩放系数,peft 自带的merge_and_unload()会自动处理——但一旦你为了"更灵活"手写合并逻辑,这一步就是最容易漏掉的地方。

我见过最典型的错法:合并脚本是从别的项目里抄来的,alphar直接沿用了那个项目的默认值(比如alpha=16, r=8),但自己这次训练用的其实是alpha=32, r=16——比值凑巧数值上接近但不完全相等,合并出来的模型不会报错,也不会完全跑偏,就是权重方向对,幅度悄悄错了

# 手动实现 merge 的核心公式,标出最容易漏的一行 def manual_merge(W, A, B, alpha, r): scaling = alpha / r # <-- 这一行最容易漏乘或算错 delta = scaling * (B @ A) return W + delta

症状:模型整体表现"偏保守"或"偏激进"——比如训练目标是让模型更果断地给出结论,合并后却发现它又开始模棱两可。验证方法:检查合并脚本里alphar的取值,是否和训练配置文件(adapter_config.json)里的完全一致,而不是手抄或记忆。

病因 3:多个 adapter 依次合并,顺序反了

如果你训练了不止一个 LoRA——比如先做了一版领域知识微调,又在此基础上做了一版工具调用微调——依次合并时,第二个 adapter 训练时用的"base",到底是原始 base,还是已经合并了第一个 adapter 的模型?这个问题决定了合并顺序对不对。

顺序反了,不会报错,模型也能正常输出。但等于把"工具调用"这个增量,加到了一个错误的基座上——单独测每项能力可能都还凑合,组合能力(既有领域知识又会调用工具)会明显下降

验证方法:合并前回头确认每个 adapter 训练时from_pretrained加载的 base 路径,画一条清晰的"训练血缘线",别凭记忆合并。


PART 03:实测——两条推理路径,差多少

光说容易掉点没有说服力,做一次最小对比实验。

思路很简单:同一个 adapter,分别用"训练态"(不合并,peft 直接加载推理)和"合并态"(执行merge_and_unload()后推理),固定输入、固定随机种子,跑两遍,对比输出。

import torch from transformers import AutoModelForCausalLM, AutoTokenizer from peft import PeftModel base_path = "Qwen/Qwen3-8B" adapter_path = "./lora-adapter" prompt = "帮我总结一下 LoRA 合并容易踩的坑" tokenizer = AutoTokenizer.from_pretrained(base_path) inputs = tokenizer(prompt, return_tensors="pt") # 路径一:训练态,adapter 挂载不合并 base = AutoModelForCausalLM.from_pretrained(base_path, torch_dtype=torch.bfloat16) model_unmerged = PeftModel.from_pretrained(base, adapter_path) model_unmerged.eval() with torch.no_grad(): logits_unmerged = model_unmerged(**inputs).logits # 路径二:合并态,merge_and_unload 之后 model_merged = model_unmerged.merge_and_unload() model_merged.eval() with torch.no_grad(): logits_merged = model_merged(**inputs).logits # 对比两条路径的 logits 差异,而不是只看生成文本 diff = (logits_unmerged - logits_merged).abs().max().item() print(f"最大 logits 差异: {diff}")

在我自己跑的这一版小流程里,dtype 和 scaling 都对齐的情况下,diff是一个非常小的数值(接近浮点误差量级)——这说明合并操作本身的数学没问题。但当我故意把 base 权重的加载精度改成 fp32(和训练用的 bf16 不一致)重跑一遍,diff直接跳大了两个数量级,评测分数也应声下滑。

这里补一个"合并后自检三件套",不需要跑完整评测集也能先做一轮排查:

  1. diff 一下前后输出:上面这段代码,固定输入跑两遍,看 logits 差异量级是否正常
  2. 看权重范数变化:合并前后对比几层关键权重的 L2 范数,如果变化幅度和预期的alpha/r缩放不匹配,基本就是系数算错了
  3. 抽查一遍小型评测集:哪怕只有几十条,也能比"聊几句看着还行"更快发现问题
检查项训练态(基线)合并态(dtype/scaling 对齐)合并态(dtype 未对齐)
评测集综合得分87.387.182.1
logits 最大差异~1e-4 量级明显跳大
人工看输出正常正常看起来也正常

最后一行才是这篇文章真正想说的东西——人工看输出这一项,三种情况下的结论都是"正常"。合并这一步出问题,靠聊几句是发现不了的。


结尾:模型没变笨,是你把它算错了

合并这一步,代码永远只有两行:

merged = model.merge_and_unload() merged.save_pretrained("./merged-model")

但这两行背后,是三个容易被默认为"理所当然"的假设——dtype 对不对,scaling 系数对不对,合并顺序对不对。少一个,模型不会报错,也不会说胡话,它只会在评测集上,悄悄替你多扣几分。

这不是模型能力的问题,是工程实现的问题——好在,可复现,也可以修。

互动时间

你合并 LoRA 的时候踩过哪个坑?评论区说说,我挑几个案例补充进踩坑实录专栏。


下一篇预告:合并这一步查明白了,下一个问题自然是——怎么科学地评测一个微调完的模型,才不会被"聊几句感觉还行"骗过去?Day31 我们聊聊评测集怎么搭才靠谱。

— END —

苦猿 · 帮普通人把 AI 学进简历

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询