一、问题背景:为什么不直接上全参数微调
先说结论:不是不想全参微调,是显存不允许。
我这次的任务是给一个电商客服场景做意图识别 + 回复改写。原始模型用 Qwen2.5-7B-Instruct,zero-shot 在自建测试集上准确率只有 61.3%,尤其是"退款""换货""物流催单"这三类经常混。业务方要求准确率上 90%,且模型得私有化部署在一张 24GB 的卡上。
全参数微调 7B 模型,光权重(fp16)就要 14GB,加上 AdamW 的 optimizer states(fp32 的 momentum 和 variance)大约 56GB,再加上激活值,单卡 24GB 完全没戏。多卡 A100 也不是不能申请,但周期长、成本高,而且我们只是想做一个垂直领域的适配,全参微调既慢又容易灾难性遗忘。
所以方案定在 PEFT 路线上:LoRA 负责低秩适配,QLoRA 负责把基座量化到 4-bit 进一步省显存。最终在单张 4090 上跑通,显存峰值 19.3GB,留了 4GB 余量。
二、环境与版本
版本这块我踩过坑,所以写死在这里:
Python 3.11.9
torch 2.4.1+cu121
transformers 4.46.3
peft 0.13.2
bitsandbytes 0.44.1
datasets 3.1.0
accelerate 1.1.1
trl 0.12.1
GPU RTX 4090 24GB (驱动 550.107)
CUDA 12.1
重点提醒:bitsandbytes 一定要和 CUDA 版本对齐,我一开始装了 0.43.0 配 cu121,加载 4-bit 模型时直接报 CUDA error: no kernel image is available。升到 0.44.1 才好。另外 transformers 低于 4.43 对 Qwen2.5 的 chat template 支持有问题,会丢失 special tokens。
三、方案设计
整体思路分四层:
- 量化层:基座用 4-bit NF4 量化加载,
bnb_4bit_compute_dtype=torch.bfloat16,bnb_4bit_use_double_quant=True。双重量化能再省约 0.4GB。 - 适配层:LoRA 只挂在 attention 的 q/k/v/o 和 MLP 的 gate/up/down 上。7B 模型全部 target_modules 一共 7 个投影矩阵,可训练参数约 40.4M,占全量的 0.55%。
- 数据层:统一构造成对话格式,用 Qwen 的 chat template 编码,
labels只对 assistant 部分计算 loss(prompt 部分 mask 成 -100)。 - 训练层:bf16 + gradient checkpointing + paged AdamW,单卡 batch size 1,梯度累积 16,等效 batch 16。
LoRA 超参我做了小规模网格:rank 在 {8, 16, 32} 里试,alpha 固定为 2×rank。rank=8 时验证准确率 89.1%,rank=16 到 92.7%,rank=32 反而掉到 91.8%(过拟合迹象,训练 loss 到 0.31 但验证没涨)。最后定 rank=16、alpha=32、dropout=0.05。
四、核心实现
4.1 数据准备
原始数据是业务给的 CSV,两列:query 和 label。我把它转成 ShareGPT 风格的多轮对话,system prompt 固定,assistant 输出是"意图 + 改写后的回复"的 JSON。
import json
from datasets import Dataset
SYSTEM = "你是电商客服助手。请判断用户意图,并给出礼貌、简洁的回复。输出JSON:{\"intent\": ..., \"reply\": ...}"
def build_sample(query, label, reply):
return {
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": query},
{"role": "assistant", "content": json.dumps(
{"intent": label, "reply": reply}, ensure_ascii=False)}
]
}
raw = [
("我要退款,昨天买的耳机还没发货", "refund", "已为您提交退款申请,预计1-3个工作日到账。"),
("快递怎么还没到啊都三天了", "logistics_urge", "抱歉让您久等,我帮您催一下物流,稍后同步进度。"),
# ... 共 8600 条
]
samples = [build_sample(q, l, r) for q, l, r in raw]
# 切分
import random
random.seed(42)
random.shuffle(samples)
n_val = int(len(samples) * 0.05)
val_ds = Dataset.from_list(samples[:n_val])
train_ds = Dataset.from_list(samples[n_val:])
print(f"train={len(train_ds)}, val={len(val_ds)}")
# train=8170, val=430
编码的时候有个关键点:只对 assistant 的 token 计算 loss。我一开始偷懒整段算 loss,结果模型学会了复读 system prompt,验证准确率只有 70% 出头。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct", trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
MAX_LEN = 1024
def preprocess(ex):
msgs = ex["messages"]
# 完整对话
full_ids = tokenizer.apply_chat_template(
msgs, tokenize=True, add_generation_prompt=False)
# 只有 prompt(到 assistant 之前)
prompt_ids = tokenizer.apply_chat_template(
msgs[:-1], tokenize=True, add_generation_prompt=True)
full_ids = full_ids[:MAX_LEN]
prompt_len = min(len(prompt_ids), len(full_ids))
labels = [-100] * prompt_len + full_ids[prompt_len:]
return {
"input_ids": full_ids,
"attention_mask": [1] * len(full_ids),
"labels": labels,
}
train_tok = train_ds.map(preprocess, remove_columns=train_ds.column_names)
val_tok = val_ds.map(preprocess, remove_columns=val_ds.column_names)
4.2 模型加载与LoRA配置
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
"Qwen2.5-7B-Instruct",
quantization_config=bnb_config,
device_map={"": 0},
torch_dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)
model.config.use_cache = False
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,655,986,688 || trainable%: 0.5273
4.3 训练配置
用 trl 的 SFTTrainer,但注意新版 SFTTrainer 不再自动做 packing,我手动传已经 tokenize 好的 dataset。
from transformers import TrainingArguments
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM
args = TrainingArguments(
output_dir="./qwen2.5-7b-lora-intent",
num_train_epochs=3,
per_device_train_batch_size=1,
per_device_eval_batch_size=1,
gradient_accumulation_steps=16,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
bf16=True,
optim="paged_adamw_8bit",
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_steps=200,
save_total_limit=2,
report_to="tensorboard",
max_grad_norm=0.3,
group_by_length=True,
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=train_tok,
eval_dataset=val_tok,
tokenizer=tokenizer,
data_collator=None, # 已预编码,用默认 collator
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora-intent/final")
跑起来大概是这样:
{'loss': 1.874, 'grad_norm': 3.21, 'learning_rate': 1.94e-4, 'epoch': 0.02}
{'loss': 0.913, 'grad_norm': 1.08, 'learning_rate': 1.72e-4, 'epoch': 0.21}
{'loss': 0.612, 'grad_norm': 0.74, 'learning_rate': 1.31e-4, 'epoch': 0.48}
{'loss': 0.487, 'grad_norm': 0.52, 'learning_rate': 8.7e-5, 'epoch': 0.75}
{'loss': 0.431, 'grad_norm': 0.41, 'learning_rate': 4.2e-5, 'epoch': 0.98}
...
{'eval_loss': 0.402, 'eval_accuracy': 0.927, 'epoch': 3.0}
4.4 loss曲线
TensorBoard 上的曲线形状很典型:前 0.3 个 epoch 从 1.87 快速跌到 0.7 左右,然后进入缓慢下降区间,到第 2 个 epoch 之后基本在 0.42~0.45 之间震荡。验证 loss 在第 2.5 个 epoch 达到最低 0.398,第 3 个 epoch 微微回升到 0.402,说明 3 个 epoch 差不多是上限。如果继续训到 5 个 epoch,验证 loss 会涨到 0.45 左右,明显过拟合。
这里有个细节:验证 loss 曲线比训练 loss 平滑很多,因为验证集只有 430 条,batch=1 但没开 dropout(eval 模式),噪声主要来自样本难度差异。
五、踩坑与优化
坑1:padding side 搞反。 Qwen2.5 是 decoder-only,必须 tokenizer.padding_side = "right"。我一开始没设,默认继承 base tokenizer 的 left padding,结果 attention mask 和 labels 错位,loss 一直卡在 2.0 下不去。这个坑花了我一晚上。
坑2:target_modules 漏了 MLP。 只挂 q/k/v/o 时验证准确率 88.4%,加上 gate/up/down 三个投影后到 92.7%。7B 模型的 MLP 参数量占大头,垂直领域知识注入主要靠它。
坑3:use_reentrant=True 和 gradient checkpointing 冲突。 报 RuntimeError: element 0 of tensors does not require grad。改成 use_reentrant=False 解决,同时 enable_input_require_grads() 也可以加上,双保险。
坑4:学习率 2e-4 偏高导致早期震荡。 前 50 步 grad_norm 经常飙到 3 以上,loss 曲线有毛刺。后来把 max_grad_norm 从默认 1.0 调到 0.3,毛刺基本消失。QLoRA 因为基座是量化权重,梯度尺度比全参微调敏感,学习率别超过 2e-4。
优化:group_by_length。 数据里长短句差异很大(最短 18 token,最长 400+ token),开了 group_by_length=True 之后每个 batch 内长度接近,padding 浪费减少,吞吐从 2.1 samples/s 提到 2.8 samples/s,整体训练时间从 3 小时 20 分压到 2 小时 40 分。
六、效果数据
测试集 1200 条,三条对比线:
| 方案 | 准确率 | 意图F1 | 显存峰值 | 训练时长 |
|---|---|---|---|---|
| Qwen2.5-7B zero-shot | 61.3% | 0.58 | - | - |
| LoRA fp16 (rank=16) | 92.4% | 0.91 | 26.8GB(OOM边缘) | 3h05m |
| QLoRA 4-bit NF4 (rank=16) | 92.7% | 0.92 | 19.3GB | 2h40m |
有意思的是 QLoRA 反而比 fp16 LoRA 高 0.3 个点,我猜是 4-bit 量化本身带一点正则效果,在 8600 条这种中小规模数据上反而抑制了过拟合。当然这个差异在统计误差范围内,别过度解读。
推理侧对比一个具体 case:
输入:"我上周买的裙子尺码不对,能换个大一号的吗"
- 微调前:
"您好,请问您需要什么帮助?"(完全跑偏,把意图识别当成了打招呼) - 微调后:
{"intent": "exchange", "reply": "可以的,为您安排换货。请确认一下需要更换的尺码,我这边帮您生成换货单。"}
再比如:"订单都五天没动了是不是丢件了"
- 微调前:
"建议您联系快递公司查询。"(把球踢给用户) - 微调后:
{"intent": "logistics_urge", "reply": "抱歉让您担心,我先帮您核实物流状态,如确认丢件会立即为您补发或退款。"}
推理速度上,4-bit 量化模型 + LoRA 适配器,batch=1、max_new_tokens=128 时,单条延迟约 380ms(4090),比 fp16 基座(约 420ms)还略快一点,因为 4-bit 权重加载带宽占用小。
七、总结
这次微调整体是成功的,几个可复用的经验:
- 7B 模型 + QLoRA 是 24GB 单卡的最优解,不用纠结全参微调,垂直任务上 LoRA 的效果损失很小(我们这次甚至没损失)。
- 数据质量 > 超参调优。我花了 60% 时间在清洗数据和构造 assistant-only labels 上,这部分做对了,rank 从 8 调到 16 的提升比调学习率明显得多。
- rank 别贪大。8600 条数据,rank=16 是甜点,rank=32 开始过拟合。数据量小于 1 万条时,rank 8~16 足够。
- 版本对齐是隐形成本。bitsandbytes、torch、CUDA 三者版本不对齐,报错信息往往指向莫名其妙的地方,建议直接抄一套验证过的版本组合。
后续我打算试试把 LoRA 权重 merge 回基座再量化成 GPTQ 做部署,看看能不能把推理延迟再压一压。如果这篇对你有帮助,欢迎评论区交流你踩过的坑。