预训练与SFT:从文字接龙到对话能力

大模型的训练其实分两步:第一步是"文字接龙"(预训练),第二步是"学会说话"(SFT)。这两步看似简单,但从一个只会续写文本的模型,到一个能正经回答问题的助手,中间的转变比你想象的更有意思。

这篇文章记录BuddyGPT从预训练到SFT的完整过程,包括数据选择、训练配置、踩过的坑,以及一些个人思考。

预训练:教模型"读书"

什么是Next Token Prediction?

预训练的目标极其简单:给定前面的文字,预测下一个token


输入:  "今天天气"
目标:  "真"

输入:  "今天天气真"
目标:  "好"

就是这么朴素。没有问答、没有对话、没有任何"智能"的味道。模型就像一个疯狂读书的学生,读完整个互联网,学会了"什么文字通常跟在什么文字后面"。

但神奇的是,当这个"文字接龙"玩到极致——几十亿参数、几万亿token——模型就突然"涌现"出了推理能力、常识理解、甚至数学能力。这大概是深度学习最让人惊叹的地方。

数据:35B Token的中文语料

BuddyGPT的预训练数据构成:


数据来源                      规模           语言
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Ultra-FineWeb              1T + 120B zh    英文+中文
Firefly                    4.7B            中文
Chinese-Instruct           100B            中文
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
合计约 35B Token(实际使用量)

为什么选这些数据?几个考虑:

  1. Ultra-FineWeb 是目前质量最好的开源预训练语料之一,经过严格的质量过滤
  2. Firefly 提供了高质量的中文文本
  3. Chinese-Instruct 覆盖了大量中文场景

35B Token在大模型界不算多(LLaMA用了2T),但对于0.7B的小模型来说,这个数据量已经足够模型收敛了。

Tokenizer:借用Qwen的词表

Tokenizer的选择是一个容易被忽视但非常关键的决策。BuddyGPT直接使用了Qwen的Tokenizer:


from transformers import AutoTokenizer

# Qwen的tokenizer:151669个token的词表
# 对中文特别友好:一个中文词通常只需要1-2个token
# 对比GPT-2的tokenizer:一个中文字可能要3-4个token
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B")

# 示例:
# "你好世界" → Qwen: [4个token]
# "你好世界" → GPT2: [8-12个token]

为什么不自己训练Tokenizer?因为训练一个好的Tokenizer需要大量的语料统计,而Qwen已经在海量中文数据上训练过了,它的词表对中文非常友好。站在巨人的肩膀上,没什么不好意思的。

训练配置


# ptrain.yaml - accelerate分布式训练配置
mixed_precision: bf16              # 用bf16半精度,省显存、加速度
gradient_accumulation_steps: 20    # 梯度累积20步,等效增大batch_size

# pretrain.py 核心训练循环(简化版)

# 训练参数
seq_len = 1024          # 每条样本1024个token
batch_size_per_gpu = 1  # 每个GPU一条样本(显存有限)
grad_accum = 20         # 梯度累积20步
# 等效batch_size = 1 × 20 × n_gpus
# 每步处理的token数 = 1024 × 20 × n_gpus ≈ 64k tokens

# 学习率调度:cosine scheduler
# 从peak逐渐降低到接近0,像cos曲线一样平滑下降
scheduler = get_cosine_schedule_with_warmup(
    optimizer,
    num_warmup_steps=1000,      # 前1000步warmup:学习率从0线性增长
    num_training_steps=total_steps
)

# 训练循环
for batch in dataloader:
    # 前向传播:给定前n-1个token,预测第2到第n个token
    outputs = model(input_ids=batch["input_ids"], 
                    labels=batch["labels"])
    loss = outputs.loss
    
    # 反向传播 + 梯度累积
    loss.backward()
    
    if step % grad_accum == 0:
        optimizer.step()       # 更新参数
        scheduler.step()       # 更新学习率
        optimizer.zero_grad()  # 清空梯度

Loss下降:模型在学东西吗?

判断预训练是否正常,最重要的指标就是loss曲线。BuddyGPT的loss变化大致是:


Loss
4.0 ┤
    │ ╲
3.5 ┤  ╲ ← 3.5766(0.1B tokens处)
    │   ╲
3.0 ┤    ╲╲
    │      ╲╲
2.5 ┤        ╲╲╲
    │           ╲╲╲
2.0 ┤              ╲╲╲╲╲╲╲──────── ← 逐渐收敛
    ├──────────────────────────────
    0      5B     10B    20B    35B  Tokens

如果loss一直不降、剧烈震荡、或者突然飙升,说明训练出了问题(学习率太大、数据有问题、梯度爆炸等)。BuddyGPT的loss整体平稳下降,说明模型确实在从数据中学到东西。

我们在WandB上监控整个训练过程——loss曲线、学习率变化、梯度范数,这些都是判断训练是否健康的关键信号。

SFT:从"文字接龙"到"对话"

预训练模型的尴尬

预训练结束后,你得到了一个很"聪明"但很"没礼貌"的模型:


你:请介绍一下Python
模型:是一种编程语言。Python的创始人是Guido van Rossum。
     Python的名字来自于Monty Python。Python是一种解释型语言。
     Python是一种面向对象的语言...
     (像百科全书一样平铺直叙,停不下来)

它会续写文本,但不会"对话"。它不知道什么时候该停,不知道用户想要什么格式的回答,也不知道什么是"有帮助"的回复。

ChatML格式:教模型理解角色

SFT的核心是用对话格式的数据来微调模型。BuddyGPT使用ChatML格式:


# SFT数据格式:ChatML
messages = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮你的?"}
]

# apply_chat_template会把它转成这样的文本:
# <|im_start|>user
# 你好<|im_end|>
# <|im_start|>assistant
# 你好!有什么可以帮你的?<|im_end|>

text = tokenizer.apply_chat_template(messages, tokenize=False)

# 关键点:训练时只计算assistant回复部分的loss
# user部分作为输入,不参与loss计算
# 这样模型学的是"如何回复",而不是"如何提问"

<|im_start|><|im_end|>是特殊token,告诉模型"这里开始/结束了一个角色的发言"。这些特殊token是Qwen Tokenizer自带的,这也是我们选择Qwen词表的好处之一。

SFT数据集


数据集                规模        特点
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Chinese-Instruct-Lite 精选        高质量中文指令
Belle 2M              200万条     中文通用对话
MOSS                  大规模      多样化中文对话
ShareGPT              社区贡献    真实用户对话
Alpaca                5万条       经典指令数据
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
合计约 400万条指令数据

RoPE内插:把上下文窗口从1024拉到4096

预训练时我们用的是seq_len=1024,但对话场景经常需要更长的上下文。直接推理超过1024的文本,模型会"困惑"——因为它从没见过1024之后的位置编码。

解决方案是RoPE内插(interpolation)


# RoPE内插的直觉:
# 原来:位置0, 1, 2, ..., 1023 对应频率f(0), f(1), ..., f(1023)
# 内插后:位置0, 1, 2, ..., 4095 被压缩映射到原来的频率范围
# 等于把"尺子的刻度变密"了,原来的1024格现在要装4096个位置

# 在模型配置中设置:
config.rope_scaling = {
    "type": "linear",      # 线性内插
    "factor": 4.0           # 4倍扩展:1024 → 4096
}

这个技巧非常实用:不需要重新预训练,只在SFT阶段做一次内插,就能显著扩展上下文长度。

个人思考

预训练和SFT的关系,我觉得可以这样理解:

预训练像是上学——系统地学习世界知识、语言规律、逻辑关系。学的东西很多但不太"实用"。

SFT像是入职培训——学会在特定场景下如何应用知识,用什么格式、什么语气、什么方式来回答问题。

两者缺一不可。没有预训练的SFT就像给文盲做职业培训——格式学会了但内容空洞。没有SFT的预训练就像一个满腹经纶的学者不会说人话——知识有了但不知道怎么输出。

BuddyGPT的35B Token预训练 + 400万条SFT,规模虽然不大,但完整地走了一遍"从语料到对话"的全流程。过程中最大的感受是:数据质量比数据量更重要。一条高质量的对话数据,抵得过十条噪声数据。

参考资料