一、问题背景:为什么我要自己微调一个7B模型

事情的起因很朴素:我们做的是一个垂直领域的客服助手,基座用的是Qwen2.5-7B-Instruct。直接prompt engineering的效果其实不算差,但在几个高频场景上一直不稳——比如用户问“我这个订单能不能改地址”,模型经常给出“建议联系客服”这种废话,而我们要的是“已发货订单不支持改地址,可拒收后重新下单”这种明确话术。

统计了一下,在200条真实测试集上,基座模型的意图命中率只有61.3%,话术合规率更低。全量微调7B模型对我们这种小团队来说成本太高(至少需要A100 80G级别),所以LoRA是唯一现实的选择。至于QLoRA,是因为我手头只有一张4090 24G,fp16加载7B模型光权重就要14G,加上优化器状态和激活值根本跑不动,4bit量化后权重只占约4.5G,才留出了训练空间。

这篇文章就把整个过程摊开讲,包括我踩的坑。

二、环境与版本

先把版本钉死,避免复现问题:

  • GPU:RTX 4090 24GB(单卡)
  • CUDA 12.1 / PyTorch 2.3.1
  • transformers 4.43.3
  • peft 0.12.0
  • trl 0.9.6
  • bitsandbytes 0.43.1
  • accelerate 0.33.0
  • 基座模型:Qwen2.5-7B-Instruct

bitsandbytes这个版本号要特别注意,0.43.x对CUDA 12.1的支持是稳的,我之前用0.41在4bit加载时报过 CUDA error: an illegal memory access,换版本后解决,后面踩坑部分细说。

三、方案设计

整体思路:

  1. 数据:3200条人工标注的客服问答对,按8:1:1切分训练/验证/测试。格式统一成Alpaca风格的instruction/input/output。
  2. 量化:QLoRA,4bit NF4量化 + double quant,计算时用bf16。
  3. LoRA配置:r=16,alpha=32,dropout=0.05,target_modules覆盖所有线性层(q/k/v/o/gate/up/down_proj)。这里我特意没只挂q_proj和v_proj,因为客服场景需要模型学到较强的领域表达,全线性层注入效果明显更好,代价是显存多占约1G。
  4. 训练:3 epoch,batch size 1 + 梯度累积16(等效batch 16),lr 2e-4,cosine调度,warmup 100步。
  5. 评估:验证集loss曲线 + 测试集上人工抽检100条的意图命中率、话术合规率。

选r=16而不是8,是因为我先用r=8跑了一轮,验证loss在0.9左右就下不去了,升到16后能到0.71。alpha按惯例取2r。

四、核心实现

4.1 数据准备

数据处理这块没什么花活,重点是模板要和基座模型的chat template对齐,不然训练和推理的分布会错位。Qwen2.5用的是ChatML格式,我直接用tokenizer的apply_chat_template,省得手写出错。

import json
from datasets import Dataset

def build_dataset(path):
    samples = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            item = json.loads(line)
            messages = [
                {"role": "system", "content": "你是XX公司的客服助手,回答需简洁、明确、符合话术规范。"},
                {"role": "user", "content": item["query"]},
                {"role": "assistant", "content": item["response"]},
            ]
            samples.append({"messages": messages})
    return Dataset.from_list(samples)

train_ds = build_dataset("data/train.jsonl")   # 2560 条
val_ds   = build_dataset("data/val.jsonl")     # 320 条
print(train_ds[0])

数据质量上有两个细节值得说:一是把“我不知道”“请联系人工”这类兜底回答从训练集里剔掉了约200条,否则模型会学成复读机;二是同一意图的样本做了去重,避免某类问题过采样导致过拟合。

4.2 QLoRA训练配置

下面是完整的训练脚本核心部分,可以直接跑:

import torch
from transformers import (
    AutoModelForCausalLM, AutoTokenizer,
    BitsAndBytesConfig, TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer

model_id = "Qwen/Qwen2.5-7B-Instruct"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
)

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)
model.config.use_cache = False

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.527

args = TrainingArguments(
    output_dir="./output/lora-qwen2.5-7b",
    per_device_train_batch_size=1,
    gradient_accumulation_steps=16,
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_steps=100,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=50,
    save_strategy="steps",
    save_steps=200,
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    max_grad_norm=0.3,
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=train_ds,
    eval_dataset=val_ds,
    tokenizer=tokenizer,
    max_seq_length=1024,
    packing=False,
)
trainer.train()
trainer.save_model("./output/lora-qwen2.5-7b/final")

几个参数是有意为之的:optim="paged_adamw_8bit" 是为了防显存峰值OOM,paged版本在梯度累积时更稳;max_grad_norm=0.3 比默认1.0小,因为我发现QLoRA下梯度偶尔会尖峰,clip严一点loss曲线更平滑;max_seq_length=1024 是因为我们的问答对95%在600 token以内,设太大纯浪费显存。

训练峰值显存约19.2G,单步(含累积16)约5.6秒,3个epoch共480步,总耗时4小时38分。

五、踩坑与优化

坑1:loss前期震荡。 前50步loss在1.7~2.1之间来回跳,一开始以为是lr太高,降到1e-4后收敛变慢但震荡依旧。后来发现是梯度累积16步期间数据长度差异太大(有的样本200 token,有的900 token),改成按长度分桶采样(用 LengthGroupedSampler)后,前50步震荡幅度收敛到±0.1以内。

坑2:4bit加载OOM。 第一次跑的时候没加 prepare_model_for_kbit_training,直接get_peft_model,结果反向传播时显存飙到24G爆掉。这个函数会把layernorm等层转fp32并冻结,必须加。

坑3:评估集loss和训练loss不同步。 第2个epoch开始,训练loss降到0.6,但验证loss在0.85附近横盘。排查后是验证集里有约8%的样本输出长度超过1024被截断,标签错位。把验证集重新按长度过滤后,验证loss正常跟着降到0.71。

优化: 试过r=32,验证loss只比r=16低0.02,但训练时间多了40%,性价比不高,最终定在r=16。

六、效果数据

Loss曲线(每50步记录):

Step Train Loss Eval Loss
50 1.52 1.61
150 1.08 1.12
250 0.83 0.92
350 0.74 0.78
480 0.71 0.71

典型特征:前100步快速下降(学格式和话术模板),150~300步是主要能力提升段,300步后趋于平缓,第3个epoch收益已经很小,说明3 epoch对3200条数据是合适的,再多就过拟合了。

推理效果对比(测试集抽100条人工评估):

指标 基座模型 LoRA微调后
意图命中率 61.3% 87.6%
话术合规率 54.0% 83.2%
平均响应长度(token) 118 76

举个具体例子,用户问“订单发货了还能改地址吗”:

  • 基座回答:“您好,建议您联系客服人员处理哦,他们会给您更准确的答复。”(意图未命中,废话)
  • 微调后:“已发货订单不支持修改收货地址。您可以拒收后重新下单,或联系快递员协商改派。”(命中“已发货改地址”意图,话术合规)

响应长度从118降到76也是个意外收获,说明模型学会了更直接地表达,少了很多客套铺垫。

七、总结

这轮实践下来几个结论:单卡4090用QLoRA微调7B是完全可行的,3200条数据、3 epoch、4.5小时就能拿到26个点的意图命中率提升;r=16配全线性层注入是垂直领域场景的甜点配置;数据质量(去兜底样本、去重、长度过滤)对最终效果的影响比调参大得多。

后续打算试试把LoRA权重merge回基座再量化部署,看推理延迟能压到多少,有结果再写一篇。代码和配置都在上面了,版本号照抄基本能复现,遇到OOM先检查 prepare_model_for_kbit_training 和 paged_adamw_8bit 这两个点。