一、问题背景:为什么我们要微调7B模型
先说结论:通用大模型在垂直领域上的表现,往往比想象中差。
我们做的是一个电商售后客服场景。直接用Qwen2.5-7B-Instruct跑测试集,200条真实用户问题里,能给出符合业务口径回答的只有123条,准确率61.5%。问题集中在三类:
- 不知道我们特定的退换货政策(比如"7天无理由但拆封的电子产品除外");
- 回复风格太"AI",用户问"我东西坏了怎么办",它给你列一二三四五条通用建议,而我们要的是"亲,方便提供下订单号吗?我帮您查下保修状态";
- 会编造不存在的售后流程。
全量微调7B模型?一张24G的4090想都别想。FP16权重就要14GB,加上优化器状态、梯度、激活值,起步60GB+。所以我们走LoRA路线,后来为了进一步压显存又试了QLoRA。
这篇博客就把整个过程摊开讲,包括数据怎么搞、参数怎么设、loss怎么读、效果到底提升多少。
二、环境与版本
环境这东西必须先说清楚,不然代码跑不起来别怪我。
- GPU:NVIDIA RTX 4090 24GB(单卡)
- CUDA:12.1
- PyTorch:2.3.1+cu121
- transformers:4.44.2
- peft:0.12.0
- bitsandbytes:0.43.3
- trl:0.9.6
- accelerate:0.33.0
- 基座模型:Qwen2.5-7B-Instruct
这里有个坑先提前说:bitsandbytes在Windows上装起来极其痛苦,我用的是WSL2 + Ubuntu 22.04。如果你也是Windows,强烈建议直接WSL,别折腾原生Windows。
三、方案设计:LoRA还是QLoRA
先说两者的区别,一句话:LoRA是在FP16的基座模型上挂低秩矩阵,QLoRA是把基座模型先4-bit量化再挂LoRA。
显存账大概是这样(7B模型,batch size=1,seq len=1024):
| 方案 | 基座权重 | 优化器+梯度 | 激活值 | 总计 |
|---|---|---|---|---|
| 全量微调 | 14GB | 56GB | 4GB | ~74GB |
| LoRA (FP16) | 14GB | 0.5GB | 4GB | ~19GB |
| QLoRA (NF4) | 3.5GB | 0.5GB | 4GB | ~8-11GB |
LoRA其实4090也能跑,但一旦你把seq len拉到2048或者batch size提到4,就容易OOM。QLoRA给的余量更大,而且实测效果掉得不多(后面有数据)。
最终我两个都跑了,主推QLoRA。
LoRA配置核心参数:
- 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(全挂上,效果比只挂q,v好)
QLoRA额外加:
- load_in_4bit=True
- bnb_4bit_quant_type="nf4"
- bnb_4bit_compute_dtype=torch.bfloat16
- bnb_4bit_use_double_quant=True
四、核心实现
4.1 数据准备
数据格式走标准的Alpaca风格,instruction/input/output三段式。我们自己标了1.2万条,切分是 11000训练 / 1000验证。
import json
from datasets import Dataset
def build_dataset(path):
records = []
with open(path, 'r', encoding='utf-8') as f:
for line in f:
item = json.loads(line)
# 走Qwen的chat template
messages = [
{"role": "system", "content": "你是一名专业的电商售后客服。"},
{"role": "user", "content": item["instruction"] +
(("\n" + item["input"]) if item.get("input") else "")},
{"role": "assistant", "content": item["output"]}
]
records.append({"messages": messages})
return Dataset.from_list(records)
train_ds = build_dataset("data/train.jsonl")
eval_ds = build_dataset("data/val.jsonl")
print(f"train: {len(train_ds)}, eval: {len(eval_ds)}")
# train: 11000, eval: 1000
数据这块踩过一个坑:一开始我instruction里带了大量重复的"请回答以下问题:"前缀,结果模型学得很快但也学废了——推理时你不加这个前缀它就不理你。后来统一清洗掉了。数据质量比数量重要,1.2万条干净数据 > 5万条脏数据。
4.2 训练脚本
完整训练代码,可以直接抄:
import torch
from transformers import (
AutoModelForCausalLM, AutoTokenizer,
BitsAndBytesConfig, TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
MODEL_PATH = "Qwen/Qwen2.5-7B-Instruct"
# 1. 4-bit量化配置(QLoRA核心)
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_PATH, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
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.config.use_cache = False # 训练时必须关,否则和gradient checkpointing冲突
# 2. LoRA配置
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"],
)
# 3. 训练参数
training_args = TrainingArguments(
output_dir="./output/qwen2.5-7b-lora",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效batch = 16
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
eval_strategy="epoch",
bf16=True,
gradient_checkpointing=True,
optim="paged_adamw_8bit",
max_grad_norm=0.3,
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_ds,
eval_dataset=eval_ds,
peft_config=lora_config,
tokenizer=tokenizer,
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.model.save_pretrained("./output/qwen2.5-7b-lora/final")
tokenizer.save_pretrained("./output/qwen2.5-7b-lora/final")
跑起来后显存占用稳定在 10.8GB 左右,训练速度约 2.3 it/s,3个epoch(11000条 × 3 / 16 ≈ 2062步)大约 15 分钟一轮,全程45分钟。
五、踩坑与优化
说几个真实踩过的坑,都是血泪:
坑1:loss一开始不降反升。
原因是learning rate给到了5e-4,QLoRA在4-bit基座上学习率太大会震荡。降到2e-4后曲线立刻正常。经验值:LoRA/QLoRA的lr建议1e-4 ~ 2e-4。
坑2:use_cache=False忘了关。
不关会报错,因为gradient checkpointing和KV cache冲突。这个报错信息还挺隐晦,第一次遇到会懵。
坑3:max_grad_norm=0.3比默认1.0好。
QLoRA本身梯度就有点噪,梯度裁剪严一点收敛更稳。这是我对比了0.3和1.0之后的结果,0.3的eval loss低约0.04。
坑4:target_modules别只挂q,v。
一开始我按老教程只挂q_proj, v_proj,结果模型学不会业务话术。挂上MLP层(gate/up/down)之后,回复风格的迁移明显更好。代价是参数量从约4M涨到约20M,但相比7B基座还是毛毛雨。
坑5:padding_side要设成right。
Qwen训练时用right padding,推理时如果用left padding,长序列场景下可能出现位置编码错乱。虽然现在HF基本处理好了,但还是设一下保险。
六、效果数据
训练loss曲线(3 epoch):
- Epoch 1:train loss 1.42 → 0.78,eval loss 0.81
- Epoch 2:train loss 0.78 → 0.51,eval loss 0.53
- Epoch 3:train loss 0.51 → 0.42,eval loss 0.49
Epoch 3的eval loss只比epoch 2降了0.04,基本收敛了。再往后train loss还在降但eval开始平,有轻微过拟合迹象,所以3个epoch踩住了。
推理效果对比(200条测试集,人工评估):
| 指标 | 基座模型 | LoRA微调 | QLoRA微调 |
|---|---|---|---|
| 业务口径准确率 | 61.5% | 88.0% | 87.3% |
| 风格符合度 | 42.0% | 91.5% | 90.5% |
| 幻觉率 | 18.5% | 4.0% | 4.5% |
| 平均响应长度 | 187字 | 96字 | 98字 |
QLoRA相比LoRA掉了不到1个点,但显存省了将近一半,非常划算。
几个具体的case对比:
Case 1:
用户:我买的手机用了3天屏幕有亮点,能换吗?
- 基座:根据消费者权益保护法,您可以在7天内……(一通法律条文,没回答问题)
- 微调后:亲,3天内出现亮点属于质量问题,可以免费换新的哦~麻烦提供下订单号和屏幕亮点的照片,我这边帮您走换货流程。
Case 2:
用户:退货运费谁出?
- 基座:一般来说,退货运费的承担方需要根据具体情况判断……(含糊)
- 微调后:如果是质量问题,运费我们承担;如果是7天无理由,运费需要您先垫付,收到货后我们会退给您。
差别非常直观。
合并LoRA权重到基座模型:
from peft import PeftModel
from transformers import AutoModelForCausalLM
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
torch_dtype=torch.bfloat16,
device_map="auto"
)
model = PeftModel.from_pretrained(base, "./output/qwen2.5-7b-lora/final")
merged = model.merge_and_unload()
merged.save_pretrained("./output/qwen2.5-7b-merged")
合并后权重约15GB,可以脱离peft库独立部署,推理速度也比动态加载LoRA快15%左右。
七、总结
几个takeaway:
- 7B模型微调,QLoRA是当前单卡最优解。24G显存随便跑,甚至16G都够。效果相比全精度LoRA掉不到1个点。
- 数据质量 > 数据数量。1.2万条清洗过的数据,比5万条脏数据效果好得多。
- lr、grad_norm、target_modules是三个最关键的调参点。这几个调好,其他用默认值基本没问题。
- 一定要留验证集看eval loss。只看train loss容易过拟合还不自知。
- 微调不是银弹。如果你的任务只是改变输出格式,prompt engineering可能就够了,别上来就微调。但如果涉及领域知识注入和风格迁移,微调确实是目前最有效的手段。
下一步我打算试试DPO,把模型的对齐再往上推一档,有结果再写一篇。
代码已经整理到仓库里,需要的同学可以自取。有问题评论区聊。