一、为什么我要在24G卡上微调7B模型

先说背景。我手头有一个垂直领域的结构化抽取任务:从客服对话里抽取「订单号、问题类型、处理方案、情绪」四个字段。直接调GPT-4o效果不错,但成本压不住,而且数据不能出内网。于是打算用Qwen2.5-7B-Instruct做基座,用几百条业务数据做指令微调。

问题来了:全量微调7B模型,FP16权重就要14G,加上Adam的优化器状态(一阶+二阶动量,各14G)、梯度14G,再算上激活值,起码要80G显存。A100 80G都够呛能跑大batch。我只有一张4090,所以LoRA是唯一解,再叠加QLoRA把基座量化到4bit,才能把显存压到可接受范围。

这篇文章不讲原理推导,LoRA的低秩分解、QLoRA的NF4量化网上已经很多人写过了。我只写我实际跑通的完整流程和踩过的坑,代码可直接复制。

二、环境与版本

版本这块必须说清楚,因为peft和transformers的API在近几个版本改得挺频繁,网上很多老代码直接报错。

OS: Ubuntu 22.04
GPU: RTX 4090 24G
CUDA: 12.1
Python: 3.10.13
torch: 2.4.0+cu121
transformers: 4.44.2
peft: 0.13.2
bitsandbytes: 0.43.3
trl: 0.10.1
datasets: 2.21.0
accelerate: 0.34.2

安装命令:

pip install torch==2.4.0 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.44.2 peft==0.13.2 bitsandbytes==0.43.3 \
            trl==0.10.1 datasets==2.21.0 accelerate==0.34.2

注意bitsandbytes一定要装0.43以上,0.41在CUDA 12.1上会有量化kernel的兼容问题,我当时卡了半天。

三、数据准备

数据是核心。我拿到的是约1200条客服对话,人工标注质量参差。清洗后留下860条。

格式上我统一成Alpaca式的instruction/input/output,但实际用chat template更稳。Qwen2.5自带chat template,直接用tokenizer.apply_chat_template。

先看一条样本长这样:

{
  "conversations": [
    {"role": "system", "content": "你是客服工单结构化助手,请从对话中抽取订单号、问题类型、处理方案、情绪四个字段,以JSON输出。"},
    {"role": "user", "content": "用户:我上周买的手机到现在还没发货,订单号是20240912001,你们到底什么时候发?\n客服:非常抱歉,我帮您查询一下……您的订单预计今天发出。"},
    {"role": "assistant", "content": "{\"order_id\": \"20240912001\", \"issue_type\": \"发货延迟\", \"solution\": \"催促发货\", \"emotion\": \"不满\"}"}
  ]
}

数据构造有几个我踩过的坑,写在代码注释里:

import json
from datasets import Dataset

def build_dataset(path):
    raw = [json.loads(l) for l in open(path, encoding="utf-8")]
    data = []
    for item in raw:
        msgs = item["conversations"]
        # 坑1:system prompt必须固定,训练和推理要一致,否则效果断崖
        # 坑2:assistant内容里的JSON不要带换行和多余空格,否则模型学到脏格式
        text = tokenizer.apply_chat_template(
            msgs,
            tokenize=False,
            add_generation_prompt=False
        )
        data.append({"text": text})
    return Dataset.from_list(data)

# 坑3:一定要切分验证集,否则loss降了你也不知道是不是过拟合
ds = build_dataset("data/clean.jsonl")
ds = ds.train_test_split(test_size=0.08, seed=42)
train_ds, eval_ds = ds["train"], ds["test"]
print(f"train: {len(train_ds)}, eval: {len(eval_ds)}")
# train: 791, eval: 69

数据量不大,860条对LoRA来说够用,但要保证字段分布均衡,我特意检查了emotion字段,把「中性」类从原来的62%下采样到35%,否则模型会偷懒全输出中性。

四、方案设计与核心实现

方案上:

  • 基座:Qwen2.5-7B-Instruct,4bit NF4量化加载,双重量化开启
  • LoRA:target_modules覆盖q/k/v/o/gate/up/down全部线性层,r=16,alpha=32,dropout=0.05
  • 训练:bf16计算,batch=4,梯度累积8(等效batch 32),lr=2e-4,cosine调度,warmup 30步,3个epoch
  • 优化器:paged_adamw_8bit,进一步省显存

为什么r取16?我试过r=8和r=32,r=8欠拟合(eval loss停在0.42下不去),r=32比r=16只好了0.01的loss但显存多1.2G,不划算。alpha=2r是常规做法。

完整训练代码:

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_path = "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_path, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"  # 坑4:训练必须right padding

model = AutoModelForCausalLM.from_pretrained(
    model_path,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False  # 坑5:训练时必须关,否则和梯度检查点冲突

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="./output_qwen_lora",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_steps=30,
    logging_steps=10,
    save_strategy="epoch",
    eval_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    report_to="none",
    seed=42,
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    tokenizer=tokenizer,
    dataset_text_field="text",
    max_seq_length=1024,  # 坑6:Qwen2.5支持32k,但业务数据最长就800token,设1024省显存
    packing=False,
)

trainer.train()
trainer.save_model("./output_qwen_lora/final")

跑起来显存峰值11.8G,训练速度2.3 it/s(含梯度累积),3个epoch大概48分钟。这个数字我挺满意。

五、踩坑与优化记录

几个真实卡过我的问题:

  1. loss从第2个epoch开始反弹。train loss一路降到0.18,但eval loss在epoch 2后从0.31升到0.38。典型的过拟合。解决办法:把dropout从0.05提到0.1,同时把epoch从5降到3,eval loss稳在0.29。

  2. 推理时输出格式错乱。训练时assistant内容是被chat template包起来的,推理时我忘了加generation prompt,模型直接续写用户的话。后来统一用apply_chat_template(add_generation_prompt=True),问题消失。

  3. padding_side设错导致loss虚高。一开始用left padding,loss比right padding高了0.15,因为Qwen是decoder-only,left padding会让label对齐错位。改成right后正常。

  4. max_seq_length设4096显存爆到22G。业务数据用不到那么长,降到1024后显存直接省了6G多。别盲目跟着默认配置走。

  5. QLoRA推理速度慢。4bit加载推理比FP16慢约35%,如果线上QPS要求高,建议训练完用merge_and_unload把LoRA合并回FP16基座,部署时用vLLM,吞吐能翻好几倍。

合并权重的代码:

from peft import PeftModel
from transformers import AutoModelForCausalLM

base = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
model = PeftModel.from_pretrained(base, "./output_qwen_lora/final")
model = model.merge_and_unload()
model.save_pretrained("./merged_qwen_7b")

六、效果数据

我在69条验证集 + 额外80条线上真实badcase上做了评估,字段级准确率(四个字段全对才算对):

模型 全字段准确率 order_id issue_type emotion
Qwen2.5-7B-Instruct 原始 61.2% 88.3% 70.1% 65.4%
+ LoRA (r=16) 87.9% 97.6% 89.2% 84.7%
+ LoRA (r=32) 88.4% 97.6% 90.1% 85.2%

r=32相比r=16只提升0.5个点,显存多1.2G,性价比不高,最终选r=16。

loss曲线(手动记录的关键点):

step 10   train_loss 1.842  eval_loss -
step 50   train_loss 0.612  eval_loss -
step 99   train_loss 0.284  eval_loss 0.312   (epoch 1)
step 198  train_loss 0.176  eval_loss 0.291   (epoch 2)
step 297  train_loss 0.142  eval_loss 0.287   (epoch 3)

epoch 2到3 eval loss基本平了,再训就是过拟合,所以3个epoch停手是对的。

推理对比举个真实例子:

输入(用户对话):

用户:你们这个会员我昨天刚续费,今天就扣了我两次钱,订单20241008003,给我退!
客服:非常抱歉,我核实一下,确实是系统重复扣款,我这边为您提交退款。

原始模型输出:

好的,我帮您处理一下退款问题,请您提供订单号。

(漏字段、没按JSON输出)

微调后输出:

{"order_id": "20241008003", "issue_type": "重复扣款", "solution": "提交退款", "emotion": "愤怒"}

格式和字段都对了,这也是我最看重的收益。

七、总结

几点结论:

  1. QLoRA + LoRA在单张4090上微调7B模型完全可行,显存11.8G,训练40多分钟,成本极低。
  2. r=16对千条量级的垂直任务够用,别盲目上r=64,性价比很差。
  3. 数据质量比调参重要。我花在清洗和均衡字段上的时间,比调超参多三倍,但收益也大得多。
  4. 训练完记得merge权重,QLoRA的4bit推理是真的慢。
  5. 一定要留验证集,否则loss曲线会骗你。

代码我整理成了一个脚本,改改数据路径就能跑。如果后面数据量涨到几万条,我会考虑上r=32或者直接全量微调,但就目前这个量级,LoRA是性价比最高的方案。

有跑不通的地方,多半是bitsandbytes和CUDA版本的问题,先检查这个。