一、为什么我要做这次微调
事情是这样的:我们团队有个内部客服助手,最初直接调API用通用大模型,效果勉强能用,但一遇到产品线的专有名词就开始胡说。比如用户问"你们的企业版支持SAML单点登录吗",模型答得头头是道,实际上我们的企业版压根没做这个功能。这种"自信地编造"在客服场景里是致命的。
prompt engineering试了两周,把产品手册塞进system prompt,效果有提升但上下文太长,成本和延迟都上去了。最后决定走微调这条路:用历史工单和标准回复构造数据集,让7B模型学会我们产品领域的说话方式和事实边界。
选7B而不是更大的模型,理由很朴素——手上只有一张RTX 4090(24GB),全参数微调7B至少需要60GB+显存,不现实。LoRA是唯一务实的选择。
二、环境与版本
先把环境交代清楚,避免读者照着做的时候版本对不上:
- GPU:RTX 4090 24GB,驱动 550.54
- CUDA 12.1,PyTorch 2.3.1
- transformers 4.42.3
- peft 0.11.1
- bitsandbytes 0.43.1(QLoRA量化依赖)
- trl 0.9.6(SFTTrainer)
- accelerate 0.31.0
- 基座模型:Qwen2.5-7B-Instruct
这里有个小提醒:peft和transformers的版本耦合比较紧,我一开始用transformers 4.40配peft 0.11,加载LoRA时抛了target_modules相关的warning,升到4.42后消失。建议按官方兼容表来。
三、方案设计
整体思路是QLoRA + LoRA:基座模型用4-bit NF4量化加载,冻结全部原始参数,只在注意力层和MLP层注入低秩适配器。这样可训练参数只有约40M,占全量的0.55%,显存和优化器状态压力都小。
关键配置决策:
- LoRA rank r=16:试过r=8和r=32,r=8在验证集上欠拟合(loss下不去),r=32提升不明显但显存和训练时间增加约20%,最终选16。
- alpha=32:按经验取2r,缩放系数比较稳。
- target_modules:覆盖
q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj,也就是注意力和MLP全打。只打q/v效果会差一截,这点后面踩坑部分细说。 - dropout=0.05:数据量不算大,加一点正则防过拟合。
- 学习率2e-4,cosine调度,warmup比例0.03。
- batch size:per_device 4 + gradient_accumulation 8,等效batch 32。
- max_seq_length 2048:我们的对话样本95%在1500 token以内,2048够用且不浪费显存。
四、核心实现
4.1 数据准备
原始数据是工单系统导出的JSON,字段包括用户问题、标准回复、产品线标签。我做了几件事:清洗掉回复长度小于20字符的样本、按产品线分层采样避免长尾产品被淹没、统一转成Qwen的ChatML格式。
import json
from datasets import Dataset
def build_sample(item):
system = "你是XX公司的客服助手,只依据已知产品信息回答,不确定时明确说不知道。"
return {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": item["question"].strip()},
{"role": "assistant", "content": item["answer"].strip()},
]
}
raw = json.load(open("tickets_clean.json", encoding="utf-8"))
samples = [build_sample(x) for x in raw if len(x["answer"]) >= 20]
# 简单按9:1切分
n = len(samples)
train_ds = Dataset.from_list(samples[: int(n * 0.9)])
eval_ds = Dataset.from_list(samples[int(n * 0.9):])
print(f"train={len(train_ds)}, eval={len(eval_ds)}") # train=7740, eval=860
最终训练集7740条,验证集860条。数据质量比数量重要——我第一版用了1.5万条未清洗数据,里面混了不少"您好,稍等"这种无信息量的回复,训出来的模型变得特别啰嗦,后来砍到8.6k反而更好。
4.2 训练脚本
用trl的SFTTrainer,配合peft的LoraConfig和bitsandbytes的4-bit量化配置:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
model_id = "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_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)
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"],
)
training_args = SFTConfig(
output_dir="./qwen2.5-7b-lora",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=8,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_strategy="epoch",
bf16=True,
max_seq_length=2048,
gradient_checkpointing=True,
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_ds,
eval_dataset=eval_ds,
peft_config=lora_config,
tokenizer=tokenizer,
)
trainer.train()
trainer.model.save_pretrained("./qwen2.5-7b-lora-final")
训练耗时:3个epoch、约2900步,单卡4090跑了4小时12分钟,峰值显存17.8GB。这个数字比预期好,主要归功于gradient_checkpointing和4-bit量化。
五、踩坑与优化
坑一:target_modules只打q/v,效果差一截。 我最初照搬一些教程只对q_proj和v_proj注入LoRA,训完发现模型能学会说话风格,但事实性知识记不住,验证集loss卡在1.1左右下不去。改成全打注意力+MLP后,同样epoch数loss直接降到0.9以下。原因也简单:MLP层承载了大量事实知识,不训它等于只学了"语气"没学"内容"。
坑二:packing导致loss异常。 trl默认开启样本packing(把多条短样本拼进一个2048窗口提升吞吐),但我们的对话样本长度差异大,packing后attention mask处理不当,loss曲线出现明显锯齿。关掉packing、改用普通padding后曲线平滑了,代价是训练时间多了约15%,能接受。
坑三:eval_steps设太密拖慢训练。 一开始设eval_steps=20,860条验证集每20步跑一次,训练时间被拉长近一倍。改成100步一次,既能看到趋势又不浪费算力。
优化点: 学习率从3e-4降到2e-4是我调参的主要收益来源。3e-4时loss前期下降快但200步后开始震荡,2e-4配合cosine调度全程平稳,最终收敛值也低0.03左右。
六、效果数据
Loss曲线(描述一下,读者可对照自己的训练):
- 训练loss:起始1.42,前100步快速降到1.05,之后缓慢下降,第3个epoch末稳定在0.71左右,全程无反弹。
- 验证loss:起始1.40,第1个epoch末1.02,第2个epoch末0.91,第3个epoch末0.87。第3个epoch验证loss降幅明显收窄(0.91→0.87),说明接近收敛,再训下去有过拟合风险,这也是我只训3个epoch的原因。
- 训练/验证loss差距全程小于0.16,没有明显过拟合迹象,dropout=0.05起了作用。
推理效果对比(同一批50条测试问题,人工评估):
| 指标 | 基座模型 | LoRA微调后 |
|---|---|---|
| 领域问答准确率 | 61.3% | 83.7% |
| 事实性编造率 | 22.0% | 6.4% |
| 回复风格符合度(1-5分) | 3.2 | 4.5 |
一个具体例子:问"企业版是否支持SAML单点登录",基座模型回答"支持,可通过管理后台配置",纯属编造;微调后回答"当前企业版暂不支持SAML单点登录,如有需求可联系客户经理登记",这正是我们想要的行为。
推理速度方面,4-bit量化后单条回复(平均180 token)生成耗时约1.9秒,比全精度基座慢约8%,在客服场景完全可接受。
七、总结
这次微调给我的最大体会是:数据质量 > 超参调优 > 模型规模。8.6k条清洗过的样本,配一个不算激进的LoRA配置,在单张消费级显卡上就能把7B模型的领域准确率拉高20多个百分点。几个可以直接复用的结论:
- target_modules务必覆盖MLP层,别只打q/v;
- 关掉packing,除非你的样本长度高度一致;
- 验证loss降幅收窄就该停,别迷信固定epoch数;
- 学习率2e-4配cosine是7B LoRA比较稳的起点。
后续我打算试试把LoRA权重merge回基座再量化部署,对比一下推理延迟,有结果再写一篇。代码和配置都贴在上面了,照着跑应该能复现,遇到版本问题欢迎评论区交流。