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
数据预处理流程

完整处理步骤:

  1. 格式转换:instruction +input +output → chat template
  2. 分词编码: text → token IDs
  3. 序列构造:生成X(输入)和Y(目标)序列
  4. 掩码生成:创建loss_mask标识有效位置
  5. 批处理:组装成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,.,yt1)为可见上下文(context)。那么,语言模型给出的条件分布为Pϕ(yt∣Ct)P_ϕ(y_t |C_t)Pϕ(ytCt)
其中ϕϕϕ表示模型参数。
在这里插入图片描述

2.2 SFT 的整体损失函数

对某一训练样本(x, y)而言,自回归语言模型的损失函数为:
在这里插入图片描述
解释:

  1. 对于每个输出 token yty_tyt,都计算logPϕ(yt∣ct)log P_ϕ(y_t | c_t)logPϕ(ytct)并取负和;
  2. 模型通过因果注意力机制确保 yty_tyt 只能依赖 xi{x_i}xi 及先前 y1,...,yt−1{y₁, ..., y_{t-1}}y1,...,yt1

我们也可以用交叉熵 (或者perplexity) 来表述SFT的损失函数:
在这里插入图片描述
其中,ccc 代表生成的上文,Yi,cY_{i,c}Yi,c是指示变量,表示取 label token 对应的概率。

在LLM中使用交叉熵损失实际本质上是衡量两个概率分布的之间的差异:

  • X: 预测的下一个token在词表中的概率分布
  • Y:真实的下一个token的One-Hot编码
1.2 数据集层面

若在微调数据集中有多个样本,
在这里插入图片描述
则目标是最小化平均 loss:
在这里插入图片描述
通过随机梯度下降迭代更新 ϕϕϕ

3. 微调流程

3.1 数据准备

  1. Task data:收集与下游任务相关的 pairs(x, y);
  2. Tokenization: 与预训练相同的 BPE / SentencePiece 词典,以保持embedding一致;
  3. 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在训练方式上没有任何区别,主要区别在于数据的组成形式上:

  1. pretrain的每条数据都是满编4K/8K,sft的每条数据原本多长就是多长;[图3.2.1 大模型监督微调思考](图3.2.1 大模型监督微调思考)
  2. sft会引入pretrain阶段未见过的special_token,来让它们学习全新的语义;
  3. sft会让模型见到最重要的eos_token,pretrain模型因为没见过该token而无法停止生成;
  4. 借助special_token,sft会把语料切分成不同的角色,标配的有system、user、assistant,根据业务需求也可以有"背景"、“旁白”、"事件"等等;
  5. 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,可以结合这几个思路进行数据收集:

  1. OpenAI官网列出了ChatGPT擅长的所有任务项,诸如翻译、emoji聊天……之类的。我们就每个任务项都想办法来一点数据,照着尖子生的作业抄;
  2. LLM毕竟是个语言模型,传统的每个NLP模型它都应该能胜任,那就把什么NER、机器阅读理解、意图识别等传统的NLP任务也给模型补充一点,如果已有类似任务就不补充了。训练数据也很好搞,传统NLP数据集质量都很高,直接拿来用就行;
  3. 参考业务需求,下游业务需要某个特殊场景的任务,那就让sft阶段提前见一见,这种数据的典型代表就是过年前给模型灌一些对春联、猜灯谜的的数据。只要数据质量没问题,一般都不会破坏模型能力;
  4. ……

重点来了,每一条sft训练数据必须要task_type类型,千万别搞大杂烩,否则对后续的case分析简直是灾难性的伤害。在实际工作中,双层task_type都很常见,比如"逻辑推理-常识推理","逻辑推理-cot多步骤推理"这种。至于每种task_type的数据量,别搞平均主义:难task_type酒数据多点,简单task_type就数据少点,也要结合自己的base模型能力动态调整。

task_type的划分就是sft数据最重要的基建工作,没有之一。

我们还需要从数据形式的角度来兼顾数据的多样性:

  1. prompt表达方式多样性,不要千篇一律的"把中文句子A翻译成英文",也要适当有一些"我在英国旅游,我现在需要向路人问路,我想表达A的意思,该怎么说","我是一个英文老师,我需要向我的学生讲解句子A用英文怎么写,请你用最正宗的表达方式帮我完成。"这么做的目的是防止模型只认识prompt中的几个关键token,进而导致训练过拟合或者泛化性变差;
  2. prompt长度均衡,既要有短数据,也要有长数据,避免模型的attention退化到无法聚焦长prompt。长数据还不能是字面意思的长,要有那种关键信息藏在开头/中间/结尾的各种数据场景,避免模型在训练时偷懒,只对prompt的起始token或结束token有attention;
  3. answer长度均衡,不能让模型没出输几个token就停止,适当的有一些语料让它学会输出尽量长的answer,否则模型会很难follow"不少于2000字"这种指令;
  4. 多轮聊天的切换topic能力,也就是说,有的数据当前query是和session有关系的,有的数据则是当前query和session毫无关系,要让模型自己学会判断query是否和session有关。类似的数据还要有system是否生效,有些数据system是个摆设,有些数据的answer则和system直接相关;
  5. answer分布的多样性,这最重要,千万别总共一万条训练数据,一千条数据的answer都说同一句话,answer可是算loss的,太单一的话会严重让模型过拟合;
  6. ……

概括起来,所有的数据形式多样性都可以总结为一句话:数据形式不能让模型找到规律,关键信息在prompt中的位置分布要足够随机。目的是避免模型在训练时退化,只聚焦于某些或某些位置的token,而不是聚焦于完整的prompt。模型和人一样,骨子里都是有偷懒倾向的。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐