这个系列终于讲到微调了。如果你一路跟过来,前面几篇都是在讲大模型的基础结构、推理机制和量化原理,这些是理解整条链路的地基。从这篇开始,我们正式进入“改动模型”的阶段,而第一篇先解决一个关键问题:既然全量微调又贵又不现实,那怎么用最少的成本把模型调成我们想要的样子?答案就是参数高效微调,也就是圈内常说的 PEFT。
这篇文章我会把 PEFT 的原理、主流实现方式、以及它和量化结合后衍生出的 QLoRA 这套组合拳,用我自己在实践中的理解讲清楚。不管你是刚接触微调的新手,还是已经跑过几个脚本但没深究原理的同学,看完这篇应该都能对“为什么大家都在用 LoRA”这件事有更明确的认识。
1. 为什么需要参数高效微调:全量微调带来的现实问题
1.1 全量微调的成本账
在 PEFT 出现之前,模型微调最直接的方式是全量微调。听起来很简单:在预训练权重的基础上,用我们的业务数据继续训练,让模型往特定任务方向偏移。但实际操作一次你就知道,这个方案在现在的大模型时代基本走不通。
账很好算。假如我们要微调一个 7B 参数的模型,全量微调意味着需要更新全部 70 亿参数。优化器状态这块先不谈,光是参数本身的显存占用就是 70 亿乘以 2 字节(FP16),大约 14GB。但训练不是只有参数,还有梯度、优化器动量、以及前向计算时保存的中间激活值。如果用 AdamW 优化器,单参数需要维护 8 到 16 字节的状态信息,70 亿参数光优化器状态就要 56GB 以上的显存。整套算下来,单卡 A100 80G 都紧巴巴,还要想着做批量并行、梯度累积。
这还是 7B 的小模型。如果是 13B、34B,甚至更大的底座,全量微调对绝大多数团队和个人开发者来说,门槛高得离谱,不仅买不起那么多卡,连训练稳定性和通信开销都够喝一壶的。
1.2 PEFT 的核心思想:用“小改造”撬动“大模型”
参数高效微调的思路和全量微调完全不同。它不去动那 70 亿个原始参数,而是保持原模型冻结不动,在旁边额外增加一小部分可训练参数,或者只对模型内部某些结构做低成本的改动,让它去适配新的任务。
为什么这能行得通?这里要先理解一个预训练模型的本质。大模型在海量文本上做完预训练之后,它的底层通用表征已经非常强大,掌握了语法、逻辑、知识、模式识别这些能力。特定任务的微调,本质上是让模型把已有的能力“导向”到某个方向,而不是重新学一套能力。既然大模型的骨干网络已经够用,那我们就只需要在关键路径上插入一些轻量的“方向盘”和“快捷键”,引导它输出我们想要的结果。
用一个生活化的类比:全量微调相当于把整栋房子的承重墙全部拆了重新砌,为了调整房间的朝向和布局,要动土方工程。而 PEFT 相当于装修,主体结构不动,只是改改墙面、换换灯具、挪挪家具位置,就能让房子呈现完全不同的风格。少花几十倍的功夫,达到七八成甚至九成以上的效果。
这就是 PEFT 最核心的价值:用极少的可训练参数(通常占总参数的 0.1% 到 2%),在保持模型主体能力不丢失的前提下,完成对特定任务的适配。在显存需求、训练速度、过拟合风险三个维度上,都比全量微调有质的改善。
2. 主流 PEFT 路线:LoRA、Adapter、Prefix-Tuning 与提示微调
PEFT 不是一个单一技术,而是好几类方法的统称。理解它们之间的区别,有助于我们在不同场景下做选型。
2.1 LoRA:低秩矩阵注入
LoRA 是目前当之无愧的主流方案。它的原理一句话就能说明白:冻结原始权重矩阵,在旁路添加两个低秩分解矩阵,用它们的乘积来模拟权重更新量。
比如某层原始权重矩阵 W 的维度是 d×d,这个矩阵本身很大。LoRA 不去更新 W,而是训练一个低秩分解:A 是 d×r,B 是 r×d,中间维度 r 通常只有 8、16、64 这种量级。训练完成后,模型的实际计算变成了 W + AB,而 AB 就是那层对该任务产生的“修正量”。
常规矩阵 A、B 的组合照样能表达丰富的变换。这就是低秩假设:大模型在适应具体任务时,权重更新量其实被压缩在一个很低维的子空间里,不需要高秩的全量更新。换句话说,真正需要学习的“新东西”没那么多,几个核心方向的调整就能覆盖绝大部分需求。
LoRA 的优势之一在于,训练时候我们只更新两个小矩阵,显存占用小;训练完成后,A、B 矩阵还可以合并回原始权重,也就是说推理时完全不增加额外计算量,模型结构和原来一模一样,只是权重值变了。这个特性让 LoRA 在部署上非常干净,一个底座模型可以同时挂多个不同的 LoRA 适配器,按需切换,互不干扰。
2.2 Adapter:瓶颈结构的旁路模块
Adapter 是比 LoRA 更早的一类思路。它在 Transformer 的某个子层之后插入一个小的前馈网络模块,这个模块通常采用“先降维再升维”的瓶颈设计,比如隐藏层 4096 维先压到 256 维,再升回 4096 维,中间用非线性激活函数。训练时只更新这些 Adapter 模块内的参数,主干模型全部冻结。
Adapter 的缺点也比较明显。第一,它增加了推理时的深度,每个 Adapter 都是一次额外的矩阵运算,在多次推理延迟敏感的场景下不太友好。第二,如果每个任务挂一个 Adapter,模型文件里要额外存储这些模块,反向传播时调度的复杂度也高一些。不过在 PEFT 早期,Adapter 是解决资源不足问题的重要方案,很多多任务学习的场景下仍有一席之地。
2.3 Prefix-Tuning 与 Prompt-Tuning:只在输入端动手
这两类方式更“轻”一些,它们不改模型内部结构,而是在输入序列层面做文章。
Prefix-Tuning 的做法是在输入序列前面加上一段可学习的虚拟向量,这些向量会被当作模型输入的一部分参与每层 Transformer 的计算。训练过程中只更新这些前缀虚拟 Token 的参数,原始模型完全不动。它的直觉是:给模型一段可以“引路”的提示信号,让模型的注意力集中在特定任务需要的语义方向上。
Prompt-Tuning 则更进一步,只在最开始的输入层拼接可学习的软提示向量,不插到每一层去。它的参数规模更小,一个任务只需要几个到几十个 Token 长度的可学习向量。缺点是效果通常不如 Prefix-Tuning 和 LoRA,对模型规模的依赖也比较强,模型太小时表达能力受限。
这两类方法现在较少单独用于严肃的生产微调任务,更多出现在 Prompt 工程、模型评测这些轻量实验场景。但理解它们有助于我们明白:PEFT 的本质是去寻找“最小的可干预维度”。
2.4 互相对比与选型建议
我整理过一张对比表,基本把几条路线的关键差异列全了:
| 方法 | 可训练参数量 | 推理额外开销 | 训练显存占用 | 效果稳定性 | 适用场景 |
|---|---|---|---|---|---|
| 全量微调 | 100% | 无 | 极高 | 上限高,但易过拟合 | 数据量极大、任务差异极大的场景 |
| LoRA | 0.1% - 1% | 可合并,无额外开销 | 低 | 稳定,主流 | 绝大多数微调任务 |
| Adapter | 1% - 3% | 有额外层计算 | 低 | 中等 | 多任务并发部署 |
| Prefix-Tuning | 0.1% - 1% | 增加序列长度 | 低 | 中等 | 文本生成方向控制 |
| Prompt-Tuning | 0.01% - 0.1% | 增加输入 Token | 极低 | 偏弱 | 快速原型实验 |
从我的经验看,如果没有特殊理由,新项目直接选 LoRA 基本不会错。它效果好、推理无额外开销、生态成熟,后续我们要讲的 QLoRA 也是建立在 LoRA 维度上的。
3. LoRA 实操:基于 transformers 与 PEFT 库的完整流程
理论说完了,我们用一套具体流程走一遍 LoRA 微调。示例场景做的是“将通用底座模型改造成特定风格的问答模型”,这是最常见的微调业务场景。
我用的是当前主流的技术栈:Hugging Face transformers 负责模型加载和训练流程,PEFT 库负责 LoRA 适配器管理,数据集用 JSON 格式的指令微调数据。整个过程在单张 24GB 显存的 GPU 上就能跑,这对很多个人开发者和中小团队很有吸引力。
3.1 环境准备与依赖安装
先说一下环境。我习惯用 conda 管理独立环境,避免包版本互相污染。Python 版本建议 3.10 及以上,PyTorch 版本根据显卡驱动和 CUDA 版本决定。下面是一组我实测比较稳定的版本组合:
conda create -n lora-finetune python=3.10 conda activate lora-finetune pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.38.2 pip install peft==0.9.0 pip install datasets==2.17.1 pip install accelerate==0.27.0 pip install bitsandbytes==0.42.0这里想提醒一个点:transformers、PEFT、bitsandbytes 三个库的版本兼容性非常容易踩坑。如果加载模型时报一些稀奇古怪的错误,比如“Unrecognized keyword argument”或者量化相关的 AttributeError,大概率是版本组合不匹配。我自己的经验是:优先按照 PEFT 库的 release notes 里推荐的版本组合去安装,不要盲目全装最新版。
3.2 加载基础模型与分词器
加载模型这块,我先从普通的 4-bit 量化加载谈起,后面单独讲 QLoRA 的细节。基础代码长这样:
import torch from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments from peft import LoraConfig, get_peft_model model_name = "某开源基础模型路径/7B版本" # 4-bit 量化加载 model = AutoModelForCausalLM.from_pretrained( model_name, load_in_4bit=True, torch_dtype=torch.bfloat16, device_map="auto", ) tokenizer = AutoTokenizer.from_pretrained(model_name) tokenizer.pad_token = tokenizer.eos_token在跑真实任务前,要确认两件事:第一,当前模型支持 bfloat16 吗?如果不支持就要退回 fp16。第二,tokenizer 是否有 padding token,很多开源模型的 tokenizer 默认没有 pad_token,需要手动设置为 eos_token,不然后面训练时批量 padding 会报错。
加载完成之后,检查一下模型的显存占用,4-bit 加载一个 7B 模型通常只会占 4 到 6 GB,这给后续训练参数留出了足够空间。
提示:如果你只是做推理验证,不想训练,load_in_4bit=True 就是最省显存的加载方式之一。但要注意,4-bit 加载下模型权重会被量化,生成的随机性和质量会略低于原始 16-bit 推理,这在长文本生成场景中偶尔能察觉到差异。
3.3 配置 LoRA 参数
LoRA 的性能依赖几个核心超参数的选择,这一块值得反复调优。我的推荐起始配置如下:
lora_config = LoraConfig( r=8, # 低秩矩阵的秩 lora_alpha=32, # 缩放系数 target_modules=[ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj" ], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()r 值决定了低秩矩阵的维度。r 太小,表达能力不足;r 太大,参数量和过拟合风险都会上升。从 8 起步是稳妥的,数据量充足且任务复杂度高时可以加到 16 或 32。
lora_alpha 是缩放系数,它的直觉是放大低秩矩阵的更新幅度。实际效果上,alpha 和 r 的比值比绝对数值更重要。在秩 r=8 时,lora_alpha 用 16 或 32 效果都不错;如果你把 r 提到 32,lora_alpha 也相应提到 64 或 128,保持平衡。
target_modules 的选择值得特别说明。如果你看老一点的教程,很多人只做 q_proj 和 v_proj,因为早期论文里发现这样效果不错。但后来的实践表明,对中文场景的指令微调来说,把注意力层的所有投影和 MLP 层的三个投影全部覆盖,效果会更稳定。上面配置里的 gate_proj、up_proj、down_proj 就是 MLP 部分的模块名,具体需要对照你用的模型架构来确认。
model.print_trainable_parameters()这行会输出类似下面这样一行:
trainable params: 8,388,608 || all params: 6,938,000,000 || trainable%: 0.12%看到可训练参数占比在 0.1% 左右,就说明 LoRA 配置生效了。
3.4 训练循环与参数监控
数据处理好之后,训练参数这块需要仔细权衡。以下是一套适合单卡 24GB 显存的配置:
training_args = TrainingArguments( output_dir="./lora_output", num_train_epochs=3, per_device_train_batch_size=4, gradient_accumulation_steps=4, learning_rate=2e-4, lr_scheduler_type="cosine", warmup_ratio=0.05, logging_steps=10, save_steps=500, evaluation_strategy="steps", eval_steps=500, save_total_limit=2, fp16=True, report_to="none", )几个关键点解释一下:
LoRA 的学习率和全量微调完全不同。全量微调通常用 1e-5 到 5e-5 的小学习率,因为所有参数都在更新,步子大了容易冲乱预训练权重。LoRA 因为只更新少量新增参数,反而可以用更大的学习率,2e-4 甚至 5e-4 都常见。如果学习率太低,LoRA 适配器训练速度会明显偏慢,收敛不到好的位置。
批量大小这里用了 per_device_batch_size=4 加 gradient_accumulation_steps=4,等效 batch size 是 16。显存不够时,优先降 per_device_batch_size,靠梯度累积补等效批量,这是一个百试百灵的策略。
warmup_ratio=0.05 的含义是让前 5% 的步数学习率从 0 慢慢升到目标值。这个比例对应 3 个 epoch、几百个训练步数的情况正合适,避免一开始就把适配器参数冲得太猛。
训练开始之后,我强烈推荐你盯着 loss 曲线看,不要直接丢在后台跑。loss 如果从一开始就持续下降,说明配置基本合理。如果 loss 震荡很厉害,第一反应是调低学习率。如果 loss 降得很慢,可以适当调大 lora_alpha。
实操心得:我踩过最大的坑之一是训练时忘了冻结原始模型,导致显存爆炸。使用 get_peft_model 之后,原始模型会自动被冻结,这在大多数正常流程下没问题。但如果你自己写训练循环,或者在 get_peft_model 之前手动调用了 model.train(),原始模型参数就会变成可训练状态。正确做法是只对 PEFT 包装后的模型调用 train(),并确保原始模型处于 eval 模式或冻结状态。
3.5 模型合并与推理验证
训练结束后,我们需要把 LoRA 适配器合并回底座模型,得到一个可以直接部署的完整模型。这一步的做法是:
from peft import PeftModel base_model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.bfloat16, device_map="auto", ) lora_model = PeftModel.from_pretrained( base_model, "./lora_output/checkpoint-500", ) merged_model = lora_model.merge_and_unload() merged_model.save_pretrained("./merged_model") tokenizer.save_pretrained("./merged_model")merge_and_unload 会把 LoRA 分支的权重融合到原始权重矩阵中,得到一个“纯原版结构”的模型。这个模型的大小和底座模型完全一样,推理速度和底座模型也完全一样,没有任何额外开销。
在真正部署之前,一定要做一轮对比测试。我习惯的做法是准备一组同样的 prompt,分别喂给底座模型和合并后的模型,对比输出差异。如果底座模型对某个问题答得模棱两可,微调后模型能给出更具体、更符合业务风格的回答,说明微调方向是对的。如果微调后模型出现明显的胡言乱语、重复输出,那可能是训练过头了,需要回退到更早的 checkpoint。
这里还有个值得记住的技巧:不要急着删中间 checkpoint。训练过程中的最后一个 checkpoint 不一定是效果最好的那个,尤其是在学习率还没完全衰减到 0 的时候。多保存几个中间 checkpoint,用验证集逐个评估,选最优的那个做合并。save_total_limit=2 会限制只保留最近两个,如果你有条件,建议调到 3 到 5。
4. 把量化引入微调:QLoRA 的工程细节
4.1 量化在微调中的角色
LoRA 虽然已经大幅降低了可训练参数的量,但底座模型本身还是以高精度格式存在显存里的。一个 7B 模型即使加载成 FP16,也要占约 14GB 显存,训练时还要算上 LoRA 分支的激活值。这样算下来,24GB 显存虽然够用,但批量大小和序列长度都受限。
如果模型规模再往上走,比如 13B,FP16 加载就要 26GB,单卡直接放不下。于是大家自然会想:能不能把底座模型先量化到更低的精度,腾出显存空间给训练计算?这就是 QLoRA 的思路——用 4-bit 的底座模型 + LoRA 分支来做微调。
你可能会担心:底座模型都量化到 4-bit 了,精度损失很大,训练出来的效果会不会变差?答案是不会的,因为关键细节在于:训练时只更新 LoRA 分支,而 LoRA 分支以高精度(FP16 或 BF16)保存和计算。底座模型虽然被量化,但它只是提供前向计算的特征提取能力,真正学习任务偏好的更新量都在 LoRA 的分支里,这部分是精确训练的。QLoRA 论文里的结论和后来大量复现实验都表明,4-bit 量化 + LoRA 的效果可以和 FP16 底座 + LoRA 非常接近,差距通常在一个点以内。
4.2 QLoRA 的关键组件
QLoRA 不是简单地把模型加载成 4-bit 就完事,它有几个关键设计值得讲清楚。
第一个是 NF4 量化格式。普通 4-bit 量化直接把数值均匀映射到 16 个离散值上,但神经网络权重服从近似正态分布,均匀量化会造成大量精度浪费在罕见的大数值上。NF4 格式采用分位数映射,让量化间隔依据正态分布的概率密度分布来划分,使得大多数权重所在区间有更高的表示精度。这是 QLoRA 相比简单 4-bit 方案效果更好的主要原因之一。
第二个是双重量化。为了存储 4-bit 权重,我们还需要额外的量化常数记录每个块的缩放因子。如果这些常数用 FP32 保存,一块 64 个权重就要付 4 字节的常数开销,几乎白省了空间。双重量化是对那些缩放因子再做一次 8-bit 量化,把额外的存储开销降到极低。这个细节单独看没什么,但组合进大模型里能省出好几个 GB 的显存。
第三个是分页优化器。训练过程中如果偶尔出现显存峰值,优化器状态会自动被暂时挪到 CPU 内存里,腾出 GPU 显存给当前算子完成计算,峰值过了再搬回来。这个机制和操作系统里的虚拟内存很像,能让训练过程在显存“刚刚好”的情况下更稳定地跑下去。
实际代码里,使用 QLoRA 只需要在加载模型时改一个参数:
model = AutoModelForCausalLM.from_pretrained( model_name, load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, device_map="auto", )后面接 LoRA 配置和训练流程,和普通 LoRA 完全一致。
4.3 实际收益与硬件门槛
拿 7B 模型举例,FP16 加载底座需要约 14GB,而 4-bit NF4 量化加载只需要约 4GB 到 5GB。省出来的近 10GB 显存可以用来加大批量、加长序列,或者干脆用更小的消费级显卡跑训练。我用 24GB 显存跑 7B QLoRA 时,序列长度可以开到 2048,批量大小 4,训练过程非常从容。如果换到 16GB 显存的卡,同样能把主流 7B 模型微调跑起来,这在全量微调时代是不可想象的。
有一个常见误解需要澄清:QLoRA 是训练方法,不是推理方法。训练完合并 LoRA 权重之后,你可以选择把模型转回 FP16 部署,也可以继续以 4-bit 格式部署做推理。前者效果上限高一些,后者省显存。通常我会先把模型合并为 FP16 版本做验收测试,等确认效果没问题后,再根据部署环境决定是否量化推理版本。
5. 实战踩坑记录与调参心得
5.1 显存不够怎么办
“程序猝死”是微调遇到最多的问题之一。显存不够的报错通常是 CUDA out of memory。按下面的顺序排查,大部分情况下能解决:
先看批量大小,per_device_batch_size 降到 1 是最直接的降显存手段。接着看梯度累积,设置 gradient_accumulation_steps=8 甚至 16,等效批量可以保持住,只是训练速度变慢。再看序列长度,如果业务不需要长上下文,把最大长度从 2048 降到 1024,显存占用能立刻减少将近一半。最后看加载格式,确保底座模型是 4-bit 加载而不是默认的 FP16。
一个很隐蔽的坑是 attention mask 的问题。用 transformers 训练时,如果输入数据的 padding 策略不对,实际参与计算的 token 数比你想象的更多,显存就会悄悄超支。训练前用 tokenizer 的 padding 和 truncation 正确处理好每条样本。
如果以上都调整过了还是爆显存,那就真的要怀疑代码里是不是有哪个地方把模型参数错误地变成了可训练状态。用 model.print_trainable_parameters() 确认可训练参数只有 LoRA 分支那部分。
5.2 训练不收敛或者过拟合
loss 一开始就不降,首先检查学习率。LoRA 的学习率低于 1e-5 基本就是原地踏步,高于 1e-3 又会剧烈震荡,2e-4 左右是绝大多数场景的甜点区。
另一种情况是 loss 下降了,但验证集 loss 后期反而上升,这就是过拟合信号。解决手段有几个:增大 dropout(lora_dropout 从 0.05 调到 0.1),减小秩 r(从 16 回到 8),或者提前结束训练(把 num_train_epochs 从 3 降到 2)。不要迷信加数据,先检查数据质量。如果训练集里有很多重复样本或噪声标签,模型背答案的速度比学规律快得多,过拟合是必然的。
实操心得:有一类问题非常难排查——数据按对话记录组织时,角色标识和回复格式五花八门。如果数据里“user”和“assistant”的编号不统一,或者系统提示词里的格式说明和训练样本里的格式不一致,模型学习到的迁移规律会被严重干扰。做微调前花半天时间清洗一遍数据格式的收益,远比花一周调参来得高。
5.3 推理阶段质量下降
微调完合并模型后,推理效果和预期差距很大,首先要区分是“能力受损”还是“风格不对”。
能力受损的典型表现是:模型回答明显变短、信息量下降、逻辑性变差。极有可能是训练数据里缺少知识性的样本,导致模型在适配任务风格时把通用知识能力挤掉了。解决办法是在训练数据里混入一定比例的高质量通用问答数据,让模型在适应新任务的同时保留原有能力。这个比例我一般控制在 20% 到 30% 之间。
风格不对的典型表现是:输出格式没完全按预期走,或者特定场景下的措辞不对。这通常是 LoRA 的秩偏低、表达能力不够。把 r 调大一点、alpha 同步放大,重新训练一轮,往往有改善。
还有一种情况是量化误差。如果你部署时用的是 4-bit 推理版本,部分模型的量化策略会在某些生成任务上放大误差。可以试试把 compute_dtype 从 fp16 换成 bf16,或者改用 8-bit 量化部署,一般能立竿见影。
6. 微调之后的下一站
这一章讲的是参数高效微调的入门框架,但离真正把模型稳定落地还有距离。接下来几条线索值得继续深入:第一条是数据工程,如何构造高质量指令数据、如何做数据配比、如何处理长尾分布,这些直接决定微调的上限。第二条是评测体系,微调模型不能只看训练 loss,要在真实业务场景里建立可复现的评测集,量化对比每个版本的变化。第三条是 PEFT 与其他技术的组合,比如 LoRA 和知识编辑、LoRA 和模型融合,这些在进阶实践中会频繁遇到。
我在实际操作中的体会是,PEFT 最难的不是跑通代码,而是建立起一套自己的判断标准:什么样的数据值得微调、什么样的任务适合用 PEFT、效果不满意时该往哪个方向调。把这一章的内容消化透,你已经具备了在单卡环境下完整跑通一次微调实验的能力。接下来的章节会继续深入量化微调的更多细节,以及它们在真实业务场景里的落地经验。