一、问题背景:为什么这次必须微调
先交代场景。我们有一个内部工单系统,每天产生大约2000条用户反馈,内容是自由文本,需要做两件事:一是打上业务分类标签(退款、物流、账号、功能缺陷、其他,共5类),二是生成一句不超过40字的摘要给运营看。
一开始我们直接调Qwen2.5-7B-Instruct的API做zero-shot,结果不太行:分类准确率只有71.3%,主要错在"退款"和"物流"之间——用户说"东西一直没到,我要退钱",模型经常只给一个标签。摘要则倾向于复述原文,长度控制也不稳定,经常写到80字以上。
prompt engineering试了两天,加了few-shot示例、加了输出格式约束,准确率到78%左右就上不去了,而且token成本翻倍。这个任务本身很窄、标签体系固定、输出格式严格,是典型的适合微调的场景。于是决定上手LoRA。
选Qwen2.5-7B-Instruct的原因很简单:中文表现好、社区支持完善、7B规模在单卡24GB上能跑得动。如果你的场景是纯英文,Llama-3.1-8B也是同类选择,流程完全一样。
二、环境与版本
环境这东西必须先说清楚,不然代码贴出来别人跑不通。我这次用的是:
- GPU:单卡 RTX 4090 24GB
- CUDA 12.1,驱动 550.54
- Python 3.10.13
- torch 2.3.1+cu121
- transformers 4.44.2
- peft 0.12.0
- trl 0.9.6
- bitsandbytes 0.43.3
- datasets 2.21.0
- accelerate 0.33.0
有一点要提醒:trl 0.9.x 和 0.8.x 的 SFTTrainer 参数差异不小,网上很多教程还是老版本的 dataset_text_field 写法,直接抄会报错。下面代码按 0.9.6 写。
三、方案设计
整体思路是标准的SFT(监督微调),但有几个设计决策值得说:
1. 任务合并成单轮指令。 分类和摘要放在一个输出里,用固定模板:
类别:退款
摘要:用户反馈下单三天未发货,要求取消订单并退款。
这样一次推理出两个结果,省一半调用。代价是输出格式必须严格,所以训练数据里所有样本都统一成这个模板,一条不符合的都删掉。
2. LoRA vs QLoRA 两条路都跑。 LoRA(fp16基座)显存占用高但速度快;QLoRA(4bit量化基座)显存省一半,速度慢约30%。我最终线上用QLoRA,因为要留显存给并发推理;训练阶段两个都测了,数据在第五节。
3. LoRA超参。 r=16, alpha=32, dropout=0.05,target_modules 覆盖 q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj。全模块覆盖比只挂 q/v 效果好,验证loss低约0.05,这是实测的。
4. 数据格式。 用 chat template,只对 assistant 部分计算loss。这一点很关键,如果对 prompt 也算loss,模型会学成"复读用户输入",摘要质量会明显下降。
四、核心实现
4.1 数据准备
数据来自历史工单,人工标注了1200条,剩下用强模型(GPT-4o)按标注规范扩了3600条,人工抽检了300条,一致率92%。最终 4800 训练 / 400 验证。
数据存成 jsonl,每行一条:
{"instruction": "请对以下工单进行分类并生成摘要。\n工单内容:快递显示已签收但我没收到,联系快递员电话打不通,麻烦处理一下。", "output": "类别:物流\n摘要:用户称快递显示签收但未收到,且无法联系快递员,要求处理。"}
加载和格式化的代码如下,注意 train_on_inputs=False 的效果是通过 DataCollatorForSeq2Seq 配合 labels mask 实现的,这里我直接手写 mask 更可控:
import json
from datasets import Dataset
from transformers import AutoTokenizer
MODEL_PATH = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
def load_jsonl(path):
rows = []
with open(path, encoding="utf-8") as f:
for line in f:
rows.append(json.loads(line))
return Dataset.from_list(rows)
def format_and_mask(example):
messages = [
{"role": "system", "content": "你是工单处理助手,输出必须严格遵循两行格式:类别:xxx\\n摘要:xxx"},
{"role": "user", "content": example["instruction"]},
]
prompt_ids = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True
)
answer_ids = tokenizer(example["output"] + tokenizer.eos_token, add_special_tokens=False).input_ids
input_ids = prompt_ids + answer_ids
labels = [-100] * len(prompt_ids) + answer_ids # 关键:prompt 不参与 loss
return {"input_ids": input_ids, "labels": labels, "attention_mask": [1] * len(input_ids)}
train_ds = load_jsonl("data/train.jsonl").map(format_and_mask, remove_columns=["instruction", "output"])
eval_ds = load_jsonl("data/val.jsonl").map(format_and_mask, remove_columns=["instruction", "output"])
print("max len:", max(len(x) for x in train_ds["input_ids"])) # 实测 512 以内,截断设 768 足够
实测序列长度分布:P95 是 410,最长 623,所以 max_seq_length=768 完全够,不用上 2048,省显存。
4.2 训练配置与代码
QLoRA 版本(线上用的这个):
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
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(
MODEL_PATH, 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"],
)
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="out/qwen2.5-7b-lora",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效 batch 16
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=200,
save_total_limit=2,
bf16=True,
gradient_checkpointing=True,
optim="paged_adamw_8bit",
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=train_ds,
eval_dataset=eval_ds,
data_collator=None, # 我们的 dataset 已含 labels,用默认 collator 即可
max_seq_length=768,
packing=False,
)
trainer.train()
trainer.model.save_pretrained("out/qwen2.5-7b-lora/final")
几个参数选择的理由:学习率 2e-4 是 LoRA 的常用起点,我试过 1e-4(收敛慢,3 epoch 只到 0.68)和 5e-4(第 2 epoch 开始 eval loss 反弹,过拟合)。gradient_accumulation_steps=8 是因为 batch_size=2 时 24GB 显存已用掉 21.3GB,再大就 OOM。packing=False 是因为我们的样本短且格式固定,packing 会把不同样本拼一起,反而让格式学习变差,实测 packing=True 时验证 loss 高 0.08。
LoRA(非量化)版本只需去掉 bnb 相关三行,把 prepare_model_for_kbit_training 换成 model.enable_input_require_grads(),其余不变。显存占用 22.8GB,勉强能跑,但没余量做别的。
五、踩坑与优化
踩的坑不算少,挑几个有代表性的:
坑1:loss 不降,一直在 1.4 附近晃。 排查了半天发现是 chat template 用错了——我手动拼了字符串,没用 apply_chat_template,导致 special token 缺失,模型把整段当纯文本学。换成 apply_chat_template 后第一个 epoch 就降到 0.9。
坑2:摘要长度失控,经常写两行以上。 原因是训练数据里有 7% 的样本摘要超了 40 字,模型学到了"可以写长"。清洗掉这些样本后,加上推理时 max_new_tokens=64 的硬约束,问题消失。
坑3:QLoRA 下 eval loss 抖动明显。 前 100 步 eval loss 在 0.7~0.95 之间跳。后来把 eval_steps 从 20 调到 50、eval batch 从 1 调到 4,抖动收窄到 ±0.03,判断是 4bit 量化 + 小 batch 的数值噪声,不是真问题。
坑4:推理时忘了 merge。 直接用 peft 的 adapter 加载,速度比 merge 后慢 40%(每层多一次矩阵加)。上线前用 merge_and_unload() 合并,7B 模型 merge 后 fp16 约 15.2GB,单卡推理没问题。
优化点: 把 target_modules 从 4 个扩到 7 个(加上 MLP 三层),验证 loss 从 0.66 降到 0.61,训练时间只多了 18%。这个性价比很高,建议默认就全覆盖。
六、效果数据
训练曲线(QLoRA 版,3 epoch,共 900 步):
| 步数 | train loss | eval loss |
|---|---|---|
| 50 | 1.12 | 1.05 |
| 150 | 0.78 | 0.82 |
| 300 | 0.62 | 0.68 |
| 450 | 0.55 | 0.63 |
| 600 | 0.51 | 0.62 |
| 750 | 0.49 | 0.61 |
| 900 | 0.47 | 0.61 |
第 600 步之后 eval loss 基本走平,train loss 还在缓降,说明 3 epoch 已经接近这个数据量的上限,再多训就是过拟合。最终用第 750 步的 checkpoint。
推理效果对比(验证集 400 条,greedy decoding):
| 指标 | 基座 zero-shot | LoRA | QLoRA |
|---|---|---|---|
| 分类准确率 | 71.3% | 94.8% | 94.6% |
| 摘要 ROUGE-L | 0.28 | 0.44 | 0.44 |
| 格式合规率 | 82.5% | 99.8% | 99.5% |
| 平均输出长度 | 71 字 | 38 字 | 39 字 |
| 单条推理耗时(4090) | 1.9s | 0.9s | 1.1s |
LoRA 和 QLoRA 的精度差距在 0.2 个百分点以内,基本可以认为等价,QLoRA 的代价是推理慢 0.2s、训练慢约 30%(2.5h vs 1.9h)。显存上 QLoRA 训练峰值 15.6GB,LoRA 22.8GB,差了 7GB,这 7GB 决定了你能不能在同一张卡上同时开个 jupyter。
一个具体的 bad case 对比:输入"下单五天了物流一直显示待揽收,客服也不回,我不想要了"。基座输出"类别:其他\n摘要:用户反馈订单物流信息未更新,客服未回复。"——分类错了,摘要也没抓住"不想要了"这个退款意图。微调后输出"类别:退款\n摘要:用户因物流五天未揽收且客服未回复,要求取消订单。"——正确。
七、总结
这次微调的整体结论:7B 模型 + LoRA,在单卡 24GB 上,2~3 小时、不到 1 万条数据,就能把一个窄任务的准确率从 71% 拉到 94% 以上,这个投入产出比是值得的。几个可以直接复用的经验:
第一,数据格式的严格性比数据量更重要,7% 的脏样本就能带偏输出长度。第二,apply_chat_template + prompt 部分 mask 掉 loss,这两件事没做对,后面调参都是白费。第三,target_modules 全覆盖的收益明显大于调 r 和 alpha。第四,QLoRA 和 LoRA 在 7B 规模上精度几乎无差,选哪个看显存而不是看效果。
后续打算试试把 r 提到 32 看还有没有空间,以及用 DPO 在分类边界样本上再对齐一轮——现在剩下的 5.4% 错误基本都集中在"退款 vs 物流"的模糊描述上。有进展再写一篇。