一、问题背景:为什么这次必须微调

先交代场景。我们有一个内部工单系统,每天产生大约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 物流"的模糊描述上。有进展再写一篇。