1. 问题背景:为什么不用全参微调
我手头有个医疗客服场景,需要模型识别用户意图(挂号、退费、问诊等8类)并抽取关键槽位(科室、时间、症状)。直接用Qwen2.5-7B-Instruct的zero-shot效果:意图准确率78.3%,但槽位F1只有0.71——最典型的问题是模型把“明天下午三点”识别成“明天”和“下午”两个独立实体。
当时考虑两个方案:
- 全参微调:7B模型用AdamW+BF16,单卡4090(24G)根本跑不动,ZeRO-3+CPU offload勉强能跑但batch size只能设1,训练速度惨不忍睹。
- LoRA:冻结原模型,只训练低秩矩阵。理论显存占用是原模型的1/10左右,而且效果在指令遵循类任务上接近全参微调。
我选了LoRA,后来为了在双卡(两张3090 24G)上并行训练又试了QLoRA。下面直接说实操。
2. 环境与版本
Python 3.10.12
torch 2.1.2+cu118
transformers 4.38.2
peft 0.9.0
datasets 2.17.0
accelerate 0.27.0
bitsandbytes 0.43.1
注意:peft 0.9.0以上才支持target_modules传字符串列表,老版本传["q_proj","v_proj"]会报错。显卡驱动要≥525.60.13,否则bitsandbytes的4bit量化会崩。
3. 方案设计:LoRA还是QLoRA?
我的训练数据是3万条人工标注的客服对话(每条包含用户query、意图标签、槽位标签)。格式化后长这样:
用户:我想挂明天下午王医生的号
意图:挂号预约
槽位:{"科室": "心血管内科", "时间": "明天下午", "医生": "王医生"}
LoRA配置:
- r=16,alpha=32,dropout=0.05
- 只注入q_proj和v_proj(经验证,加k_proj和o_proj提升不到1%但显存多占2G)
- 学习率2e-4,余弦退火,warmup 200步
QLoRA配置:
- 4bit NF4量化,bnb_4bit_use_double_quant=True
- bnb_4bit_quant_type="nf4",compute_dtype=torch.bfloat16
- 其他跟LoRA一致
训练时用pack_of_2(gradient_checkpointing=True)来省显存。
4. 核心实现:数据准备和训练代码
4.1 数据预处理
from datasets import Dataset
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct", trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
def format_example(example):
# 按Qwen2.5的chat模板格式化
messages = [
{"role": "user", "content": example["query"]},
{"role": "assistant", "content": f"意图:{example['intent']}\n槽位:{example['slots']}"}
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return {"text": text}
dataset = Dataset.from_json("train.jsonl").map(format_example)
def tokenize_function(examples):
model_inputs = tokenizer(examples["text"], truncation=True, max_length=512, padding="max_length")
# 关键:labels和input_ids相同,但padding部分要设为-100
labels = []
for i, input_ids in enumerate(model_inputs["input_ids"]):
# 找到第一个eos_token的位置,后面的padding都算-100
eos_pos = input_ids.index(tokenizer.eos_token_id) if tokenizer.eos_token_id in input_ids else len(input_ids)
labels.append([-100 if j >= eos_pos else input_id for j, input_id in enumerate(input_ids)])
model_inputs["labels"] = labels
return model_inputs
tokenized_dataset = dataset.map(tokenize_function, batched=True, remove_columns=dataset.column_names)
tokenized_dataset.save_to_disk("train_tokenized")
踩坑提示:Qwen2.5的tokenizer默认add_generation_prompt=False,如果你忘了设,模型会在训练时多学一个``token,导致loss永远降不下去。
4.2 LoRA训练代码
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import TrainingArguments, Trainer, AutoModelForCausalLM
import torch
# 加载模型(LoRA用BF16,QLoRA用4bit)
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
torch_dtype=torch.bfloat16,
device_map="auto",
use_cache=False # 必须关,否则gradient checkpointing冲突
)
# QLoRA额外加这两行
# from transformers import BitsAndBytesConfig
# 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(..., quantization_config=bnb_config)
# model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters() # 应该输出7B参数里只训练了800万
training_args = TrainingArguments(
output_dir="./qwen7b-lora-checkpoints",
per_device_train_batch_size=8,
gradient_accumulation_steps=16, # 等效batch_size=8*16=128
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_steps=200,
logging_steps=50,
save_steps=500,
evaluation_strategy="steps",
eval_steps=500,
fp16=False, # 用bf16,A100上fp16会溢出
bf16=True,
gradient_checkpointing=True,
save_total_limit=2,
load_best_model_at_end=True,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset,
eval_dataset=eval_tokenized,
)
trainer.train()
5. 踩坑与优化记录
坑1:loss震荡不收敛
- 现象:前200步loss在1.8左右横跳,不下降
- 原因:学习率设太高(5e-4),且warmup太短
- 解决:降到2e-4,warmup加到200步,loss曲线立刻变得平滑
坑2:QLoRA显存爆了
- 现象:bitsandbytes报CUDA OOM,即使batch_size=1
- 原因:bnb_4bit_compute_dtype设成了fp16,在3090上跑会异常
- 解决:改成torch.bfloat16,显存占用从17G降到14.5G
坑3:推理结果出现乱码
- 现象:微调后模型输出`标记
- 原因:训练时apply_chat_template没带add_generation_prompt=True,模型没学会正确的回复格式
- 解决:推理脚本里固定加上add_generation_prompt=True`,并在解码时跳过特殊token
6. 效果数据对比
6.1 Loss曲线
训练3000步,loss从1.82降到0.87(eval loss 0.91)。前500步下降快,后面平台期明显,说明数据量3万对7B模型来说偏少——如果加到8万,loss应该能到0.7以下。
6.2 推理效果对比
测试集500条,温度0.2,max_new_tokens=128,beam search=4:
| 指标 | 原始Qwen2.5-7B | LoRA微调 | QLoRA微调 |
|---|---|---|---|
| 意图准确率 | 78.3% | 91.2% | 90.6% |
| 槽位F1 | 0.71 | 0.88 | 0.87 |
| 平均响应延迟(ms) | 420 | 435 | 492 |
| 训练显存占用(G) | - | 23.8 | 14.5 |
| 训练时间(h) | - | 96 | 102 |
关键结论:
- LoRA和QLoRA在效果上几乎无差别(差0.6%意图准确率),但QLoRA能省9G显存,代价是训练时间多了6小时(4bit反量化开销)
- 槽位F1提升最明显的是“时间”槽位,从0.62涨到0.85——模型学会了“明天下午”是一个整体
- 延迟增加3.5%是因为LoRA的秩矩阵计算,可接受
7. 总结
LoRA/QLoRA在7B模型上完全够用,除非你的任务需要极端领域知识(比如医学诊断),否则没必要全参微调。
几个经验值:
- 单卡24G用LoRA,双卡或单卡16G用QLoRA(4bit)
- r=16是性价比最高的点,r=32效果提升不到1%但显存翻倍
- 数据质量比数据量重要:我挑了2000条错误标注,重新清洗后准确率直接+3%
- 推理时记得merge_and_unload()合并LoRA权重,能省一半显存
最后说一句,别迷信QLoRA的“无损压缩”,在数学推理任务上它比LoRA差5%左右(我测过GSM8K)。但如果你只做指令遵循类任务,QLoRA就是白嫖的显存优化器。