从零开始动手搭建了 LLaMA2 模型,并完成了它从预训练到微调的整个过程。接下来我们会结合主流的大模型训练框架,讲解在实际操作中如何高效地进行训练、调优,以及如何通过各种技巧让模型发挥出更好的性能。

01

1 模型预训练

在上一章我们拆解了大型语言模型(LLM)的内部结构并手写实现了 LLaMA 的预训练和微调流程,亲自跑通了从模型构建到 SFT 的每一步,因而对模型原理和训练细节有了更直观的理解。但把模型“从零造”也带来了一些现实问题:一是手写网络结构工作量大,很难跟上研究界频繁的结构创新;二是自己实现的训练流程不容易做大规模多卡分布式,训练速度和资源利用率较低;三是与已有的预训练模型生态不兼容,没法直接复用那些成熟的预训练权重。为了解决这些痛点,本章我们把视角转向产业化工具:先讲清楚主流的训练框架——Transformers 是什么、它如何把复杂的模型构建与训练变得模块化;再介绍配套的分布式训练工具(比如 DeepSpeed)和高效微调技术(比如 PEFT),说明它们如何在多卡训练、显存优化和参数高效微调上帮你省时省力。最后通过实操示例,用 Transformers + DeepSpeed + PEFT 完整跑通 Pretrain 和 SFT 流程,让你的训练管线更贴合业界主流实践,能够更容易复用已有权重并在大规模环境中高效运行。

3.1.1 框架介绍

AutoModel

🚀 高效的训练体系

Trainer

PyTorch 原生 DDP(Data Distributed Parallel)

DeepSpeed(微软的高效分布式训练库)

Megatron-LM(大规模模型并行框架)

只需简单配置参数,就可以启用数据并行、模型并行和流水线并行等混合并行模式。比如,在 8 卡 A100 集群上,利用 Transformers 配合 DeepSpeed,训练百亿参数级别的模型也不再是难题。此外,Transformers 还提供了 SavingPolicy、LoggingCallback 等组件,让训练过程可以自动保存、记录与监控;并能与 Deepspeed、PEFT、wandb、Swanlab 等工具无缝集成,实现从训练到可视化的一站式流程。

🌍 构建了全球最大的开源 AI 社区

Transformers 不仅是个框架,更是 Hugging Face 庞大生态的核心。基于它,Hugging Face 建立了一个开放的 AI 社区:

提供了数亿个预训练模型参数;

集成了25 万+ 不同类型数据集;

通过 Transformers、Datasets、Evaluate 等模块打通了模型、数据与评估全链路。

这样,研究者和开发者可以直接选用现成的模型或数据集,快速完成自己的微调、实验或产品开发。

💡 从“重新预训练”到“高效微调”

在如今的大模型(LLM)时代,重新预训练一个模型的成本极高。因此,研究者和企业的重点,逐渐从“重新训练”转向在现有大模型上做高效微调(SFT、Post-Train)。而 Transformers 恰好提供了这一切所需的工具与接口:无论是加载 LLaMA、Qwen、DeepSeek 这样的开源大模型,还是在自己的数据上进行高效微调,都能轻松实现。

在接下来的内容中,我们将以 Transformers 框架 为基础,结合 DeepSpeed 和 PEFT,动手实践大模型的 Pretrain 和 SFT 流程,让你的训练管线更贴合业界主流实践,能够更容易复用已有权重并在大规模环境中高效运行。

3.1.2 初始化 LLM

在使用大模型时,我们并不需要从头去手写模型结构。Hugging Face 的 Transformers 框架为我们提供了高度模块化的接口,只需几行代码,就能快速加载、修改或初始化一个完整的 LLM。

下面我们就以 Qwen-3-1.5B 为例,讲解如何从配置文件出发,创建一个可直接使用的模型实例。

🧩 1. 使用 AutoModel 初始化模型结构

AutoModel

⚙️ 2. 下载模型配置与参数

我们先通过 Hugging Face 提供的命令行工具下载模型。这里使用了镜像站点以加快访问速度

import os# 设置环境变量,使用 Hugging Face 镜像网站os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
# 下载 Qwen-2.5-1.5B 模型os.system('huggingface-cli download --resume-download Qwen/Qwen3-1.5B --local-dir your_local_dir')

"Qwen/Qwen3-1.5B"

🧱 3. 加载模型配置(config.json)

AutoConfig

from transformers import AutoConfig
# 模型参数路径model_path = "your_local_dir"config = AutoConfig.from_pretrained(model_path)

config

🧠 4. 基于配置初始化模型

AutoModelForCausalLM

from transformers import AutoModelForCausalLM
# 基于配置文件初始化一个模型(从零开始)model = AutoModelForCausalLM.from_config(config, trust_remote_code=True)

这一步生成的模型就是一个从零初始化的 Qwen-3-1.5B,结构与配置文件完全一致。如果你的目标是重新训练或修改模型结构,这样做非常合适。

🧩 5. 加载预训练好的模型权重

在实际项目中,我们通常不会从零开始训练一个 LLM。更常见的做法是直接加载一个已经预训练好的模型权重,然后在自己的语料上进行微调或后训练。

from transformers import AutoModelForCausalLM
# 加载一个预训练好的模型model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True)

这行代码会自动加载对应的参数文件(包括模型结构和权重),几乎可以“开箱即用”。

✂️ 6. 初始化分词器(Tokenizer)

大模型离不开分词器。我们需要加载与模型对应的 tokenizer 来完成文本编码。

from transformers import AutoTokenizer
# 加载预训练 tokenizertokenizer = AutoTokenizer.from_pretrained(model_path)

加载完成后,就可以直接对文本进行分词、编码或反解码操作了:

text = "你好,世界!"inputs = tokenizer(text, return_tensors="pt")print(inputs)

3.1.3 预训练数据处理

在完成模型结构的加载之后,下一步就是准备预训练所需的数据。与第五章相同,这里我们仍然使用「序列猴子」开源数据集作为预训练数据源。这个数据集包含了丰富的中文文本,非常适合用于大语言模型(LLM)的基础训练。

📦 1. 加载数据集

Hugging Face 提供了一个与 Transformers 完美配合的库

datasets它能帮我们轻松下载、加载和预处理各种格式的数据集,而无需手动解析文件。

mobvoi_seq_monkey_general_open_corpus.jsonl

# 加载预训练数据from datasets import load_datasetds = load_dataset('json', data_files='/mobvoi_seq_monkey_general_open_corpus.jsonl')

💡 提示:

由于开源语料体积通常较大,首次加载可能会耗时较长甚至占用较多内存。建议在测试阶段先抽取一小部分数据集进行验证。加载完成后,ds 是一个 DatasetDict 对象。默认情况下,数据会被放在 train 这个键下,我们可以这样查看数据内容:

ds["train"][0]

🔍 2. 查看数据集结构

数据集对象的 features 属性可以展示其特征(也就是每一列的字段名)。

对于这个语料集,通常只有一个 "text" 

字段:

# 查看特征column_names = list(ds["train"].features)# 输出结果: ["text"]

我们记录下列名,是因为后续在分词处理时需要移除原始的文本列。

✂️ 3. 使用 Tokenizer 进行分词处理在大模型训练中,原始文本必须先被分词(tokenize),转换为模型能够理解的数字序列。我们使用前面加载好的 tokenizer 来完成这一步。

# 对数据集进行 tokenizedef tokenize_function(examples):    # 使用预训练 tokenizer 进行分词    output = tokenizer([item for item in examples["text"]])    return output# 批量处理数据集tokenized_datasets = ds.map(    tokenize_function,    batched=True,            # 启用批处理,加快速度    num_proc=10,             # 启用多进程    remove_columns=column_names,  # 移除原始 'text' 列    load_from_cache_file=True,    desc="Running tokenizer on dataset",)

处理完成后,每个样本都会包含:

  • input_ids:文本的 token 序列;

  • attention_mask:掩码,用于标记哪些位置是 padding。

🧱 4. 拼接文本块(Chunking)

在预训练阶段,我们一般会采用 自回归语言

模(CLM, Causal Language Modeling) 的任务形式。与微调不同,CLM 训练通常不需要保留文本的边界,而是把多个句子拼接成固定长度的文本块,这样可以提升训练效率。

这里我们将每个样本拼接成长度为 2048 个 token 的文本段。

from itertools import chainblock_size = 2048  # 每个训练块的长度def group_texts(examples):    # 拼接所有文本序列    concatenated_examples = {k: list(chain(*examples[k])) for k in examples.keys()}    total_length = len(concatenated_examples[list(examples.keys())[0]])    # 按 block_size 切分    if total_length >= block_size:        total_length = (total_length // block_size) * block_size    result = {        k: [t[i: i + block_size] for i in range(0, total_length, block_size)]        for k, t in concatenated_examples.items()    }    # CLM 任务中,labels = input_ids    result["labels"] = result["input_ids"].copy()    return result# 批量拼接lm_datasets = tokenized_datasets.map(    group_texts,    batched=True,    num_proc=10,    load_from_cache_file=True,    desc=f"Grouping texts in chunks of {block_size}",    batch_size=40000,)train_dataset = lm_datasets["train"]

✅ 5. 得到可直接训练的数据集

经过以上几步,我们最终得到了一个可直接用于 Causal LM 预训练 的数据集。其中每个样本都是长度为 2048 的 token 序列,具备 input_idsattention_mask、labels 三列信息,可以直接送入模型进行训练。

3.1.4 使用 Trainer 进行训练

在准备好模型与数据集之后,我们终于可以开始训练啦!Transformers 框架为此提供了一个非常实用的工具-Trainer 类。它封装了完整的训练逻辑,包括模型前向传播、反向梯度计算、日志记录、模型保存等步骤。相比自己手写训练循环,使用 Trainer 能让整个流程更高效、更稳定,也更易于可视化和调试。

⚙️ 1. 配置训练超参数

在使用 Trainer 之前,需要先定义训练所用的超参数。这一步通过 TrainingArguments

 类完成。

from transformers import TrainingArguments# 配置训练参数training_args = TrainingArguments(    output_dir="output",              # 模型与日志输出路径    per_device_train_batch_size=4,    # 每张 GPU 的 batch_size    gradient_accumulation_steps=4,    # 梯度累计步数,等效于更大的 batch_size    logging_steps=10,                 # 每隔多少步打印一次 loss    num_train_epochs=1,               # 训练轮数    save_steps=100,                   # 每隔多少步保存一次模型    learning_rate=1e-4,               # 学习率    gradient_checkpointing=True       # 开启梯度检查点以节省显存)

💡 小贴士:

  • gradient_accumulation_steps 用于在

    显存受限的情况下模拟更大的批量大小。

  • gradient_checkpointing 会在训练时

    重新计算部分中间结果,能有效降低显存

    使用量,非常适合大模型。

🧠 2. 实例化 Trainer 训练器

有了超参数,就可以用模型、tokenizer 和数据集来构建一个训练器对象。Trainer 会自动处理训练过程中的梯度更新、日志输出以及模型保存。

from transformers import Trainer, default_data_collatorfrom torchdata.datapipes.iter import IterableWrapper# 实例化 Trainertrainer = Trainer(    model=model,                               # 要训练的模型    args=training_args,                        # 超参数配置    train_dataset=IterableWrapper(train_dataset),  # 训练数据    eval_dataset=None,                         # 本例不进行验证    tokenizer=tokenizer,                       # 对应的分词器    data_collator=default_data_collator        # CLM 任务默认的批处理器)

🧩 说明:

  • 这里我们使用了 default_data_collator,它适用于自回归语言建模(CLM)任务;若是做掩码语言建模(MLM,如 BERT),则需使用 DataCollatorForLanguageModeling;IterableWrapper 用于将数据集包装为可迭代对象,方便 Trainer 读取。

🚀 3. 启动训练

准备工作完成后,一行代码就能开始训练:

trainer.train()

运行后,Trainer 会:

  • 自动管理优化器和学习率调度;

  • 定期打印训练日志(包括 loss);

  • 按配置周期保存模型权重到 output 目录。

📁 提示:
所有训练日志、模型权重和配置文件都会保存在 output/ 文件夹下,方便后续继续训练或推理使用。为了保持项目结构清晰,建议将训练脚本保存为:

./code/pretrain.ipynb

这样你可以直接在 Notebook 中运行,查看训练日志和中间结果。

3.1.5 使用 DeepSpeed 实现分布式训练

在实际的大模型训练中,预训练往往规模庞大、时间漫长,如果直接在 Jupyter Notebook 里运行,非常容易因为网络、内存或连接中断导致训练中止。因此,真正的工程实践中,我们通常会使用 bash 脚本配合 Python 脚本来启动训练任务,不仅更加稳定,也更方便做分布式部署。在这一节,我们将介绍如何在 Hugging Face Transformers 框架的基础上,使用 DeepSpeed 来实现高效的多卡分布式预训练,从而完成一个业界可用的 LLM Pretrain 方案。

🧩 一、训练脚本结构概览

我们将完整的训练流程写在一个 Python 脚本中(pretrain.py),用于实现模型加载、数据准备、训练配置、日志记录以及分布式加速的全流程。

核心依赖包括:

import torch, deepspeed, datasets, transformersfrom transformers import Trainer, TrainingArguments, AutoModelForCausalLM, AutoTokenizer

这些模块分别负责模型加载、训练逻辑、优化器管理以及 DeepSpeed 分布式并行。

⚙️ 二、定义训练参数

在大模型训练中,超参数(如学习率、batch size、训练轮数)往往由 bash 脚本传入。因此,我们通过 Hugging Face 提供的 HfArgumentParser 工具来解析命令行参数。
为了更清晰地组织参数,我们将超参分为三类:

  • ModelArguments

    模型相关参数

    (如模型路径、数据类型等)

  • DataTrainingArguments

    数据处理参数

    (如训练文件路径、block size 等)

  • TrainingArguments

    Transformers 

    内置的训练配置

    (如 batch size、

    日志频率、保存策略等)

这种模块化的设计方便扩展与维护,也与 Hugging Face 的官方结构保持一致。

🧾 三、日志记录与断点恢复

在长时间训练中,日志记录是非常关键的一环。
我们使用 Python 自带的 logging 模块 来替代简单的 print,确保训练过程中的重要信息(如 GPU 使用、loss、学习率变化)都能被系统地记录下来:

logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s")logger.setLevel(logging.INFO)

此外,训练中断几乎是无法避免的,因此脚本会自动检测是否存在 checkpoint(断点文件),并在

重新启动时从最近一次保存的位置继续训练。这一步通过 Transformers 自带的:

from transformers.trainer_utils import get_last_checkpoint

即可自动完成,非常方便。

🧠 四、模型加载与初始化

模型加载部分支持两种方式:

  • 从零开始预训练(from scratch)

基于 config.json 初始化模型;

加载已有预训练模型继续训练

使用 model_name_or_path 参数直接加载。

无论哪种方式,都会输出模型参数总量,方便我们了解模型规模是否符合资源预算。

📊 五、数据与 Trainer 封装

数据加载依然延续前面章节的逻辑,完成分词与批处理后,交由 Trainer 来统一管理训练。
Trainer 是 Transformers 框架的核心训练封装,它能自动处理:

  • 梯度累计(gradient accumulation)

  • 学习率调度(scheduler)

  • 日志输出与模型保存(logging & 

  • checkpoint)

  • 与 DeepSpeed、SwanLab 等工具集成

只需简单几行代码即可完成训练流程:

trainer = Trainer(    model=model,    args=training_args,    train_dataset=train_dataset,    tokenizer=tokenizer)trainer.train()

📈 六、训练过程可视化:SwanLab 集成

在大规模训练中,仅靠命令行日志难以直观监控训练进度。因此我们使用 SwanLab(一个开源的实验可视化平台),在训练脚本开头加入:

import swanlabswanlab.init(project="pretrain", experiment_name="from_scratch")

启动训练后,终端会显示一个监控 URL,点击即可在网页上查看训练曲线(loss、学习率、显存使用等)。

🚀 七、使用 DeepSpeed 启动分布式训练

完成 Python 脚本后,我们通过一个 bash 脚本来启动多卡分布式训练:

CUDA_VISIBLE_DEVICES=0,1deepspeed pretrain.py \    --config_name autodl-tmp/qwen-1.5b \    --tokenizer_name autodl-tmp/qwen-1.5b \    --train_files dataset.jsonl \    --per_device_train_batch_size 16 \    --gradient_accumulation_steps 4 \    --num_train_epochs 1 \    --learning_rate 1e-4 \    --deepspeed ./ds_config_zero2.json \    --report_to swanlab

通过 --deepspeed 参数加载 DeepSpeed 的配置文件,即可让训练自动启用分布式加速。

⚡ 八、DeepSpeed 配置文件(ZeRO-2)

下面是一个典型的 ZeRO Stage-2 配置文件

 ds_config_zero2.json

{  "bf16": {"enabled": "auto"},  "optimizer": {"type": "AdamW", "params": {"lr": "auto"}},  "zero_optimization": {    "stage": 2,    "reduce_scatter": true,    "overlap_comm": true  },  "gradient_accumulation_steps": "auto",  "train_micro_batch_size_per_gpu": "auto"}

这一配置能显著减少显存占用,使多卡并行更加高效。

✅ 九、启动训练

最后,在终端执行:

bash ./code/pretrain.sh

即可正式启动多卡预训练任务。DeepSpeed 会自动管理 GPU 通信、显存分配与模型保存,让我们能够在有限资源下完成百亿级参数模型的训练。

3.2 模型有监督微调

在上一节中,我们已经学习了如何借助 Transformers 框架来高效地完成大模型的预训练,让模型具备通用的语言理解和生成能力。
接下来,我们要迈出下一步,让模型学会“听懂指令、理解任务”。在这一部分,我们将介绍如何使用 Transformers 框架 对已经预训练好的模型进行 有监督微调(Supervised Fine-Tuning,简称 SFT)。通过微调,我们可以让模型在通用能力的基础上,进一步掌握特定场景下的任务逻辑,比如客服问答、代码补全、教育对话等,从而真正“变聪明、懂语境、能落地”。

3.2.1 Pretrain VS SFT

在开始动手微调之前,我们先来回顾一下——预训练(Pretrain) 和 有监督微调(SFT) 的核心区别到底是什么。

在前面提到的 LLM 三阶段训练流程(Pretrain → SFT → RLHF) 中:Pretrain 阶段:模型面对的是海量的无监督文本,比如网页、书籍、百科。它的任务很“朴素”——学习语言的规律,理解世界知识。训练目标是通过自回归(CLM)的方式预测“下一个词”,

比如看到“今天阳光”,模型学会预测“明媚”。

SFT 阶段:模型已经掌握了语言规律,现在要学会“听懂指令”。这个阶段使用的是成对的指令数据

(例如:“帮我写一段Python代码” → “当然,这里是一个示例…”),通过这种方式让模型理解“输入”和“输出”之间的关系,从而能更好地按照人类意图生成内容。换句话说,Pretrain 是让模型学会说话,SFT 是让模型学会听话。在训练实现上,两者的主要差异在于 loss(损失函数)计算范围不同

  • Pretrain:对整个文本序列计算 loss

(模型要预测每个 token)。

  • SFT:只对输出部分计算 loss

    指令部分不参与损失计算)。

因此,从代码实现的角度来看,SFT 的训练逻辑和 Pretrain 基本一致,唯一的不同就在于数据处理环节——我们需要把成对的“指令 + 回复”样本转化成模型可以理解的输入格式。本部分我们将基此前的训练流程,编写一个完整的 SFT 脚本(finetune.py),展示如何在已有的预训练模型基础上,完成指令微调。

3.2.2 微调数据处理

在这一节中,我们将展示如何对预训练模型进行 SFT(Supervised Fine-Tuning,有监督微调)。与预训练不同,SFT 是在带有明确输入输出的对话数据上训练模型,让模型更好地理解人类指令并生成符合预期的回答。我们使用 贝壳开源的 BelleGroup 数据集,它包含了丰富的多轮人机对话,非常适合用于微调聊天模型。为了让模型理解对话的结构,我们需要定义一个 Chat Template(对话模板)。模板里明确了每一轮对话的角色和格式,

包括:

  • System:系统提示词,激活模型能力

    ,通常不变,例如 "You are a

     helpful assistant."

  • User:用户输入,在 BelleGroup 

    数据集中对应 "human"

    Assistant:模型的回答,是微调时

    需要模型学习生成的部分

在训练中,为了帮助模型区分角色和文本结构,我们定义了一些 特殊 token

# 不同的 tokenizer 需要特别定义# BOSim_start = tokenizer("<|im_start|>").input_ids# EOSim_end = tokenizer("<|im_end|>").input_ids# PADIGNORE_TOKEN_ID = tokenizer.pad_token_id# 换行符nl_tokens = tokenizer('\n').input_ids# 角色标识符_system = tokenizer('system').input_ids + nl_tokens_user = tokenizer('human').input_ids + nl_tokens_assistant = tokenizer('assistant').input_ids + nl_tokens

这些标记可以让模型更清楚地识别对话的起始、结束和角色,从而减少语义混淆。在微调时,我们只让模型学习 Assistant 的回答,而不去预测 User 的输入。因此,用户文本在计算 loss 时会被遮蔽,使用 IGNORE_TOKEN_ID(通常是 -100)。接下来,我们需要将多轮对话拼接成一个完整的文本序列,并生成模型训练需要的输入和目标序列:

# 拼接多轮对话input_ids, targets = [], []for i in tqdm(range(len(sources))):    source = sources[i]    # 从 user 开始    if source[0]["from"] != "human":        source = source[1:]    input_id, target = [], []    system = im_start + _system + tokenizer(system_message).input_ids + im_end + nl_tokens    input_id += system    target += im_start + [IGNORE_TOKEN_ID] * (len(system)-3) + im_end + nl_tokens    assert len(input_id) == len(target)    for j, sentence in enumerate(source):        role = roles[sentence["from"]]        _input_id = tokenizer(role).input_ids + nl_tokens + \            tokenizer(sentence["value"]).input_ids + im_end + nl_tokens        input_id += _input_id        if role == '<|im_start|>human':            _target = im_start + [IGNORE_TOKEN_ID] * (len(_input_id)-3) + im_end + nl_tokens        elif role == '<|im_start|>assistant':            _target = im_start + [IGNORE_TOKEN_ID] * len(tokenizer(role).input_ids) + \                _input_id[len(tokenizer(role).input_ids)+1:-2] + im_end + nl_tokens        else:            print(role)            raise NotImplementedError        target += _target    assert len(input_id) == len(target)    input_id += [tokenizer.pad_token_id] * (max_len - len(input_id))    target += [IGNORE_TOKEN_ID] * (max_len - len(target))    input_ids.append(input_id[:max_len])    targets.append(target[:max_len])

拼接完后,我们将 token 序列转换为 Torch.tensor,并生成训练数据集所需的字典格式:

input_ids = torch.tensor(input_ids)targets = torch.tensor(targets)return dict(    input_ids=input_ids,    labels=targets,    attention_mask=input_ids.ne(tokenizer.pad_token_id),)

为了方便在 Trainer 中使用,我们定义一个自定义 Dataset 类,将上述处理逻辑封装起来:

class SupervisedDataset(Dataset):    def __init__(self, raw_data, tokenizer, max_len: int):        super(SupervisedDataset, self).__init__()        sources = [example["conversations"] for example in raw_data]        data_dict = preprocess(sources, tokenizer, max_len)        self.input_ids = data_dict["input_ids"]        self.labels = data_dict["labels"]        self.attention_mask = data_dict["attention_mask"]    def __len__(self):        return len(self.input_ids)    def __getitem__(self, i) -> Dict[str, torch.Tensor]:        return dict(            input_ids=self.input_ids[i],            labels=self.labels[i],            attention_mask=self.attention_mask[i],        )

有了这个数据集类之后,微调过程与预训练几乎一致。下面是主函数示例,展示了如何加载参数、初始化模型和数据,并启动 Trainer:

# 加载脚本参数parser = HfArgumentParser((ModelArguments, DataTrainingArguments, TrainingArguments))model_args, data_args, training_args = parser.parse_args_into_dataclasses()# 初始化 SwanLabswanlab.init(project="sft", experiment_name="qwen-1.5b")# 设置日志logging.basicConfig(    format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",    datefmt="%m/%d/%Y %H:%M:%S",    handlers=[logging.StreamHandler(sys.stdout)],)transformers.utils.logging.set_verbosity_info()log_level = training_args.get_process_log_level()logger.setLevel(log_level)datasets.utils.logging.set_verbosity(log_level)transformers.utils.logging.set_verbosity(log_level)transformers.utils.logging.enable_default_handler()transformers.utils.logging.enable_explicit_format()logger.warning(    f"Process rank: {training_args.local_rank}, device: {training_args.device}, n_gpu: {training_args.n_gpu}"    + f"distributed training: {bool(training_args.local_rank != -1)}, 16-bits training: {training_args.fp16}")logger.info(f"Training/evaluation parameters {training_args}")# 检查 checkpointlast_checkpoint = Noneif os.path.isdir(training_args.output_dir):    last_checkpoint = get_last_checkpoint(training_args.output_dir)    if last_checkpoint is None and len(os.listdir(training_args.output_dir)) > 0:        raise ValueError(f"输出路径 ({training_args.output_dir}) 非空")    elif last_checkpoint is not None and training_args.resume_from_checkpoint is None:        logger.info(f"从 {last_checkpoint}恢复训练")# 设置随机数种子set_seed(training_args.seed)# 初始化模型logger.warning("加载预训练模型")logger.info(f"模型参数地址:{model_args.model_name_or_path}")model = AutoModelForCausalLM.from_pretrained(model_args.model_name_or_path,trust_remote_code=True)n_params = sum({p.data_ptr(): p.numel() for p in model.parameters()}.values())logger.info(f"继承一个预训练模型 - Total size={n_params/2**20:.2f}M params")# 初始化 Tokenizertokenizer = AutoTokenizer.from_pretrained(model_args.model_name_or_path)logger.info("完成 tokenizer 加载")# 加载微调数据with open(data_args.train_files) as f:    lst = [json.loads(line) for line in f.readlines()[:10000]]logger.info("完成训练集加载")logger.info(f"训练集地址:{data_args.train_files}")logger.info(f'训练样本总数:{len(lst)}')train_dataset = SupervisedDataset(lst, tokenizer=tokenizer, max_len=2048)logger.info("初始化 Trainer")trainer = Trainer(    model=model,    args=training_args,    train_dataset= IterableWrapper(train_dataset),    tokenizer=tokenizer)# 从 checkpoint 加载checkpoint = Noneif training_args.resume_from_checkpoint is not None:    checkpoint = training_args.resume_from_checkpointelif last_checkpoint is not None:        checkpoint = last_checkpointlogger.info("开始训练")train_result = trainer.train(resume_from_checkpoint=checkpoint)trainer.save_model() 

最后,微调启动方式与预训练类似,可通过 finetune.sh 脚本结合 DeepSpeed 启动多卡训练实现高效微调。

3.3 高效微调

在之前的章节,我们详细介绍了如何利用 Transformers 框架对模型进行预训练、SFT(监督微调)以及 RLHF(强化学习与人类反馈)训练的方法和细节。这些方法可以帮助我们让模型变得更加智能,处理各种复杂任务。但是,问题也随之而来:由于 大型语言模型(LLM)的参数非常庞大,训练数据量也很大,像 SFT 和 RLHF 这样的训练方式通常需要调整整个模型的所有参数。这样一来,就会消耗大量的计算资源和时间,甚至对一些资金和资源有限的企业或研究团队来说,都会感到很大的压力。因此,如何在资源有限的情况下,高效、快速地对模型进行特定领域或任务的微调,成为了一个非常重要的问题。通过这种方法,即使是小规模的团队也可以低成本地让 大型语言模型 完成各种具体任务,帮助解决实际问题。

3.3.1 高效微调方案

针对全量微调的昂贵问题,目前有两种常用的高效微调方法:Adapt Tuning 和 Prefix Tuning。这两种方法都旨在降低微调的计算和存储成本,尤其是在资源有限的情况下,帮助实现更高效的模型调整。

Adapt Tuning

Adapt Tuning 是通过在预训练模型中加入“适配器”(Adapter)层来实现高效微调的。其基本思路是在模型的每一层中插入一个专门用于下游任务的模块(Adapter)。在微调时,原始模型的参数保持不变,只更新适配器层的参数。

  • 如何实现?

    在每个 Transformer 层中插入一个 Adapter 模块。Adapter 模块的结构通常包括两个前馈子层,第一个将输入的维度(d)投影到一个较小的维度(m),通过控制 m 的大小来限制新增参数的量,通常 m 会小于 d。第二个前馈子层将投影后的数据还原回原始维度 d,并作为 Adapter 的输出(如图所示)。

这种方式的优点是 参数量大大减少,只有 Adapter 层的参数在微调时更新,极大地减少了训练开销。

    LoRA(Low-Rank Adaptation)

    LoRA 是 Adapt Tuning 的一种改进方法。它通过低秩矩阵分解来减少 Adapter 模块的计算和存储开销。具体来说,LoRA 通过将参数矩阵分解为两个低秩矩阵来减少计算量,同时仍能有效地调整模型的输出。由于 LoRA 采用了低秩结构,它在减少计算复杂度的同时,仍保持了良好的微调效果。

    Prefix Tuning

    另一种高效的微调方法是 Prefix Tuning,它不改变预训练模型的结构,而是在输入序列的前面添加一段与任务相关的可训练的“前缀”

    (prefix)。

    • 如何实现?

      在每个输入 token 前面加入一些虚拟 token,这些虚拟 token 会根据任务进行微调。在微调过程中,只有这些 prefix 部分的参数会更新,而模型的主体部分(即原始的预训练模型)参数会保持冻结。

    这种方法的优势是:只需要更新较少的参数,微调成本相对较低,而且能够为不同任务保存不同的前缀,提供了很好的灵活性。

    • 问题:
      尽管 Prefix Tuning 微调开销较小,但它有一个固定的缺点:由于加入了虚拟 token,这会占用模型可用的序列长度。因此,随着微调质量的提高,模型可以处理的有效输入序列长度会逐渐变小,可能会影响模型对长文本的处理能力。

    3.3.2 LoRA 微调

    在大型语言模型(LLM)的训练中,通常我们需要对模型的所有参数进行调整。然而,这种方法对于资源有限的团队来说,既昂贵又耗时。想象一下,如果我们只是解决一个小的、细分的任务,实际上并不需要让整个模型都发挥作用。只要我们聚焦在模型的某一小部分——也就是一个特定的“子空间”——就能解决这个问题。那么,是否有可能在不改变所有参数的情况下,依然能达到理想的效果呢?这里的关键点在于 本征秩(Intrinsic Rank)。本征秩是指模型权重矩阵的“复杂度”,简单来说,就是模型为了解决某个任务所需要的最少维度。如果模型仅仅需要在一个小的“子空间”内进行调整,那么我们就不需要调整所有参数,只需要调整一个较小的子空间来完成任务。

    模型如何降低本征秩

    预训练的模型通常会隐式地降低本征秩,这意味着它已经在处理大量任务时“简化”了自己。在进行微调时,特别是针对特定的任务,我们可以发现,模型中的权重矩阵的本征秩会变得更低。对于简单的任务,模型的本征秩也会更低,意味着它不需要那么多的参数来解决问题。

    LoRA:让微调变得更高效

    LoRA(低秩适配)正是基于这个思想来进行高效微调的。它通过低秩分解矩阵来简化模型,减少微调所需的计算量。具体来说,LoRA 通过将模型中复杂的矩阵分解成两个低秩的矩阵,从而减少参数的数量和计算量,但依然能够保持很好的性能。假设我们的模型在预训练时的参数是 θ₀,而在微调过程中,我们得到的微调参数是 θᴅ,这时 θᴅ 可以通过以下公式来表示:

    θᴅ = θ₀ + θd * M

    其中,M 就是 LoRA 所优化的低秩矩阵,它包含了微调所需的关键信息。通过优化这个小的矩阵,我们就能完成高效的微调,而不需要调整整个模型。

    LoRA 的优势

    与其他高效微调方法相比,LoRA 具有以下几个显著优势:

    • 小型模块,灵活切换任务
      LoRA 允许针对不同的任务构建小型的适配模块,这样我们就能在共享预训练模型的基础上,快速切换不同的任务。这使得 LoRA 特别适合需要处理多种任务的情况。

      优化器简化,硬件要求低
      LoRA 使用 自适应优化器,无需计算梯度或维护所有参数的优化状态,这不仅让训练更加效,也降低了硬件的要求。

      推理速度无损
      LoRA 的设计非常简单,在部署时将可训练的矩阵与原始冻结的权重合并,这样就避免了推理时的延迟问题,确保了模型的高效运行。

      与其他方法兼容
      LoRA 可以与其他微调方法结合使用,增加了其灵活性和适应性。因此,LoRA 被认为是目前高效微调 大型语言模型(LLM)的最佳方法,特别适合于资源受限的环境中,或者在缺乏大量训练数据的情况下,LoRA 往往是微调的首选方法。

    3.3.3 LoRA 微调的原理

    1. 低秩参数化更新矩阵

    LoRA 的核心思想是通过低秩分解来对预训练模型的权重进行微调。假设我们

    有一个预训练的权重矩阵 W₀,它的维度为 Rᴅ×k,其中 d 是上一层的输出维度,

    k 是下一层的输入维度。LoRA 假设在训练过程中,权重矩阵的更新 ΔW 也可以通过低秩分解来表示。

    具体来说,我们将更新矩阵 ΔW 分解为两个矩阵 B 和 A,其中 B 的维度为 

    Rᴅ×rA 的维度为 Rr×kr 是预设的秩,表示低秩的维度。因此,更新矩阵 ΔW 可以表示为:

    ΔW = B * A

    最终,更新后的权重矩阵为:

    W₀ + ΔW = W₀ + B * A

    在训练过程中,W₀ 被冻结,不参与更新,而 A 和 B 则包含可训练的参数。

    LoRA 的前向传播函数 如下:

    h = W₀ * x + ΔW * x = 

    W₀ * x + B * A * x

    其中,x 是输入数据,h 是输出结果。在训练开始时,A 使用随机高斯分布进行初始化,B 使用零初始化。然后,使用 Adam 优化器来对 A 和 B 进行训练。

    2. 应用于 Transformer

    在 Transformer 结构中,LoRA 主要应用于注意力模块的四个权重矩阵:WqWkWv 和 Wo。这些矩阵在 LoRA 中进行低秩分解和微调,而其他部分(如 MLP 层的权重矩阵)则被冻结,保持不变。通过消融实验(即逐步去除不同组件,观察效果),研究发现同时调整 Wq 和 Wv(即查询矩阵和值矩阵)会产生最佳的微调效果。

    3. 可训练参数的数量

    根据 LoRA 的设计,训练时的可训练参数数量取决于以下几个因素:

    Lora:应用 LoRA 的权重矩阵的个数

    (通常是 4,因为 Transformer 的注意力模块有 4 个权重矩阵)。

    dmodel:Transformer 模型的输入和输出维度(即每一层的维度)。

    r:LoRA 的秩,通常设置为 4、8 或 16。

    因此,可训练的参数数量为:

    Θ = 2 * Lora * Dmodel * r

    这个公式说明了在应用 LoRA 微调时,训练参数的数量是相对较少的,特别是在秩 r 较小的情况下,能显著减少微调所需的计算资源。

    3.3.4 LoRA 的代码实现

    下面,我们来了解如何通过 peft 库实现 LoRA 微调,并简单分析它的实现过程。

    1. LoRA 微调实现流程

    LoRA 微调的主要步骤如下:

    确定要使用 LoRA 的层
    在 peft 库中,LoRA 微调支持三种类型的层:nn.Linearnn.Embedding 和 nn.Conv2d。我们需要选择合适的层来应用 LoRA 微调。

    替换为 LoRA 层
    目标层会被替换为一个 LoRA 层。LoRA 层实质上是在原有层的输出基础上增加一个旁路,通过低秩分解(矩阵 A 和 B)来模拟参数更新。

    冻结原参数,进行微调
    在 LoRA 层中,原始的权重矩阵被冻结(不更新),而新的参数矩阵 A 和 B 会被训练并优化。

    2. 确定 LoRA 层

    在进行 LoRA 微调时,我们需要设置一个重要参数 target_modules,它通常是一个字符串列表,指定需要应用 LoRA 微调的层。例如,在一个 Transformer 模型中,q_proj 和 v_proj 分别代表注意力机制中的 Wq 和 Wv,我们可以这样指定:

    target_modules = ["q_proj", "v_proj"]

    然后,使用正则表达式匹配模型中的层,

    找到对应的层进行 LoRA 微调:

    # 找到模型的各个组件中,名字里带 "q_proj" 和 "v_proj" 的层target_module_found = re.fullmatch(self.peft_config.target_modules, key)

    3. 替换 LoRA 层

    对于找到的每一个目标层,我们会创建一个新的 LoRA 层来替换它。LoRA 层继承了 nn.Linear 和 LoraLayer 类。LoraLayer 类中包含了 LoRA 所需的超参数,如秩(r)、归一化系数(lora_alpha)、dropout 比例(lora_dropout)等。

    class LoraLayer:    def __init__(self, r: int, lora_alpha: int, lora_dropout: float, merge_weights: bool):        self.r = r        self.lora_alpha = lora_alpha        if lora_dropout > 0.0:            self.lora_dropout = nn.Dropout(p=lora_dropout)        else:            self.lora_dropout = lambda x: x        self.merged = False        self.merge_weights = merge_weights        self.disable_adapters = False

    在实现 LoRA 层时,我们会继承 

    nn.Linear 来定义 Linear 类:​​​​​​​

    class Linear(nn.Linear, LoraLayer):    def __init__(self, in_features: int, out_features: int, r: int = 0, lora_alpha: int = 1, lora_dropout: float = 0.0, fan_in_fan_out: bool = False, merge_weights: bool = True, **kwargs):        # 继承两个基类的构造函数        nn.Linear.__init__(self, in_features, out_features, **kwargs)        LoraLayer.__init__(self, r=r, lora_alpha=lora_alpha, lora_dropout=lora_dropout, merge_weights=merge_weights)        self.fan_in_fan_out = fan_in_fan_out        # 训练时可更新的参数矩阵 A 和 B        if r > 0:            self.lora_A = nn.Linear(in_features, r, bias=False)            self.lora_B = nn.Linear(r, out_features, bias=False)            self.scaling = self.lora_alpha / self.r            self.weight.requires_grad = False  # 冻结原始权重        self.reset_parameters()        if fan_in_fan_out:            self.weight.data = self.weight.data.T

    替换时,我们将原层的 weight 和 bias 复制到新的 LoRA 层中,并将新的 LoRA 层分配到指定的设备上。

    4. 训练

    完成 LoRA 层的替换后,我们就可以进行微调训练了。在训练过程中,原始权重矩阵被冻结,只会更新 A 和 B 矩阵,从而大幅度降低显存占用。在 LoRA 层的 forward 函数中,前向传播计算过程如下:

    def forward(self, x: torch.Tensor):    if self.disable_adapters:        if self.r > 0 and self.merged:            self.weight.data -= (                transpose(self.lora_B.weight @ self.lora_A.weight, self.fan_in_fan_out) * self.scaling            )            self.merged = False        return F.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias)    elif self.r > 0 and not self.merged:        result = F.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias)        if self.r > 0:            result += self.lora_B(self.lora_A(self.lora_dropout(x))) * self.scaling        return result    else:        return F.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias)

    5. 使用 peft 实现 LoRA 微调

    peft 库使得 LoRA 微调变得非常简单。下面是使用 peft 库对模型进行 LoRA 微调的步骤:

    1)导入必要的库:

    import torch.nn as nnfrom transformers import AutoTokenizer, AutoModelfrom peft import get_peft_model, LoraConfig, TaskType, PeftModelfrom transformers import Trainer

    2)加载预训练的基座模型和 tokenizer:

    tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)model = AutoModel.from_pretrained(MODEL_PATH, trust_remote_code=True)

    3)设置 LoRA 微调的配置:

    peft_config = LoraConfig(    task_type=TaskType.CAUSAL_LM,    inference_mode=False,    r=8,    lora_alpha=32,    lora_dropout=0.1,)

    4)获取 LoRA 微调模型:

    model = get_peft_model(model, peft_config)

    5)使用 Trainer 进行训练:

    trainer = Trainer(    model=model,    args=training_args,    train_dataset=IterableWrapper(train_dataset),    tokenizer=tokenizer)trainer.train()

    LoRA 微调的优势通过 LoRA 微调,我们可以在不修改整

    个模型的情况下,快速适应下游任务。LoRA 的优点包括:

    显存占用小:由于只更新少量的参数,显存消耗大幅度降低。

    高效性:在不牺牲性能的前提下,微调的速度和资源消耗都得到了优化。

    灵活性:支持针对不同任务和模型的灵活配置。

    然而,如果需要学习新的知识或在没有任务特定数据的情况下进行模型训练,LoRA 的效果可能会有所下降,因为它只调整低秩矩阵,因此在某些场景下并不适合进行预训练或知识注入任务。

    Logo

    有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

    更多推荐