Skip to content

第 12 章 · 微调生成模型

本章目标:掌握生成式 LLM 微调的两步法——先用 QLoRA 做指令微调(SFT),再用 DPO 做人类偏好对齐,全程使用 TRL 库在消费级 GPU 上完成。

12.1 环境准备(可选)

如果你在 Google Colab(或其他云平台)上查看本笔记本,需要取消注释并运行下面的代码块来安装本章依赖:

💡 注意:运行本章示例需要 GPU。在 Google Colab 中,进入 Runtime > Change runtime type > Hardware accelerator > GPU > GPU type > T4

python
# %%capture
# !pip install -q accelerate==0.31.0 peft==0.11.1 bitsandbytes==0.43.1 transformers==4.41.2 trl==0.9.4 sentencepiece==0.2.0 triton==3.1.0

12.2 监督微调(SFT)

12.2.1 数据预处理

第一步是把对话数据格式化成模型使用的聊天模板。我们加载 TinyLlama 的分词器专门用来调用 apply_chat_template

python
from transformers import AutoTokenizer
from datasets import load_dataset


# Load a tokenizer to use its chat template
template_tokenizer = AutoTokenizer.from_pretrained("TinyLlama/TinyLlama-1.1B-Chat-v1.0")

def format_prompt(example):
    """Format the prompt to using the <|user|> template TinyLLama is using"""

    # Format answers
    chat = example["messages"]
    prompt = template_tokenizer.apply_chat_template(chat, tokenize=False)

    return {"text": prompt}

# Load and format the data using the template TinyLLama is using
dataset = (
    load_dataset("HuggingFaceH4/ultrachat_200k",  split="test_sft")
      .shuffle(seed=42)
      .select(range(3_000))
)
dataset = dataset.map(format_prompt)

看一条格式化后的 prompt 长什么样:

python
# Example of formatted prompt
print(dataset["text"][2576])

12.2.2 模型量化

加载基座模型 TinyLlama-1.1B 并做 4-bit 量化(这就是 QLoRA 中的 "Q")。BitsAndBytesConfig 配置了 NF4 量化类型、float16 计算精度和嵌套量化:

python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

model_name = "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T"

# 4-bit quantization configuration - Q in QLoRA
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,  # Use 4-bit precision model loading
    bnb_4bit_quant_type="nf4",  # Quantization type
    bnb_4bit_compute_dtype="float16",  # Compute dtype
    bnb_4bit_use_double_quant=True,  # Apply nested quantization
)

# Load the model to train on the GPU
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    device_map="auto",

    # Leave this out for regular SFT
    quantization_config=bnb_config,
)
model.config.use_cache = False
model.config.pretraining_tp = 1

# Load LLaMA tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=False)
tokenizer.pad_token = "<PAD>"
tokenizer.padding_side = "left"

12.2.3 配置

LoRA 配置

LoRA 不更新原模型权重,而是在注意力与 MLP 的投影层上注入低秩适配器。r=64 是秩,lora_alpha=32 是缩放系数:

python
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model

# Prepare LoRA Configuration
peft_config = LoraConfig(
    lora_alpha=32,  # LoRA Scaling
    lora_dropout=0.1,  # Dropout for LoRA Layers
    r=64,  # Rank
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=  # Layers to target
     ['k_proj', 'gate_proj', 'v_proj', 'up_proj', 'q_proj', 'o_proj', 'down_proj']
)

# prepare model for training
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, peft_config)

训练配置

训练参数使用梯度累积(等效 batch size 为 8)与梯度检查点来节省显存:

python
from transformers import TrainingArguments

output_dir = "./results"

# Training arguments
training_arguments = TrainingArguments(
    output_dir=output_dir,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,
    optim="paged_adamw_32bit",
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    num_train_epochs=1,
    logging_steps=10,
    fp16=True,
    gradient_checkpointing=True
)

12.2.4 开始训练!

使用 TRL 的 SFTTrainer 执行监督微调,训练完成后保存 QLoRA 权重:

python
from trl import SFTTrainer

# Set supervised fine-tuning parameters
trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    dataset_text_field="text",
    tokenizer=tokenizer,
    args=training_arguments,
    max_seq_length=512,

    # Leave this out for regular SFT
    peft_config=peft_config,
)

# Train model
trainer.train()

# Save QLoRA weights
trainer.model.save_pretrained("TinyLlama-1.1B-qlora")

12.2.5 合并 Adapter

训练得到的是 LoRA 适配器权重,推理前需要把它合并回基座模型:

python
from peft import AutoPeftModelForCausalLM

model = AutoPeftModelForCausalLM.from_pretrained(
    "TinyLlama-1.1B-qlora",
    low_cpu_mem_usage=True,
    device_map="auto",
)

# Merge LoRA and base model
merged_model = model.merge_and_unload()

12.2.6 推理

用 TinyLlama 预定义的提示模板测试指令微调后的模型:

python
from transformers import pipeline

# Use our predefined prompt template
prompt = """<|user|>
Tell me something about Large Language Models.</s>
<|assistant|>
"""

# Run our instruction-tuned model
pipe = pipeline(task="text-generation", model=merged_model, tokenizer=tokenizer)
print(pipe(prompt)[0]["generated_text"])

12.3 偏好对齐微调(PPO/DPO)

12.3.1 数据预处理

DPO 需要的数据形态是「同一个 prompt 对应一个 chosen 回答和一个 rejected 回答」。这里使用 argilla/distilabel-intel-orca-dpo-pairs 数据集,并过滤掉平局、低分和属于 GSM8K 训练集的样本:

python
from datasets import load_dataset

def format_prompt(example):
    """Format the prompt to using the <|user|> template TinyLLama is using"""

    # Format answers
    system = "<|system|>\n" + example['system'] + "</s>\n"
    prompt = "<|user|>\n" + example['input'] + "</s>\n<|assistant|>\n"
    chosen = example['chosen'] + "</s>\n"
    rejected = example['rejected'] + "</s>\n"

    return {
        "prompt": system + prompt,
        "chosen": chosen,
        "rejected": rejected,
    }

# Apply formatting to the dataset and select relatively short answers
dpo_dataset = load_dataset("argilla/distilabel-intel-orca-dpo-pairs", split="train")
dpo_dataset = dpo_dataset.filter(
    lambda r:
        r["status"] != "tie" and
        r["chosen_score"] >= 8 and
        not r["in_gsm8k_train"]
)
dpo_dataset = dpo_dataset.map(format_prompt, remove_columns=dpo_dataset.column_names)
dpo_dataset

12.3.2 模型量化

重新加载 SFT 阶段保存的 QLoRA 权重并合并,为 DPO 阶段准备基座:

python
from peft import AutoPeftModelForCausalLM
from transformers import BitsAndBytesConfig, AutoTokenizer

# 4-bit quantization configuration - Q in QLoRA
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,  # Use 4-bit precision model loading
    bnb_4bit_quant_type="nf4",  # Quantization type
    bnb_4bit_compute_dtype="float16",  # Compute dtype
    bnb_4bit_use_double_quant=True,  # Apply nested quantization
)

# Merge LoRA and base model
model = AutoPeftModelForCausalLM.from_pretrained(
    "TinyLlama-1.1B-qlora",
    low_cpu_mem_usage=True,
    device_map="auto",
    quantization_config=bnb_config,
)
merged_model = model.merge_and_unload()

# Load LLaMA tokenizer
model_name = "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=False)
tokenizer.pad_token = "<PAD>"
tokenizer.padding_side = "left"

12.3.3 配置

LoRA 配置与 SFT 阶段相同:

python
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model

# Prepare LoRA Configuration
peft_config = LoraConfig(
    lora_alpha=32,  # LoRA Scaling
    lora_dropout=0.1,  # Dropout for LoRA Layers
    r=64,  # Rank
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=  # Layers to target
     ['k_proj', 'gate_proj', 'v_proj', 'up_proj', 'q_proj', 'o_proj', 'down_proj']
)

# prepare model for training
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, peft_config)

DPO 使用专门的 DPOConfig,学习率比 SFT 更小(1e-5),并用 max_steps 控制训练步数:

python
from trl import DPOConfig

output_dir = "./results"

# Training arguments
training_arguments = DPOConfig(
    output_dir=output_dir,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,
    optim="paged_adamw_32bit",
    learning_rate=1e-5,
    lr_scheduler_type="cosine",
    max_steps=200,
    logging_steps=10,
    fp16=True,
    gradient_checkpointing=True,
    warmup_ratio=0.1
)

12.3.4 DPO 训练

DPOTrainer 通过 beta=0.1 控制偏离参考模型的强度,训练后保存 DPO 适配器:

python
from trl import DPOTrainer

# Create DPO trainer
dpo_trainer = DPOTrainer(
    model,
    args=training_arguments,
    train_dataset=dpo_dataset,
    tokenizer=tokenizer,
    peft_config=peft_config,
    beta=0.1,
    max_prompt_length=512,
    max_length=512,
)

# Fine-tune model with DPO
dpo_trainer.train()

# Save adapter
dpo_trainer.model.save_pretrained("TinyLlama-1.1B-dpo-qlora")

最后把 SFT 模型与 DPO 适配器逐层合并,得到最终的偏好对齐模型:

python
from peft import PeftModel

# Merge LoRA and base model
model = AutoPeftModelForCausalLM.from_pretrained(
    "TinyLlama-1.1B-qlora",
    low_cpu_mem_usage=True,
    device_map="auto",
)
sft_model = model.merge_and_unload()

# Merge DPO LoRA and SFT model
dpo_model = PeftModel.from_pretrained(
    sft_model,
    "TinyLlama-1.1B-dpo-qlora",
    device_map="auto",
)
dpo_model = dpo_model.merge_and_unload()

用同样的提示模板对比 DPO 前后的输出质量:

python
from transformers import pipeline

# Use our predefined prompt template
prompt = """<|user|>
Tell me something about Large Language Models.</s>
<|assistant|>
"""

# Run our instruction-tuned model
pipe = pipeline(task="text-generation", model=dpo_model, tokenizer=tokenizer)
print(pipe(prompt)[0]["generated_text"])

本章小结

  • 生成式 LLM 微调两步法:先 SFT 指令微调教会模型「怎么回答」,再 DPO 偏好对齐教会模型「回答得更好」;
  • QLoRA = 4-bit NF4 量化 + LoRABitsAndBytesConfig 负责量化配置,让 1.1B 模型的微调能在单张 T4 上完成;
  • 聊天模板必须与基座一致:直接复用 TinyLlama 分词器的 apply_chat_template,避免格式错位;
  • SFT 用 SFTTrainer + TrainingArguments,DPO 用 DPOTrainer + DPOConfig,后者额外需要 chosen/rejected 成对数据与 beta 强度参数;
  • 多阶段适配器可叠加合并:QLoRA → merge → 再挂 DPO LoRA → 再 merge,最终得到单一完整模型。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. QLoRA 中的 "Q" 指的是什么?

2. DPO 训练数据与 SFT 数据的核心区别是?

3. 关于 SFT 与 DPO 的训练配置,下列说法正确的是?

4. SFT 与 DPO 两个阶段的 LoRA 适配器如何组合成最终模型?

🛠️ 动手实践

  1. 把 SFT 数据量从 range(3_000) 改为 range(1_000),其余超参不变,对比两种数据规模下推理输出的连贯性差异。
  2. 将 LoRA 的 r 从 64 改成 16、lora_alpha 从 32 改成 16,重新走一遍 SFT 流程,比较训练显存占用与生成质量的变化。
  3. 在 DPO 数据过滤条件中把 chosen_score >= 8 放宽为 >= 5,观察可用样本数量的变化,并对比放宽前后模型的回答风格差异。