一、问题背景:为什么我要自己微调一个7B模型
事情的起因很朴素:我们做的是一个垂直领域的客服助手,基座用的是Qwen2.5-7B-Instruct。直接prompt engineering的效果其实不算差,但在几个高频场景上一直不稳——比如用户问“我这个订单能不能改地址”,模型经常给出“建议联系客服”这种废话,而我们要的是“已发货订单不支持改地址,可拒收后重新下单”这种明确话术。
统计了一下,在200条真实测试集上,基座模型的意图命中率只有61.3%,话术合规率更低。全量微调7B模型对我们这种小团队来说成本太高(至少需要A100 80G级别),所以LoRA是唯一现实的选择。至于QLoRA,是因为我手头只有一张4090 24G,fp16加载7B模型光权重就要14G,加上优化器状态和激活值根本跑不动,4bit量化后权重只占约4.5G,才留出了训练空间。
这篇文章就把整个过程摊开讲,包括我踩的坑。
二、环境与版本
先把版本钉死,避免复现问题:
- GPU:RTX 4090 24GB(单卡)
- CUDA 12.1 / PyTorch 2.3.1
- transformers 4.43.3
- peft 0.12.0
- trl 0.9.6
- bitsandbytes 0.43.1
- accelerate 0.33.0
- 基座模型:Qwen2.5-7B-Instruct
bitsandbytes这个版本号要特别注意,0.43.x对CUDA 12.1的支持是稳的,我之前用0.41在4bit加载时报过 CUDA error: an illegal memory access,换版本后解决,后面踩坑部分细说。
三、方案设计
整体思路:
- 数据:3200条人工标注的客服问答对,按8:1:1切分训练/验证/测试。格式统一成Alpaca风格的instruction/input/output。
- 量化:QLoRA,4bit NF4量化 + double quant,计算时用bf16。
- LoRA配置:r=16,alpha=32,dropout=0.05,target_modules覆盖所有线性层(q/k/v/o/gate/up/down_proj)。这里我特意没只挂q_proj和v_proj,因为客服场景需要模型学到较强的领域表达,全线性层注入效果明显更好,代价是显存多占约1G。
- 训练:3 epoch,batch size 1 + 梯度累积16(等效batch 16),lr 2e-4,cosine调度,warmup 100步。
- 评估:验证集loss曲线 + 测试集上人工抽检100条的意图命中率、话术合规率。
选r=16而不是8,是因为我先用r=8跑了一轮,验证loss在0.9左右就下不去了,升到16后能到0.71。alpha按惯例取2r。
四、核心实现
4.1 数据准备
数据处理这块没什么花活,重点是模板要和基座模型的chat template对齐,不然训练和推理的分布会错位。Qwen2.5用的是ChatML格式,我直接用tokenizer的apply_chat_template,省得手写出错。
import json
from datasets import Dataset
def build_dataset(path):
samples = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
item = json.loads(line)
messages = [
{"role": "system", "content": "你是XX公司的客服助手,回答需简洁、明确、符合话术规范。"},
{"role": "user", "content": item["query"]},
{"role": "assistant", "content": item["response"]},
]
samples.append({"messages": messages})
return Dataset.from_list(samples)
train_ds = build_dataset("data/train.jsonl") # 2560 条
val_ds = build_dataset("data/val.jsonl") # 320 条
print(train_ds[0])
数据质量上有两个细节值得说:一是把“我不知道”“请联系人工”这类兜底回答从训练集里剔掉了约200条,否则模型会学成复读机;二是同一意图的样本做了去重,避免某类问题过采样导致过拟合。
4.2 QLoRA训练配置
下面是完整的训练脚本核心部分,可以直接跑:
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_id = "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_id, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False
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.527
args = TrainingArguments(
output_dir="./output/lora-qwen2.5-7b",
per_device_train_batch_size=1,
gradient_accumulation_steps=16,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_steps=100,
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=200,
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
max_grad_norm=0.3,
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=train_ds,
eval_dataset=val_ds,
tokenizer=tokenizer,
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.save_model("./output/lora-qwen2.5-7b/final")
几个参数是有意为之的:optim="paged_adamw_8bit" 是为了防显存峰值OOM,paged版本在梯度累积时更稳;max_grad_norm=0.3 比默认1.0小,因为我发现QLoRA下梯度偶尔会尖峰,clip严一点loss曲线更平滑;max_seq_length=1024 是因为我们的问答对95%在600 token以内,设太大纯浪费显存。
训练峰值显存约19.2G,单步(含累积16)约5.6秒,3个epoch共480步,总耗时4小时38分。
五、踩坑与优化
坑1:loss前期震荡。 前50步loss在1.7~2.1之间来回跳,一开始以为是lr太高,降到1e-4后收敛变慢但震荡依旧。后来发现是梯度累积16步期间数据长度差异太大(有的样本200 token,有的900 token),改成按长度分桶采样(用 LengthGroupedSampler)后,前50步震荡幅度收敛到±0.1以内。
坑2:4bit加载OOM。 第一次跑的时候没加 prepare_model_for_kbit_training,直接get_peft_model,结果反向传播时显存飙到24G爆掉。这个函数会把layernorm等层转fp32并冻结,必须加。
坑3:评估集loss和训练loss不同步。 第2个epoch开始,训练loss降到0.6,但验证loss在0.85附近横盘。排查后是验证集里有约8%的样本输出长度超过1024被截断,标签错位。把验证集重新按长度过滤后,验证loss正常跟着降到0.71。
优化: 试过r=32,验证loss只比r=16低0.02,但训练时间多了40%,性价比不高,最终定在r=16。
六、效果数据
Loss曲线(每50步记录):
| Step | Train Loss | Eval Loss |
|---|---|---|
| 50 | 1.52 | 1.61 |
| 150 | 1.08 | 1.12 |
| 250 | 0.83 | 0.92 |
| 350 | 0.74 | 0.78 |
| 480 | 0.71 | 0.71 |
典型特征:前100步快速下降(学格式和话术模板),150~300步是主要能力提升段,300步后趋于平缓,第3个epoch收益已经很小,说明3 epoch对3200条数据是合适的,再多就过拟合了。
推理效果对比(测试集抽100条人工评估):
| 指标 | 基座模型 | LoRA微调后 |
|---|---|---|
| 意图命中率 | 61.3% | 87.6% |
| 话术合规率 | 54.0% | 83.2% |
| 平均响应长度(token) | 118 | 76 |
举个具体例子,用户问“订单发货了还能改地址吗”:
- 基座回答:“您好,建议您联系客服人员处理哦,他们会给您更准确的答复。”(意图未命中,废话)
- 微调后:“已发货订单不支持修改收货地址。您可以拒收后重新下单,或联系快递员协商改派。”(命中“已发货改地址”意图,话术合规)
响应长度从118降到76也是个意外收获,说明模型学会了更直接地表达,少了很多客套铺垫。
七、总结
这轮实践下来几个结论:单卡4090用QLoRA微调7B是完全可行的,3200条数据、3 epoch、4.5小时就能拿到26个点的意图命中率提升;r=16配全线性层注入是垂直领域场景的甜点配置;数据质量(去兜底样本、去重、长度过滤)对最终效果的影响比调参大得多。
后续打算试试把LoRA权重merge回基座再量化部署,看推理延迟能压到多少,有结果再写一篇。代码和配置都在上面了,版本号照抄基本能复现,遇到OOM先检查 prepare_model_for_kbit_training 和 paged_adamw_8bit 这两个点。