一、为什么我要做这次微调

事情是这样的:我们团队有个内部客服助手,最初直接调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多个百分点。几个可以直接复用的结论:

  1. target_modules务必覆盖MLP层,别只打q/v;
  2. 关掉packing,除非你的样本长度高度一致;
  3. 验证loss降幅收窄就该停,别迷信固定epoch数;
  4. 学习率2e-4配cosine是7B LoRA比较稳的起点。

后续我打算试试把LoRA权重merge回基座再量化部署,对比一下推理延迟,有结果再写一篇。代码和配置都贴在上面了,照着跑应该能复现,遇到版本问题欢迎评论区交流。