人工智能实践(语言智能)
第6讲:大模型微调

上机实验:Qwen2.5-1.5B 的 LoRA 微调

在单张 A100-40G 上用 LLaMA-Factory 或 TRL 对 Qwen2.5-1.5B 做 LoRA 微调,跑通完整 SFT 流程

实验概述

本实验让你第一次完整走完 SFT 流水线:从数据准备到 LoRA 微调,再到推理对比和简单评估。为了让所有同学在单张 A100-40G(或等价)上都能复现,我们选择一个轻量组合

项目详情
基座模型Qwen/Qwen2.5-1.5B(不是 -Instruct)
数据集500-1000 条小规模指令数据(可用 llamafactory/alpaca_gpt4_zh 的子集)
方法LoRA(BF16,r=32,α=64)
工具栈两条路线二选一:路线 A:LLaMA-Factory(YAML 配置)路线 B:TRL(Python 代码)
预计时间A100-40G 上约 15-25 分钟训练;T4 下约 45 分钟

GPU 要求:推荐 A100-40G / RTX 4090 / 3090。T4-16G 需降级到 QLoRA + 更小的 seq_len。训练前先跑 nvidia-smi 确认 GPU 可用。

实验步骤

步骤 1:环境配置(10 分钟)

安装依赖(两条路线都需要):

# 建议用 conda 或 venv 新建环境
pip install "torch>=2.2" --index-url https://download.pytorch.org/whl/cu121

# 路线 A:LLaMA-Factory
pip install llamafactory

# 路线 B:TRL
pip install "transformers>=4.45" "peft>=0.13" "trl>=0.12" \
            "datasets>=2.19" "accelerate>=0.34" "bitsandbytes" \
            "sentencepiece" "protobuf"

验证环境:

import torch, transformers, peft, trl
print("torch:", torch.__version__)
print("transformers:", transformers.__version__)
print("peft:", peft.__version__)
print("trl:", trl.__version__)
print("CUDA available:", torch.cuda.is_available())
if torch.cuda.is_available():
    print("GPU:", torch.cuda.get_device_name(0))

步骤 2:数据准备(15 分钟)

下载并裁剪一个小规模指令数据集(500-1000 条)。

from datasets import load_dataset

# 以 Alpaca-GPT4 中文版为例
dataset = load_dataset("llamafactory/alpaca_gpt4_zh", split="train")
print(f"Total: {len(dataset)}")

# 抽样 1000 条
small_ds = dataset.shuffle(seed=42).select(range(1000))

# 转换为 messages 格式(SFT 通用)
def to_messages(example):
    user = example["instruction"]
    if example.get("input", "").strip():
        user += "\n" + example["input"]
    return {
        "messages": [
            {"role": "user", "content": user},
            {"role": "assistant", "content": example["output"]},
        ]
    }

small_ds = small_ds.map(to_messages, remove_columns=small_ds.column_names)

# 过滤过短 / 过长
def ok(ex):
    u, a = ex["messages"][0]["content"], ex["messages"][1]["content"]
    if not u.strip() or not a.strip():
        return False
    if len(a) < 20 or len(u) + len(a) > 4000:
        return False
    return True

small_ds = small_ds.filter(ok)
print(f"After filter: {len(small_ds)}")

# 划分 95/5
split = small_ds.train_test_split(test_size=0.05, seed=42)
train_ds, eval_ds = split["train"], split["test"]

# 存到本地 jsonl
train_ds.to_json("data/train.jsonl", force_ascii=False)
eval_ds.to_json("data/eval.jsonl", force_ascii=False)
print(f"Train: {len(train_ds)}, Eval: {len(eval_ds)}")

加分项:用你自己课程里感兴趣的领域数据(如北大研究生手册问答、课程 FAQ)替代 Alpaca。数据量 500 条也能看出明显风格变化——这就是 LIMA 那篇论文的核心洞见。

步骤 3:训练配置(5 分钟)

configs/qwen2_5_lora_sft.yaml 写入:

### 模型
model_name_or_path: Qwen/Qwen2.5-1.5B
trust_remote_code: true

### 方法
stage: sft
do_train: true
finetuning_type: lora
lora_rank: 32
lora_alpha: 64
lora_target: q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj
lora_dropout: 0.05

### 数据集
dataset: my_alpaca_zh    # 见下方 data/dataset_info.json
template: qwen
cutoff_len: 2048
max_samples: 1000
overwrite_cache: true
preprocessing_num_workers: 4

### 输出
output_dir: saves/qwen2.5-1.5b/lora/sft
logging_steps: 10
save_steps: 200
overwrite_output_dir: true

### 训练
per_device_train_batch_size: 4
gradient_accumulation_steps: 4
learning_rate: 2.0e-4
num_train_epochs: 3
lr_scheduler_type: cosine
warmup_ratio: 0.1
bf16: true
gradient_checkpointing: true
ddp_timeout: 180000000

### 评估
val_size: 0.05
per_device_eval_batch_size: 4
eval_strategy: steps
eval_steps: 100

data/dataset_info.json 注册数据集:

{
  "my_alpaca_zh": {
    "file_name": "train.jsonl",
    "formatting": "sharegpt",
    "columns": {
      "messages": "messages"
    },
    "tags": {
      "role_tag": "role",
      "content_tag": "content",
      "user_tag": "user",
      "assistant_tag": "assistant"
    }
  }
}

把下列代码保存为 train.py

import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig

# ===== 1. 模型 =====
model_name = "Qwen/Qwen2.5-1.5B"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    trust_remote_code=True,
)

# ===== 2. LoRA =====
lora_config = LoraConfig(
    r=32,
    lora_alpha=64,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

# ===== 3. 数据 =====
train_ds = load_dataset("json", data_files="data/train.jsonl", split="train")
eval_ds  = load_dataset("json", data_files="data/eval.jsonl",  split="train")

# ===== 4. 训练配置 =====
sft_config = SFTConfig(
    output_dir="./qwen2.5-1.5b-sft",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    per_device_eval_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.1,
    bf16=True,
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    max_length=2048,
    packing=False,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=100,
    save_strategy="steps",
    save_steps=200,
    save_total_limit=2,
    report_to="none",
    seed=42,
    assistant_only_loss=True,     # 只在 assistant token 上算损失
)

# ===== 5. 启动 =====
trainer = SFTTrainer(
    model=model,
    args=sft_config,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    processing_class=tokenizer,
)

train_result = trainer.train()
trainer.save_model("./qwen2.5-1.5b-sft/final")
tokenizer.save_pretrained("./qwen2.5-1.5b-sft/final")

print(f"\n=== Training finished ===")
print(f"Train loss: {train_result.training_loss:.4f}")
print(f"Runtime:    {train_result.metrics['train_runtime']:.0f}s")
print(f"Throughput: {train_result.metrics['train_samples_per_second']:.2f} samples/s")

步骤 4:开始训练(15-25 分钟)

llamafactory-cli train configs/qwen2_5_lora_sft.yaml

也可以直接启 WebUI:

llamafactory-cli webui
# 浏览器打开 http://localhost:7860,在 UI 里选择同样的配置
python train.py

期望看到的日志

trainable params: 18,464,768 || all params: 1,561,174,016 || trainable%: 1.1827

[100/570]  loss=1.248  eval_loss=1.203  lr=1.85e-4
[200/570]  loss=1.105  eval_loss=1.094  lr=1.55e-4
[300/570]  loss=1.021  eval_loss=1.056  lr=1.12e-4
[400/570]  loss=0.962  eval_loss=1.034  lr=0.65e-4
[500/570]  loss=0.928  eval_loss=1.028  lr=0.19e-4

损失解读

  • train_loss 持续下降是正常的
  • eval_loss 也应同步下降或保持平稳
  • 如果 eval_loss 在中途开始上升,说明过拟合了——减少 epoch 或增大 lora_dropout

步骤 5:推理对比(10 分钟)

同一组 prompts 对比 base 模型和微调后的 LoRA 模型:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

base_name = "Qwen/Qwen2.5-1.5B"
adapter_dir = "./qwen2.5-1.5b-sft/final"

tokenizer = AutoTokenizer.from_pretrained(adapter_dir, trust_remote_code=True)

def load(name, adapter=None):
    m = AutoModelForCausalLM.from_pretrained(
        name, torch_dtype=torch.bfloat16, device_map="auto",
        trust_remote_code=True,
    )
    if adapter:
        m = PeftModel.from_pretrained(m, adapter)
    m.eval()
    return m

def chat(model, prompt, max_new=256):
    messages = [{"role": "user", "content": prompt}]
    text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(
            **inputs,
            max_new_tokens=max_new,
            do_sample=True,
            temperature=0.7,
            top_p=0.9,
            pad_token_id=tokenizer.eos_token_id,
        )
    return tokenizer.decode(
        out[0][inputs.input_ids.shape[1]:],
        skip_special_tokens=True,
    )

# ===== 测试 prompts =====
prompts = [
    "用三句话介绍量子计算。",
    "写一个 Python 函数计算斐波那契数列。",
    "请以 JSON 格式列出中国四大发明。",
    "小明有 15 个苹果,给了小红 3 个,又买了 7 个,现在有多少个?",
    "用 Markdown 表格对比 Python 和 Java 的三个优缺点。",
]

# ===== 基座 =====
base = load(base_name)
print("===== BASE =====")
for p in prompts:
    print(f"\n[Q] {p}")
    print(f"[A] {chat(base, p)}")
del base
torch.cuda.empty_cache()

# ===== SFT =====
sft = load(base_name, adapter=adapter_dir)
print("\n===== SFT =====")
for p in prompts:
    print(f"\n[Q] {p}")
    print(f"[A] {chat(sft, p)}")

期望观察

维度Base 模型SFT 模型
指令跟随经常只做补全,不回答明确作答、结构清晰
格式遵循几乎忽视"用 JSON / 表格"的要求倾向于按要求输出
终止常常不停下更自然地结束
语气像在续写文本像在对话

步骤 6:简单评估(15 分钟)

定性:在 eval.jsonl 或自己准备的 20 条测试问题上人工打分(1-5 分):

  • 指令遵循:是否做了用户要求的事
  • 格式遵守:是否用了要求的 JSON / 表格 / 代码块
  • 事实性:回答的事实对不对
  • 流畅度:语言自然吗

定量(加分项):用 LLM-as-Judge(参见第 8 讲)让 GPT-4 / Claude 对 base 和 SFT 的输出做双盲打分:

# 伪代码
judge_prompt = """你是一位严格的评委。下面给出同一个问题和两份回答 A/B。
请从【指令遵循、格式、事实性、流畅度】四个维度 1-5 打分,并选出胜者。

问题:{question}
A:{ans_a}
B:{ans_b}
"""

记录胜率(SFT 相对 base 的 win-rate),比如 72% 意味着 500 轮里 SFT 赢了 360 轮。

加分实验

实验 1:换一个超参

修改一个关键超参,重新训练,对比损失曲线和推理质量:

  • lora_rank:32 → 8 / 128
  • learning_rate:2e-4 → 2e-5 / 1e-3
  • num_train_epochs:3 → 1 / 10

实验 2:换一个领域

把 Alpaca 换成你自己领域的 500 条问答(课程 FAQ、研究生手册、实验室代码注释),看看模型能否学会你的领域术语和回答风格

实验 3:LLaMA-Factory vs TRL

用同一份数据,两条路线各跑一次,对比:

  • 跑通的时间(写代码 / 调 YAML 用了多久)
  • 训练速度
  • 最终损失

交付物清单

完成实验后提交:

  • 训练损失曲线training_loss.png
  • 推理对比表:5-10 个 prompts 的 base vs SFT 输出对比
  • 简单评估结果:人工打分表 或 LLM-as-Judge 的胜率
  • 一页书面分析(约 500 字),讨论:
    • 哪些维度(指令跟随 / 格式 / 风格)变化最明显?
    • 哪些维度基本没变化?为什么?
    • 你调整的一个超参对结果有什么影响?
    • 你观察到 LoRA 训练的哪些""?

时间预估(A100-40G,1000 条数据,3 epoch):

  • 环境 + 数据:~25 分钟
  • 配置 + 训练:~20-30 分钟
  • 推理对比:~10 分钟
  • 评估:~15 分钟
  • 总计约 80-90 分钟

下一步

完成本实验后,你已经具备了独立做一次 LoRA 微调的能力。如果你希望更进一步:

  • 本课程第 7 讲(Agent):把你微调的模型挂进一个 Agent 框架,看它在多步工具调用里的表现
  • 姊妹课程 大语言模型后训练:在 SFT 基础上继续做 DPO / GRPO,学习完整的后训练流水线
  • 第 8 讲(LLM as Judge):把你的 SFT 模型做系统化的自动评估