大模型监督微调SFT流程
1. 概述
1.1 背景:自回归语言模型
自回归语言模型(Autoregressive LM) 如 GPT、LaMA 等,遵循从左到右 (或先前 token 到当前 token) 的预测方式。在预训练阶段,模型在大规模无标注文本上学到对下一个 token 的分布表示;在下游应用中,通过微调 (fine-tuning)可使模型适应特定任务、领域或风格(如问答、代码生成、对话系统等)。
1.2 微调的重要性
大型模型预训练通常只学习一般语言分布和常识知识,但实际任务往往需要更精确的指令遵从与特定的输出格式。微调能在保留模型大部分语言能力的同时,通过少量任务数据或人工反馈来定向引导输出质量与安全性,在生成质量与可控性上获得极大提升。
1.3 SFT的输入和输出格式
原始指令数据格式
SFT训练使用标准的三元组数据结构:
{
"instruction":"用户指令,告知模型需要完成的任务",
"input":"执行指令所需的补充输入内容(可为空)",
"output":"模型应该给出的标准回复"
}
具体数据示例
- 翻译任务示例:
{
"instruction":"将下列文本翻译成英文:",
"input":"今天天气真好",
"output": "Today is a nice day!"
}
- 问答任务示例:
{
"instruction":"回答以下用户问题,仅输出答案。",
"input":"1+1等于几?",
"output":"2"
}
- 角色扮演示例:
{
"instruction":"你父亲是谁?",
"input":"",
"output":"家父是大理寺少卿甄远道。"
}
Chat Template转换格式
原始数据通过chat template转换成训练格式:
- LLaMA格式:
### Instruction:
将下列文本翻译成英文:今天天气真好
### Markdown:
Today is a nice day!
多轮对话:
### Instruction:
你好,我是新用户
### Response:
欢迎!很高兴为您服务
### Instruction:
请推荐一些旅游景点
### Response:
根据您的需求,我推荐以下景点...
- 现代Chat格式:
<|im_start|>user
将下列文本翻译成英文:今天天气真好<|im_end|>
<|im_start|>assistant
Today is a nice day!<|im_end|>
多轮对话:
<|im_start|>system
你是一个专业的旅游顾问<|im_end|>
<|im_start|>user
你好,我是新用户<|im_end|>
<|im_start|>assistant
欢迎! 很高兴为您服务<|im_end|>
<|im_start|>user
请推荐一些旅游景点<|im_end|>
<|im_start|>assistant
根据您的需求,我推荐以下景点...<|im_end|>
LLaMA格式特点
- 简单明确:使用自然语言标识符(### Instruction:### Response:)
- 单轮导向:主要设计用于单轮指令-回复模式
- 人类可读: 格式直观,易于理解和调试
- 兼容性强:对tokenizer要求较低,适用于多种模型
现代Chat格式特点
- 结构化标记: 使用特殊token (<|im_start|>、<|im_end|>)
- 角色区分:明确区分system、user、assistant角色
- 多轮支持:天然支持多轮对话上下文
- 精确控制: 便于精确控制loss计算范围
Tokenization后的输入输出
模型实际处理格式:
#完整序列tokenization
input_ids =[BOS, T1, T2, T3, T4, T5, T6, EoS]
#训练时的输入输出对
X=[BOS, T1, T2, T3, T4, T5, T6] #模型输入(前n-1个token)
Y =[T1, T2, T3, T4, T5, T6, EOS]#预测目标(后n-1个token)
Loss Mask设计
掩码原理:
# loss计算掩码 (0=不计算loss, 1=计算loss)
loss_mask = [0, 0, 0, 1, 1, 1, 1] # 只对assistant回复计算loss
# ↑用户prompt ↑assistant回复
代码实现逻辑:
def generate_loss_mask(input_ids):
mask = [0] * len(input_ids)
# 查找assistant开始位置
assistant_start = find_assistant_position(input_ids)
# 查找eos结束位置
eos_position = find_eos_position(input_ids)
# 只对assistant部分设置mask=1
for pos in range(assistant_start, eos_position + 1):
mask[pos] = 1
return mask
数据预处理流程
完整处理步骤:
- 格式转换:instruction +input +output → chat template
- 分词编码: text → token IDs
- 序列构造:生成X(输入)和Y(目标)序列
- 掩码生成:创建loss_mask标识有效位置
- 批处理:组装成batch送入模型训练
这种格式设计确保了模型只学习生成assistant的回复,而不会学习重复用户的输入,从而实现真正的指令遵循能力。
2. 自回归 LLM 的微调目标
在自回归大语言模型微调时,我们通常有以下场景:给定Prompt (提示词) 序列[x1, x2, x3, …],希望训练模型在给定 Prompt 后,自回归地生成目标输出序列 [y1, y2, …]。为此,下面以严格的数学形式阐述如何计算训练过程的损失 loss,即对次序化 token 进行对数似然估计。
与预训练阶段的目标函相同,但是计算的范围不同。
数学本质相同
- 两个阶段都使用交叉熵损失(Cross-Entropy Loss)
- 都基于下一词预测(Next Token Prediction)
- 都是最大化条件概率 P(w_i |w_<i)
关键区别在于计算范围
预训练阶段:
#所有位置都计算损失
loss_mask =[1, 1, 1, 1, 1, 1, 1, 1] # 全1
total_loss = sum(all_position_losses)
SFT微调阶段:
#只对assistant回复部分计算损失
loss_mask =[0, 0, 0, 1, 1, 1, 1, 0] # 部分为1
total_loss = sum(masked_position_losses)
具体实现对比
- 预训练代码示例:
def pretrain_loss(logits, labels):
"""预训练损失:所有位置都计算"""
# logits:[batch_size, seq_len, vocab_size]
# labels: [batch_size, seq_len]
shift_logits = logits[..., :-1, :].contiguous()
shift_labels = labels[..., 1:].contiguous()
#所有位置都参与损失计算
loss = F.cross_entropy(
shift_logits.view(-1, shift_logits.size(-1)),
shift_labels.view(-1),
ignore_index=-100 # 忽略padding
)
return loss
- SFT微调代码示例:
def sft_loss(logits, labels, loss_mask):
"""SFT损失: 只计算mask=1的位置"""
# logits: [batch_size, seq_len, vocab_size]
# labels: [batch_size, seq_len]
# loss_mask: [batch_size, seq_len]
shift_logits = logits[..., :-1, :].contiguous()
shift_labels = labels[..., 1:].contiguous()
shift_mask = loss_mask[..., 1:].contiguous()
# 将不需要计算损失的位置设为-100
masked_labels = shift_labels.clone()
masked_labels[shift_mask == 0] = -100
loss = F.cross_entropy(
shift_logits.view(-1, shift_logits.size(-1)),
masked_labels.view(-1),
ignore_index=-100
)
return loss
Loss Mask的具体作用
- 预训练序列处理:
输入:[BOS,今天,天气,很,好, EOS]
标签:[今天,天气,很, 好, EOS, PAD]
掩码:[1, 1, 1, 1, 1, 0] #除padding外都计算
- SFT序列处理:
输入:[BOS, <user>,今天,天气,怎么样, </user>, <assistant>,今天, 天气, 很, 好, </assistant>]
标签:[<user>,今天, 天气, 怎么样, </user>, <assistant>,今天, 天气, 很, 好, </assistant>, EoS]
掩码:[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]
为什么这样设计?
- 预训练目标:
- 学习语言知识:理解词汇、语法、常识
- 无差别学习:所有文本内容都是学习目标
- 最大化数据利用:每个token都提供训练信号
- SFT目标:
- 学习指令遵循:如何理解和执行用户指令
- 区分角色:用户输入vs 模型回复
- 避免模仿用户:不学习重复用户的话
2.1 形式化表示
设给定一条数据条目:
- Prompt: x=(x1,x2,⋅⋅⋅,xm)x=(x₁, x₂,⋅⋅⋅,xₘ)x=(x1,x2,⋅⋅⋅,xm)
- Output (要模型生成的目标序列):y=(y1,y2,⋅.⋅,yn)y=(y₁, y₂,⋅.⋅, yₙ)y=(y1,y2,⋅.⋅,yn)
在自回归假设下,对于第 t 个输出 token yty_tyt,模型只能 (或主要)利用所有先前的 token 来预测,即Ct=(x1,x2,...,xm,y1,⋅.⋅,yt−1)C_t=(x₁, x₂,...,xₘ, y₁,⋅.⋅,y_{t-1})Ct=(x1,x2,...,xm,y1,⋅.⋅,yt−1)为可见上下文(context)。那么,语言模型给出的条件分布为Pϕ(yt∣Ct)P_ϕ(y_t |C_t)Pϕ(yt∣Ct),
其中ϕϕϕ表示模型参数。

2.2 SFT 的整体损失函数
对某一训练样本(x, y)而言,自回归语言模型的损失函数为:

解释:
- 对于每个输出 token yty_tyt,都计算logPϕ(yt∣ct)log P_ϕ(y_t | c_t)logPϕ(yt∣ct)并取负和;
- 模型通过因果注意力机制确保 yty_tyt 只能依赖 xi{x_i}xi 及先前 y1,...,yt−1{y₁, ..., y_{t-1}}y1,...,yt−1;
我们也可以用交叉熵 (或者perplexity) 来表述SFT的损失函数:

其中,ccc 代表生成的上文,Yi,cY_{i,c}Yi,c是指示变量,表示取 label token 对应的概率。
在LLM中使用交叉熵损失实际本质上是衡量两个概率分布的之间的差异:
- X: 预测的下一个token在词表中的概率分布
- Y:真实的下一个token的One-Hot编码
1.2 数据集层面
若在微调数据集中有多个样本,

则目标是最小化平均 loss:

通过随机梯度下降迭代更新 ϕϕϕ。
3. 微调流程
3.1 数据准备
- Task data:收集与下游任务相关的 pairs(x, y);
- Tokenization: 与预训练相同的 BPE / SentencePiece 词典,以保持embedding一致;
- Block packing: 将(x, y)拼接成单个序列输入:[x, special_token, y] 或类似方式。
3.2 训练配置
训练工程侧的技术可以参考目3.1.5 大模型训练侧优化,常用DeepSpeed框架。
3.3 推理与调试
训练后可对下游任务验证集做语言生成测试,指定温度/Top-k等超参,并对结果评估(如 BLEU, ROUGE, perplexity)。若结果不理想,需再次调整学习率、batch size或数据配比。
4. SFT与pretrain的区别
首先,sft和pretrain在训练方式上没有任何区别,主要区别在于数据的组成形式上:
- pretrain的每条数据都是满编4K/8K,sft的每条数据原本多长就是多长;[图3.2.1 大模型监督微调思考](图3.2.1 大模型监督微调思考)
- sft会引入pretrain阶段未见过的special_token,来让它们学习全新的语义;
- sft会让模型见到最重要的eos_token,pretrain模型因为没见过该token而无法停止生成;
- 借助special_token,sft会把语料切分成不同的角色,标配的有system、user、assistant,根据业务需求也可以有"背景"、“旁白”、"事件"等等;
- sft的prompt不做loss,但这并不是说它不能做loss。主要原因是prompt的同质化比较严重,不做loss_mask的话,同样的一句话会被翻来覆去的学,但如果你能保证你的每条prompt都是独一无二的,就完全可以省去prompt的loss_mask环节。对了,session数据一定要想清楚是每一个answer都算loss,还是只对最后一轮的answer算loss。
除此之外,训练目的也不一样。pretrain是在背书,纯粹的学习知识;sft则是在做题,学习的是指令follow能力。切勿在sft阶段强行给模型做知识注入,比如训个50W条的code数据,所有的知识注入工作应该采用continue-pretrain的思路进行,否则都会使得模型的通用能力掉点明显(sft做知识注入基本上是100%某个知识,但continue-pretrain做知识注入会控制在10%~20%左右的比例)。
5. 数据多样性
目前的LLM从业人员大多都会认同:sft训练数据的核心是数据多样性和数据质量,数据数量并不重要。
数据质量就不谈了,prompt可以不那么严谨,能看懂就行,但answer是尽量一个标点符号都不要有错误的,该中文引号就中文引号,该单引号就单引号,该把GPT5啰哩啰嗦的回复精简一下就精简。
我们重点说说数据多样性。即使到了今天,也没人能定义清楚说怎样的一份训练数据叫做数据多样性足够好。我们能做的只能是从先验的角度,把模型能遇到的各种任务类型都让它见一次。从个人经验来说,我认为数据多样性主要包含两个维度,“数据用途"和"数据形式”。
先说数据用途,也就是task_type,可以结合这几个思路进行数据收集:
- OpenAI官网列出了ChatGPT擅长的所有任务项,诸如翻译、emoji聊天……之类的。我们就每个任务项都想办法来一点数据,照着尖子生的作业抄;
- LLM毕竟是个语言模型,传统的每个NLP模型它都应该能胜任,那就把什么NER、机器阅读理解、意图识别等传统的NLP任务也给模型补充一点,如果已有类似任务就不补充了。训练数据也很好搞,传统NLP数据集质量都很高,直接拿来用就行;
- 参考业务需求,下游业务需要某个特殊场景的任务,那就让sft阶段提前见一见,这种数据的典型代表就是过年前给模型灌一些对春联、猜灯谜的的数据。只要数据质量没问题,一般都不会破坏模型能力;
- ……
重点来了,每一条sft训练数据必须要task_type类型,千万别搞大杂烩,否则对后续的case分析简直是灾难性的伤害。在实际工作中,双层task_type都很常见,比如"逻辑推理-常识推理","逻辑推理-cot多步骤推理"这种。至于每种task_type的数据量,别搞平均主义:难task_type酒数据多点,简单task_type就数据少点,也要结合自己的base模型能力动态调整。
task_type的划分就是sft数据最重要的基建工作,没有之一。
我们还需要从数据形式的角度来兼顾数据的多样性:
- prompt表达方式多样性,不要千篇一律的"把中文句子A翻译成英文",也要适当有一些"我在英国旅游,我现在需要向路人问路,我想表达A的意思,该怎么说","我是一个英文老师,我需要向我的学生讲解句子A用英文怎么写,请你用最正宗的表达方式帮我完成。"这么做的目的是防止模型只认识prompt中的几个关键token,进而导致训练过拟合或者泛化性变差;
- prompt长度均衡,既要有短数据,也要有长数据,避免模型的attention退化到无法聚焦长prompt。长数据还不能是字面意思的长,要有那种关键信息藏在开头/中间/结尾的各种数据场景,避免模型在训练时偷懒,只对prompt的起始token或结束token有attention;
- answer长度均衡,不能让模型没出输几个token就停止,适当的有一些语料让它学会输出尽量长的answer,否则模型会很难follow"不少于2000字"这种指令;
- 多轮聊天的切换topic能力,也就是说,有的数据当前query是和session有关系的,有的数据则是当前query和session毫无关系,要让模型自己学会判断query是否和session有关。类似的数据还要有system是否生效,有些数据system是个摆设,有些数据的answer则和system直接相关;
- answer分布的多样性,这最重要,千万别总共一万条训练数据,一千条数据的answer都说同一句话,answer可是算loss的,太单一的话会严重让模型过拟合;
- ……
概括起来,所有的数据形式多样性都可以总结为一句话:数据形式不能让模型找到规律,关键信息在prompt中的位置分布要足够随机。目的是避免模型在训练时退化,只聚焦于某些或某些位置的token,而不是聚焦于完整的prompt。模型和人一样,骨子里都是有偷懒倾向的。
更多推荐



所有评论(0)