一、为什么选择LoRA而不是全量微调
先说结论:不是不想全量微调,是钱包不允许。
我这次的任务是让一个7B的基座模型学会我们业务领域的结构化输出——具体来说是把用户口语化的需求转成固定JSON格式。全量微调7B模型,FP16精度下光权重就要14GB,加上优化器状态(AdamW的momentum+variancce)、梯度、激活值,保守估计需要80GB以上显存。A100 80G租一天的钱够我吃一个月外卖了。
LoRA的思路很朴素:冻结原始权重,在旁边插两个低秩矩阵A和B,只训练这两个小矩阵。参数量能降到原来的0.1%~1%。QLoRA更进一步,把基座模型用4-bit NF4量化加载,进一步压显存。
我的硬件是单卡RTX 4090 24GB,目标模型Qwen2.5-7B-Instruct。最终方案:QLoRA(4-bit量化)+ LoRA(r=16),峰值显存14.2GB,训练3个epoch约4.5小时。
二、环境与版本
环境版本这块我踩过坑,先列清楚:
- Python: 3.10.13
- PyTorch: 2.3.1 + cu121
- transformers: 4.44.2
- peft: 0.12.0
- bitsandbytes: 0.43.3
- trl: 0.9.6
- accelerate: 0.33.0
- datasets: 2.20.0
- GPU: RTX 4090 24GB
- CUDA: 12.1
这里有个坑:bitsandbytes 0.43.x 对 CUDA 12.1 兼容性最好,我一开始装了0.44.0,加载4-bit模型时报 CUDA error: no kernel image is available,降级后解决。
三、数据准备
数据格式我用的是Alpaca风格的三段式:instruction / input / output。原始数据来自业务日志,清洗后大概8000条。举一条样例:
{
"instruction": "将下面的用户需求转成标准JSON格式",
"input": "帮我订一张明天下午从北京到上海的高铁票,二等座",
"output": "{\"action\":\"book_train\",\"from\":\"北京\",\"to\":\"上海\",\"date\":\"明天下午\",\"seat\":\"二等座\"}"
}
数据处理的几个关键点:
- 去重:用SimHash做近似去重,删掉了约600条高度相似的样本。
- 长度过滤:token长度超过1024的直接截断,实测99%的样本在512以内。
- 划分:train:val:test = 8:1:1,即6400/800/800。最终训练时我用了全部8000条(含验证集)做3 epoch,单独留了100条做最终测试。
数据加载用的是datasets库,配合trl的SFTTrainer。这里贴一段核心的prompt template:
def format_prompt(sample):
return f"""system
你是一个专业的结构化数据转换助手,请严格按照要求输出JSON。
user
{sample['instruction']}
{sample['input']}
assistant
{sample['output']}"""
Qwen2.5用的是ChatML格式,和这两个特殊token必须和官方一致,否则loss会异常高。
四、核心实现:QLoRA配置与训练
4.1 加载4-bit量化模型
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
model_name = "Qwen/Qwen2.5-7B-Instruct"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
bnb_4bit_use_double_quant=True 这个参数我强烈建议开,能再省0.5GB左右显存,几乎不掉点。
4.2 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",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.5273
target_modules 一定要覆盖全部7个线性层,只调q/v的话效果会差一截。r=16是我试过16/32/64之后性价比最高的,r=32只提升0.8个点但显存多花1.8GB。
4.3 训练配置
from transformers import TrainingArguments
from trl import SFTTrainer
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-lora-json",
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # 等效batch=16
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
max_grad_norm=0.3,
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_dataset,
tokenizer=tokenizer,
formatting_func=format_prompt,
max_seq_length=1024,
packing=False,
)
trainer.train()
几个参数的解释:
optim="paged_adamw_8bit":优化器状态用8-bit分页,省显存神器gradient_checkpointing=True:用时间换显存,训练慢约20%,但省30%显存learning_rate=2e-4:LoRA的经典学习率,比全量微调的2e-5高一个数量级max_grad_norm=0.3:QLoRA论文推荐值,防止梯度爆炸
五、踩坑与优化
坑1:OOM在step 47出现。一开始batch_size=8,跑到第47步爆显存。改batch=4+grad_accum=4后稳定。
坑2:loss不下降。第一次训练loss从1.87掉到1.65就卡住了,排查发现是formatting_func没生效,SFTTrainer用了默认的text字段。加packing=False并显式传formatting_func后正常。
坑3:推理时输出乱码。加载LoRA权重做推理时,忘记合并tokenizer的special tokens,导致`被拆成多个token。用tokenizer.add_special_tokens`或直接复用训练时的tokenizer即可。
坑4:prepare_model_for_kbit_training必须调用。不调用的话embedding层和layernorm会用fp16计算,容易NaN。
优化方向:试了Unsloth,训练速度能提升约1.8倍,显存再降2GB左右,但和trl 0.9.6有版本冲突,最后没用。如果对速度有要求,建议直接上Unsloth。
六、效果数据
6.1 Loss曲线
Epoch 1: 1.87 → 0.94 (avg 1.12)
Epoch 2: 0.94 → 0.63 (avg 0.71)
Epoch 3: 0.63 → 0.52 (avg 0.56)
第3个epoch已经基本收敛,再训下去验证loss开始抬头,有轻微过拟合。
6.2 推理效果对比
测试集100条,评价标准是JSON字段完全匹配:
| 模型 | 字段完全匹配 | JSON可解析 | 平均生成时间 |
|---|---|---|---|
| Qwen2.5-7B-Instruct 原始 | 61% | 78% | 1.2s |
| + LoRA (r=16, 3ep) | 89% | 97% | 1.3s |
举个具体case:
输入:"下周三上午十点提醒我开会,会议室A"
原始模型输出:
好的,我会在下周三上午十点提醒您开会,会议室A。
(是自然语言,不是JSON)
微调后输出:
{"action":"set_reminder","time":"下周三上午十点","content":"开会","location":"会议室A"}
字段完全正确。
6.3 显存与耗时
- 训练峰值显存:14.2GB
- 3 epoch耗时:4小时32分钟(约2.2 steps/s)
- LoRA权重文件大小:约155MB(fp16)
- 合并后模型大小:约15.2GB
七、总结
LoRA/QLoRA让单卡24GB微调7B模型成为现实,这套方案我跑下来有几个结论:
- r=16 + 全7层target_modules 是性价比拐点,再往上收益递减。
- QLoRA的4-bit量化几乎不掉点,但显存省了一半,强烈推荐。
- 数据质量比数据量重要,我清洗前后8000条 vs 12000条脏数据,前者效果好20个点。
- 推理时直接merge LoRA权重,比用PEFT动态加载快约15%,且部署简单。
下一步我准备试试DPO做偏好对齐,把JSON格式的严格性再拉高一点。有踩过DPO坑的朋友欢迎评论区交流。