大模型的监督微调基础详解
在预训练完毕之后,我们的模型已经成为了一个学习完所有知识的学生,但是他缺乏用适当的方式表达知识的能力,还是停留在续写文本的阶段,无法直接回答我们的问题,所以监督微调这个时候就出来了,这个指令微调的作用,就是让模型在预训练的基础上,通过特定的数据和训练,让模型能够更好的回答用户的问题。预训练一般包括三大要素,网络结构,损失函数,训练数据。指令微调和预训练的方式几乎没有区别,只是训练数据的不同。
前言
在预训练完毕之后,我们的模型已经成为了一个学习完所有知识的学生,但是他缺乏用适当的方式表达知识的能力,还是停留在续写文本的阶段,无法直接回答我们的问题,所以监督微调这个时候就出来了,这个指令微调的作用,就是让模型在预训练的基础上,通过特定的数据和训练,让模型能够更好的回答用户的问题。
预训练一般包括三大要素,网络结构,损失函数,训练数据。指令微调和预训练的方式几乎没有区别,只是训练数据的不同。
1.对话模板
很多人误以为微调就是把“问题”和“答案”丢给模型,但实际上,模型在底层只认识一长串被拼接起来的 Token 流。如果没有对话模板,模型就无法区分“哪里是用户说的话”、“哪里是它自己该说的话”,也无法知道“什么时候该停止生成”。
1. 为什么需要对话模板?(从 Base 到 Chat)
Base Model(基座模型) 的本质是“文本接龙”。 如果你给它输入:“你好”,它可能会续写:“,我是李雷。” 或者 “吗?今天天气不错。”
Chat Model(对话模型) 的目标是“角色扮演”。 为了让模型学会对话,我们需要构造一种特殊的格式(Format),强制模型理解对话的轮次。
这就好比剧本:
-
Base Model 看到的是散文。
-
Chat Model 看到的是带有角色标记的剧本。
2. 对话模板的核心解剖
一个标准的对话模板通常包含三个核心要素:
-
特殊 Token(Special Tokens): 用于标识开始、结束、角色分割。
-
角色定义(Roles): System(设定)、User(人类)、Assistant(模型)。
-
内容(Content): 实际的对话文本。
[BOS] <角色开始标记> system <内容结束标记> 你是一个有用的助手 <轮次结束标记>
<角色开始标记> user <内容结束标记> 什么是勾股定理? <轮次结束标记>
<角色开始标记> assistant <内容结束标记>
此时模型看到这里,就知道轮到它生成了,直到它输出一个“轮次结束标记”为止
3. 主流的对话模板格式
不同的模型家族采用了完全不同的“方言”。理解这些“方言”对于微调至关重要。
A. ChatML 格式 (OpenAI, Qwen, Yi 等)
这是目前最清晰、最通用的格式之一。它通过显式的 XML 风格标签来分割。
-
特点:结构极度清晰,不容易混淆。
-
特殊 Token:
<|im_start|>,<|im_end|>
<|im_start|>system
你是一个人工智能助手。<|im_end|>
<|im_start|>user
你好,介绍一下你自己。<|im_end|>
<|im_start|>assistant
我是 Qwen,由阿里云开发的大模型。<|im_end|>
这个夯爆了
B. Llama 2 格式
Llama 2 采用了一种比较复杂且有点“反直觉”的格式,它大量使用了 [INST] 和 <<SYS>> 标记。
-
特点:System Prompt 被包裹在第一个 User 的
[INST]块里,逻辑比较隐晦。 -
特殊 Token:
<s>(BOS),[INST],[/INST],<<SYS>>,</s>(EOS)
<s>[INST] <<SYS>>
You are a helpful assistant.
<</SYS>>
Hi! [/INST] Hello! I am here to help. </s><s>[INST] What is 1+1? [/INST]
注意:Llama 2 的多轮对话如果不处理好 <s> 和 </s> 的位置,很容易崩坏,个人感觉拉完了
C. Llama 3 格式
Llama 3 抛弃了 Llama 2 复杂的括号体系,转向了类似 ChatML 的 Header ID 体系,更加现代
-
特点:使用了 Reserved Tokens 来标记 Header。
-
特殊 Token:
<|begin_of_text|>,<|start_header_id|>,<|end_header_id|>,<|eot_id|>(End of Turn)
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>
Hello<|eot_id|><|start_header_id|>assistant<|end_header_id|>
这个不错,泛化性也很好,给个顶级吧
4. 深入核心:训练时的 Loss Masking(至关重要)
这部分是很多初学者容易忽略的核心细节。
在 SFT(监督微调)训练时,我们把上面的模板拼成一长串 ID 输入给模型。但是,我们不希望模型学习“用户说了什么”,我们只希望模型学习“助手该怎么回答”。
如果让模型学习预测用户的输入,模型就会“精神分裂”,在推理时可能会自言自语,自己扮演用户提问。
因此,我们需要构建 Labels 进行 Masking(掩码):
-
Input IDs (输入给模型的):
[System 指令] [User 问题] [Assistant 回答] [EOS] -
Labels (计算损失用的):
[-100, ..., -100][-100, ..., -100][Assistant 回答 ID][EOS ID]
解释:
-
在 PyTorch 中,Label 为
-100的位置会被 CrossEntropyLoss 忽略。 -
所以,System Prompt 和 User Prompt 的部分,Loss 都是 0。
-
模型只在 Assistant 回答的部分计算梯度并更新参数。
总结:
-
我们不希望模型浪费脑力去学习“用户会怎么提问”(这是预训练干的事)。
-
我们只希望模型学习“针对这个问题,该怎么生成回答”。
代码讲解:
假设我们有这样一条数据:
-
问题 (Instruction):
你好 -
回答 (Response):
我是AI
我们来看代码如何一步步处理它。
import torch
from transformers import AutoTokenizer
# 1. 加载分词器 (这里用伪代码代表任意分词器)
# 假设我们要用的模型是 Llama 或者 Qwen
tokenizer = AutoTokenizer.from_pretrained("facebook/opt-125m")
# 2. 定义我们的特殊 Token (这取决于具体模型,这里举例)
# 假设 <bos> 是句子开始,<eos> 是句子结束
bos_token = tokenizer.bos_token
eos_token = tokenizer.eos_token
# 3. 定义原始文本
instruction = "你好"
response = "我是AI"
print(f"BOS: {bos_token}, EOS: {eos_token}")
将文本转为数字 (ID)
我们需要分别把“问题”和“回答”变成数字,然后拼接起来。
# --- 步骤讲解 ---
# 1. 把“问题”变成 ID 列表
# add_special_tokens=False 是因为我们要手动控制开始和结束符
instruction_ids = tokenizer.encode(instruction, add_special_tokens=False)
# 2. 把“回答”变成 ID 列表,并加上结束符 <eos>
# 因为模型说完 "我是AI" 必须学会闭嘴,所以 <eos> 也是要学习的一部分
response_ids = tokenizer.encode(response, add_special_tokens=False) + [tokenizer.eos_token_id]
# 3. 拼接 Input IDs (模型看到的所有内容)
# 结构通常是: [BOS] + [问题 ID] + [回答 ID] + [EOS]
# 注意:有些模型不需要 BOS,这里为了原理完整加上了
input_ids = [tokenizer.bos_token_id] + instruction_ids + response_ids
print("Input IDs (给模型看的):", input_ids)
# 假设输出是: [1, 200, 300, 400, 500, 2]
# (1是BOS, 200是"你", 300是"好", 400是"我", 500是"是AI", 2是EOS)
制作 Labels (核心 Masking 逻辑)
这一步就是你问的“只关注回答”的实现细节。我们需要构造一个和 input_ids 一样长的列表,但是把不需要学习的地方填成 -100。
# --- 步骤讲解 ---
# 1. 计算不需要学习的部分有多长?
# 不需要学习的部分 = [BOS] + [问题]
# 所以长度是 1 (BOS) + len(instruction_ids)
context_length = 1 + len(instruction_ids)
# 2. 构造 Labels 列表
# 逻辑:
# Part A (问题部分): 全部填充 -100
# Part B (回答部分): 必须是真实的 input_ids (即 response_ids)
# [-100] * n 会生成一个有 n 个 -100 的列表
labels = [-100] * context_length + response_ids
# --- 验证长度 ---
# input_ids 和 labels 的长度必须严格相等,否则报错
assert len(input_ids) == len(labels)
print("Labels (用来算分的):", labels)
# 假设输出是: [-100, -100, -100, 400, 500, 2]
# 注意前三个变成了 -100,对应 BOS, "你", "好"
# 后三个保留了 ID,对应 "我", "是AI", EOS
送入模型计算
最后,我们看看这两个列表怎么配合工作。
# 转换为 PyTorch 张量
input_tensor = torch.tensor([input_ids]) # 增加一个 batch 维度
label_tensor = torch.tensor([labels])
# 模拟模型前向传播
# model(input_ids, labels=labels)
# 内部会自动发生以下事情:
# 1. 模型看着 input_ids 预测下一个词。
# 2. 拿到预测结果,去跟 labels 对比。
# 3. 遇到 labels 里是 -100 的位置,CrossEntropyLoss 直接跳过。
# 4. 只计算 "400", "500", "2" 这几个位置的误差。
这里有一个容易混淆的概念:Attention Mask 和 Loss Mask (Labels)。
Attention Mask(注意力掩码):
这是告诉模型:“你看的时候,不要看 padding(填充)的部分”。
如果你做 Batch 训练(一次喂好几条数据),长短不一,短的要补 0,这时需要 Attention Mask 告诉模型 0 是没意义的。无论是否微调,这个都要有。
Labels Masking(也就是我们今天讲的):
-
这是告诉模型:“你算分的时候,不要算 Question 的部分”。
-
如果你不做这个操作,把
labels设为和input_ids一模一样,模型就会试图去预测input_ids里的每一个词,包括“用户说的话”。 -
后果: 你的模型会变成一个复读机,或者在回答问题之前,总是倾向于先生成一个问题。
5. 为什么模板不匹配会产生严重后果?
如果你用 Llama 3 的模型,却强行喂给它 Llama 2 的 [INST] 格式,会发生什么?
-
能力退化(Distribution Shift):模型在预训练或之前的微调中,从未见过这种格式,它会感到“困惑”,导致输出质量大幅下降。
-
停不下来(EOS Failure):模型可能不知道该在什么时候停止,因为它在等待 Llama 3 的
<|eot_id|>,结果你只给了它一个普通的</s>,它可能会一直生成乱码或开始重复用户的输入。 -
指令遵循失效:模型可能无法识别哪里是 System Prompt,导致它无法遵守“你是一个安全助手”之类的核心指令。
6. 现在的最佳实践:Jinja2 模板
为了解决“每个模型格式都不一样”的痛苦,Hugging Face 的 transformers 库引入了 Chat Template 机制,通常直接写在 tokenizer_config.json 里,使用 Jinja2 语法。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B-Instruct")
messages = [
{"role": "system", "content": "你是个有用的助手"},
{"role": "user", "content": "你好"},
]
# 这一步会自动读取 config 中的 jinja 模板,把 list 转成 string
formatted_prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True # 这一步很重要,告诉模型“轮到你说了”
)
print(formatted_prompt)
输出(模型看到的实际样子):
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
你是个有用的助手<|eot_id|><|start_header_id|>user<|end_header_id|>
你好<|eot_id|><|start_header_id|>assistant<|end_header_id|>
总结
对话模板不仅仅是字符串拼接,它是将人类交互逻辑翻译成模型概率分布约束的协议。
-
做微调时:必须严格对齐 Base 模型训练时使用的模板(特别是 Special Token IDs),并正确实施 Loss Masking。
-
做推理时:务必使用
tokenizer.apply_chat_template,避免手动拼接字符串产生的格式错误。
2.噪声嵌入微调(NEFTune)
噪声嵌入微调算是一种可以提高大模型精度和泛化性的技术,就像我们搞CV,数据增强一样,数据不多的情况下,把一张图加噪,或者旋转,裁剪等,以此来防止过拟合。
所以简单用一句话概括:NEFTune 就是在微调过程中,给输入的 Token Embedding 向量人为地撒一把“噪声”,防止模型死记硬背。
1. 痛点:SFT 的“过拟合”陷阱
我们在做 SFT 时,数据量通常不大(几千条到几万条高质量对话)。 这就导致了一个问题:模型很容易过拟合(Overfit)于训练数据的具体措辞和格式。
例子:
-
训练数据里,所有回答都是“好的,基于以上信息...”。
-
如果你不加噪声,模型会死记硬背这个“好的,基于...”,导致它的泛化能力变差,稍微换个问法它可能就不知道怎么接了。
-
这种现象在学术上叫失去了分布外的泛化能力(OOD Generalization)。
2. NEFTune 的核心原理
NEFTune 的做法非常暴力美学:它不改动模型结构,也不改动 Loss 函数,只改动Forward Pass(前向传播)的第一步。
流程对比
传统 SFT:Input IDs
Embedding Layer 精确的向量
Transformer Layers
NEFTune SFT: Input IDs Embedding Layer 精确向量 + 随机噪声
Transformer Layers
数学公式
假设模型的 Embedding 后的向量序列是(形状是 [Batch, Seq_Len, Dim])。
我们生成一个噪声矩阵 ,加进去:
其中:
通常是从均匀分布 (Uniform Distribution)
中采样的,而不是正态分布(这是一个反直觉的细节,论文实验证明均匀分布更好)。
是缩放系数。这非常关键,噪声不能太大把原始信息淹没,也不能太小没效果。
论文提出 应该根据序列长度
和嵌入维度
动态调整:
。
import torch
import torch.nn as nn
# 假设这是你的大模型的一个 Embedding 层
# vocab_size=32000, hidden_dim=4096
embeddings = nn.Embedding(32000, 4096)
# 模拟输入:Batch=2, Length=10
input_ids = torch.tensor([[1, 2, 3] * 3 + [1], [4, 5, 6] * 3 + [4]]) # shape [2, 10]
# --- 原始的前向传播 ---
original_embeds = embeddings(input_ids)
# --- NEFTune 的核心函数 ---
def neftune_forward(module, input, output):
"""
这是一个 Hook 函数。
module: 当前层 (Embedding层)
input: 输入给 Embedding 层的 input_ids
output: Embedding 层算出来的结果 (即上面的 original_embeds)
"""
# 1. 只有在训练模式下才加噪声!推理时千万别加!
if module.training:
# output 的形状是 [Batch, Seq_Len, Hidden_Dim]
# 2. 生成噪声 (均匀分布 -1 到 1)
# device=output.device 保证噪声和模型在同一张显卡上
noise = torch.rand_like(output) * 2 - 1
# 3. 计算缩放系数 (Scaling Factor)
# 论文建议系数 k 通常在 5 到 15 之间
k = 10
batch_size, seq_len, hidden_dim = output.shape
# 缩放公式: k / sqrt(L * d)
scale = k / torch.sqrt(torch.tensor(seq_len * hidden_dim))
# 4. 将噪声注入到 Embedding 中
# detach() 是为了不让梯度传导回噪声生成过程(虽然这里是随机数,本身也没梯度)
output = output + noise * scale
print("已注入噪声,Scale:", scale.item())
return output
# --- 激活 NEFTune ---
# 使用 register_forward_hook,在 Embedding 算完之后,自动执行我们的函数
handle = embeddings.register_forward_hook(neftune_forward)
# --- 测试 ---
embeddings.train() # 开启训练模式
noisy_embeds = embeddings(input_ids) # 此时自动触发 Hook
embeddings.eval() # 开启推理模式
clean_embeds = embeddings(input_ids) # 此时不会触发加噪声逻辑
# 清理 Hook (如果不清理,之后每次跑都会加)
handle.remove()
为什么这么简单的操作有效?(深层直觉)
这部分可以用“流形(Manifold)”的概念来解释
A. 模糊化决策边界 (Smoothing Decision Boundary)
在没有噪声时,Embedding 是高维空间中一个极小的点。模型拼命学习如何从这个具体的点映射到答案。 加上噪声后,这个点变成了一团云(Cloud)。 模型被迫学习:“不管输入向量落在这团云的哪个位置,意思都是一样的,我都要输出这个答案。”
这迫使模型忽略细微的扰动,去抓取更本质的语义特征。
B. 正则化效果 (Regularization)
这类似于图像领域的 Data Augmentation(数据增强)。 你给图片加高斯噪声、旋转、裁剪,是为了让卷积神经网络更鲁棒。 NEFTune 就是 NLP 领域的“数据增强”。
C. 对话能力的提升
实验发现,加了 NEFTune 的模型,在 AlpacaEval 等对话榜单上提升显著。模型变得更“敢说”了,回复的长度通常变长,且逻辑连贯性更好。
-
原因推测:过拟合的模型倾向于通过“死记硬背”快速结束句子(因为训练数据里短句多)。加了噪声后,模型对当前状态不那么“确信”,反而倾向于生成更多 Token 来解释和补充,从而提高了回答的丰富度。
5. 注意事项与坑
虽然 NEFTune 好用,但实验时要注意以下两点:
-
Loss 会变大:
使用了 NEFTune 后,你会发现训练时的 Training Loss 比不加噪声时要高。这是正常的! 不要因为 Loss 没降下去就以为训练挂了。因为你增加了任务难度,模型很难把 Loss 刷到极低,但这正是防止过拟合的体现。
-
不适合严谨推理任务(可能是个坑):
NEFTune 在聊天、创作类任务上效果拔群。
但在数学、代码这种需要精确 Token 匹配的任务上,有时会有副作用。因为数学公式对噪声很敏感,Embedding 稍微偏一点,意思可能就变了。如果是做的 RL+LLM 数学推理方向,建议谨慎设置那个缩放系数 k(可以调小一点,比如 5)。
总结
噪声嵌入微调(NEFTune) = Embedding 层 + 均匀分布噪声。
它本质上是一种正则化手段,通过把“点”变成“云”,强迫模型学习语义而非死记硬背。这对于小数据量的 SFT 至关重要。
更多推荐



所有评论(0)