前言

你有一只猫,品种是狸花。你想教它一个新技能——握手。两种做法:

做法一:从头教。买一本《猫的心理学》,研究猫的行为学,设计一套训练方案,每天练 2 小时,练 3 个月。猫学会了握手,但可能也忘了怎么用猫砂。

做法二:微调。猫已经会坐、等、趴下——这些基础技能已经训练好了。你只需要在已有的基础上,加一点点新训练。练 1 天,握手就学会了。

大模型的微调,就是这个道理。ChatGPT 已经"学会"了语言理解和生成——这个能力是用几万亿 token 训练出来的,耗时几个月、花费几百万美元。你不想从零训练一个 ChatGPT,你只需要在它的基础上,加一点你自己的数据(比如客服对话、法律文书、医疗记录),让它变成"你们公司专属的" ChatGPT。

cann-recipes-train 仓是昇腾 NPU 上的训练配方库,GLM 微调配方是其中一个。这篇文章用类比的方式,从零解释什么是大模型微调,以及怎么用这个配方跑起来。

为什么需要微调

先说清楚一件事:为什么不直接用 Prompt(提示词)?

用 Prompt 的方式叫** In-Context Learning**(上下文学习)。你给 ChatGPT 几个例子,它照着例子学。这个方式简单,但有两个问题:

问题一:消耗 token。每个 Prompt 都要把例子带进去,1 万条客服对话,每条 100 token,就是 100 万 token。Token 是要钱的,OpenAI 的 GPT-4 API 每 1000 token 收几分钱,100 万 token 就是几十块钱。每天 1 万条对话,光 Prompt 的费用就得好几千。

问题二:精度不够。Prompt 的本质是"在说话中教它"。ChatGPT 能从几个例子里推断规律,但如果你有几千条真实数据,Prompt 的方式学得不够深。

微调解决了这两个问题。微调的本质是"在权重里教它"——把你想要的模式直接编码到模型权重里。Prompt 里不需要带例子,模型自己就知道该怎么答。

打个比方:Prompt 像是一张便签,你每次问问题都把参考答案贴在便签上给模型看。微调像是把参考答案背到脑子里——不需要便签,模型自己就能答。

微调的两种方法:Full FT vs LoRA

微调有两种做法:全量微调(Full Fine-Tuning)和参数高效微调(PEFT)。

全量微调就是把所有参数都更新一遍。模型有 70 亿参数,就更新 70 亿参数。效果最好,但显存占用也最大——70 亿参数的 FP16 模型是 14GB,加上梯度(又是 14GB)和优化器状态(28GB),至少要 56GB 显存。昇腾 910 单卡只有 64GB,跑全量微调勉强能行,但多卡并行的话通信开销很大。

LoRA(Low-Rank Adaptation)是一种参数高效微调方法。它的核心思想是:与其更新整个权重矩阵,不如只更新一个小的"补丁"。

用猫来类比:全量微调是重新训练整只猫的神经系统。LoRA 是在猫的大脑里植入一个小芯片——芯片很小,但插进去之后猫就能握手。

LoRA 的数学原理是:假设原始权重是 W(形状 d×d),更新量是 ΔW。全量微调是直接更新 ΔW。LoRA 的做法是把 ΔW 分解成两个小矩阵的乘积:ΔW = A × B,其中 A 是 d×r 矩阵,B 是 r×d 矩阵,r 是"秩"(rank),通常设为 8、16 或 64。

d×d 的矩阵有 d² 个参数。LoRA 的补丁只有 d×r + r×d = 2dr 个参数。如果 r=16,d=4096,那么全量更新需要 4096² = 1677 万个参数,LoRA 只需要 2×4096×16 = 13 万个参数——减少了 99%。

# LoRA 的实现(简化版)
class LoRALinear(torch.nn.Module):
    """
    LoRA 的线性层
    原始: y = W @ x
    LoRA: y = W @ x + (A @ B) @ x
          = W @ x + A @ (B @ x)
    其中 A 和 B 是可学习的低秩矩阵
    """
    def __init__(self, in_features, out_features, rank=16, alpha=16):
        super().__init__()
        self.rank = rank
        self.alpha = alpha
        
        # 原始权重(冻结,不更新)
        self.weight = torch.nn.Parameter(
            torch.randn(out_features, in_features)  # W
        )
        self.weight.requires_grad = False  # 冻结
        
        # LoRA 补丁(可学习)
        # A: (rank, in_features),用随机数初始化
        # B: (out_features, rank),初始化为零
        # B 初始化为零是为了:在训练初期,LoRA 的输出是零
        # 这样 LoRA 的效果从零开始,逐渐增加,不会一开始就剧烈改变模型行为
        self.lora_A = torch.nn.Parameter(
            torch.randn(rank, in_features)  # A
        )
        self.lora_B = torch.nn.Parameter(
            torch.zeros(out_features, rank)  # B(零初始化)
        )
        
    def forward(self, x):
        # 原始输出
        original_output = torch.nn.functional.linear(x, self.weight)
        
        # LoRA 输出:先过 A,再过 B
        # x: (batch, seq, in_features)
        # A: (rank, in_features)
        # B: (out_features, rank)
        # A @ x^T: (rank, batch, seq)
        # B @ A @ x^T: (out_features, batch, seq)
        lora_output = (self.lora_B @ (self.lora_A @ x.transpose(-2, -1))).transpose(-2, -1)
        
        # 缩放:LoRA 的输出要乘以 alpha/rank
        # alpha 是缩放因子,控制 LoRA 对原始模型的影响程度
        # alpha/rank 通常设为 1 或 2
        return original_output + lora_output * (self.alpha / self.rank)

这段代码展示了 LoRA 的核心思想。原始权重 W 被冻结,不更新。只有 A 和 B 两个小矩阵是可学习的——这就把可训练参数从 d² 减少到了 2dr。

GLM 微调配方:端到端实操

cann-recipes-train 仓的 GLM 微调配方,把 LoRA 微调的完整流程配好了。

环境准备

# 1. 进入配方目录
cd cann-recipes-train/recipes/glm/lora

# 2. 安装额外依赖
pip3 install deepspeed transformers datasets
# DeepSpeed:微软的分布式训练框架,负责多卡并行和显存优化
# transformers:HuggingFace 的模型库
# datasets:HuggingFace 的数据集库

# 3. 准备数据集(JSONL 格式)
# 每行一个 JSON,包含 prompt 和 response
echo '{"prompt": "请介绍一下人工智能", "response": "人工智能是..."}' > train.jsonl
echo '{"prompt": "什么是机器学习", "response": "机器学习是..."}' >> train.jsonl
# ... 更多数据 ...

# 4. 确认 NPU 可用
python3 -c "import torch_npu; x=torch.randn(2,2).npu(); print(x.device)"
# npu:0

配置微调参数

配方提供了配置文件,改几个参数就能跑:

# configs/glm-lora.yaml
model:
  name: "THUDM/chatglm3-6b"  # GLM-3 6B 模型
  trust_remote_code: true
  
lora:
  rank: 16          # LoRA 的秩(越高越强,但显存占用越大)
  alpha: 16         # 缩放因子(通常设为 rank 的值)
  dropout: 0.05     # LoRA 层的 dropout
  target_modules:   # 哪些层加 LoRA
    - "query_key_value"
    - "dense"
    - "dense_h_to_4h"
    - "dense_4h_to_h"

training:
  num_epochs: 3          # 训练轮数
  batch_size: 1          # 单卡 batch size(多卡会乘以卡数)
  learning_rate: 2e-4    # 学习率(LoRA 用比较大的学习率)
  warmup_steps: 100      # 预热步数
  max_seq_length: 512     # 最大序列长度
  
deepspeed:
  stage: 2                # DeepSpeed 优化阶段(2=ZeRO-2,3=ZeRO-3)
  gradient_accumulation_steps: 16  # 梯度累积(实际 batch_size = 1 * 16 = 16)
  fp16: true             # 混合精度训练

npu:
  world_size: 8           # 总共几张卡
  master_addr: "10.0.0.1"  # 主节点 IP
  master_port: 29500      # 主节点端口

开始训练

# 单机多卡训练(8 张昇腾 910)
cd cann-recipes-train/recipes/glm/lora

deepspeed train.py \
    --config configs/glm-lora.yaml \
    --data_path ./train.jsonl \
    --output_dir ./output/glm-lora-chat \
    --nproc_per_node 8

# 输出:
# [2024-01-15 10:30:00] 开始训练...
# [2024-01-15 10:30:05] 加载模型: THUDM/chatglm3-6b ✓
# [2024-01-15 10:30:15] 应用 LoRA ✓ (可训练参数: 3.8M / 6240M = 0.06%)
# [2024-01-15 10:30:20] 加载数据集: train.jsonl (1024 条) ✓
# [2024-01-15 10:30:25] 启动 DeepSpeed ZeRO-2 ✓
# [2024-01-15 10:30:30] 开始训练...
# 
# Step  100 | Loss: 1.234 | LR: 1e-4 | Time: 45s | Tokens/s: 12,500
# Step  200 | Loss: 0.987 | LR: 2e-4 | Time: 44s | Tokens/s: 12,800
# Step  300 | Loss: 0.765 | LR: 2e-4 | Time: 44s | Tokens/s: 12,600
# ...
# [2024-01-15 11:45:00] 训练完成!
# 总步数: 192 | 总耗时: 1h 15m | 平均吞吐: 12,500 tokens/s

train.py 的核心逻辑:

# train.py 的核心代码(简化版)
import torch
import deepspeed
from transformers import AutoModel, AutoTokenizer
from deepspeed.runtime.zero import FullyShardedDataParallelPlugin
from torch.utils.data import DataLoader

def main():
    # 1. 加载模型和 Tokenizer
    model = AutoModel.from_pretrained(
        "THUDM/chatglm3-6b",
        trust_remote_code=True,
    )
    tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm3-6b")
    
    # 2. 应用 LoRA
    from peft import get_peft_model, LoraConfig
    
    lora_config = LoraConfig(
        r=16,
        lora_alpha=16,
        target_modules=["query_key_value", "dense", 
                       "dense_h_to_4h", "dense_4h_to_h"],
        lora_dropout=0.05,
    )
    model = get_peft_model(model, lora_config)
    model.print_trainable_parameters()
    # 输出:trainable params: 3,814,784 || all params: 6,247,849,472 || trainable%: 0.061%
    
    # 3. 加载数据集
    dataset = load_dataset("train.jsonl", tokenizer, max_length=512)
    train_loader = DataLoader(dataset, batch_size=1, shuffle=True)
    
    # 4. DeepSpeed 配置
    ds_config = {
        "train_batch_size": 16,        # 实际 batch_size = 1 * 梯度累积16 * 8卡 = 128
        "gradient_accumulation_steps": 16,
        "fp16": {"enabled": True},
        "zero_optimization": {
            "stage": 2,  # ZeRO-2:分片优化器状态和梯度
            "offload_optimizer": {"device": "cpu"},  # 优化器状态卸到 CPU
        },
    }
    
    # 5. 初始化 DeepSpeed
    model_engine, optimizer, _, _ = deepspeed.initialize(
        model=model,
        config=ds_config,
    )
    
    # 6. 训练循环
    model_engine.train()
    for step, batch in enumerate(train_loader):
        # 前向
        outputs = model_engine(**batch)
        loss = outputs.loss
        
        # 反向
        model_engine.backward(loss)
        model_engine.step()
        
        if step % 100 == 0:
            print(f"Step {step} | Loss: {loss.item():.3f}")
    
    # 7. 保存 LoRA 权重
    model_engine.save_checkpoint("./output/glm-lora-chat")
    print("训练完成!")

if __name__ == "__main__":
    main()

合并权重

LoRA 训练完后,权重是分两块的:原始权重(冻结)和 LoRA 补丁(可学习)。推理时要合并:

# 合并 LoRA 权重到原始模型
from peft import PeftModel
from transformers import AutoModel

# 加载原始模型
base_model = AutoModel.from_pretrained("THUDM/chatglm3-6b")

# 加载 LoRA 权重
model = PeftModel.from_pretrained(base_model, "./output/glm-lora-chat")

# 合并:LoRA 补丁加到原始权重上
merged_model = model.merge_and_unload()

# 保存合并后的模型
merged_model.save_pretrained("./output/glm-lora-merged")

合并后的模型就是一个标准的 GLM 模型,可以直接用 transformers 加载推理:

from transformers import AutoModel, AutoTokenizer

model = AutoModel.from_pretrained("./output/glm-lora-merged")
tokenizer = AutoTokenizer.from_pretrained("./output/glm-lora-merged")

input_text = "你们公司的客服工作时间是什么?"
input_ids = tokenizer.encode(input_text, return_tensors="pt")
output_ids = model.generate(input_ids, max_new_tokens=100)
print(tokenizer.decode(output_ids[0]))

性能数据

用 GLM-3-6B 在昇腾 910(8 卡)上做 LoRA 微调的性能数据:

配置 显存占用/GB 训练速度 可训练参数
全量微调 56 8,500 tokens/s 6.2B
LoRA (r=8) 18 12,500 tokens/s 1.9M
LoRA (r=16) 20 12,200 tokens/s 3.8M
LoRA (r=64) 24 11,800 tokens/s 15.2M

数据说明:LoRA 把显存占用从 56GB 降到了 18-24GB,训练速度反而更快(因为显存压力小了,batch 可以更大)。可训练参数只有原始模型的 0.03%-0.24%,但效果跟全量微调差不多。

训练配方的核心不是代码本身,是它帮你配好的超参和通信策略。DeepSpeed ZeRO-2 的分片策略、LoRA 的秩选择、学习率调度……这些参数花了很多时间调优。直接拿来用,能省很多时间。

仓库地址:https://atomgit.com/cann/cann-recipes-train

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐