1. 问题背景:为什么不用全量微调?
最近在做一个法律咨询助手,需要让模型学会「先给结论,再列法条,最后附免责声明」的固定输出结构。试过直接prompt工程,但基座模型(Qwen2.5-7B-Instruct)在长回答(>300字)时经常结构混乱,且容易编造法条编号。
全量微调?7B模型的LoRA权重(fp16)就要14G,加上优化器状态(AdamW的momentum+variance),单卡3090直接爆显存。更不用说反向传播时保存的激活值。所以必须上LoRA/QLoRA。
2. 环境与版本:踩坑从torch版本开始
Python: 3.10.12
CUDA: 12.1
PyTorch: 2.1.2 (必须用2.1+,否则QLoRA的4-bit反量化会报错)
transformers: 4.40.1
peft: 0.10.0 (最新版已经合并了LoRA和QLoRA接口)
bitsandbytes: 0.43.1 (Linux必备,Windows用户请用WSL)
datasets: 2.19.0
最坑的是bitsandbytes,老版本在3090上会报CUDA setup failed。必须确认import bitsandbytes不报错,且bnb.nn.Linear4bit能被调用。第一次跑直接崩溃,最后重装了0.43.1才解决。
3. 方案设计:QLoRA + 中文法律指令数据
任务拆解:
- 基座模型:Qwen/Qwen2.5-7B-Instruct(4-bit NF4量化)
- 量化配置:BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type='nf4', bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
- LoRA配置:r=16, alpha=32, dropout=0.05, target_modules=['q_proj','k_proj','v_proj','o_proj','gate_proj','up_proj','down_proj']
为什么target_modules要全挂?因为7B模型里MLP的参数量占总参数2/3,只调attention层效果会打折扣。我实测过只挂q_proj和v_proj,loss下降速度慢30%。
数据构建:
- 来源:中国裁判文书网公开数据 + ChatGPT4生成的高质量问答对
- 格式:{"instruction": "...", "input": "...", "output": "..."}
- 总量:8000条,按8:1:1切分训练/验证/测试
- 关键预处理:所有输入长度截断至1024 tokens(保留完整法律条文),输出截断至512 tokens
4. 核心实现:训练脚本和Loss曲线
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
# 加载4-bit量化模型
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
quantization_config=BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True
),
trust_remote_code=True
)
# 准备模型并包装LoRA
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16, alpha=32, dropout=0.05,
target_modules=['q_proj','k_proj','v_proj','o_proj','gate_proj','up_proj','down_proj'],
bias='none', task_type='CAUSAL_LM'
)
model = get_peft_model(model, lora_config)
# 显示可训练参数量
model.print_trainable_parameters() # 输出: trainable params: 8,388,608 || all params: 7,931,889,664 || trainable%: 0.1057
training_args = TrainingArguments(
output_dir="./qwen7b-lora-law",
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
learning_rate=2e-4,
num_train_epochs=3,
logging_steps=10,
save_steps=500,
warmup_steps=100,
fp16=True,
evaluation_strategy="steps",
eval_steps=500,
report_to="tensorboard",
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_ds,
eval_dataset=eval_ds,
tokenizer=tokenizer,
max_seq_length=1536, # 1024 input + 512 output
dataset_text_field="text", # 需要预处理成text字段
packing=False,
)
trainer.train()
训练时的Loss曲线(TensorBoard截图类似):
Step 0: loss=1.42
Step 100: loss=1.38
Step 300: loss=1.15
Step 500: loss=0.89 (eval loss同时开始下降)
Step 800: loss=0.77
Step 1200: loss=0.73 (开始收敛)
但中间有个意外:Step 200时loss突然从1.30跳到1.35,排查后发现是batch里混入了超长样本(>1536 tokens),被截断后padding占了大量比例。解决:在预处理时强制按max_seq_length截断,且在DataCollatorForLanguageModeling里设置pad_to_multiple_of=8。
5. 踩坑与优化:loss不降和显存溢出
坑1:QLoRA默认不能训练embedding
如果你想让模型学习新词汇(法律术语),需要加上:
model.enable_input_require_grads()
model.get_input_embeddings().weight.requires_grad_(True)
否则embedding层梯度为0,新词表学不动。
坑2:fp16 vs bf16
在3090(Ampere架构)上,fp16没问题。但如果你用A100,建议用bf16更稳定。实测fp16下,loss在0.75附近震荡了200步不降,换成bf16后继续下降。原因:fp16的精度在长序列梯度累积时不够。
坑3:梯度累积导致的学习率震荡
gradient_accumulation_steps=8 + batch_size=1,实际batch size=8。如果显存允许,直接batch_size=4 + accumulation=2收敛更稳定。我因为显存限制(17.8G峰值),只能保持1+8。
优化:学习率调度
初始learning_rate=2e-4在训练初期loss下降很快,但后期过拟合。改为cosine调度(默认linear),并在最后500步做warmup_ratio=0.03,最终loss稳定在0.70左右。
6. 效果对比:指令遵循率从43%到81%
在100条人工标注的法律问答上,对比基座和微调后模型:
| 指标 | 基座Qwen2.5-7B | LoRA微调(D3) |
|---|---|---|
| 指令遵循准确率 | 43% | 81% |
| 回答结构正确率 | 37% | 89% |
| 法条引用准确率 | 12% | 54% |
| 回答平均长度 | 487字 | 356字 |
| 回答长度方差 | 3124 | 1190 |
最直观的对比(法律咨询示例):
用户:用人单位拖欠工资三个月,我提出解除劳动合同,能拿经济补偿金吗?
基座回答:
根据《劳动法》相关规定,用人单位拖欠工资,劳动者可以解除劳动合同。但具体补偿要结合实际情况分析,建议咨询专业律师。(150字,结构混乱,没有列法条)
LoRA微调后回答:
结论:可以主张经济补偿金。
依据:根据《劳动合同法》第38条第1款第2项规定,用人单位未及时足额支付劳动报酬的,劳动者可以解除劳动合同。第46条规定,劳动者依第38条解除合同的,用人单位应当支付经济补偿金。第47条规定,经济补偿按劳动者在本单位工作年限,每满一年支付一个月工资的标准支付。
特别注意:需保留工资流水、考勤记录等证据,并在解除通知中明确载明解除理由为“拖欠工资”。(326字,结构清晰,法条准确)
7. 总结与建议
- LoRA r值的坑:r=32虽然理论上容量更大,但实测在8K数据上过拟合严重(训练loss 0.62 / eval loss 0.85)。r=16是最平衡的。如果你数据量>50K,可以尝试r=32。
- QLoRA的性能代价:4-bit量化让训练速度降低约15%(对比纯LoRA fp16),但显存节省60%以上,对于单卡用户是绝对的首选。
- 数据质量 >> 数据量:我用8K高质量指令数据,效果优于之前试过的20K自动爬取数据(后者指令遵循率只有67%)。
- 生产环境注意事项:LoRA权重只有16MB,可以热加载到基座模型上,不需要重新部署整个7B模型。推理时用
merge_and_unload()合并权重,速度提升20%。
最后,如果你想复现,完整代码和数据集已开源在[GitHub链接]。有任何问题欢迎在评论区讨论,尤其是关于QLoRA的量化参数,我踩了三天坑才跑通。
(全文完)