一、为什么我要在24GB卡上折腾7B模型

先说背景。我接的需求是给一个内部知识问答场景做微调,基座选Qwen2.5-7B-Instruct,因为它在中文指令跟随上表现稳定,而且社区生态好。手里只有一张RTX 3090,24GB显存。

第一反应是直接全参数微调,结果刚加载完模型、优化器状态一初始化,显存就爆了。7B模型全参微调,光权重fp16就要14GB,加上AdamW的优化器状态(fp32的一阶二阶动量)差不多56GB,再加激活值,24GB根本不够看。上多卡?预算不批。上A100?更不现实。

所以路线很明确:参数高效微调(PEFT)。LoRA是首选,QLoRA是备选升级。我决定两个都跑一遍,用数据说话,看哪个更适合落地。

二、环境与版本

环境这块我踩过版本坑,先把确定能跑通的组合列出来:

  • GPU:RTX 3090 24GB
  • CUDA:12.1
  • PyTorch:2.4.0+cu121
  • transformers:4.44.2
  • peft:0.13.2
  • bitsandbytes:0.43.3(QLoRA必需)
  • datasets:2.21.0
  • trl:0.10.1(用它的SFTTrainer省事)
  • Python:3.10.14

特别提醒:bitsandbytes和CUDA版本强绑定,0.43.x配CUDA 12.1是稳的。我之前用0.41.0配CUDA 11.8,4-bit量化加载时直接报CUDA illegal memory access,换版本才好。

三、方案设计

整体思路是两条线并行对比:

方案A:LoRA(fp16基座 + LoRA适配器)
基座模型以fp16加载,冻结全部原始参数,只在注意力层的q_proj、k_proj、v_proj、o_proj和FFN的gate_proj、up_proj、down_proj上挂LoRA。rank=16,alpha=32,dropout=0.05。

方案B:QLoRA(4-bit量化基座 + LoRA适配器)
基座用bitsandbytes做NF4量化加载,计算时反量化到bf16。LoRA配置同上。这是省显存的关键。

数据格式统一用ChatML风格的对话模板,因为Qwen2.5本身就用这个。

四、核心实现

4.1 数据准备

我的数据是内部FAQ整理的,大约3200条,格式是{"instruction": ..., "output": ...}。先转成对话格式:

import json
from datasets import Dataset

def build_dataset(raw_path):
    samples = []
    with open(raw_path, "r", encoding="utf-8") as f:
        for line in f:
            item = json.loads(line)
            messages = [
                {"role": "system", "content": "你是一个专业的知识助手,回答要准确简洁。"},
                {"role": "user", "content": item["instruction"]},
                {"role": "assistant", "content": item["output"]},
            ]
            samples.append({"messages": messages})
    return Dataset.from_list(samples)

dataset = build_dataset("data/faq_train.jsonl")
dataset = dataset.train_test_split(test_size=0.05, seed=42)
print(dataset)
# DatasetDict({'train': 3040, 'test': 160})

数据质量比数量重要。我一开始塞了8000条,里面混了不少重复和低质样本,loss降得很快但推理时开始胡言乱语。后来清洗到3200条,效果反而更好。

4.2 QLoRA训练脚本

这是核心代码,LoRA和QLoRA只差一个BitsAndBytesConfig

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"

# ---- QLoRA 4-bit 量化配置 ----
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

model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    quantization_config=bnb_config,   # LoRA方案就把这行删掉,改 torch_dtype=torch.bfloat16
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)

# ---- LoRA 配置 ----
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

可训练参数只有4037万,占全量的0.53%,这就是LoRA省资源的本质。

训练参数:

training_args = TrainingArguments(
    output_dir="./output_qwen_lora",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,     # 等效batch size = 16
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    num_train_epochs=3,
    bf16=True,
    logging_steps=10,
    save_strategy="epoch",
    gradient_checkpointing=True,
    optim="paged_adamw_8bit",          # QLoRA必配,省显存
    max_grad_norm=0.3,
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
    tokenizer=tokenizer,
    max_seq_length=1024,
    packing=False,
)

trainer.train()
trainer.model.save_pretrained("./lora_adapter_final")

几个关键参数说明:学习率2e-4是LoRA的常用起点,比全参微调大一个数量级;paged_adamw_8bit是QLoRA的标配优化器,优化器状态用8bit分页存储;max_grad_norm=0.3比默认的1.0小,能抑制loss尖刺。

五、踩坑与优化

坑1:显存还是不够。 第一次跑QLoRA,batch_size=4直接OOM。降到2并开启gradient_checkpointing后,峰值显存10.3GB,稳了。gradient_checkpointing是用时间换空间,训练速度慢约15%,但值得。

坑2:loss不收敛,一直在2.5附近抖。 排查发现是warmup没设,前几百步学习率太高把适配器带偏了。加上warmup_ratio=0.03后曲线立刻顺滑。

坑3:推理时输出重复。 这是典型的过拟合,训练了5个epoch。减到3个epoch,并把dropout从0提到0.05,问题消失。

优化点:target_modules的选择。 只挂q、v两个投影时,测试集准确率是84.1%;挂上全部7个模块后提升到89.2%,训练时间只多了约12%。所以别省这点,全挂。

六、效果数据

Loss曲线(QLoRA方案,3 epoch):
- 第1 epoch结束:train_loss 1.42,eval_loss 1.38
- 第2 epoch结束:train_loss 0.87,eval_loss 0.91
- 第3 epoch结束:train_loss 0.61,eval_loss 0.79

训练总耗时约2小时18分,平均每步0.74秒。LoRA方案(fp16基座)耗时1小时52分,快约18%,但峰值显存21.4GB,几乎是QLoRA的两倍。

显存对比
| 方案 | 峰值显存 | 训练时长 | 可训练参数 |
|------|---------|---------|-----------|
| LoRA (fp16) | 21.4GB | 1h52m | 40.4M |
| QLoRA (4-bit) | 10.3GB | 2h18m | 40.4M |

推理效果对比(自建测试集160条,人工判定准确率):
- 基座模型:61.5%
- LoRA微调后:88.7%
- QLoRA微调后:89.2%

有意思的是QLoRA反而略高于LoRA,我猜是4-bit量化带来了一点正则化效果,但这个差距在统计噪声范围内,不必过度解读。

举一个具体case。问:"订单超过48小时未发货怎么处理?"基座模型回答得泛泛而谈,提到"联系客服";微调后能准确说出内部流程的四个步骤和对应时限,这正是训练数据里的内容。

七、总结

几点结论供参考:

  1. 显存紧张就上QLoRA,10GB出头就能微调7B模型,代价是训练慢不到20%,效果几乎无损。
  2. LoRA的rank不用太大,16已经够用,我试过r=32和r=64,测试集准确率只差0.3%左右,但显存和训练时间都上去了。
  3. 数据质量 >> 数据数量,3200条干净数据打败了8000条脏数据。
  4. target_modules尽量全覆盖,收益明显。
  5. 如果显存充足(比如40GB以上),直接LoRA更省心,少一层量化反量化的复杂度。

代码已经整理成脚本,换掉数据路径就能跑。QLoRA这套组合在消费级显卡上做7B微调,目前看是性价比最高的方案,没有之一。