一、问题背景:为什么我最终选了LoRA而不是全参微调
事情起因很简单:我们有一个垂直领域的客服场景,基座模型Qwen2.5-7B-Instruct在通用对话上表现不错,但一遇到行业术语和固定话术就开始"自由发挥"。比如用户问"订单超过48小时未发货怎么办",它会给出一段很礼貌但完全不符合我们SOP的回答。
第一反应当然是全参微调。但算了一下账:7B模型FP16全参微调,光权重+梯度+优化器状态就要 7B × (2+2+8) ≈ 84GB,至少需要2张A100 80G,或者用DeepSpeed ZeRO-3切到4张卡。我们手上只有一张4090,这条路直接堵死。
于是转向PEFT。LoRA的原理不复杂:冻结原权重,在注意力层和FFN层注入低秩矩阵A、B,只训练这两个小矩阵。参数量能压到原来的0.1%~1%。QLoRA再进一步,把基座量化到4bit(NF4),进一步省显存。
最终选型:QLoRA + LoRA,基座4bit加载,LoRA权重用BF16训练。既省显存,又不会因为量化损失太多效果。
二、环境与版本
版本这东西,差一个小版本就可能报一堆奇怪的错,所以我直接把环境贴全:
# 关键依赖
torch==2.4.0+cu121
transformers==4.44.2
peft==0.13.2
bitsandbytes==0.43.3
accelerate==0.34.2
datasets==2.21.0
trl==0.9.6
# 硬件
# 1 × RTX 4090 24G
# CUDA 12.1, Driver 550.90
几个版本坑先提前说:
bitsandbytes必须 ≥0.43,否则在CUDA 12.1下会报libbitsandbytes_cuda121.so找不到。peft0.13 之后target_modules支持正则匹配,写起来更省事。transformers建议 ≥4.41,不然Qwen2.5的chat template要自己手写。
三、方案设计
整体思路分四步:
- 数据准备:2000条客服问答,统一成 Alpaca 格式(instruction / input / output)。
- 4bit量化加载基座:NF4 + double quant + BF16计算。
- 注入LoRA:rank=16, alpha=32, dropout=0.05,覆盖所有线性层。
- 训练 + 推理:trainer 跑3 epoch,推理时把 LoRA 权重 merge 回基座。
为什么 rank 选16而不是8或32?我做了个小对比:rank=8 时 eval_loss 停在 0.52 就下不去了,明显欠拟合;rank=32 时 loss 能到 0.38,但训练时间增加40%,显存峰值到21.3G,离爆不远。rank=16 是性价比拐点,loss 0.41,显存19.8G,训练2小时出头。
四、核心实现
4.1 数据准备
数据我用一个 dict list 直接搞定,避免引入太多依赖。核心是套 Qwen2.5 的 chat template:
from datasets import Dataset
def build_sample(instruction, input_text, output_text):
messages = [
{"role": "system", "content": "你是一名专业的电商客服助手,回答必须严格遵循SOP。"},
{"role": "user", "content": instruction + ("\n" + input_text if input_text else "")},
{"role": "assistant", "content": output_text},
]
return {"messages": messages}
raw_data = [...] # 你的2000条数据
dataset = Dataset.from_list([build_sample(*item) for item in raw_data])
dataset = dataset.train_test_split(test_size=0.05, seed=42)
Tokenize 的时候用 tokenizer.apply_chat_template,注意要 add_generation_prompt=False,因为训练时 assistant 的回答也在序列里:
def tokenize(example):
text = tokenizer.apply_chat_template(
example["messages"],
tokenize=False,
add_generation_prompt=False,
)
out = tokenizer(text, truncation=True, max_length=1024, padding=False)
out["labels"] = out["input_ids"].copy()
return out
train_ds = dataset["train"].map(tokenize, remove_columns=dataset["train"].column_names)
eval_ds = dataset["test"].map(tokenize, remove_columns=dataset["test"].column_names)
4.2 4bit加载 + LoRA注入
这是整个流程的核心,我完整贴出来:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
MODEL_PATH = "Qwen/Qwen2.5-7B-Instruct"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
torch_dtype=torch.bfloat16,
)
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()
model.enable_input_require_grads()
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,986,688 || trainable%: 0.5273
可以看到可训练参数只有 4037万,占比 0.53%,这就是显存能压下来的根本原因。
4.3 训练配置
from transformers import TrainingArguments, Trainer, DataCollatorForSeq2Seq
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-lora-cs",
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=50,
save_steps=100,
save_total_limit=2,
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
report_to="none",
remove_unused_columns=False,
)
collator = DataCollatorForSeq2Seq(tokenizer, padding=True, return_tensors="pt")
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_ds,
eval_dataset=eval_ds,
data_collator=collator,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora-cs/final")
关键参数解释:
per_device_train_batch_size=2+grad_accum=8= 全局 batch 16,4090上刚好不爆。optim="paged_adamw_8bit":优化器状态用8bit分页,省约3G显存。learning_rate=2e-4:LoRA常用区间,比全参微调大1~2个数量级。gradient_checkpointing=True:用时间换显存,训练慢约20%,但省4~5G。
五、踩坑与优化
坑1:loss 一开始是 nan。
原因是我一开始用了 fp16=True,Qwen2.5在fp16下梯度容易溢出。改成 bf16=True 就稳了。4090支持BF16,没有理由用fp16。
坑2:eval_loss 比 train_loss 低。
前100步看到 eval_loss 比 train_loss 还低,一度以为代码写错了。其实是 dropout=0.05 只作用于训练,eval时关闭;再加上训练loss是滑动平均,eval是单次。跑完1个epoch后两者就正常交叉了。
坑3:推理时忘了 merge。
第一次推理直接用peft加载,速度慢了一倍还多。正确做法是:
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(
MODEL_PATH, torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./qwen2.5-7b-lora-cs/final")
model = model.merge_and_unload()
model.save_pretrained("./qwen2.5-7b-merged")
merge之后推理速度和原模型完全一致,没有任何额外开销。
坑4:OOM在step 800左右。
原因是 save_total_limit 设了5,checkpoint堆积。改成2之后稳定。另外 paged_adamw_8bit 相比 adamw_torch 能省下大约3G,强烈建议开。
优化点:数据长度。
一开始 max_length=2048,平均每条样本pad到1800+,浪费严重。统计了一下95分位是780,直接砍到1024,训练速度提升约35%。
六、效果数据
Loss曲线特征:
- 前50步:train_loss 2.31 → 1.12,下降非常陡。
- 100~400步:1.12 → 0.58,稳定下降。
- 400~750步(约2 epoch):0.58 → 0.41,开始收敛。
- eval_loss 最终 0.41,train_loss 0.38,没有明显过拟合。
推理效果对比(3个典型case):
| 用户提问 | 原始Qwen2.5-7B | LoRA微调后 |
|---|---|---|
| 订单超48小时未发货 | "非常抱歉给您带来不便,建议您联系客服…"(不落地) | "已为您核实,超过48小时未发货可申请每单5元补偿,路径:我的订单-申请赔付" |
| 七天无理由退货范围 | 泛泛而谈,未区分品类 | 准确列出"定制类、生鲜类、拆封的数码产品除外" |
| 发票开具时间 | "一般3-5个工作日" | "电子发票24小时内推送至预留邮箱,纸质发票3个工作日内寄出" |
人工评测:随机抽200条,3位同事盲评"是否严格符合SOP"。原始模型 62%,微调后 89%。指令遵循率提升 27 个百分点。
性能数字汇总:
| 指标 | 数值 |
|---|---|
| 可训练参数 | 40.37M(0.53%) |
| 训练时长 | 2小时07分(3 epoch, 750 steps) |
| 显存峰值 | 19.8 GB |
| 最终 eval_loss | 0.41 |
| 指令遵循率 | 62% → 89% |
| 单条推理延迟 | 与原模型一致(merge后) |
七、总结
LoRA/QLoRA 让单卡微调7B模型从"想都不敢想"变成"一个下午的事"。几点经验:
- QLoRA + BF16 是4090这个档位的标配组合,别碰fp16。
- rank 16、alpha 32 对7B指令微调是个不错的默认值,rank再大边际收益递减。
- target_modules 一定要覆盖FFN(gate/up/down),只调attention效果会差一截,实测eval_loss差0.06左右。
- 推理务必merge,否则你会怀疑自己训练了个假的LoRA。
- 数据质量 > 数据数量。2000条精标数据,比2万条噪声数据的收益大得多。
下一步我打算试试 DoRA 和 rsLoRA,看看在同样显存下能不能再压一压loss。如果有结果,再写一篇。