1. 问题背景:7B模型微调,显存是第一道坎
最近接到一个任务:需要把Qwen2-7B-Instruct微调到某个垂直领域(法律文书摘要),要求模型能输出符合特定格式的摘要。全参数微调在单卡A100(40G)上跑没问题,但公司资源有限,能申请到的只有一张RTX 4090(24G)。跑了一次全参微调,batch size=1、seq_len=2048时显存直接爆掉——7B模型的AdamW优化器状态就要吃掉约4倍参数量大小的显存,即28GB,加上激活值根本不够。
于是转向LoRA和QLoRA。这两者本质都是“冻结原模型,只训练少量旁路参数”,区别在于QLoRA额外对原模型做了4-bit量化(NormalFloat4),显存占用更低,但会引入量化误差。这篇文章记录我完整跑通这两个方案的过程,包括数据准备、训练配置、loss曲线以及最终的推理效果对比。
2. 环境与版本
强烈建议先固定版本,LLM生态一天一个样,版本不匹配会浪费大量时间排查。我的环境如下:
| 组件 | 版本 |
|---|---|
| Python | 3.10.12 |
| PyTorch | 2.1.2+cu118 |
| transformers | 4.40.1 |
| peft | 0.10.0 |
| bitsandbytes | 0.43.1 |
| datasets | 2.19.0 |
| accelerate | 0.29.2 |
| GPU | NVIDIA RTX 4090 24GB |
这里特别提醒:peft和transformers必须配套,pEFT 0.10.0对应transformers>=4.38.0,低于这个版本会报PEFTConfig的import错误。另外bitsandbytes在Windows下需要额外安装bitsandbytes-windows轮子包,否则load_in_4bit=True会直接报CUDA setup failed。
3. 方案设计:LoRA vs QLoRA的取舍
先说结论:参数规模决定策略。7B模型全参微调需要约140GB显存(FP16),无人能及;LoRA只训练0.5%-1%的参数,显存占用在16-24G之间;QLoRA进一步把原模型压缩到4-bit,显存能降到8-10G。
我的设计如下:
- LoRA配置:
r=16,alpha=32,target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]。dropout=0.05,bias="none"。 - QLoRA配置:在LoRA基础上,原模型用
BitsAndBytesConfig加载,load_in_4bit=True,bnb_4bit_quant_type="nf4",bnb_4bit_compute_dtype=torch.bfloat16,bnb_4bit_use_double_quant=True。
关键点:目标模块必须包含所有线性层。很多教程只改q_proj和v_proj,但在我的实验中发现只改这两个模块,loss下降明显变慢(见第5节)。原因很简单:LoRA的可学习参数太少,拟合能力不足。
4. 核心实现:数据准备与训练代码
4.1 数据准备
法律文书摘要任务,我手动标注了2000条样本(输入为裁判文书原文,输出为300字以内的摘要)。格式为{"instruction": "...", "input": "...", "output": "..."}。用datasets库加载并做tokenize:
from datasets import load_dataset
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2-7B-Instruct", trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token # Qwen2必须设置pad_token,否则训练报错
def preprocess(example):
# 构建对话模板(Qwen2的chat模板)
messages = [
{"role": "system", "content": "你是一个法律文书摘要专家。"},
{"role": "user", "content": f"请对以下文书进行摘要:\n{example['input']}"},
{"role": "assistant", "content": example["output"]}
]
text = tokenizer.apply_chat_template(messages, tokenize=False)
# tokenize并截断到2048
enc = tokenizer(text, truncation=True, max_length=2048, padding="max_length")
enc["labels"] = enc["input_ids"].copy()
# 将padding部分设为-100,避免计算loss
enc["labels"] = [-100 if t == tokenizer.pad_token_id else t for t in enc["labels"]]
return enc
dataset = load_dataset("json", data_files="legal_data.jsonl")["train"]
dataset = dataset.map(preprocess, batched=False, remove_columns=dataset.column_names)
这里有个细节:labels中的pad位置必须设为-100。如果不做这一步,模型会在padding token上计算loss,导致loss曲线异常(损失值偏高且不下降)。
4.2 训练配置(LoRA)
from transformers import TrainingArguments, Trainer
from peft import LoraConfig, get_peft_model
import torch
# LoRA配置
lora_config = LoraConfig(
r=16,
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"
)
# 加载基础模型(LoRA用FP16,QLoRA用4bit)
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2-7B-Instruct",
torch_dtype=torch.bfloat16,
device_map="auto"
)
model = get_peft_model(model, lora_config)
print(f"可训练参数: {model.print_trainable_parameters()}") # 约0.5%的参数
training_args = TrainingArguments(
output_dir="./lora_checkpoints",
num_train_epochs=3,
per_device_train_batch_size=2,
gradient_accumulation_steps=4, # 等效batch_size=8
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_steps=200,
evaluation_strategy="steps",
eval_steps=200,
save_total_limit=2,
remove_unused_columns=False,
report_to="tensorboard",
fp16=False, # QLoRA用bf16更稳定
bf16=True,
gradient_checkpointing=True, # 关键:用显存换速度
optim="paged_adamw_8bit" # QLoRA必须用paged优化器
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=dataset,
eval_dataset=dataset.select(range(200)),
tokenizer=tokenizer,
)
trainer.train()
4.3 QLoRA只需改加载方式
QLoRA的核心区别在于基础模型的加载方式,其余代码完全一致:
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True
)
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2-7B-Instruct",
quantization_config=bnb_config,
device_map="auto",
torch_dtype=torch.bfloat16,
)
model = get_peft_model(model, lora_config)
注意:QLoRA必须设置optim="paged_adamw_8bit",因为4-bit量化下的参数更新需要分页优化器来处理显存碎片。另外gradient_checkpointing=True是必须的,否则24G显存也扛不住7B模型的反向传播。
5. 踩坑与优化:三个关键调整
5.1 训练损失不下降?检查target_modules
第一轮实验我只用了["q_proj", "v_proj"],跑了500步loss从1.8降到1.5后就卡住了。换全部线性层后,loss降到1.2。原因:LoRA的可学习参数只有0.1%,而QKV三个投影矩阵的权重共享了大部分信息,只调其中两个会限制模型的表示能力。
5.2 QLoRA的loss震荡
QLoRA在训练初期loss波动比LoRA大(0.1左右),这是因为4-bit量化的精度损失被梯度放大。后来我做了两个调整:
- 学习率从2e-4降到1e-4:QLoRA对学习率更敏感,高学习率会导致量化误差累积。
- warmup_ratio从0.03提到0.1:让模型先用低学习率适应量化权重。
调整后loss曲线明显平滑。
5.3 显存优化技巧
gradient_checkpointing=True:显存从24G降到13G,但训练速度慢约30%。per_device_train_batch_size=2+gradient_accumulation_steps=4:等效batch size=8,显存占用仅为batch size=8的1/4。- 将序列长度从2048降到1024:显存减少约40%,但模型效果下降(摘要任务长文关键信息丢失)。最终保留2048。
6. 效果数据:loss曲线与推理对比
6.1 Loss曲线对比
训练了3个epoch(约750步),两种方案的loss曲线如下:
| 训练步数 | LoRA loss | QLoRA loss |
|---|---|---|
| 0 | 2.31 | 2.45 |
| 100 | 1.82 | 1.91 |
| 300 | 1.45 | 1.52 |
| 500 | 1.28 | 1.36 |
| 750 | 1.15 | 1.24 |
结论:QLoRA的loss整体比LoRA高约0.1,但在最后100步差距缩小到0.09。这说明4-bit量化引入了约5%的信息损失,但并不会导致不收敛。
6.2 推理效果对比
用50条测试集(与训练集不重叠)进行人工评测,对比三个维度:格式正确率(是否按模板输出)、关键信息覆盖率(人工打分)、流畅度(人工打分,5分制)。
| 模型 | 格式正确率 | 关键信息覆盖率 | 流畅度 | 推理耗时(每条) |
|---|---|---|---|---|
| 原始Qwen2-7B | 12% | 0.45 | 4.2 | 0.8s |
| LoRA微调 | 92% | 0.83 | 4.6 | 0.8s |
| QLoRA微调 | 88% | 0.81 | 4.5 | 0.9s |
关键发现:LoRA和QLoRA在格式正确率上差距不大(92% vs 88%),但在关键信息覆盖率上QLoRA比LoRA低2个百分点。量化损失主要体现在对长文细节的捕捉上。不过QLoRA的显存占用(8.2G)仅为LoRA(16.8G)的一半,并且能跑在消费级显卡上(如RTX 3060 12G就能跑)。
6.3 推理效果示例
输入:(一段3000字的民事判决书……省略)
LoRA输出:
原告张三与被告李四房屋买卖合同纠纷一案,法院经审理认定合同有效,被告未按期履行付款义务构成违约,判决被告支付购房款余款人民币200万元及逾期利息,案件受理费由被告负担。
QLoRA输出:
原被告房屋买卖合同纠纷,法院判决被告支付购房款200万及利息,诉讼费由被告承担。
虽然QLoRA输出更简略,但关键数字(200万)和判决结果都准确。对于摘要任务,QLoRA的简洁性反而是优势。
7. 总结与建议
| 方案 | 显存占用 | 训练速度(step/s) | 效果(F1) | 适用场景 |
|---|---|---|---|---|
| 全参数微调 | >24G(无法运行) | - | - | 需A100/H100 |
| LoRA | 16.8G | 1.2 | 0.85 | 单卡24G+ |
| QLoRA | 8.2G | 0.98 | 0.84 | 消费级显卡 |
最终建议:
- 如果你的显卡≥24G,优先用LoRA。虽然QLoRA显存省一半,但训练时间多18%,且效果略差。没必要为了省显存牺牲效果。
- 如果显卡≤16G,QLoRA是唯一选择。RTX 4090 D 16G版、RTX 4080 16G都可以跑,但batch size只能设为1,需要配合梯度累积。
- 学习率调整:LoRA用2e-4,QLoRA用1e-4,别偷懒直接用默认值。
- 永远不要全参数微调7B模型,除非你有A100且不在乎时间成本。
这次实践让我深刻体会到:在有限资源下,量化+适配器是性价比最高的微调方案。QLoRA虽然损失了约1%的效果,但换来了部署和调试的极大便利。如果后续需要部署到边缘设备,我甚至可以考虑用GPTQ量化到4-bit(推理时无需bitsandbytes),但那是另一个话题了。