1. 问题背景:为什么不用全量微调,偏要折腾LoRA
我手头有个法律文书问答需求,需要让模型理解「借条效力」「离婚财产分割」这类具体问题。全量微调7B模型?别闹了——单卡A100 80G都不够,除非你用DeepSpeed ZeRO-3 + 多卡,但公司就批了一张卡,还得跑其他任务。
LoRA(Low-Rank Adaptation)的思路是冻结原模型权重,只训练注入的低秩矩阵。QLoRA更进一步,把模型量化到4bit(NF4),配合双重量化(Double Quantization)和分页优化器(Paged Optimizer),把显存压到极致。我的目标不是刷榜,而是让模型在特定领域「听话」,LoRA完全够用。
2. 环境与版本:全是血泪教训
先说版本,Lock住这几个版本能省一半调试时间:
python=3.10.12
torch=2.1.2+cu118
transformers=4.37.2
peft=0.9.0
bitsandbytes=0.43.1
datasets=2.17.0
accelerate=0.27.2
注意:别用transformers 4.38+,AutoModelForCausalLM.from_pretrained的load_in_8bit参数行为变了,我直接踩了ValueError: load_in_8bit=True is not supported for this model。另外,bitsandbytes在Windows上需要手动装bitsandbytes-windows,但Linux直接pip就行。
3. 方案设计:LoRA还是QLoRA,这是个问题
我两个都试了。设计如下:
- LoRA:模型权重保持FP16,注入秩为8的LoRA矩阵。显存占用高,但训练稳定,loss收敛平滑。
- QLoRA:模型量化到NF4(4bit),LoRA矩阵加到量化后的层上。显存占用低,但loss曲线会有轻微锯齿,需要调学习率。
目标层:q_proj、v_proj(只改这两个,留k_proj和o_proj不动,减少过拟合)。LoRA的alpha设16,dropout设0.05。优化器用paged_adamw_8bit。
from peft import LoraConfig, get_peft_model, TaskType
lora_config = LoraConfig(
r=8,
lora_alpha=16,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05,
bias="none",
task_type=TaskType.CAUSAL_LM,
)
4. 核心实现:数据准备 + 训练配置 + 代码
4.1 数据准备:清洗比训练更费时间
我用的数据是自采的2000条法律问答对,格式为{"instruction": "...", "output": "..."}。训练时拼成Q: {instruction}\nA: {output}。但原始数据里有很多空行、特殊符号,比如\u3000(全角空格),直接喂给tokenizer会导致loss不降。
清洗后,数据长度分布:90%在512 token以内,所以max_length=512,超过就截断。如果序列太长,显存会暴涨,QLoRA都救不了。
4.2 训练配置:QLoRA的4bit量化参数
from transformers import BitsAndBytesConfig, AutoModelForCausalLM, AutoTokenizer
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
关键参数解释:
- bnb_4bit_use_double_quant=True:对量化常数再量化,省0.5GB/10亿参数。
- bnb_4bit_compute_dtype=torch.bfloat16:计算时用BF16,防止NF4精度损失太大。
- device_map="auto":让accelerate自动分配,别手动指定cuda:0,否则可能显存碎片。
训练循环用transformers.Trainer,配置如下(节选):
training_args = TrainingArguments(
output_dir="./lora-law-7b",
per_device_train_batch_size=4,
gradient_accumulation_steps=8, # 等效batch=32
num_train_epochs=3,
learning_rate=2e-4,
fp16=True,
logging_steps=10,
save_steps=500,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
)
5. 踩坑与优化:三个坑,两个差点弃坑
坑1:QLoRA训练的loss曲线震荡
第一次跑QLoRA,loss在0.8附近来回晃,不下降。排查发现是learning_rate设成5e-4太高了,LoRA层的参数是随机初始化的,大学习率会导致低秩矩阵更新过猛。降到2e-4后,loss开始稳定下降。
坑2:显存溢出(OOM)
在A100 80G上,LoRA跑batch_size=8没问题,但QLoRA反而OOM了?原因是device_map="auto"把部分层放到了CPU,但CPU offload的层在backward时会产生额外显存开销。解决:手动指定device_map={"": 0},强制全卡。
坑3:推理时输出重复
微调后,模型回答「借条怎么写」时,会重复输出「借条」这个词5次。这是num_beams=1的贪心搜索问题,改成num_beams=4 + repetition_penalty=1.2解决。
6. 效果数据:loss曲线与推理对比
6.1 Loss曲线
我用wandb记录,关键数字:
- LoRA:初始loss 1.82,第500步降到0.89,第1200步降到0.52,最终(1500步)0.41。曲线平滑,无反弹。
- QLoRA:初始loss 1.85,前100步震荡(从1.85到1.62再到1.70),第400步后稳定,最终0.47。比LoRA高0.06,但显存占用从52.3G降到14.3G(峰值),训练速度提升1.7倍(因为4bit矩阵乘法更快)。
6.2 推理效果对比
我随机抽了20个法律问题,用两种模型生成答案:
| 指标 | Base (Llama-2-7B) | LoRA微调 | QLoRA微调 |
|---|---|---|---|
| 答案相关度(人工打分,1-5) | 2.1 | 4.3 | 4.1 |
| 格式规范(是否包含「结论:」「依据:」) | 10% | 85% | 80% |
| 幻觉率(编造法条) | 35% | 15% | 18% |
具体案例:
- Base:「欠条和借条的区别?」→「借条是借款凭证,欠条是欠款凭证」——太笼统,没讲法律效力。
- LoRA微调:「欠条和借条的区别?」→「结论:借条更利于保护债权人。依据:民间借贷司法解释第X条,借条诉讼时效为3年,欠条若未注明还款日期,诉讼时效从出具之日起算。」——有结论、有依据。
- QLoRA微调:答案结构类似,但「诉讼时效」写成了「仲裁时效」,幻觉出现。
7. 总结:LoRA/QLoRA怎么选
我的建议:
- 有32G以上显存:直接LoRA,训练稳定,效果上限高。
- 显存紧张(<24G)或需要长上下文:QLoRA,省下的显存可以加大max_length或batch_size。
- QLoRA的loss震荡:别怕,前200步是量化误差在适应,调低学习率(2e-4左右)就能压下去。
最后说一句:LoRA不是银弹,它解决的是「特定领域指令跟随」问题,不是「模型能力提升」。微调前先评估base模型在你领域的基线表现,如果它连基础逻辑都错,LoRA也救不回来。但如果你像我一样,只是想让模型输出更规范、更贴近行业术语——LoRA/QLoRA绝对值得一试。
完整代码已放到GitHub(链接),有问题的评论区见。