一、问题背景:为什么放弃全参微调
接手一个法律文书要素抽取任务,训练集2.1万条,每条输入平均1200 token。试了全参微调Qwen2-7B-Instruct,batch_size=1、梯度累积8步,显存峰值23.6G——4090直接冒烟。更致命的是训练一个epoch要11小时,根本没法迭代。
同事建议用LoRA,但查了社区方案,7B模型LoRA微调最低也要14-16G显存。我这只有一张卡,还得跑验证集,显存预算卡死在12G以内。最后锁定了QLoRA方案:把基座模型量化到4bit,冻结全部参数,只训练LoRA适配器。
二、环境与版本:坑比想象中多
先列版本,这玩意儿版本不对直接报错:
torch==2.1.2
transformers==4.38.1
peft==0.9.0
bitsandbytes==0.43.1
accelerate==0.27.2
datasets==2.17.0
血泪坑1: 千万别用transformers 4.40+,bitsandbytes的LLM.int8()接口变了,4bit量化会静默失败——loss不下降,但训练流程不报错。我排查了一整天。
血泪坑2: peft 0.10.0和transformers 4.38.1有兼容问题,prepare_model_for_kbit_training会报AttributeError: 'LlamaForCausalLM' object has no attribute 'gradient_checkpointing_enable'。锁死0.9.0版本稳。
三、方案设计:LoRA参数怎么定
任务类型是序列标注(抽取法律文书里的当事人、金额、日期),但统一转成生成式指令格式:
输入:请从以下法律文书中抽取【原告】【被告】【标的额】...\n{文书内容}
输出:原告:张三\n被告:李四\n标的额:50000元
LoRA配置我试了三组,最终效果差异明显:
| 参数组 | r | alpha | dropout | target_modules | F1 |
|---|---|---|---|---|---|
| A | 8 | 16 | 0.1 | q_proj,v_proj | 0.83 |
| B | 16 | 32 | 0.05 | q_proj,k_proj,v_proj,o_proj | 0.87 |
| C | 32 | 64 | 0.1 | 全部linear | 0.86 |
选B组。r=16时rank容量够用,覆盖全部attention投影(q/k/v/o)比只调q和v效果好得多,但别动gate_proj和up_proj——实测加了反而过拟合。
四、核心实现:QLoRA训练代码
步骤1:4bit量化加载基座模型
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_id = "/data/models/Qwen2-7B-Instruct"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4量化比fp4更稳
bnb_4bit_use_double_quant=True, # 双重量化省显存
bnb_4bit_compute_dtype=torch.bfloat16, # 计算用bf16,避免精度损失
)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token # Qwen2没pad_token,必须手动设置
步骤2:注入LoRA适配器
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
# 关键:必须先冻结+开gradient_checkpointing
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters() # 输出: trainable params: 8.4M || all params: 7.2B || trainable%: 0.12%
步骤3:训练配置
from transformers import TrainingArguments, Trainer
training_args = TrainingArguments(
output_dir="./qwen2-lora-law",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效batch=16
num_train_epochs=3,
learning_rate=2e-4, # LoRA常用1e-4~3e-4,比全参高10倍
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,
gradient_checkpointing=True,
optim="paged_adamw_8bit", # 关键:8bit优化器省显存
report_to="tensorboard",
max_seq_length=1024,
)
五、踩坑与优化:loss曲线震荡的真相
问题1:loss不下降
第一版跑起来,loss卡在0.8左右完全不动。查了梯度,发现bitsandbytes的4bit层在bf16计算下梯度爆炸。解决办法:bnb_4bit_compute_dtype=torch.bfloat16改成torch.float16,立刻见效。
问题2:loss震荡剧烈
第500步loss从1.2掉到0.6,但第600步又弹回1.1。排查发现是gradient_checkpointing和fp16冲突。解决方案:
# 训练参数里加这两个
training_args.fp16_opt_level = "O2"
training_args.gradient_checkpointing_kwargs = {"use_reentrant": False}
问题3:显存峰值11.2G但偶尔爆
max_seq_length从2048砍到1024,显存从14.1G降到11.2G。代价是长文书需要截断——但任务里90%的文书不超过800 token,可接受。
六、效果数据:微调前后对比
训练3个epoch耗时7小时40分钟,最终loss收敛到0.32(验证集)。
推理效果对比:
| 模型 | F1 | 推理速度(tokens/s) | 显存占用 |
|---|---|---|---|
| 原版Qwen2-7B-Instruct | 0.31 | 42.3 | 15.8G |
| 全参微调(跑崩了) | - | - | - |
| LoRA (r=8) | 0.83 | 41.7 | 14.1G |
| QLoRA (r=16) | 0.87 | 40.9 | 11.2G |
实际生成对比(测试样本):
输入:...被告王五于2021年3月15日向原告借款人民币50000元整...
原版输出:原告:未知\n被告:未知\n标的额:未知 ← 完全没法用
QLoRA输出:原告:张三\n被告:王五\n标的额:50000元整 ← 准确
QLoRA比LoRA只低0.2个点F1,但显存省了3G——这个trade-off在单卡场景下完全值得。如果显存充足(比如40G的A100),建议直接用LoRA不量化,训练速度会快15%左右。
七、总结
- 7B模型在12G显存下微调,QLoRA是目前唯一可行方案,4bit NF4量化+双重量化+8bit优化器三板斧缺一不可
- LoRA的r参数不是越大越好,r=16+覆盖q/k/v/o投影是性价比最高的组合
- 版本锁死:transformers 4.38.1 + peft 0.9.0 + bitsandbytes 0.43.1,这套组合我踩完坑了直接用
- loss震荡优先查梯度检查点和fp16的兼容性,别急着调学习率
最后说句实话:QLoRA微调的效果和全参微调差距在2-3个点以内,但显存和训练速度的优势是碾压性的。如果你的业务场景对推理延迟不敏感(比如离线批量处理),QLoRA绝对值得优先尝试。