一、问题背景:为什么要自己微调7B模型
先说结论:很多业务场景下,直接调用通用大模型API并不能解决问题。我最近接到的需求是让模型稳定输出某种特定格式的结构化内容,同时要贴合垂直领域的表达习惯。用prompt工程试了两周,效果不稳定,长尾case错误率一直在15%以上。
全量微调7B模型?一张24GB的4090根本放不下。即使用A100 80GB,全量微调也需要保存优化器状态、梯度、参数副本,显存开销大约是模型参数的4倍以上,7B模型FP16下轻松超过80GB。而且全量微调还有个隐患:灾难性遗忘,通用能力容易掉。
于是转向PEFT(Parameter-Efficient Fine-Tuning),具体就是LoRA和QLoRA。LoRA只训练低秩分解矩阵,参数量能降到原来的1%以下;QLoRA在此基础上把基座模型量化到4bit,进一步压缩显存。这套方案让我在一张消费级显卡上完成了7B模型的微调。
二、环境与版本
环境版本这块我踩过坑,先列清楚,避免大家重复试错:
- 操作系统:Ubuntu 22.04
- GPU:NVIDIA RTX 4090 24GB
- CUDA:12.1
- Python:3.10.13
- PyTorch:2.2.1+cu121
- transformers:4.43.3
- peft:0.12.0
- bitsandbytes:0.43.1
- trl:0.9.6
- accelerate:0.33.0
- datasets:2.20.0
这里重点提醒:bitsandbytes和CUDA版本强绑定,0.43.x版本对CUDA 12.1支持较好,如果装错版本,QLoRA量化时会直接报CUDA error: no kernel image is available。另外peft 0.12.0开始对target_modules的自动推断更智能,但显式指定更稳妥。
安装命令:
pip install torch==2.2.1 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.43.3 peft==0.12.0 bitsandbytes==0.43.1 \
trl==0.9.6 accelerate==0.33.0 datasets==2.20.0
三、方案设计
整体思路分三步:数据准备、LoRA/QLoRA训练、推理对比。
模型选的是Qwen2.5-7B-Instruct,原因是中文能力强、社区支持好、tokenizer对中文友好。基座模型用4bit NF4量化加载,计算时用bfloat16,这就是QLoRA的经典配置。LoRA挂在attention的q_proj、k_proj、v_proj、o_proj以及MLP的gate_proj、up_proj、down_proj上,覆盖范围比只挂q、v更广,效果更稳。
关键超参:
- LoRA rank:16
- LoRA alpha:32
- LoRA dropout:0.05
- 学习率:2e-4
- 学习率调度:cosine
- warmup ratio:0.03
- batch size:per device 2,梯度累积8,等效batch 16
- epoch:3
- max_seq_length:1024
- 优化器:paged_adamw_8bit
这套配置显存峰值约9.6GB,训练3个epoch大概4.5小时。
四、核心实现
4.1 数据准备
数据格式采用Alpaca风格的instruction/input/output三字段,最终转成chat模板。我准备了约1.2万条样本,其中10%作为验证集。数据清洗主要做了三件事:去重、过滤超长样本(超过1024 token的截断)、过滤空output。
import json
from datasets import Dataset
def load_data(path):
with open(path, 'r', encoding='utf-8') as f:
raw = json.load(f)
data = []
for item in raw:
instruction = item.get('instruction', '').strip()
inp = item.get('input', '').strip()
output = item.get('output', '').strip()
if not instruction or not output:
continue
if inp:
user_content = f"{instruction}\n{inp}"
else:
user_content = instruction
# 过滤超长样本
if len(user_content) + len(output) > 1500:
continue
messages = [
{"role": "user", "content": user_content},
{"role": "assistant", "content": output}
]
data.append({"messages": messages})
# 简单去重
seen = set()
dedup = []
for d in data:
key = d["messages"][0]["content"]
if key not in seen:
seen.add(key)
dedup.append(d)
return Dataset.from_list(dedup)
dataset = load_data("train.json")
split = dataset.train_test_split(test_size=0.1, seed=42)
train_ds, eval_ds = split["train"], split["test"]
print(f"train: {len(train_ds)}, eval: {len(eval_ds)}")
4.2 模型加载与LoRA配置
QLoRA加载需要BitsAndBytesConfig,注意bnb_4bit_compute_dtype设为bfloat16,bnb_4bit_quant_type用nf4,bnb_4bit_use_double_quant开启双重量化,能再省一点显存。
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)
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,656,000,000 || trainable%: 0.527
可以看到可训练参数只有约4037万,占全量的0.527%,这就是LoRA的威力。
4.3 训练配置
用trl的SFTTrainer最省事,它内置了chat模板处理和loss计算。
from transformers import TrainingArguments
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM
def format_chat(example):
text = tokenizer.apply_chat_template(
example["messages"], tokenize=False, add_generation_prompt=False
)
return {"text": text}
train_ds = train_ds.map(format_chat)
eval_ds = eval_ds.map(format_chat)
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-lora",
per_device_train_batch_size=2,
per_device_eval_batch_size=2,
gradient_accumulation_steps=8,
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=3,
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
report_to="none",
max_grad_norm=0.3,
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_ds,
eval_dataset=eval_ds,
tokenizer=tokenizer,
max_seq_length=1024,
dataset_text_field="text",
packing=False,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora/final")
五、踩坑与优化
坑1:gradient_checkpointing和LoRA的兼容问题。 早期peft版本开启gradient_checkpointing后,LoRA梯度不回传,loss几乎不下降。解决办法是prepare_model_for_kbit_training会自动调用enable_input_require_grads,但如果你是自己手动加载,需要显式加model.enable_input_require_grads()。另外use_reentrant=False在新版PyTorch下更稳。
坑2:tokenizer的pad_token。 Qwen2.5默认没有pad_token,直接训练会报错。设置tokenizer.pad_token = tokenizer.eos_token即可,但要注意这会轻微影响loss计算,因为pad位置也会算loss。更严谨的做法是用DataCollatorForCompletionOnlyLM只对assistant部分算loss,我试过后发现收敛更干净,但配置稍微复杂。
坑3:学习率太大导致发散。 一开始用5e-4,前50步loss直接飙到3.0以上,明显发散。降到2e-4后稳定下降。QLoRA因为基座量化,对学习率更敏感,建议1e-4到2e-4之间。
坑4:eval loss回升。 第2个epoch后期eval loss开始轻微回升,典型过拟合。我把epoch从5降到3,并加了0.05的LoRA dropout,缓解明显。
优化点: 开启packing能把多个短样本拼成一条长序列,训练速度提升约30%,但要注意packing会让attention mask跨样本,可能影响效果。我最终没开packing,因为数据集里长样本占比不低,packing收益有限。
六、效果数据
Loss曲线: 训练loss从初始的1.82稳定下降到0.61,eval loss从1.75降到0.78,第3个epoch末eval loss基本走平。整体收敛平滑,没有明显震荡。
显存与速度:
- QLoRA 4bit:显存峰值9.6GB,3 epoch约4.5小时
- LoRA FP16(未量化):显存峰值18.2GB,3 epoch约3.1小时
- 全量微调(参考值):显存>80GB,单卡4090无法运行
推理效果对比: 我用50条人工标注的测试集评估,指标是格式正确率和内容准确率。
| 方案 | 格式正确率 | 内容准确率 |
|---|---|---|
| 原始Qwen2.5-7B-Instruct | 72% | 68% |
| Few-shot Prompt | 84% | 74% |
| LoRA微调后 | 96% | 89% |
格式正确率提升最明显,从72%到96%。内容准确率也从68%提到89%。推理时把LoRA权重合并回基座,用vLLM部署,吞吐比未合并版本高约1.8倍。
推理代码示例:
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
torch_dtype=torch.bfloat16,
device_map="auto",
)
model = PeftModel.from_pretrained(base, "./qwen2.5-7b-lora/final")
model = model.merge_and_unload() # 合并LoRA权重
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
messages = [{"role": "user", "content": "你的测试指令"}]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=512, temperature=0.1, do_sample=False)
print(tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))
七、总结
LoRA和QLoRA让7B模型的微调门槛降到了单张24GB显卡。这次实践的核心结论:rank=16、alpha=32、lr=2e-4、3个epoch是7B模型中文指令微调的一个稳健起点;QLoRA比LoRA省一半显存,代价是训练慢约45%;格式类任务用LoRA微调收益最大,能到96%的格式正确率。
后续我打算试试DoRA和LoRA+,据说在同等rank下效果更好。另外如果数据量再大一些,考虑用rsLoRA缩放,避免高rank下的梯度问题。有在踩同样坑的朋友欢迎交流。