一、问题背景:为什么非得微调

先说清楚场景。我手上是一个客服工单分类 + 回复生成的系统,基座用Qwen2.5-7B-Instruct。直接用prompt工程跑了一版,问题很明显:

  1. 分类标签体系是我们业务自定义的23个类目,基座模型经常把“物流停滞”判成“退款申请”,边界混淆严重;
  2. 回复生成里频繁出现“建议您联系人工客服”这类兜底话术,业务方明确要求减少;
  3. 输出格式不稳定,要求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。

三、方案设计

整体思路:

  1. 数据:3200条工单问答对,按8:1:1切训练/验证/测试。格式统一成Alpaca风格的三段式(instruction / input / output),output里强制JSON。
  2. LoRA配置:r=16,lora_alpha=32,lora_dropout=0.05,target_modules覆盖全部线性层(q/k/v/o + gate/up/down),这是关键,后面踩坑部分会讲。
  3. 训练:bf16,batch_size=4,gradient_accumulation=4,等效batch 16,lr=2e-4,cosine调度,warmup_ratio=0.03,3个epoch,max_seq_length=1024。
  4. 评估:验证集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),但作为低成本方案完全够用。