一、问题背景:为什么非得微调
先说清楚场景。我手上是一个客服工单分类 + 回复生成的系统,基座用Qwen2.5-7B-Instruct。直接用prompt工程跑了一版,问题很明显:
- 分类标签体系是我们业务自定义的23个类目,基座模型经常把“物流停滞”判成“退款申请”,边界混淆严重;
- 回复生成里频繁出现“建议您联系人工客服”这类兜底话术,业务方明确要求减少;
- 输出格式不稳定,要求JSON输出时大约有8%的样本会多带解释性文字。
全量微调7B模型在24G卡上不现实(bf16权重就要14G,加上优化器状态和梯度直接爆),所以走LoRA。这里也顺带提一句QLoRA:如果你只有16G甚至12G卡,4bit量化基座 + LoRA是可行路线,但我在4090上测下来,QLoRA因为要反量化,训练速度比纯LoRA慢约35%,显存只省了4G左右,24G卡上没必要,所以最终方案是bf16 + LoRA。
二、环境与版本
环境这块我踩过版本坑,先列清楚:
- Python 3.10.13
- torch 2.4.1 + cu121
- transformers 4.44.2
- peft 0.12.0
- trl 0.9.6
- accelerate 0.33.0
- bitsandbytes 0.43.3(虽然最终没用QLoRA,但装上了备用)
- GPU:RTX 4090 24GB,驱动 550.107.02
提醒一句:peft 0.12 和 trl 0.9.6 这个组合是验证过能跑SFTTrainer的,如果你用trl 0.11+,SFTTrainer的参数名变了(dataset_text_field还在,但max_seq_length挪到了SFTConfig里),直接抄老代码会报TypeError。
三、方案设计
整体思路:
- 数据:3200条工单问答对,按8:1:1切训练/验证/测试。格式统一成Alpaca风格的三段式(instruction / input / output),output里强制JSON。
- LoRA配置:r=16,lora_alpha=32,lora_dropout=0.05,target_modules覆盖全部线性层(q/k/v/o + gate/up/down),这是关键,后面踩坑部分会讲。
- 训练:bf16,batch_size=4,gradient_accumulation=4,等效batch 16,lr=2e-4,cosine调度,warmup_ratio=0.03,3个epoch,max_seq_length=1024。
- 评估:验证集loss + 测试集上的分类准确率和JSON格式合规率,外加人工看10条生成样本。
为什么r选16不选8?我实测r=8时验证loss在1.12左右收敛,r=16能到0.94,r=32到0.91但显存多了1.8G、训练慢了20%,性价比不行。r=16是这张卡上的甜点。
四、核心实现
4.1 数据准备
数据我存成jsonl,每行一个样本。构造时有个细节:input字段放工单原文,output里我把分类结果和回复拼成一个JSON,这样模型一次输出两个任务的结果,省一轮推理。
import json
from datasets import load_dataset
# 原始数据示例构造
sample = {
"instruction": "你是客服工单处理助手。请阅读工单内容,输出JSON,包含category(23类之一)和reply(给用户的回复,不超过80字,不要出现'联系人工客服')。",
"input": "我上周三下的单,物流显示已发货但三天没更新了,到底什么时候能到?",
"output": json.dumps({
"category": "物流停滞",
"reply": "您的包裹当前在运输途中,因中转站积压导致更新延迟,预计48小时内会有新物流信息,请留意。"
}, ensure_ascii=False)
}
with open("train.jsonl", "w", encoding="utf-8") as f:
for s in raw_samples: # 你清洗后的样本列表
f.write(json.dumps(s, ensure_ascii=False) + "\n")
dataset = load_dataset("json", data_files={
"train": "train.jsonl",
"validation": "val.jsonl"
})
print(dataset)
# DatasetDict({train: 2560, validation: 320})
数据清洗上我做了三件事:去重(按input的simhash,去掉约400条近重复)、过滤output不是合法JSON的样本、把回复里含“人工客服”的样本人工改写(约120条)。这步别偷懒,脏数据对7B模型的影响比想象中大。
4.2 训练配置与代码
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer
from datasets import load_dataset
model_id = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=True,
)
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
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"],
bias="none",
)
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
def format_sample(example):
text = (
f"system\n{example['instruction']}\n"
f"user\n{example['input']}\n"
f"assistant\n{example['output']}"
)
return {"text": text}
dataset = load_dataset("json", data_files={"train": "train.jsonl", "validation": "val.jsonl"})
dataset = dataset.map(format_sample, remove_columns=dataset["train"].column_names)
training_args = TrainingArguments(
output_dir="./lora-qwen2.5-7b",
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
num_train_epochs=3,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=200,
bf16=True,
gradient_checkpointing=True,
optim="adamw_torch",
report_to="none",
seed=42,
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset["train"],
eval_dataset=dataset["validation"],
tokenizer=tokenizer,
dataset_text_field="text",
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.save_model("./lora-qwen2.5-7b/final")
注意packing=False,这个值很关键,见踩坑部分。训练日志里trainable params是4037万,占全参0.53%,产出的adapter权重文件约77MB(fp32保存),非常轻。
4.3 loss曲线
实际跑下来,train loss从初始的1.83降到第3个epoch结束的0.71,eval loss在0.89附近触底,第3个epoch后半段eval loss开始轻微抬头(0.89→0.93),说明第3个epoch已经有点过拟合迹象。所以我最终选的是第2个epoch结束的checkpoint(step 480)而不是最后一个。
曲线形态上:前100步下降很快(1.83→1.21),100到300步是缓慢下降段,300步之后基本在0.75~0.85震荡。eval loss在step 350左右达到最低0.89,之后波动上升。如果你也看到这种形态,别迷信跑满epoch,早停或者按eval loss选checkpoint。
五、踩坑与优化
坑1:target_modules只写了q_proj和v_proj。 第一版我按老论文的惯例只加了q、v,结果验证集准确率只有72%,比全线性层版本低了12个点。7B这个量级的模型,gate/up/down三个MLP层对领域知识注入影响很大,全线性层是必须的。显存代价大概多1.2G,完全值得。
坑2:开了packing,loss虚低。 一开始为了吞吐开了packing=True,train loss掉到0.4,我还以为效果爆炸,结果推理一测格式合规率只有70%。原因是packing把多条短样本拼进一个1024窗口,attention mask处理不当会让模型学到跨样本的“串味”模式。关掉packing后吞吐降了约25%,但效果正常了。短样本为主的数据集,packing慎用。
坑3:max_seq_length=1024截断。 我的工单里有约6%的样本超过1024 token,被截断后output丢了,模型学到的是不完整输出。处理方式:超长样本单独截input保留完整output,或者直接过滤。我选了过滤,丢了大概180条。
坑4:学习率2e-4配cosine,前50步loss抖动明显。 加了warmup_ratio=0.03(约15步)后稳定不少。LoRA的lr比全参微调可以大一个量级,但2e-4基本是上限了,再大容易发散。
优化上还做了一件小事:把gradient_checkpointing打开后,显存从21.8G降到19.2G,速度只慢8%左右,24G卡上属于必开项。
六、推理效果对比
测试集320条,对比基座和LoRA微调后模型(用step 480的checkpoint合并adapter推理):
| 指标 | 基座Qwen2.5-7B-Instruct | LoRA微调后 |
|---|---|---|
| 分类准确率 | 61.3% | 84.7% |
| JSON格式合规率 | 91.9% | 99.4% |
| 回复含“联系人工客服”比例 | 17.2% | 2.8% |
| 平均生成token数 | 96 | 71 |
单看数字提升不算炸裂,但看具体case差异很明显:
- 基座对“物流停滞”和“退款申请”的混淆率是28%,微调后降到7%;
- 基座在JSON输出上偶尔会包一层
json代码块或者加一句“以下是结果:”,微调后基本绝迹; - 回复长度从96降到71,更符合业务要求的“不超过80字”。
推理侧加载方式(合并adapter,避免推理时额外开销):
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct", torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./lora-qwen2.5-7b/final")
model = model.merge_and_unload() # 合并后就是一个普通7B模型
model.eval()
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
prompt = "system\n你是客服工单处理助手...\nuser\n{工单}\nassistant\n"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=256, do_sample=False)
print(tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))
合并后模型推理速度和基座一致,单条平均延迟约0.9s(4090,batch=1,贪心解码)。
七、总结
这次LoRA微调的核心结论:24G卡上7B模型用bf16+LoRA完全可行,r=16、全线性层target、lr=2e-4、3个epoch(实际取第2个)是这套数据上的稳定配置,训练2小时40分、峰值显存19.2G。效果上分类准确率+23.4个点,格式合规率接近满分。
如果重来一次,我会在数据侧多花半天:把超长样本的output保留策略做细,而不是简单过滤;另外验证集可以扩到500条以上,现在320条的eval loss波动有点大,选checkpoint时不够稳。
QLoRA留个尾巴:如果你的卡是16G,把from_pretrained加load_in_4bit=True和bnb_4bit_compute_dtype=torch.bfloat16,其余配置不变基本能跑,代价是慢一些、eval loss会略高(我试跑过一版,验证loss 0.94 vs 0.89),但作为低成本方案完全够用。