前言

在预训练完毕之后,我们的模型已经成为了一个学习完所有知识的学生,但是他缺乏用适当的方式表达知识的能力,还是停留在续写文本的阶段,无法直接回答我们的问题,所以监督微调这个时候就出来了,这个指令微调的作用,就是让模型在预训练的基础上,通过特定的数据和训练,让模型能够更好的回答用户的问题。

预训练一般包括三大要素,网络结构,损失函数,训练数据。指令微调和预训练的方式几乎没有区别,只是训练数据的不同。

1.对话模板

很多人误以为微调就是把“问题”和“答案”丢给模型,但实际上,模型在底层只认识一长串被拼接起来的 Token 流。如果没有对话模板,模型就无法区分“哪里是用户说的话”、“哪里是它自己该说的话”,也无法知道“什么时候该停止生成”。

1. 为什么需要对话模板?(从 Base 到 Chat)

Base Model(基座模型) 的本质是“文本接龙”。 如果你给它输入:“你好”,它可能会续写:“,我是李雷。” 或者 “吗?今天天气不错。”

Chat Model(对话模型) 的目标是“角色扮演”。 为了让模型学会对话,我们需要构造一种特殊的格式(Format),强制模型理解对话的轮次。

这就好比剧本:

  • Base Model 看到的是散文。

  • Chat Model 看到的是带有角色标记的剧本。

2. 对话模板的核心解剖

一个标准的对话模板通常包含三个核心要素:

  1. 特殊 Token(Special Tokens): 用于标识开始、结束、角色分割。

  2. 角色定义(Roles): System(设定)、User(人类)、Assistant(模型)。

  3. 内容(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 MaskLoss Mask (Labels)

Attention Mask(注意力掩码):

这是告诉模型:“你看的时候,不要看 padding(填充)的部分”

如果你做 Batch 训练(一次喂好几条数据),长短不一,短的要补 0,这时需要 Attention Mask 告诉模型 0 是没意义的。无论是否微调,这个都要有。

Labels Masking(也就是我们今天讲的):

  • 这是告诉模型:“你算分的时候,不要算 Question 的部分”

  • 如果你不做这个操作,把 labels 设为和 input_ids 一模一样,模型就会试图去预测 input_ids 里的每一个词,包括“用户说的话”。

  • 后果: 你的模型会变成一个复读机,或者在回答问题之前,总是倾向于先生成一个问题。

5. 为什么模板不匹配会产生严重后果?

如果你用 Llama 3 的模型,却强行喂给它 Llama 2 的 [INST] 格式,会发生什么?

  1. 能力退化(Distribution Shift):模型在预训练或之前的微调中,从未见过这种格式,它会感到“困惑”,导致输出质量大幅下降。

  2. 停不下来(EOS Failure):模型可能不知道该在什么时候停止,因为它在等待 Llama 3 的 <|eot_id|>,结果你只给了它一个普通的 </s>,它可能会一直生成乱码或开始重复用户的输入。

  3. 指令遵循失效:模型可能无法识别哪里是 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(前向传播)的第一步

流程对比

传统 SFTInput IDs $\rightarrow$ Embedding Layer $\rightarrow$ 精确的向量 $\rightarrow$ Transformer Layers

NEFTune SFTInput IDs $\rightarrow$Embedding Layer $\rightarrow$ 精确向量 + 随机噪声 $\rightarrow$ Transformer Layers

数学公式

假设模型的 Embedding 后的向量序列是$X_{emb}$(形状是 [Batch, Seq_Len, Dim])。

我们生成一个噪声矩阵 $\epsilon$,加进去:

$X'_{emb} = X_{emb} + \alpha \cdot \epsilon$

其中:

$\epsilon$ 通常是从均匀分布 (Uniform Distribution) $U(-1, 1)$ 中采样的,而不是正态分布(这是一个反直觉的细节,论文实验证明均匀分布更好)。

$\alpha$缩放系数。这非常关键,噪声不能太大把原始信息淹没,也不能太小没效果。

论文提出 $\alpha$ 应该根据序列长度 $L$和嵌入维度 $d$动态调整:$\alpha = \frac{k}{\sqrt{L \cdot d}}$

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 好用,但实验时要注意以下两点:

  1. Loss 会变大:

    使用了 NEFTune 后,你会发现训练时的 Training Loss 比不加噪声时要高。这是正常的! 不要因为 Loss 没降下去就以为训练挂了。因为你增加了任务难度,模型很难把 Loss 刷到极低,但这正是防止过拟合的体现。

  2. 不适合严谨推理任务(可能是个坑):

    NEFTune 在聊天、创作类任务上效果拔群。

    但在数学、代码这种需要精确 Token 匹配的任务上,有时会有副作用。因为数学公式对噪声很敏感,Embedding 稍微偏一点,意思可能就变了。如果是做的 RL+LLM 数学推理方向,建议谨慎设置那个缩放系数 k(可以调小一点,比如 5)。

总结

噪声嵌入微调(NEFTune) = Embedding 层 + 均匀分布噪声

它本质上是一种正则化手段,通过把“点”变成“云”,强迫模型学习语义而非死记硬背。这对于小数据量的 SFT 至关重要。

Logo

更多推荐