一、为什么要在7B模型上折腾LoRA
先说背景。我们团队有个内部知识问答场景,数据涉及公司文档,不能走公有云API。试过直接prompt Qwen2.5-7B-Instruct,在“按固定JSON格式输出”“多轮追问时保持字段一致”这两类任务上翻车率很高,人工统计100条测试集,格式正确率只有62%。
全量微调7B?单卡4090想都别想,bf16权重就要15GB,加上优化器状态和梯度,至少需要80GB以上显存。所以LoRA基本是唯一选择。而QLoRA则是在显存更紧张(比如只有一张3090 24GB但还要留出推理空间)时的备选。
这篇文章不吹概念,只讲我实际跑通的流程和踩过的坑。
二、环境与版本
环境不一致是复现最大的敌人,先把版本钉死:
- GPU:RTX 4090 24GB(LoRA)/ RTX 3090 24GB(QLoRA对比)
- CUDA:12.1
- PyTorch:2.4.0+cu121
- transformers:4.44.2
- peft:0.13.2
- bitsandbytes:0.43.3(QLoRA必需)
- LLaMA-Factory:0.9.1
- Python:3.10.14
安装命令(建议用conda新建环境):
conda create -n lora_ft python=3.10 -y
conda activate lora_ft
pip install torch==2.4.0 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.44.2 peft==0.13.2 bitsandbytes==0.43.3
pip install llamafactory==0.9.1
注意:bitsandbytes在Windows上支持较差,本文全程在Ubuntu 22.04下完成。
三、方案设计
3.1 数据准备
自建数据集,共8620条,格式为alpaca风格:
{
"instruction": "从下面文本中抽取公司名称和成立年份,以JSON输出",
"input": "字节跳动成立于2012年3月...",
"output": "{\"company\": \"字节跳动\", \"founded\": 2012}"
}
划分:train 8000条,eval 620条。这里有个坑:eval集必须和train同分布但不同模板,否则eval_loss会虚低。我特意在eval里换了三种不同的指令措辞。
数据放在 data/mydata.json,同时在LLaMA-Factory的 data/dataset_info.json 里注册:
{
"my_instruct": {
"file_name": "mydata.json",
"columns": {
"prompt": "instruction",
"query": "input",
"response": "output"
}
}
}
3.2 LoRA vs QLoRA 配置选择
LoRA:r=16,lora_alpha=32,target_modules选 q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj(全attention+FFN),lora_dropout=0.05。
QLoRA:在LoRA基础上加 quantization_bit=4,量化类型 nf4,双量化开启,计算dtype用bf16。
为什么r选16?我试过r=8和r=32,r=8在格式任务上欠拟合,r=32相比r=16提升不到1%,但显存多了1.8GB,性价比低。
四、核心实现
4.1 LoRA训练命令
LLaMA-Factory封装得很好,直接命令行:
llamafactory-cli train \
--stage sft \
--model_name_or_path Qwen/Qwen2.5-7B-Instruct \
--dataset my_instruct \
--template qwen \
--finetuning_type lora \
--lora_rank 16 \
--lora_alpha 32 \
--lora_dropout 0.05 \
--lora_target q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj \
--output_dir saves/qwen2.5-7b/lora/sft \
--per_device_train_batch_size 2 \
--gradient_accumulation_steps 8 \
--learning_rate 1e-4 \
--num_train_epochs 3.0 \
--lr_scheduler_type cosine \
--warmup_ratio 0.1 \
--bf16 true \
--logging_steps 10 \
--save_steps 200 \
--eval_steps 200 \
--cutoff_len 1024 \
--gradient_checkpointing true
有效batch size = 2 × 8 = 16。学习率1e-4是LoRA的甜点区,试过5e-5收敛太慢,2e-4在第2个epoch就开始震荡。
4.2 QLoRA配置差异
只需要改三处:
--quantization_bit 4 \
--quantization_type nf4 \
--double_quantization true
其余保持一致,这样才能公平对比。
4.3 纯PEFT手写版(如果你不想用LLaMA-Factory)
给喜欢自己掌控的读者:
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import BitsAndBytesConfig
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16, lora_alpha=32, lora_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: 20,185,088 || all params: 7,635,801,600 || trainable%: 0.2643
可训练参数约2018万,占比0.26%,这就是LoRA香的地方。
五、踩坑与优化
坑1:loss不降反升。 一开始用了默认的 learning_rate=5e-5 加 lr_scheduler=linear,前300步loss在1.4上下横盘。换成cosine + warmup 0.1后明显改善。
坑2:QLoRA下gradient_checkpointing报错。 bitsandbytes 0.43.3需要显式调用 model.enable_input_require_grads(),否则反向传播时输入没有梯度,直接报RuntimeError。LLaMA-Factory内部已经处理,手写版记得加。
坑3:eval_loss比train_loss还低。 一开始很慌,后来发现是dropout + eval集模板太相似导致的。换模板后eval_loss才回到合理区间(略高于train)。
坑4:合并权重后推理变差。 用 merge_and_unload() 合并后直接加载,输出乱码。原因是合并时dtype没对齐,必须显式 torch_dtype=torch.bfloat16。
六、效果数据
6.1 Loss曲线
LoRA(3 epoch,约1500 step):
- step 100:train_loss 1.38,eval_loss 1.42
- step 500:train_loss 1.05,eval_loss 1.13
- step 1000:train_loss 0.82,eval_loss 0.94
- step 1500:train_loss 0.71,eval_loss 0.87
eval_loss在step 1200后基本平了,第3个epoch收益很小,说明数据量下r=16已经接近容量上限。
QLoRA曲线几乎重合,eval_loss最终0.89,比LoRA高0.02,属于量化误差的正常范围。
6.2 显存与耗时
| 方案 | 峰值显存 | 单epoch耗时 |
|---|---|---|
| LoRA (bf16) | 19.2 GB | 42 min |
| QLoRA (NF4) | 11.4 GB | 58 min |
QLoRA省了41%显存,但慢了约38%,因为反量化有额外开销。
6.3 推理效果对比
测试集100条,人工评分:
- 原始Qwen2.5-7B-Instruct:格式正确率62%,字段完整率71%
- LoRA微调后:格式正确率89%,字段完整率93%
- QLoRA微调后:格式正确率87%,字段完整率91%
举个具体例子,输入“从下面文本抽取公司名和成立年份”,原始模型会输出一段解释性文字,微调后稳定输出 {"company":"...","founded":...}。
推理时LoRA适配器加载延迟约0.8s,合并权重后无额外开销。
七、总结
LoRA在7B模型上是真香,2018万可训练参数就能把格式遵循率从62%拉到89%。QLoRA适合显存吃紧的场景,代价是训练慢38%、效果略降2个点。
几条经验:
1. r=16 + alpha=32 + 全target_modules 是7B任务的稳妥起点
2. 学习率1e-4 + cosine + warmup 0.1 比默认配置稳得多
3. eval集一定要换模板,否则loss曲线会骗你
4. 合并权重时dtype必须显式指定bf16
5. 3个epoch通常够用,再多就是过拟合
代码和数据模板都整理在上面的命令里,直接改路径就能跑。有问题的欢迎评论区交流。