一、问题背景:为什么不直接上全参数微调

先说结论:不是不想全参微调,是显存不允许。

我这次的任务是给一个电商客服场景做意图识别 + 回复改写。原始模型用 Qwen2.5-7B-Instruct,zero-shot 在自建测试集上准确率只有 61.3%,尤其是"退款""换货""物流催单"这三类经常混。业务方要求准确率上 90%,且模型得私有化部署在一张 24GB 的卡上。

全参数微调 7B 模型,光权重(fp16)就要 14GB,加上 AdamW 的 optimizer states(fp32 的 momentum 和 variance)大约 56GB,再加上激活值,单卡 24GB 完全没戏。多卡 A100 也不是不能申请,但周期长、成本高,而且我们只是想做一个垂直领域的适配,全参微调既慢又容易灾难性遗忘。

所以方案定在 PEFT 路线上:LoRA 负责低秩适配,QLoRA 负责把基座量化到 4-bit 进一步省显存。最终在单张 4090 上跑通,显存峰值 19.3GB,留了 4GB 余量。

二、环境与版本

版本这块我踩过坑,所以写死在这里:

Python            3.11.9
torch             2.4.1+cu121
transformers      4.46.3
peft              0.13.2
bitsandbytes      0.44.1
datasets          3.1.0
accelerate        1.1.1
trl               0.12.1
GPU               RTX 4090 24GB (驱动 550.107)
CUDA              12.1

重点提醒:bitsandbytes 一定要和 CUDA 版本对齐,我一开始装了 0.43.0 配 cu121,加载 4-bit 模型时直接报 CUDA error: no kernel image is available。升到 0.44.1 才好。另外 transformers 低于 4.43 对 Qwen2.5 的 chat template 支持有问题,会丢失 special tokens。

三、方案设计

整体思路分四层:

  1. 量化层:基座用 4-bit NF4 量化加载,bnb_4bit_compute_dtype=torch.bfloat16,bnb_4bit_use_double_quant=True。双重量化能再省约 0.4GB。
  2. 适配层:LoRA 只挂在 attention 的 q/k/v/o 和 MLP 的 gate/up/down 上。7B 模型全部 target_modules 一共 7 个投影矩阵,可训练参数约 40.4M,占全量的 0.55%。
  3. 数据层:统一构造成对话格式,用 Qwen 的 chat template 编码,labels 只对 assistant 部分计算 loss(prompt 部分 mask 成 -100)。
  4. 训练层:bf16 + gradient checkpointing + paged AdamW,单卡 batch size 1,梯度累积 16,等效 batch 16。

LoRA 超参我做了小规模网格:rank 在 {8, 16, 32} 里试,alpha 固定为 2×rank。rank=8 时验证准确率 89.1%,rank=16 到 92.7%,rank=32 反而掉到 91.8%(过拟合迹象,训练 loss 到 0.31 但验证没涨)。最后定 rank=16、alpha=32、dropout=0.05。

四、核心实现

4.1 数据准备

原始数据是业务给的 CSV,两列:query 和 label。我把它转成 ShareGPT 风格的多轮对话,system prompt 固定,assistant 输出是"意图 + 改写后的回复"的 JSON。

import json
from datasets import Dataset

SYSTEM = "你是电商客服助手。请判断用户意图,并给出礼貌、简洁的回复。输出JSON:{\"intent\": ..., \"reply\": ...}"

def build_sample(query, label, reply):
    return {
        "messages": [
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": query},
            {"role": "assistant", "content": json.dumps(
                {"intent": label, "reply": reply}, ensure_ascii=False)}
        ]
    }

raw = [
    ("我要退款,昨天买的耳机还没发货", "refund", "已为您提交退款申请,预计1-3个工作日到账。"),
    ("快递怎么还没到啊都三天了", "logistics_urge", "抱歉让您久等,我帮您催一下物流,稍后同步进度。"),
    # ... 共 8600 条
]

samples = [build_sample(q, l, r) for q, l, r in raw]

# 切分
import random
random.seed(42)
random.shuffle(samples)
n_val = int(len(samples) * 0.05)
val_ds = Dataset.from_list(samples[:n_val])
train_ds = Dataset.from_list(samples[n_val:])
print(f"train={len(train_ds)}, val={len(val_ds)}")
# train=8170, val=430

编码的时候有个关键点:只对 assistant 的 token 计算 loss。我一开始偷懒整段算 loss,结果模型学会了复读 system prompt,验证准确率只有 70% 出头。

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct", trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

MAX_LEN = 1024

def preprocess(ex):
    msgs = ex["messages"]
    # 完整对话
    full_ids = tokenizer.apply_chat_template(
        msgs, tokenize=True, add_generation_prompt=False)
    # 只有 prompt(到 assistant 之前)
    prompt_ids = tokenizer.apply_chat_template(
        msgs[:-1], tokenize=True, add_generation_prompt=True)
    full_ids = full_ids[:MAX_LEN]
    prompt_len = min(len(prompt_ids), len(full_ids))
    labels = [-100] * prompt_len + full_ids[prompt_len:]
    return {
        "input_ids": full_ids,
        "attention_mask": [1] * len(full_ids),
        "labels": labels,
    }

train_tok = train_ds.map(preprocess, remove_columns=train_ds.column_names)
val_tok = val_ds.map(preprocess, remove_columns=val_ds.column_names)

4.2 模型加载与LoRA配置

import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

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(
    "Qwen2.5-7B-Instruct",
    quantization_config=bnb_config,
    device_map={"": 0},
    torch_dtype=torch.bfloat16,
    attn_implementation="flash_attention_2",
)
model.config.use_cache = False
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

4.3 训练配置

用 trl 的 SFTTrainer,但注意新版 SFTTrainer 不再自动做 packing,我手动传已经 tokenize 好的 dataset。

from transformers import TrainingArguments
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM

args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora-intent",
    num_train_epochs=3,
    per_device_train_batch_size=1,
    per_device_eval_batch_size=1,
    gradient_accumulation_steps=16,
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    bf16=True,
    optim="paged_adamw_8bit",
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=100,
    save_steps=200,
    save_total_limit=2,
    report_to="tensorboard",
    max_grad_norm=0.3,
    group_by_length=True,
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=train_tok,
    eval_dataset=val_tok,
    tokenizer=tokenizer,
    data_collator=None,  # 已预编码,用默认 collator
)

trainer.train()
trainer.save_model("./qwen2.5-7b-lora-intent/final")

跑起来大概是这样:

{'loss': 1.874, 'grad_norm': 3.21, 'learning_rate': 1.94e-4, 'epoch': 0.02}
{'loss': 0.913, 'grad_norm': 1.08, 'learning_rate': 1.72e-4, 'epoch': 0.21}
{'loss': 0.612, 'grad_norm': 0.74, 'learning_rate': 1.31e-4, 'epoch': 0.48}
{'loss': 0.487, 'grad_norm': 0.52, 'learning_rate': 8.7e-5, 'epoch': 0.75}
{'loss': 0.431, 'grad_norm': 0.41, 'learning_rate': 4.2e-5, 'epoch': 0.98}
...
{'eval_loss': 0.402, 'eval_accuracy': 0.927, 'epoch': 3.0}

4.4 loss曲线

TensorBoard 上的曲线形状很典型:前 0.3 个 epoch 从 1.87 快速跌到 0.7 左右,然后进入缓慢下降区间,到第 2 个 epoch 之后基本在 0.42~0.45 之间震荡。验证 loss 在第 2.5 个 epoch 达到最低 0.398,第 3 个 epoch 微微回升到 0.402,说明 3 个 epoch 差不多是上限。如果继续训到 5 个 epoch,验证 loss 会涨到 0.45 左右,明显过拟合。

这里有个细节:验证 loss 曲线比训练 loss 平滑很多,因为验证集只有 430 条,batch=1 但没开 dropout(eval 模式),噪声主要来自样本难度差异。

五、踩坑与优化

坑1:padding side 搞反。 Qwen2.5 是 decoder-only,必须 tokenizer.padding_side = "right"。我一开始没设,默认继承 base tokenizer 的 left padding,结果 attention mask 和 labels 错位,loss 一直卡在 2.0 下不去。这个坑花了我一晚上。

坑2:target_modules 漏了 MLP。 只挂 q/k/v/o 时验证准确率 88.4%,加上 gate/up/down 三个投影后到 92.7%。7B 模型的 MLP 参数量占大头,垂直领域知识注入主要靠它。

坑3:use_reentrant=True 和 gradient checkpointing 冲突。 报 RuntimeError: element 0 of tensors does not require grad。改成 use_reentrant=False 解决,同时 enable_input_require_grads() 也可以加上,双保险。

坑4:学习率 2e-4 偏高导致早期震荡。 前 50 步 grad_norm 经常飙到 3 以上,loss 曲线有毛刺。后来把 max_grad_norm 从默认 1.0 调到 0.3,毛刺基本消失。QLoRA 因为基座是量化权重,梯度尺度比全参微调敏感,学习率别超过 2e-4。

优化:group_by_length。 数据里长短句差异很大(最短 18 token,最长 400+ token),开了 group_by_length=True 之后每个 batch 内长度接近,padding 浪费减少,吞吐从 2.1 samples/s 提到 2.8 samples/s,整体训练时间从 3 小时 20 分压到 2 小时 40 分。

六、效果数据

测试集 1200 条,三条对比线:

方案 准确率 意图F1 显存峰值 训练时长
Qwen2.5-7B zero-shot 61.3% 0.58 - -
LoRA fp16 (rank=16) 92.4% 0.91 26.8GB(OOM边缘) 3h05m
QLoRA 4-bit NF4 (rank=16) 92.7% 0.92 19.3GB 2h40m

有意思的是 QLoRA 反而比 fp16 LoRA 高 0.3 个点,我猜是 4-bit 量化本身带一点正则效果,在 8600 条这种中小规模数据上反而抑制了过拟合。当然这个差异在统计误差范围内,别过度解读。

推理侧对比一个具体 case:

输入:"我上周买的裙子尺码不对,能换个大一号的吗"

  • 微调前:"您好,请问您需要什么帮助?"(完全跑偏,把意图识别当成了打招呼)
  • 微调后:{"intent": "exchange", "reply": "可以的,为您安排换货。请确认一下需要更换的尺码,我这边帮您生成换货单。"}

再比如:"订单都五天没动了是不是丢件了"

  • 微调前:"建议您联系快递公司查询。"(把球踢给用户)
  • 微调后:{"intent": "logistics_urge", "reply": "抱歉让您担心,我先帮您核实物流状态,如确认丢件会立即为您补发或退款。"}

推理速度上,4-bit 量化模型 + LoRA 适配器,batch=1、max_new_tokens=128 时,单条延迟约 380ms(4090),比 fp16 基座(约 420ms)还略快一点,因为 4-bit 权重加载带宽占用小。

七、总结

这次微调整体是成功的,几个可复用的经验:

  1. 7B 模型 + QLoRA 是 24GB 单卡的最优解,不用纠结全参微调,垂直任务上 LoRA 的效果损失很小(我们这次甚至没损失)。
  2. 数据质量 > 超参调优。我花了 60% 时间在清洗数据和构造 assistant-only labels 上,这部分做对了,rank 从 8 调到 16 的提升比调学习率明显得多。
  3. rank 别贪大。8600 条数据,rank=16 是甜点,rank=32 开始过拟合。数据量小于 1 万条时,rank 8~16 足够。
  4. 版本对齐是隐形成本。bitsandbytes、torch、CUDA 三者版本不对齐,报错信息往往指向莫名其妙的地方,建议直接抄一套验证过的版本组合。

后续我打算试试把 LoRA 权重 merge 回基座再量化成 GPTQ 做部署,看看能不能把推理延迟再压一压。如果这篇对你有帮助,欢迎评论区交流你踩过的坑。