大模型微调(Fine-Tuning)完全教程


1. SFT监督微调原理

1.1 什么是微调?

微调(Fine-Tuning) 是指在一个已经完成预训练的大语言模型基础上,使用特定领域或任务的标注数据继续训练模型,使其在目标任务上表现更优。SFT(Supervised Fine-Tuning) 即监督微调,使用"输入-输出"成对的标注数据来训练模型。

📝 核心思想

预训练模型已经学会了"通用语言能力",微调的目的是让它学会"特定任务能力"——比如医疗问答、法律咨询、代码生成等。

1.2 为什么需要微调?

  1. 通用模型不够专业:预训练模型在开放域表现良好,但在医疗、法律等垂直领域,回答的专业性和准确性不足
  2. 控制输出格式:通过微调可以让模型输出符合特定格式(如带思考链的回答、JSON格式等)
  3. 成本远低于从头训练:微调只需要少量数据和计算资源,预训练一个7B模型需要数百万美元,而微调只需要一张消费级GPU
  4. 数据隐私:企业可以用私有数据微调,模型部署在本地,数据不需要上传给API提供商

1.3 微调 vs 预训练

对比维度预训练(Pre-training)微调(Fine-tuning)
目标学习通用语言表示学习特定任务/领域能力
数据量TB级别(互联网语料)GB甚至MB级别(标注数据)
计算资源数千张GPU,数周到数月1-8张GPU,数小时到数天
学习率较大(1e-4 ~ 3e-4)较小(1e-5 ~ 2e-4)
训练方式自回归(预测下一个token)监督学习(输入-输出对)
参数更新全部参数全部参数或部分参数(如LoRA)

1.4 最简SFT代码

以下是一个最简单的SFT训练流程,展示了微调的核心逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

model_path = 'Meta-Llama-3.1-8B-Instruct'
tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False)
model = AutoModelForCausalLM.from_pretrained(model_path).to("cuda")
optimizer = torch.optim.AdamW(model.parameters())

# 构造对话数据
dialog = [{"role": "system", "content": "You are a helpful assistant."},
          {"role": "user", "content": "天空为什么是蓝色的?"},
          {"role": "assistant", "content": "这是由于光的散射引起的。"}]

# 使用Chat Template将对话转换为模型输入格式
input = tokenizer.apply_chat_template(dialog, return_tensors="pt")
input = {k: v.to("cuda") for k, v in input.items()}

# 关键:设置labels和input_ids一致——模型自回归预测下一个token
input["labels"] = input["input_ids"].clone()

output = model(**input)
loss = output.loss  # 交叉熵损失
loss.backward()
optimizer.step()
optimizer.zero_grad()

model.save_pretrained("output_dir")
💡 核心要点

SFT的本质就是:把"问题+回答"拼接成一个序列,让模型去预测下一个token。labels = input_ids.clone() 这行代码意味着模型需要预测整个序列中每个位置的下一个token。


2. LoRA低秩适配

2.1 什么是LoRA?

LoRA(Low-Rank Adaptation) 是一种参数高效微调(PEFT)方法。它的核心思想是:不直接修改预训练模型的原始权重矩阵 $W$,而是在旁边增加一个低秩分解的"旁路" $\Delta W = BA$,其中 $B \in \mathbb{R}^{d \times r}$,$A \in \mathbb{R}^{r \times d}$,$r \ll d$。

前向传播变为:$h = Wx + BAx$

📝 为什么有效

研究表明,大模型微调时的权重变化矩阵 $\Delta W$ 具有很低的"内在秩"(intrinsic rank),因此用低秩矩阵就能很好地近似这个变化。

2.2 为什么需要LoRA?

  1. 显存节约:一个7B模型全参数微调需要约60GB显存(fp16),而LoRA仅训练0.1%~1%的参数,显存需求大幅降低
  2. 训练速度快:需要计算梯度和更新的参数量大幅减少
  3. 可插拔:LoRA适配器(adapter)独立于原模型存储,一个基座模型可以搭配多个不同任务的adapter
  4. 无推理延迟:训练完成后,LoRA权重可以合并回原始权重,推理时没有额外开销

2.3 关键参数详解

r(秩/rank)

1
2
3
4
peft_config = LoraConfig(
    r=16,  # LoRA的秩
    ...
)
  • r 控制低秩矩阵的秩,直接决定了新增参数量
  • 常用值:8、16、32、64、128
  • r=8 时,新增参数约占原模型的0.1%;r=64 时约占0.8%
  • 选择建议:简单任务(如情感分类)用 r=8,复杂任务(如医疗问答)用 r=16r=32

lora_alpha(缩放因子)

1
2
3
4
5
peft_config = LoraConfig(
    r=16,
    lora_alpha=16,  # 缩放因子
    ...
)
  • 实际缩放比例为 lora_alpha / r,控制LoRA旁路对原始权重的影响强度
  • lora_alpha = r 时,缩放比例为1,即LoRA旁路的输出直接加到原始输出上
  • 常见设置lora_alpha = r(即缩放为1)或 lora_alpha = 2 * r

target_modules(目标模块)

1
2
3
4
5
peft_config = LoraConfig(
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    ...
)
  • 决定对模型的哪些线性层应用LoRA
  • Attention层q_projk_projv_projo_proj——对应注意力机制的Query、Key、Value和Output投影
  • MLP层gate_projup_projdown_proj——对应前馈神经网络
  • 对所有线性层都加LoRA效果通常最好,但会增加参数量和训练时间
  • 如果显存紧张,优先选择 q_projv_proj

完整LoRA配置示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from peft import LoraConfig, get_peft_model, TaskType

peft_config = LoraConfig(
    r=8,
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj",
                    "gate_proj", "down_proj", "up_proj"],
    task_type=TaskType.CAUSAL_LM,
    lora_alpha=16,
    lora_dropout=0.05
)
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()
# 输出类似:trainable params: 41,943,040 || all params: 8,000,000,000 || trainable%: 0.52%
⚠️ lora_dropout

lora_dropout=0 是经过Unsloth优化的推荐设置,可获得最佳性能。在手动训练中,设置一个小的dropout(如0.05)有助于防止过拟合。


3. 量化技术

3.1 什么是量化?

量化(Quantization) 是将模型权重从高精度(如float32/float16)转换为低精度(如int8/int4)表示的技术。一个float16的权重占2字节,int4只占0.5字节,因此4-bit量化可以将模型大小压缩约4倍。

3.2 为什么需要量化?

  • 一个7B参数的模型,fp16精度需要约14GB显存
  • 4-bit量化后只需约4GB显存,可以在消费级GPU(如RTX 4090的24GB)上运行
  • QLoRA = 量化 + LoRA:先量化基座模型以节约显存,再在上面加LoRA训练,两者结合实现低显存高效微调

3.3 BitsAndBytes 4-bit量化配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from transformers import BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,              # 启用4-bit量化
    bnb_4bit_use_double_quant=True, # 双重量化:对量化常数再做一次量化,进一步节约显存
    bnb_4bit_quant_type="nf4",      # 使用NormalFloat4量化类型,对正态分布权重更优
    bnb_4bit_compute_dtype=torch.bfloat16  # 计算时反量化到bfloat16精度
)

model = AutoModelForCausalLM.from_pretrained(
    model_path,
    quantization_config=bnb_config,
    torch_dtype=torch.float16
)
💡 参数解释
  • nf4(NormalFloat4):专为正态分布的神经网络权重设计的4-bit数据类型,比普通int4精度更高
  • double_quant:对量化参数本身再做一次量化,每个参数额外节约约0.4bit
  • compute_dtype:虽然权重存储为4-bit,但实际计算时会临时反量化到bfloat16/float16

3.4 BitsAndBytes 8-bit量化配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
bnb_config = BitsAndBytesConfig(
    load_in_8bit=True,  # 启用8-bit量化
)

model = AutoModelForCausalLM.from_pretrained(
    model_id_or_path,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True
)
📝 4-bit vs 8-bit
  • 4-bit:显存节约最大,适合显存非常紧张的场景,精度损失略大
  • 8-bit:精度损失更小,稳定性更好,显存需求居中
  • 对于微调场景,4-bit + LoRA(即QLoRA)是目前最流行的组合

3.5 量化后的模型准备

使用量化模型进行LoRA训练前,需要调用 prepare_model_for_kbit_training

1
2
3
from peft import prepare_model_for_kbit_training

model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)

这个函数会:

  1. 冻结所有量化层的参数
  2. 将某些层(如LayerNorm)转换为float32以保证训练稳定性
  3. 启用梯度检查点(gradient checkpointing)以进一步节约显存

4. NEFT噪声嵌入微调

4.1 什么是NEFT?

NEFT(Noisy Embedding Fine-Tuning) 是一种在训练时向输入嵌入(embedding)层的输出中添加均匀分布噪声的技术。论文发表后被集成到了HuggingFace的TRL库中。

4.2 为什么NEFT有效?

  • 嵌入层的输出是离散token到连续向量的映射,微调时容易过拟合到训练数据的特定模式
  • 添加噪声相当于一种正则化手段,增强模型的泛化能力
  • 原论文报告NEFT在AlpacaEval等基准测试上带来了显著提升

4.3 手动实现NEFT

以下代码展示了NEFT的底层实现逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
neftune_noise_alpha = 10  # 噪声强度超参数

for inputs, loss_mask in data_loader:
    input_ids = inputs.pop("input_ids")
    # 获取嵌入层输出
    input_embeddings = model.base_model.model.model.embed_tokens(input_ids)

    # 计算噪声幅度:alpha / sqrt(L * d)
    # L = 序列长度, d = 嵌入维度
    dims = torch.tensor(input_embeddings.size(1) * input_embeddings.size(2))
    mag_norm = neftune_noise_alpha / torch.sqrt(dims)

    # 添加均匀分布噪声 U(-mag_norm, mag_norm)
    input_embeddings = input_embeddings + torch.zeros_like(input_embeddings).uniform_(
        -mag_norm, mag_norm
    )

    inputs["inputs_embeds"] = input_embeddings  # 用带噪声的嵌入替换原始input_ids
    # ... 继续正常的前向传播和反向传播
📝 噪声幅度公式

mag_norm = alpha / sqrt(L * d),其中 L 是序列长度,d 是嵌入维度。这保证了噪声的相对强度与序列长度和嵌入维度无关。alpha 是需要调节的超参数,论文建议值为5~15,代码中使用10。

4.4 使用SFTTrainer内置NEFT

TRL库已经内置了NEFT支持,只需一个参数即可启用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from trl import SFTTrainer, SFTConfig

sft_config = SFTConfig(
    output_dir="/tmp",
    neftune_noise_alpha=10,  # 只需设置这个参数即可启用NEFT
    per_device_train_batch_size=1,
    max_seq_length=100,
    num_train_epochs=10,
    logging_steps=10,
    logging_strategy="steps"
)

trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    args=sft_config,
    data_collator=collator
)
trainer.train()

5. Completion-Only训练

5.1 什么是Completion-Only训练?

在标准SFT中,模型对整个序列(包括系统提示、用户问题和助手回答)都计算loss。但实际上,我们只希望模型学会"如何回答",而不需要学会"如何提问"。Completion-Only训练通过Loss Mask机制,只对助手回答部分计算损失,忽略提示部分的损失。

5.2 为什么需要Completion-Only?

  1. 减少无效学习:模型不需要花费训练资源去学习如何生成system prompt和user问题
  2. 提高训练效率:有效训练信号集中在回答部分,收敛更快
  3. 避免分布偏移:防止模型学到不必要的提问模式

5.3 使用DataCollatorForCompletionOnlyLM

TRL库提供了 DataCollatorForCompletionOnlyLM,自动实现Loss Mask:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from trl import DataCollatorForCompletionOnlyLM

# 定义response部分的起始标记——这个模板要与tokenizer的chat_template匹配
response_template = "<|start_header_id|>assistant<|end_header_id|>\n\n"
collator = DataCollatorForCompletionOnlyLM(response_template, tokenizer=tokenizer)

trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    args=sft_config,
    data_collator=collator  # 传入collator
)
⚠️ response_template必须正确

response_template 必须是tokenizer对assistant回答起始部分编码后的确切文本。如果模板不匹配,所有token的loss都会被mask掉,模型将无法学到任何东西。不同模型的chat_template不同,需要根据具体模型调整。

5.4 手动实现Loss Mask

以下代码展示了Loss Mask的底层实现原理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def sft_collate(batch, tokenizer, end_str, max_length):
    inputs = tokenizer(batch, max_length=max_length, padding=True, truncation=True)
    input_ids = inputs["input_ids"]
    input_len = len(input_ids[0])

    # 将response起始标记编码为token id序列
    end_ids = tokenizer(end_str)["input_ids"]
    end_id_len = len(end_ids)

    loss_mask = []
    for input_id in input_ids:
        # 从后向前搜索response起始标记的位置
        for i in range(len(input_id) - end_id_len, -1, -1):
            if input_id[i:i + end_id_len] == end_ids:
                # 构建mask:response部分为1,其余为0
                mask = [1] * (input_len - 1)
                mask[:i + end_id_len - 1] = [0] * (i + end_id_len - 1)
                loss_mask.append(mask)
                break
            if i == 0:  # 如果没找到(回答被截断),全部mask为0
                loss_mask.append([0] * (input_len - 1))

    inputs = {k: torch.tensor(v) for k, v in inputs.items()}
    loss_mask = torch.tensor(loss_mask)
    return inputs, loss_mask

使用Loss Mask计算损失:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
for inputs, loss_mask in data_loader:
    inputs = {k: v.to("cuda") for k, v in inputs.items()}
    loss_mask = loss_mask.to("cuda")

    logits = model(**inputs).logits[:, :-1, :]   # 去掉最后一个位置
    labels = inputs["input_ids"][:, 1:]           # 去掉第一个位置(shift right)

    logits = logits.reshape(-1, logits.size(-1))
    labels = labels.reshape(-1)
    loss_mask = loss_mask.reshape(-1)

    # 先计算每个token的loss(不做reduction)
    loss = torch.nn.functional.cross_entropy(logits, labels, reduction="none")
    # 应用mask:只保留response部分的loss
    loss = loss * loss_mask
    # 对有效token求平均
    loss = loss.sum() / loss_mask.sum()

    loss.backward()
    optimizer.step()
    optimizer.zero_grad()
💡 两种loss归一化方式
  • loss.sum() / loss_mask.sum():对有效token求平均,推荐使用
  • torch.mean(loss):对所有token(包括被mask的)求平均,会稀释梯度信号,不推荐

6. 数据格式与处理

6.1 什么是Chat Template?

Chat Template 是将多轮对话(system、user、assistant)转换为模型可以理解的单一文本序列的格式化规则。不同模型有不同的Chat Template。

6.2 为什么数据格式很重要?

  • 微调数据的格式必须与模型预训练时使用的格式一致,否则模型无法正确理解输入结构
  • 错误的格式会导致训练效果大幅下降甚至完全无效

6.3 使用tokenizer.apply_chat_template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
dialog = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "天空为什么是蓝色的?"},
    {"role": "assistant", "content": "这是由于光的散射引起的。"}
]

# tokenize=False 返回格式化后的文本字符串
chat_text = tokenizer.apply_chat_template(dialog, tokenize=False)

# tokenize=True(默认)返回token id
chat_tokens = tokenizer.apply_chat_template(dialog, return_tensors="pt")

对于Llama 3.1模型,输出的格式类似:

1
2
3
4
5
6
7
<|begin_of_text|><|start_header_id|>system<|end_header_id|>

You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>

天空为什么是蓝色的?<|eot_id|><|start_header_id|>assistant<|end_header_id|>

这是由于光的散射引起的。<|eot_id|>

6.4 自定义Prompt Template

当数据集不是标准对话格式时,需要手动构造prompt。以医疗数据集为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
train_prompt_style = """Below is an instruction that describes a task...

### Instruction:
You are a medical expert with advanced knowledge in clinical reasoning...

### Question:
{}

### Response:
<think>
{}
</think>
{}"""

EOS_TOKEN = tokenizer.eos_token  # 必须添加结束标记!

def formatting_prompts_func(examples):
    inputs = examples["Question"]
    cots = examples["Complex_CoT"]
    outputs = examples["Response"]
    texts = []
    for input, cot, output in zip(inputs, cots, outputs):
        text = train_prompt_style.format(input, cot, output) + EOS_TOKEN
        texts.append(text)
    return {"text": texts}

dataset = dataset.map(formatting_prompts_func, batched=True)
⚠️ 必须添加EOS_TOKEN

EOS_TOKEN = tokenizer.eos_token 并拼接在每条数据末尾。如果不加EOS标记,模型在推理时不知道何时停止生成,会无限输出。

6.5 法律数据处理

以下是将法律数据集转换为LLaMA-Factory格式的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import json

json_data = []
with open('DISC-Law-SFT-Triplet-released.jsonl', 'r', encoding='utf-8') as file:
    for line in file:
        data = json.loads(line)
        json_data.append(data)

template = []
for idx, data in enumerate(json_data):
    conversation = [
        {"from": "human", "value": data["input"]},
        {"from": "gpt", "value": data["output"]}
    ]
    template.append({
        "conversations": conversation,
        "system": "",
        "tools": ""
    })

# 保存为LLaMA-Factory所需的JSON格式
with open('train_data_law.json', 'w', encoding='utf-8') as f:
    json.dump(template, f, ensure_ascii=False, indent=2)
📝 数据格式对齐

不同的微调框架对数据格式的要求不同:

  • TRL/SFTTrainer:需要 {"text": "完整文本"}{"prompt": "...", "completion": "..."} 格式
  • LLaMA-Factory:需要 {"conversations": [{"from": "human", "value": "..."}, {"from": "gpt", "value": "..."}]} 格式
  • 数据预处理脚本的核心工作就是将原始数据转换为目标框架所需的格式

7. 单GPU训练流程

7.1 什么是单GPU训练?

在一张GPU上完成模型的加载、数据处理、训练和保存的完整流程。对于配合量化和LoRA的7B~8B模型,一张24GB显存的GPU(如RTX 4090)即可完成训练。

7.2 完整训练流程代码解读

以下是基于 train_on_single_gpu.py 的完整流程:

第一步:配置路径

1
2
3
model_id_or_path = "DeepSeek-R1-Distill-Qwen-7B"
dataset_path = "dataset/medical_o1_sft_Chinese.json"
output_dir = "sft_lora_model_int8_output_new"

第二步:加载数据集

1
2
3
4
5
from datasets import load_dataset

dataset = load_dataset("json", data_files=dataset_path, split="train")
print(f"数据集一共有{len(dataset)}组数据")
print(f"数据集列:", dataset.column_names)

第三步:格式化数据

1
dataset = dataset.map(formatting_prompts_func, batched=True)

第四步:设置Tokenizer

1
2
3
tokenizer = AutoTokenizer.from_pretrained(model_id_or_path, trust_remote_code=True)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
💡 padding_side

对于自回归模型(Causal LM),tokenizer.padding_side = "right" 是标准设置。左侧padding会影响attention mask的计算。

第五步:量化加载模型

1
2
3
4
5
6
7
8
bnb_config = BitsAndBytesConfig(load_in_8bit=True)

model = AutoModelForCausalLM.from_pretrained(
    model_id_or_path,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True
)

第六步:应用LoRA

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from peft import prepare_model_for_kbit_training, LoraConfig, get_peft_model

model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
    r=16, lora_alpha=16,
    target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"],
    lora_dropout=0, bias="none", task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

第七步:配置训练参数并训练

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from trl import SFTTrainer
from transformers import TrainingArguments

training_arguments = TrainingArguments(
    output_dir=output_dir,
    per_device_train_batch_size=1,
    gradient_accumulation_steps=8,
    optim="adamw_8bit",
    save_steps=500,
    logging_steps=2,
    learning_rate=1e-4,
    num_train_epochs=1,
    max_grad_norm=0.3,
    lr_scheduler_type="cosine",
    weight_decay=0.01,
    warmup_ratio=0.03,
    bf16=True,
    report_to="wandb",
)

trainer = SFTTrainer(
    model=model, tokenizer=tokenizer,
    args=training_arguments,
    train_dataset=dataset,
    peft_config=lora_config,
    dataset_text_field="text",
    max_seq_length=2048,
    packing=False,
)

trainer.train()

第八步:保存和记录

1
2
3
4
5
6
trainer.save_model(output_dir)  # 保存LoRA adapter

metrics = train_result.metrics
trainer.log_metrics("train", metrics)
trainer.save_metrics("train", metrics)
trainer.save_state()

7.3 启动训练命令

1
2
3
4
5
6
7
8
9
# 使用nohup在后台运行
nohup accelerate launch train_on_single_gpu.py > training.log 2>&1 &

# 查看训练进度
tail -f training.log

# 停止训练
ps aux | grep "accelerate train_on_single_gpu.py"
kill <进程号>

8. 多GPU分布式训练

8.1 什么是分布式训练?

当单张GPU的显存或计算能力不足以高效训练时,使用多张GPU并行训练。DeepSpeed ZeRO 是一种流行的分布式训练策略,通过将模型状态(参数、梯度、优化器状态)分片到多个GPU上来减少每张GPU的显存占用。

8.2 DeepSpeed ZeRO阶段

阶段分片内容显存节约
ZeRO-1优化器状态~4x
ZeRO-2优化器状态 + 梯度~8x
ZeRO-3优化器状态 + 梯度 + 参数~Nx (N=GPU数)
📝 为什么选择ZeRO-2

ZeRO-2在显存节约和通信开销之间取得了较好的平衡。ZeRO-3虽然显存节约最大,但通信开销也最大,且与LoRA配合时可能存在兼容性问题。

8.3 DeepSpeed ZeRO-2配置文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
{
    "fp16": {
        "enabled": true,
        "loss_scale": 0,
        "loss_scale_window": 1000,
        "initial_scale_power": 16,
        "hysteresis": 2,
        "min_loss_scale": 1
    },
    "bf16": {
        "enabled": false
    },
    "optimizer": {
        "type": "AdamW",
        "params": {
            "lr": "auto",
            "betas": "auto",
            "eps": "auto",
            "weight_decay": "auto"
        }
    },
    "scheduler": {
        "type": "WarmupLR",
        "params": {
            "warmup_min_lr": "auto",
            "warmup_max_lr": "auto",
            "warmup_num_steps": "auto"
        }
    },
    "zero_optimization": {
        "stage": 2,
        "offload_optimizer": {
            "device": "none",
            "pin_memory": true
        },
        "allgather_partitions": true,
        "allgather_bucket_size": 2e8,
        "overlap_comm": true,
        "reduce_scatter": true,
        "reduce_bucket_size": 2e8,
        "contiguous_gradients": true
    },
    "gradient_accumulation_steps": "auto",
    "gradient_clipping": "auto",
    "train_batch_size": "auto",
    "train_micro_batch_size_per_gpu": "auto"
}
💡 “auto” 值的含义

设置为 "auto" 的参数会自动从 HuggingFace 的 TrainingArguments 中获取对应值,避免在两个地方重复配置。

关键配置解读:

  • "stage": 2:使用ZeRO Stage 2
  • "offload_optimizer": {"device": "none"}:优化器状态保留在GPU上(设为 "cpu" 可卸载到CPU以进一步节约显存,但会降低训练速度)
  • "overlap_comm": true:通信与计算重叠,提高效率
  • "allgather_bucket_size""reduce_bucket_size":控制通信的分块大小,影响通信效率

8.4 Accelerate配置文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
compute_environment: LOCAL_MACHINE
distributed_type: DEEPSPEED

deepspeed_config:
  deepspeed_config_file: ds_config_zero2.json

gpu_ids: all
num_machines: 1
num_processes: 2        # GPU数量

rdzv_backend: static
same_network: true
use_cpu: false

关键参数:

  • distributed_type: DEEPSPEED:使用DeepSpeed作为分布式后端
  • num_processes: 2:使用2张GPU
  • gpu_ids: all:使用所有可见GPU
  • deepspeed_config_file:指向DeepSpeed JSON配置文件

8.5 多GPU训练脚本的关键差异

与单GPU脚本相比,多GPU脚本有以下关键改动:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 1. 不要设置 device_map="auto"——交给accelerate处理
model = AutoModelForCausalLM.from_pretrained(
    model_id_or_path,
    # device_map="auto",  # 移除!accelerate会自动分配设备
    trust_remote_code=True
)

# 2. 在TrainingArguments中传入deepspeed配置
training_arguments = TrainingArguments(
    ...
    deepspeed=args.deepspeed,  # 传入DeepSpeed配置文件路径
    fp16=True,                 # 由DeepSpeed配置决定精度
    gradient_checkpointing=True,  # 梯度检查点节约显存
)

# 3. Wandb只在主进程初始化
if os.environ.get("LOCAL_RANK", "0") == "0":
    wandb.init(project="finetune_dsr1-ds")

8.6 启动多GPU训练

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 使用accelerate启动,指定DeepSpeed配置
accelerate launch \
    --config_file accelerate_config.yaml \
    --deepspeed_config_file ds_config_zero2.json \
    train_on_multi_gpu.py \
    --model_id_or_path DeepSeek-R1-Distill-Qwen-7B \
    --dataset_path dataset/medical_o1_sft_Chinese.json \
    --per_device_train_batch_size 1 \
    --gradient_accumulation_steps 8 \
    --learning_rate 1e-4

9. Unsloth高效微调框架

9.1 什么是Unsloth?

Unsloth 是一个专为大模型微调优化的开源框架。它通过手动编写CUDA kernel和优化内存管理,实现了比标准HuggingFace训练快2倍、显存节约30%的效果,同时保持与HuggingFace生态完全兼容。

9.2 为什么使用Unsloth?

  1. 2倍训练速度:通过优化的CUDA kernel加速前向和反向传播
  2. 30%显存节约:通过梯度检查点优化 use_gradient_checkpointing="unsloth"
  3. 2倍更快推理:内置推理优化
  4. 零代码修改:API与HuggingFace完全兼容,只需替换模型加载方式
  5. 支持4-bit量化:内置量化支持,无需手动配置BitsAndBytes

9.3 Unsloth使用方法

安装

1
2
pip install unsloth
pip install bitsandbytes unsloth_zoo

加载模型

1
2
3
4
5
6
7
8
9
from unsloth import FastLanguageModel
import torch

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/DeepSeek-R1-Distill-Llama-8B",
    max_seq_length=2048,
    dtype=None,          # None为自动检测,T4/V100用float16,Ampere+用bfloat16
    load_in_4bit=True,   # 启用4-bit量化
)

配置LoRA

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    lora_alpha=16,
    lora_dropout=0,                         # 0是Unsloth优化过的
    bias="none",
    use_gradient_checkpointing="unsloth",   # 使用Unsloth优化的梯度检查点
    random_state=3407,
    use_rslora=False,
    loftq_config=None,
)

训练

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from trl import SFTTrainer
from transformers import TrainingArguments
from unsloth import is_bfloat16_supported

# 切换到训练模式
FastLanguageModel.for_training(model)

trainer = SFTTrainer(
    model=model, tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=2048,
    args=TrainingArguments(
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,
        warmup_steps=5,
        max_steps=60,
        learning_rate=2e-4,
        fp16=not is_bfloat16_supported(),
        bf16=is_bfloat16_supported(),
        logging_steps=1,
        optim="adamw_8bit",
        weight_decay=0.01,
        lr_scheduler_type="linear",
        seed=3407,
        output_dir="outputs",
    ),
)
trainer.train()

推理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 切换到推理模式——比标准HuggingFace快2倍
FastLanguageModel.for_inference(model)

inputs = tokenizer([prompt], return_tensors="pt").to("cuda")
outputs = model.generate(
    input_ids=inputs.input_ids,
    attention_mask=inputs.attention_mask,
    max_new_tokens=1200,
    use_cache=True,
)
response = tokenizer.batch_decode(outputs)
💡 for_training 与 for_inference

Unsloth要求在训练和推理之间显式切换模式。FastLanguageModel.for_training(model)FastLanguageModel.for_inference(model) 会应用不同的优化策略。


10. 医疗领域微调实战

10.1 项目概述

  • 基座模型:DeepSeek-R1-Distill-Llama-8B(DeepSeek R1的蒸馏版本)
  • 训练数据:medical-o1-reasoning-SFT(华佗o1的医疗推理数据集,包含500条中文医疗问答)
  • 框架:Unsloth + SFTTrainer
  • GPU:单张NVIDIA RTX 4090(24GB)

10.2 数据集结构

数据集包含三个字段:

字段含义示例
Question医疗问题“急性阑尾炎病人已发病5天…”
Complex_CoT思考链(Chain of Thought)“首先分析症状…然后考虑…”
Response最终回答“应采取以下步骤…”

10.3 训练效果对比

训练前(原始模型回答):

  • 模型给出的建议缺乏针对性,包含一些不够专业的表述(如"内窥镜检查"用于阑尾炎诊断)

训练后(微调后回答):

  • 思考过程更有条理:先退烧、再抗感染、再保护肠道、最后考虑手术
  • 回答更加专业和实用

10.4 关键配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 训练参数
per_device_train_batch_size = 2
gradient_accumulation_steps = 4    # 有效batch_size = 2 * 4 = 8
warmup_steps = 5
max_steps = 60                     # 仅训练60步(演示目的)
learning_rate = 2e-4
optim = "adamw_8bit"
weight_decay = 0.01
lr_scheduler_type = "linear"

# LoRA参数
r = 16
lora_alpha = 16
target_modules = ["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"]
医疗微调实战要点
  1. 使用 load_in_4bit=True 在24GB显存的GPU上加载8B模型
  2. 数据格式包含 <think>...</think> 标签,训练模型输出思考链
  3. 仅用500条数据、60步训练就能看到明显效果提升
  4. 必须在数据末尾添加 EOS_TOKEN

11. 法律领域微调实战

11.1 项目概述

  • 基座模型:Qwen3系列
  • 训练数据:DISC-Law-SFT(法律领域SFT数据集,JSONL格式)
  • 框架:LLaMA-Factory

11.2 数据预处理流程

原始法律数据格式(JSONL):

1
{"input": "请问合同违约的法律后果是什么?", "output": "根据《民法典》第577条..."}

转换为LLaMA-Factory所需格式:

1
2
3
4
5
6
7
8
{
  "conversations": [
    {"from": "human", "value": "请问合同违约的法律后果是什么?"},
    {"from": "gpt", "value": "根据《民法典》第577条..."}
  ],
  "system": "",
  "tools": ""
}

11.3 数据处理代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import json

# 读取原始JSONL数据
json_data = []
with open('DISC-Law-SFT-Triplet-released.jsonl', 'r', encoding='utf-8') as file:
    for line in file:
        data = json.loads(line)
        json_data.append(data)

# 转换为目标格式
template = []
for data in json_data:
    conversation = [
        {"from": "human", "value": data["input"]},
        {"from": "gpt", "value": data["output"]}
    ]
    template.append({
        "conversations": conversation,
        "system": "",
        "tools": ""
    })

# 保存
with open('train_data_law.json', 'w', encoding='utf-8') as f:
    json.dump(template, f, ensure_ascii=False, indent=2)
📝 法律微调的特殊考量
  1. 法律文本通常较长,需要设置较大的 max_seq_length
  2. 法律回答需要高度准确,建议使用较大的 r 值(如32或64)
  3. 法律数据的专业性强,建议使用较低的学习率防止"遗忘"预训练知识
  4. 数据中的法律条文引用必须准确,数据质量比数据数量更重要

12. 训练超参数调优

12.1 什么是训练超参数?

超参数是在训练开始前设定的、控制训练过程行为的参数,与模型内部学习到的参数不同,超参数需要人为调整。

12.2 核心超参数详解

learning_rate(学习率)

1
2
learning_rate = 2e-4  # Unsloth医疗微调使用
learning_rate = 1e-4  # 单GPU/多GPU训练使用
  • 作用:控制每次参数更新的步长大小
  • LoRA微调推荐范围:1e-5 ~ 5e-4
  • 过大:训练不稳定,loss震荡或发散
  • 过小:收敛速度极慢,训练效率低
  • 建议:LoRA微调通常用 1e-4 ~ 2e-4

per_device_train_batch_size(每设备批大小)

1
2
per_device_train_batch_size = 1  # 显存紧张时
per_device_train_batch_size = 2  # Unsloth + 4bit量化时
  • 作用:每张GPU每次前向传播处理的样本数
  • 受限于显存:batch_size越大,显存占用越多
  • 增大batch_size的方法:使用量化、LoRA、梯度检查点来释放显存

gradient_accumulation_steps(梯度累积步数)

1
2
gradient_accumulation_steps = 4  # Unsloth
gradient_accumulation_steps = 8  # 单GPU/多GPU
  • 作用:累积多个micro-batch的梯度后再执行一次参数更新
  • 有效batch_size = per_device_batch_size * gradient_accumulation_steps * num_gpus
  • 例:1 * 8 * 2 = 16(2张GPU,每张batch_size=1,累积8步)
  • 好处:在显存不足以支持大batch_size时,通过梯度累积模拟大batch的效果

warmup_steps / warmup_ratio(预热)

1
2
warmup_steps = 5       # 前5步线性增长学习率
warmup_ratio = 0.03    # 前3%的步数用于预热
  • 作用:训练开始时学习率从0线性增长到设定值
  • 为什么需要:避免训练初期模型权重还不稳定时使用过大的学习率导致参数偏移

lr_scheduler_type(学习率调度器)

1
2
lr_scheduler_type = "linear"   # 线性衰减到0
lr_scheduler_type = "cosine"   # 余弦退火
  • linear:学习率从初始值线性降低到0
  • cosine:学习率按余弦曲线衰减,后期衰减更慢,通常效果更好
  • 建议:cosine是大多数微调场景的首选

weight_decay(权重衰减)

1
weight_decay = 0.01
  • 作用:L2正则化,防止模型过拟合
  • 常用值:0.01 ~ 0.1
  • LoRA微调中通常设置为0.01即可

max_grad_norm(梯度裁剪)

1
max_grad_norm = 0.3
  • 作用:当梯度的范数超过阈值时进行裁剪,防止梯度爆炸
  • 常用值:0.3 ~ 1.0

optim(优化器)

1
2
optim = "adamw_8bit"   # 使用bitsandbytes的8-bit AdamW
optim = "adamw_torch"  # 标准PyTorch AdamW
  • adamw_8bit:将优化器状态(如动量和方差)用8-bit存储,节约约75%的优化器显存
  • adamw_torch:标准精度,显存占用更大但稳定性更好

12.3 超参数调优建议

场景learning_ratebatch_sizegradient_accumulationepochs
小数据集(<1K条)1e-4 ~ 2e-41~24~83~5
中数据集(1K~10K条)5e-5 ~ 1e-42~44~81~3
大数据集(>10K条)2e-5 ~ 5e-54~84~81
💡 调优经验法则
  1. 先跑通再调优:先用默认参数跑一轮,观察loss曲线
  2. 观察loss:loss应该稳步下降;如果震荡,降低learning_rate;如果下降太慢,提高learning_rate
  3. 有效batch_size不宜太大:对于LoRA微调,有效batch_size在8~32之间通常效果较好
  4. epochs不宜过多:微调容易过拟合,1~3个epoch通常足够
  5. 使用wandb监控:通过 report_to="wandb" 实时查看训练曲线

13. 小测验

选择题

1. SFT微调中,labels = input_ids.clone() 这行代码的作用是什么? A. 将标签设置为全零向量 B. 让模型预测整个序列中每个位置的下一个token C. 只让模型预测assistant回答部分 D. 复制一份input_ids用于数据增强

2. LoRA中参数 r=16 的含义是什么? A. 训练16个epoch B. 低秩矩阵的秩为16 C. 使用16个目标模块 D. 每16步保存一次checkpoint

3. 以下哪个不是BitsAndBytes 4-bit量化的配置参数? A. bnb_4bit_use_double_quant B. bnb_4bit_quant_type="nf4" C. bnb_4bit_compute_dtype=torch.bfloat16 D. bnb_4bit_gradient_accumulation=True

4. NEFT噪声嵌入中,噪声幅度的计算公式 alpha / sqrt(L * d) 中,L和d分别代表什么? A. 学习率和数据量 B. 序列长度和嵌入维度 C. 层数和dropout率 D. LoRA秩和alpha值

5. Completion-Only训练中,为什么需要Loss Mask? A. 防止梯度爆炸 B. 加速训练速度 C. 只对助手回答部分计算损失,忽略提示部分 D. 减少显存占用

6. 在DeepSpeed ZeRO-2配置中,"offload_optimizer": {"device": "none"} 的含义是什么? A. 不使用任何优化器 B. 优化器状态保留在GPU上,不卸载到CPU C. 使用CPU进行优化器计算 D. 禁用ZeRO优化

7. Unsloth中 use_gradient_checkpointing="unsloth" 相比 True 有什么优势? A. 不使用梯度检查点 B. 使用更多显存但速度更快 C. 使用Unsloth优化版本,节约更多显存(约30%)并支持更长上下文 D. 只在推理时使用

8. 以下关于 tokenizer.pad_token = tokenizer.eos_token 的说法,哪个是正确的? A. 这会导致模型在训练时提前停止 B. 这是因为很多Causal LM没有专门的pad_token,需要手动指定 C. 这会覆盖eos_token的功能 D. 这只在推理时需要设置

9. gradient_accumulation_steps=8per_device_train_batch_size=1 在单GPU上的有效batch_size是多少? A. 1 B. 4 C. 8 D. 16

10. 在多GPU训练脚本中,为什么要移除 device_map="auto" A. 因为它会导致OOM B. 因为accelerate/DeepSpeed会自动处理设备分配,手动指定会冲突 C. 因为多GPU不支持device_map D. 因为它只适用于推理

简答题

11. 请解释为什么在SFT训练数据末尾必须添加EOS_TOKEN,不添加会导致什么问题?

12. 请比较手动实现的Loss Mask方式中 loss.sum() / loss_mask.sum()torch.mean(loss) 两种损失归一化方式的区别,说明哪种更合理。

13. 请说明LoRA的 lora_alphar 的关系。如果 r=16, lora_alpha=32,实际的缩放比例是多少?这意味着什么?

14. 在法律领域微调中,将原始JSONL数据转换为LLaMA-Factory格式时,数据结构发生了怎样的变化?请描述关键字段的映射关系。

15. 请解释DeepSpeed ZeRO-2配置中 "auto" 值的作用机制,以及它如何与HuggingFace TrainingArguments协同工作。


14. 思维导图结构建议

以下是本教程的思维导图骨架,建议使用Obsidian的Markmap插件或XMind绘制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
大模型微调 (Fine-Tuning)
├── 基础概念
│   ├── SFT监督微调
│   │   ├── 定义:在预训练模型上用标注数据继续训练
│   │   ├── 核心:labels = input_ids.clone()
│   │   └── 对比:微调 vs 预训练(数据量、计算量、学习率)
│   └── 数据格式
│       ├── Chat Template(apply_chat_template)
│       ├── 自定义Prompt Template
│       ├── EOS_TOKEN(必须添加)
│       └── 不同框架的数据格式要求
├── 参数高效微调 (PEFT)
│   ├── LoRA低秩适配
│   │   ├── 原理:W + BA, r << d
│   │   ├── 关键参数:r, lora_alpha, target_modules
│   │   └── 优势:可插拔、无推理延迟、节约显存
│   └── 量化技术
│       ├── 4-bit量化(nf4, double_quant)
│       ├── 8-bit量化
│       ├── QLoRA = 量化 + LoRA
│       └── prepare_model_for_kbit_training
├── 训练优化技术
│   ├── NEFT噪声嵌入
│   │   ├── 原理:向embedding添加均匀噪声
│   │   ├── 公式:alpha / sqrt(L * d)
│   │   └── 使用:neftune_noise_alpha=10
│   ├── Completion-Only训练
│   │   ├── Loss Mask机制
│   │   ├── DataCollatorForCompletionOnlyLM
│   │   └── 手动实现 vs 库函数
│   └── 超参数调优
│       ├── learning_rate (1e-5 ~ 5e-4)
│       ├── batch_size & gradient_accumulation
│       ├── lr_scheduler (cosine推荐)
│       ├── warmup & weight_decay
│       └── optim (adamw_8bit)
├── 训练框架
│   ├── 单GPU训练
│   │   ├── SFTTrainer + TrainingArguments
│   │   ├── 完整8步流程
│   │   └── accelerate launch
│   ├── 多GPU分布式训练
│   │   ├── DeepSpeed ZeRO-2
│   │   ├── accelerate_config.yaml
│   │   ├── ds_config_zero2.json
│   │   └── LOCAL_RANK环境变量
│   └── Unsloth框架
│       ├── FastLanguageModel.from_pretrained
│       ├── FastLanguageModel.get_peft_model
│       ├── for_training / for_inference
│       └── 2x速度、30%显存节约
└── 行业实战
    ├── 医疗领域
    │   ├── DeepSeek R1 + medical-o1-reasoning-SFT
    │   ├── 思考链(CoT)训练
    │   └── <think>...</think> 标签
    └── 法律领域
        ├── Qwen3 + DISC-Law-SFT
        ├── JSONL到LLaMA-Factory格式转换
        └── 数据预处理脚本