一、为什么我要在24G卡上微调7B模型
先说背景。我手头有一个垂直领域的结构化抽取任务:从客服对话里抽取「订单号、问题类型、处理方案、情绪」四个字段。直接调GPT-4o效果不错,但成本压不住,而且数据不能出内网。于是打算用Qwen2.5-7B-Instruct做基座,用几百条业务数据做指令微调。
问题来了:全量微调7B模型,FP16权重就要14G,加上Adam的优化器状态(一阶+二阶动量,各14G)、梯度14G,再算上激活值,起码要80G显存。A100 80G都够呛能跑大batch。我只有一张4090,所以LoRA是唯一解,再叠加QLoRA把基座量化到4bit,才能把显存压到可接受范围。
这篇文章不讲原理推导,LoRA的低秩分解、QLoRA的NF4量化网上已经很多人写过了。我只写我实际跑通的完整流程和踩过的坑,代码可直接复制。
二、环境与版本
版本这块必须说清楚,因为peft和transformers的API在近几个版本改得挺频繁,网上很多老代码直接报错。
OS: Ubuntu 22.04
GPU: RTX 4090 24G
CUDA: 12.1
Python: 3.10.13
torch: 2.4.0+cu121
transformers: 4.44.2
peft: 0.13.2
bitsandbytes: 0.43.3
trl: 0.10.1
datasets: 2.21.0
accelerate: 0.34.2
安装命令:
pip install torch==2.4.0 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.44.2 peft==0.13.2 bitsandbytes==0.43.3 \
trl==0.10.1 datasets==2.21.0 accelerate==0.34.2
注意bitsandbytes一定要装0.43以上,0.41在CUDA 12.1上会有量化kernel的兼容问题,我当时卡了半天。
三、数据准备
数据是核心。我拿到的是约1200条客服对话,人工标注质量参差。清洗后留下860条。
格式上我统一成Alpaca式的instruction/input/output,但实际用chat template更稳。Qwen2.5自带chat template,直接用tokenizer.apply_chat_template。
先看一条样本长这样:
{
"conversations": [
{"role": "system", "content": "你是客服工单结构化助手,请从对话中抽取订单号、问题类型、处理方案、情绪四个字段,以JSON输出。"},
{"role": "user", "content": "用户:我上周买的手机到现在还没发货,订单号是20240912001,你们到底什么时候发?\n客服:非常抱歉,我帮您查询一下……您的订单预计今天发出。"},
{"role": "assistant", "content": "{\"order_id\": \"20240912001\", \"issue_type\": \"发货延迟\", \"solution\": \"催促发货\", \"emotion\": \"不满\"}"}
]
}
数据构造有几个我踩过的坑,写在代码注释里:
import json
from datasets import Dataset
def build_dataset(path):
raw = [json.loads(l) for l in open(path, encoding="utf-8")]
data = []
for item in raw:
msgs = item["conversations"]
# 坑1:system prompt必须固定,训练和推理要一致,否则效果断崖
# 坑2:assistant内容里的JSON不要带换行和多余空格,否则模型学到脏格式
text = tokenizer.apply_chat_template(
msgs,
tokenize=False,
add_generation_prompt=False
)
data.append({"text": text})
return Dataset.from_list(data)
# 坑3:一定要切分验证集,否则loss降了你也不知道是不是过拟合
ds = build_dataset("data/clean.jsonl")
ds = ds.train_test_split(test_size=0.08, seed=42)
train_ds, eval_ds = ds["train"], ds["test"]
print(f"train: {len(train_ds)}, eval: {len(eval_ds)}")
# train: 791, eval: 69
数据量不大,860条对LoRA来说够用,但要保证字段分布均衡,我特意检查了emotion字段,把「中性」类从原来的62%下采样到35%,否则模型会偷懒全输出中性。
四、方案设计与核心实现
方案上:
- 基座:Qwen2.5-7B-Instruct,4bit NF4量化加载,双重量化开启
- LoRA:target_modules覆盖q/k/v/o/gate/up/down全部线性层,r=16,alpha=32,dropout=0.05
- 训练:bf16计算,batch=4,梯度累积8(等效batch 32),lr=2e-4,cosine调度,warmup 30步,3个epoch
- 优化器:paged_adamw_8bit,进一步省显存
为什么r取16?我试过r=8和r=32,r=8欠拟合(eval loss停在0.42下不去),r=32比r=16只好了0.01的loss但显存多1.2G,不划算。alpha=2r是常规做法。
完整训练代码:
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"
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" # 坑4:训练必须right padding
model = AutoModelForCausalLM.from_pretrained(
model_path,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False # 坑5:训练时必须关,否则和梯度检查点冲突
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
args = TrainingArguments(
output_dir="./output_qwen_lora",
per_device_train_batch_size=4,
gradient_accumulation_steps=8,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_steps=30,
logging_steps=10,
save_strategy="epoch",
eval_strategy="epoch",
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
report_to="none",
seed=42,
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=train_ds,
eval_dataset=eval_ds,
tokenizer=tokenizer,
dataset_text_field="text",
max_seq_length=1024, # 坑6:Qwen2.5支持32k,但业务数据最长就800token,设1024省显存
packing=False,
)
trainer.train()
trainer.save_model("./output_qwen_lora/final")
跑起来显存峰值11.8G,训练速度2.3 it/s(含梯度累积),3个epoch大概48分钟。这个数字我挺满意。
五、踩坑与优化记录
几个真实卡过我的问题:
-
loss从第2个epoch开始反弹。train loss一路降到0.18,但eval loss在epoch 2后从0.31升到0.38。典型的过拟合。解决办法:把dropout从0.05提到0.1,同时把epoch从5降到3,eval loss稳在0.29。
-
推理时输出格式错乱。训练时assistant内容是被chat template包起来的,推理时我忘了加generation prompt,模型直接续写用户的话。后来统一用apply_chat_template(add_generation_prompt=True),问题消失。
-
padding_side设错导致loss虚高。一开始用left padding,loss比right padding高了0.15,因为Qwen是decoder-only,left padding会让label对齐错位。改成right后正常。
-
max_seq_length设4096显存爆到22G。业务数据用不到那么长,降到1024后显存直接省了6G多。别盲目跟着默认配置走。
-
QLoRA推理速度慢。4bit加载推理比FP16慢约35%,如果线上QPS要求高,建议训练完用merge_and_unload把LoRA合并回FP16基座,部署时用vLLM,吞吐能翻好几倍。
合并权重的代码:
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_qwen_lora/final")
model = model.merge_and_unload()
model.save_pretrained("./merged_qwen_7b")
六、效果数据
我在69条验证集 + 额外80条线上真实badcase上做了评估,字段级准确率(四个字段全对才算对):
| 模型 | 全字段准确率 | order_id | issue_type | emotion |
|---|---|---|---|---|
| Qwen2.5-7B-Instruct 原始 | 61.2% | 88.3% | 70.1% | 65.4% |
| + LoRA (r=16) | 87.9% | 97.6% | 89.2% | 84.7% |
| + LoRA (r=32) | 88.4% | 97.6% | 90.1% | 85.2% |
r=32相比r=16只提升0.5个点,显存多1.2G,性价比不高,最终选r=16。
loss曲线(手动记录的关键点):
step 10 train_loss 1.842 eval_loss -
step 50 train_loss 0.612 eval_loss -
step 99 train_loss 0.284 eval_loss 0.312 (epoch 1)
step 198 train_loss 0.176 eval_loss 0.291 (epoch 2)
step 297 train_loss 0.142 eval_loss 0.287 (epoch 3)
epoch 2到3 eval loss基本平了,再训就是过拟合,所以3个epoch停手是对的。
推理对比举个真实例子:
输入(用户对话):
用户:你们这个会员我昨天刚续费,今天就扣了我两次钱,订单20241008003,给我退!
客服:非常抱歉,我核实一下,确实是系统重复扣款,我这边为您提交退款。
原始模型输出:
好的,我帮您处理一下退款问题,请您提供订单号。
(漏字段、没按JSON输出)
微调后输出:
{"order_id": "20241008003", "issue_type": "重复扣款", "solution": "提交退款", "emotion": "愤怒"}
格式和字段都对了,这也是我最看重的收益。
七、总结
几点结论:
- QLoRA + LoRA在单张4090上微调7B模型完全可行,显存11.8G,训练40多分钟,成本极低。
- r=16对千条量级的垂直任务够用,别盲目上r=64,性价比很差。
- 数据质量比调参重要。我花在清洗和均衡字段上的时间,比调超参多三倍,但收益也大得多。
- 训练完记得merge权重,QLoRA的4bit推理是真的慢。
- 一定要留验证集,否则loss曲线会骗你。
代码我整理成了一个脚本,改改数据路径就能跑。如果后面数据量涨到几万条,我会考虑上r=32或者直接全量微调,但就目前这个量级,LoRA是性价比最高的方案。
有跑不通的地方,多半是bitsandbytes和CUDA版本的问题,先检查这个。