一、问题背景:为什么不用全参微调?
上周接了个法律文书摘要项目,需要让模型理解《民法典》条文。试了全参微调Llama-2-7B,A100上跑了2小时显存就爆了——单条序列长度1024,batch_size=4时优化器状态就吃掉28GB。更离谱的是,微调完模型对训练集过拟合到loss=0.03,但验证集ROUGE-L只有0.19。
后来换成LoRA(Low-Rank Adaptation)才想明白:全参微调本质是在1024维空间里找最优解,但7B模型的参数矩阵本身就是低秩的(实测有效秩约37%)。与其更新全部70亿参数,不如冻结原权重,只训练两个低秩矩阵A和B(秩r=16),参数量瞬间降到420万,仅为原来的0.06%。
二、环境与版本:Python 3.10 + CUDA 11.8
建议直接用我这套组合,踩过坑的版本组合:
torch==2.1.2+cu118
transformers==4.38.2
peft==0.10.0
bitsandbytes==0.43.1
datasets==2.18.0
accelerate==0.27.2
特别提醒:bitsandbytes必须用0.43.1以上,否则4-bit量化会报CUDA error: no kernel image available。另外,transformers和peft版本必须配套,我一开始用transformer 4.36+peft 0.9,加载QLoRA模型时直接报KeyError: 'lora_A'。
三、方案设计:QLoRA的量化与适配器布局
QLoRA相比原始LoRA多了两步:
1. 4-bit NF4量化:将原始FP16权重量化到4-bit,显存直接砍4倍。NF4(NormalFloat4)比INT4好在它按正态分布分段量化,对激活值分布更友好。
2. 双适配器设计:在q_proj和v_proj上挂LoRA适配器,而不是全部线性层。实测只挂q/v能保留70%效果,但显存少花2.1GB。
我的配置:
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import BitsAndBytesConfig
# 4-bit量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True, # 二次量化,再省0.4GB
bnb_4bit_compute_dtype=torch.bfloat16
)
# LoRA配置
lora_config = LoraConfig(
r=16, # 低秩矩阵维度
lora_alpha=32, # 缩放系数,alpha/r=2
target_modules=["q_proj", "v_proj"],
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM"
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=bnb_config,
device_map="auto",
torch_dtype=torch.bfloat16
)
model = prepare_model_for_kbit_training(model) # 冻结原权重,开启梯度检查点
model = get_peft_model(model, lora_config)
显存实测:4-bit加载后模型占5.8GB,加上梯度检查点(gradient_checkpointing=True)和适配器参数,峰值显存14.2GB。3090的24GB完全能跑,甚至还能开batch_size=8。
四、核心实现:数据准备与训练循环
数据格式用的是Alpaca模板:
{
"instruction": "根据以下法律条文,回答用户问题",
"input": "《民法典》第1043条规定:家庭应当树立优良家风...",
"output": "该规定确立了夫妻忠实义务,具体包括..."
}
清洗时发现三个坑:一是原始数据有HTML标签(`),要正则扒掉;二是长文本超出512 token直接截断,导致回答不完整;三是重复样本太多,用datasets的shuffle前先做了deduplicate`。
训练配置:
from transformers import TrainingArguments
training_args = TrainingArguments(
output_dir="./legal-lora",
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # 等效batch_size=16
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=50,
save_steps=500,
fp16=False,
bf16=True, # 关键:3090支持BF16,比FP16更稳
optim="paged_adamw_8bit", # 8-bit优化器,再省2GB
)
训练时loss曲线(用tensorboard记录):
Step 0: loss=1.82 # 初始
Step 500: loss=1.21 # 下降快
Step 1000: loss=0.83 # 变缓
Step 1500: loss=0.61 # 开始震荡
Step 2000: loss=0.47 # 收敛
重点:loss在1200步后出现抖动,怀疑是学习率过大。改成cosine衰减后,step1500的loss从0.72降到0.58。另外,前500步loss下降斜率很大,但生成质量没跟上——说明模型在学格式,还没学语义。建议前500步只记录loss,别急着评估。
五、踩坑与优化:三个致命错误
坑1:BF16精度导致loss=NaN
第一次训练用fp16=True,跑了200步loss直接变NaN。查了显卡温度(78°C正常),最后发现是FP16的精度溢出——Llama的激活值范围太大,FP16的指数位不够。换成bf16=True后问题消失,loss稳定下降。
坑2:序列长度512 vs 1024
用512训练完,推理时输入超过512,模型直接乱写。原因是位置编码没训练到长序列。把max_seq_length=1024后,显存多了3GB,但loss下降更均匀。建议直接按推理场景的最大长度设定。
坑3:LoRA适配器没合并就推理
用model.generate()测试时,如果不先merge_and_unload(),推理速度只有2 token/s。合并后提升到18 token/s。代码:
from peft import PeftModel
model = PeftModel.from_pretrained(base_model, "./legal-lora/checkpoint-2000")
merged_model = model.merge_and_unload() # 关键步骤
merged_model.save_pretrained("./legal-lora-merged")
六、效果数据:对比微调前后
用200条法律问答测试(非训练集),对比三个版本:
| 指标 | 原版Llama-2-7B | LoRA微调(r=16) | 全参微调(参考) |
|---|---|---|---|
| PPL (困惑度) | 28.4 | 17.9 | 15.2 |
| ROUGE-L | 0.21 | 0.58 | 0.63 |
| BLEU-4 | 0.09 | 0.34 | 0.41 |
| 单条生成时间 | 3.2s | 1.8s | 2.1s |
定性对比:
问:“夫妻分居满两年,是否自动离婚?”
原版回答:“根据法律规定,分居满两年可以自动离婚。”(错误,混淆了分居和离婚条件)
微调后回答:“根据《民法典》第1079条,因感情不和分居满二年,调解无效的,应当准予离婚。但需注意:分居需因感情不和,且需向人民法院提起诉讼,不存在自动离婚。”(正确,且引用法条)
七、总结与建议
- 什么时候用LoRA:领域数据量<10万条,或者只有单卡。如果数据量百万级,直接全参微调更好。
- 秩r怎么选:r=16是性价比甜点。我试过r=8(loss=0.61)和r=32(loss=0.42),但r=32显存多占4.1GB,推理速度慢20%。
- 量化精度:4-bit够用,3-bit(GPTQ)在7B模型上会掉2-3%准确率,不推荐。
- 训练策略:建议先用0.5个epoch试跑,看loss是否降到1.0以下,再决定是否全量训练。省电省时间。
最后说句实在的:LoRA不是银弹,它适合“快速适配”场景。如果你要模型学全新知识(比如新语言),还是得全参微调或者继续预训练。但就法律文本摘要这个任务,LoRA用1/10的显存达到了全参92%的效果,值了。
代码托管在github.com/yourname/legal-lora,有问题评论区聊。