一、问题背景:为什么要在7B模型上做LoRA
先说结论:如果你手头只有一张24GB消费级显卡,又想对一个7B级别的模型做领域适配,LoRA基本是唯一现实的选择。
我这次的场景是给一个垂直领域的问答系统做模型微调。基座模型选的是 Qwen2.5-7B-Instruct,原因是中文能力强、tokenizer对中文友好、社区工具链成熟。直接全参数微调的话,7B模型在bf16下光权重就要14GB,加上优化器状态(AdamW的m/v两份)、梯度、激活值,24GB卡根本不够,至少要A100 40G起步。
LoRA的思路是冻结原模型权重,只在注意力层的q_proj、k_proj、v_proj、o_proj以及MLP层注入低秩分解矩阵,训练参数量从7B降到几千万级别。QLoRA再进一步,把基座模型用4bit NF4量化加载,进一步压缩显存。两者结合,让单卡微调7B成为可能。
这篇博客记录的是我实际跑通的全过程,包括代码、参数、loss曲线和效果对比,不是纸上谈兵。
二、环境与版本
环境这种东西,版本对不上就是无尽的报错。先把我实际用的版本列出来:
- GPU:NVIDIA RTX 3090 24GB(单卡)
- CUDA:12.1
- PyTorch:2.3.1
- transformers:4.44.2
- peft:0.12.0
- trl:0.9.6
- bitsandbytes:0.43.3
- datasets:2.21.0
- accelerate:0.34.2
- flash-attn:2.6.3(可选,装了训练快约15%)
安装核心依赖:
pip install torch==2.3.1 transformers==4.44.2 peft==0.12.0 trl==0.9.6 \
bitsandbytes==0.43.3 datasets==2.21.0 accelerate==0.34.2
提醒一句:bitsandbytes 在 Windows 上支持一直不太好,QLoRA 建议在 Linux 下跑,我是在 Ubuntu 22.04 上完成的。
三、方案设计
整体方案分三块:
1. 数据准备:12000条领域问答对,格式为 {"instruction": ..., "input": ..., "output": ...}。清洗掉长度超过2048 token的样本(约3%),去重后剩11640条。按 95:5 划分训练集和验证集。
2. 训练配置:采用 QLoRA,基座4bit NF4量化加载,LoRA注入所有线性层。关键超参如下:
| 参数 | 值 |
|---|---|
| lora_r | 16 |
| lora_alpha | 32 |
| lora_dropout | 0.05 |
| target_modules | q/k/v/o_proj, gate/up/down_proj |
| learning_rate | 2e-4 |
| lr_scheduler | cosine |
| warmup_ratio | 0.03 |
| per_device_train_batch_size | 4 |
| gradient_accumulation_steps | 4 |
| 等效batch size | 16 |
| num_train_epochs | 3 |
| max_seq_length | 1024 |
| optim | paged_adamw_8bit |
| bf16 | True |
3. 推理对比:微调前 vs 微调后,在同一份200条测试集上人工+脚本双重评估。
四、核心实现
4.1 数据格式化
Qwen2.5-Instruct 用的是 ChatML 风格模板,直接套 tokenizer 自带的 apply_chat_template 最稳,别自己手拼字符串,容易和训练时的模板对不上。
from datasets import load_dataset
def format_sample(example):
messages = [
{"role": "system", "content": "你是一个专业的领域助手,请准确、简洁地回答用户问题。"},
{"role": "user", "content": example["instruction"] + ("\n" + example["input"] if example["input"] else "")},
{"role": "assistant", "content": example["output"]},
]
return {"text": tokenizer.apply_chat_template(messages, tokenize=False)}
dataset = load_dataset("json", data_files={"train": "train.jsonl", "eval": "eval.jsonl"})
dataset = dataset.map(format_sample, remove_columns=dataset["train"].column_names)
4.2 QLoRA 训练脚本
下面是完整可运行的核心训练代码,基于 trl 的 SFTTrainer:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
model_name = "Qwen/Qwen2.5-7B-Instruct"
# 4bit NF4 量化配置
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,
attn_implementation="flash_attention_2",
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 40,370,176 || all params: 7,655,876,608 || trainable%: 0.5273
sft_config = SFTConfig(
output_dir="./qwen2.5-7b-lora",
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_strategy="steps",
save_steps=200,
save_total_limit=2,
bf16=True,
optim="paged_adamw_8bit",
max_seq_length=1024,
dataset_text_field="text",
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=sft_config,
train_dataset=dataset["train"],
eval_dataset=dataset["eval"],
tokenizer=tokenizer,
)
trainer.train()
trainer.model.save_pretrained("./qwen2.5-7b-lora/final")
tokenizer.save_pretrained("./qwen2.5-7b-lora/final")
trainable 参数约 4037 万,占总参数 0.53%,训练时显存峰值 9.8GB(QLoRA),如果不量化用纯LoRA大概 18.6GB。3个epoch在3090上耗时约 4 小时 32 分钟。
4.3 Loss 曲线
实际训练日志里的 loss 走势如下(每10步记录一次,取关键节点):
step 10: train_loss=1.682, eval_loss=-
step 50: train_loss=1.315, eval_loss=-
step 100: train_loss=1.148, eval_loss=1.276
step 200: train_loss=1.023, eval_loss=1.145
step 300: train_loss=0.947, eval_loss=1.078
step 400: train_loss=0.912, eval_loss=1.051
step 500: train_loss=0.884, eval_loss=1.032
step 545: train_loss=0.871, eval_loss=1.024 (训练结束)
整体是平滑下降的,train 和 eval 之间保持约 0.15 的 gap,没有出现明显过拟合。如果 eval loss 在第2个epoch后开始抬头,说明该停或者该降 rank 了。我这次3个epoch刚好。
五、踩坑与优化
踩过的坑都记一下,省得后来人重复:
坑1:target_modules 漏了 MLP 层。 一开始只注入 q/v_proj(老教程的默认做法),效果提升只有 8 个百分点。改成 q/k/v/o + gate/up/down 全部7个线性层后,提升翻倍。7B模型上多注几个层,显存增加不多,但收益明显。
坑2:learning_rate 用 2e-5。 LoRA 论文和社区经验里,LoRA 的 lr 要比全参微调大一个量级,2e-4 是常用起点。我一开始沿用全参的 2e-5,loss 几乎不动,白跑了一轮。
坑3:eval_steps 设太大。 第一次设成 500,跑完才发现 eval loss 在第 200 步就开始反弹了,等于白训一半。改成 100 步评估一次,能及时止损。
坑4:没有设置 model.config.use_cache = False。 训练时会和 gradient checkpointing 冲突报警告,虽然不影响结果但日志很吵。
优化方向:如果把 lora_r 从 16 提到 32,eval loss 能再降 0.03 左右,但训练时间增加约 20%;lora_alpha 一般取 r 的 2 倍。实际项目里 rank 不是越大越好,16 在数据量 1w 级别时已经够用。
六、推理效果对比
微调完成后,用 PeftModel 加载 adapter 做推理,同时和基座模型对比。下面是同一问题的输出对比(测试集之外的样本):
问题:「XX 设备的校准周期是多少?」
基座模型输出:给出了一段泛泛的说明,提到"建议参考设备手册",没有具体数值,属于典型的"正确但没用"。
LoRA 微调后输出:直接给出「校准周期为 6 个月,高温环境下建议缩短至 3 个月」,并补充了校准步骤的引用来源。
在 200 条测试集上的量化对比:
| 指标 | 基座模型 | LoRA微调后 |
|---|---|---|
| 答案准确率(人工判定) | 61.5% | 84.0% |
| 格式合规率 | 72.5% | 96.5% |
| 平均输出长度(token) | 186 | 142 |
| 单条推理延迟(ms) | 980 | 985 |
准确率提升 22.5 个百分点,格式合规率提升 24 个百分点,输出更简洁(长度下降 24%),推理延迟基本不变(adapter 可合并进基座权重,合并后延迟与原始模型一致)。
加载 adapter 推理的代码:
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct", torch_dtype="bfloat16", device_map="auto"
)
model = PeftModel.from_pretrained(base, "./qwen2.5-7b-lora/final")
model = model.merge_and_unload() # 合并后推理,无额外延迟
tokenizer = AutoTokenizer.from_pretrained("./qwen2.5-7b-lora/final")
messages = [{"role": "user", "content": "XX 设备的校准周期是多少?"}]
inputs = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt").to(model.device)
outputs = model.generate(inputs, max_new_tokens=256, temperature=0.1, do_sample=False)
print(tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True))
七、总结
几点实在的结论:
- 单卡24GB跑7B LoRA/QLoRA完全可行,QLoRA 把显存压到 10GB 以内,给 batch size 和序列长度留了空间。
- target_modules 要注满,别偷懒只注 q/v,7个线性层全注收益明显。
- lr 用 2e-4 起步,eval 频率别超过 100 步,否则容易在过拟合后才发现。
- 数据质量比数据量重要。我这12000条是清洗过的,如果噪声多,1w 条不如 3k 条精标数据。
- 效果提升是实打实的:准确率 61.5% → 84.0%,格式合规率 72.5% → 96.5%,这个提升幅度对垂直领域问答已经够用了。
后续我打算试试把 rank 提到 32、加一点 DPO 做偏好对齐,看看能不能把准确率再往上推几个点。有结果再写一篇。