1. 问题背景:当我只有一张3090,却想微调7B模型

上个月接到一个任务:让公司内部的ChatGLM2-6B模型学会回答我们产品文档里的技术问题。基础模型虽然聪明,但对内部API和特定术语完全“无感”,必须微调。

我手头只有一张RTX 3090(24GB显存)。全参微调7B模型?理论显存需求至少54GB(FP16),想都别想。于是把目光投向了LoRA(Low-Rank Adaptation)和它的进阶版QLoRA。

先说结论:LoRA适合显存稍充裕(>=16GB)且追求训练速度的场景;QLoRA则是在单卡小显存下的“救命稻草”。本文记录的是我最终采用的QLoRA方案,但也会对比LoRA的数据。

2. 环境与版本:别小看版本组合拳

这次踩的最大的坑就是版本不兼容。这里给出我最终验证可行的组合:

  • Python: 3.10.12
  • PyTorch: 2.0.1 (CUDA 11.8)
  • transformers: 4.35.2
  • peft: 0.6.2
  • bitsandbytes: 0.41.3
  • datasets: 2.15.0
  • 模型:ChatGLM2-6B (官方权重fp16)

特别注意:bitsandbytes在Windows下支持很烂,建议直接用WSL2或Linux。另外,transformer版本不要低于4.34,否则QLLaMA的4-bit加载会报错。

3. 方案设计:为什么选QLoRA + 4-bit NF4

我的核心矛盾是:显存只有24GB,但我想batch size尽可能大(为了BN稳定和训练速度)。QLoRA的核心创新是:

  1. 4-bit NormalFloat (NF4) 量化基座模型权重,显存直接砍4倍。
  2. 分页优化器(Paged AdamW),在显存溢出时利用CPU内存,防止OOM崩溃。
  3. 双LoRA适配器(注意力层的Q和V矩阵分别加低秩矩阵),实现低成本参数更新。

我的参数选择(非玄学,有依据):

  • r=16:秩的大小。太小拟合能力不足(试过r=8,loss下不去),太大参数量剧增,16是性价比拐点。
  • alpha=32:缩放系数,一般设r的2倍。
  • dropout=0.05:防止过拟合,因为训练集只有5000条。
  • target_modules:对于ChatGLM2,我选择query_key_value(即QKV融合矩阵)。

4. 核心实现:数据准备与训练代码

4.1 数据准备:从文档到指令对

我的原始数据是200页产品Markdown文档。我写了个脚本自动抽取标题和正文,然后调用GPT-4生成指令问答对,最后人工抽样清洗了200条。最终数据集格式为JSON:

{
  "instruction": "如何配置API访问令牌?",
  "output": "在控制台左侧导航栏点击'API管理'...(具体步骤省略)"
}

加载与格式化代码:

from datasets import load_dataset
from transformers import AutoTokenizer

dataset = load_dataset("json", data_files="train_data.json", split="train")

def format_example(example):
    # ChatGLM2的prompt模板
    prompt = f"[Round 1]\n\n问:{example['instruction']}\n\n答:"
    return {"prompt": prompt, "output": example["output"]}

dataset = dataset.map(format_example)
tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm2-6b", trust_remote_code=True)

def tokenize_fn(example):
    # 将prompt和output拼接,注意mask掉prompt部分的loss
    prompt_ids = tokenizer(example["prompt"], return_tensors="pt")["input_ids"][0]
    output_ids = tokenizer(example["output"], return_tensors="pt")["input_ids"][0]
    input_ids = torch.cat([prompt_ids, output_ids], dim=0)
    labels = torch.cat([torch.full_like(prompt_ids, -100), output_ids], dim=0)
    return {"input_ids": input_ids, "labels": labels}

tokenized_dataset = dataset.map(tokenize_fn, remove_columns=dataset.column_names)

这里关键点:将prompt部分的label设为-100,这样loss只计算回答部分,否则模型会学偏。

4.2 训练配置:QLoRA加载 + PagedAdamW

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

# 4-bit量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",  # NormalFloat4
    bnb_4bit_use_double_quant=True,  # 双量化,进一步省显存
    bnb_4bit_compute_dtype=torch.float16  # 计算时反量化为FP16
)

model = AutoModel.from_pretrained(
    "THUDM/chatglm2-6b",
    quantization_config=bnb_config,
    trust_remote_code=True,
    device_map="auto"  # 自动分配到GPU和CPU
)

# 准备k-bit训练:冻结量化层,并启用梯度检查点
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["query_key_value"],  # ChatGLM2的QKV合并矩阵
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 8,388,608 || all params: 6,743,306,240 || trainable%: 0.1244

# 训练参数
from transformers import TrainingArguments, Trainer

training_args = TrainingArguments(
    output_dir="./qlora_chatglm2",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,  # 等效batch size=32
    learning_rate=2e-4,
    num_train_epochs=5,
    logging_steps=50,
    save_strategy="epoch",
    evaluation_strategy="epoch",
    optim="paged_adamw_8bit",  # 分页优化器,防止OOM
    fp16=True,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
)

这段代码跑起来后,torch.cuda.max_memory_allocated()显示的峰值是 11.2GB,相比全参微调(54GB)下降了79.3%。训练速度约6.5步/秒(batch_size=4),每个epoch耗时约38分钟。作为对比,同参数下LoRA(8-bit)需要14.8GB显存,速度约8.2步/秒。

5. 踩坑与优化:三个让我想砸电脑的问题

问题1:loss不降反升
现象:第2个epoch loss从1.2跳到1.8。排查后发现是学习率过大(我最初设了5e-4),配合cosine调度在warmup阶段震荡。解决:降到2e-4,并增加warmup_ratio到0.05。

问题2:生成时重复输出“问:”
这是ChatGLM2的常见问题。原因是训练时prompt模板带了[Round 1],推理时忘了加。解决:推理代码里必须原样带上模板。

问题3:QLoRA微调后基础能力退化
有几次微调后,模型连“1+1等于几”都答错。原因是LoRA适配器权重与基座模型叠加时产生了“灾难性遗忘”。解决:减少epoch到5,并降低LoRA的alpha到32(原来是64),同时增加数据多样性。

6. 效果数据:loss曲线与推理对比

6.1 Loss曲线

下图是我训练过程中的loss曲线(每50步记录一次):

  • Epoch 1:loss从3.2降至1.8,下降明显。
  • Epoch 2-3:loss从1.8降至1.1,速度放缓。
  • Epoch 4-5:loss在0.85-0.95之间震荡,开始出现过拟合迹象(eval loss微升)。

最终选择Epoch 4的checkpoint作为最终模型,因为Epoch 5的eval loss已开始上升。

6.2 推理效果对比

我在100条领域测试集上对比了“原版ChatGLM2”和“QLoRA微调后”的生成效果:

指标 原版 QLoRA微调后 提升
ROUGE-L 0.21 0.38 +81%
BLEU-4 0.09 0.22 +144%
人工评分(1-5分) 2.3 4.1 +78%

具体示例(原版答案和微调后答案对比):

问题:如何设置Webhook的签名密钥?

  • 原版:Webhook是一种回调机制……(泛泛而谈,没有具体操作)
  • 微调后:登录控制台,进入「开发者中心」→「Webhook管理」,点击「编辑」→「安全设置」,在「签名密钥」输入框中填入至少32位字符的密钥,点击「保存」。系统会自动生成X-Signature头,用于请求校验。

7. 总结与建议

这次实践让我对LoRA/QLoRA有了更实在的理解:

  1. QLoRA不是银弹:在24GB显存下,训练速度比LoRA慢约20%,但显存节省30%以上。如果你有32GB以上显存,直接用LoRA(8-bit)更划算。
  2. 数据质量 > 数据量:我刚开始用自动抽取的2万条数据,效果很差;清洗到5000条高质量数据后,效果反而翻倍。
  3. LoRA超参是玄学但可调r=16alpha=32是大多数场景的稳定起点,不要轻易动target_modules
  4. 推理时务必保留prompt模板:这是ChatGLM系模型最容易翻车的地方。

最后,模型文件我上传到了HuggingFace(your_name/chatglm2-qlora-webhook),如果需要可以直接拿去推理。有问题欢迎在评论区交流,我尽量回复。