大模型炼成记:Token、预训练与 RLHF 对齐
大模型炼成记:Token、预训练与 RLHF 对齐
一个能流畅聊天、写代码、做推理的大模型,是怎么从"一堆参数"变成今天的样子的?很多介绍只会说一句"先预训练再微调",但真正决定模型能力和体验的,恰恰藏在这条流水线的每一个具体环节里:文字是怎么被切碎喂给模型的、"预测下一个词"这个朴素任务为什么能催生出智能、ChatGPT 是怎么从一个只会接话茬的模型变得"听话"的、同一个问题为什么每次回答都不一样、以及为什么大家都在拼命堆参数和数据。本文按照训练流水线的真实顺序,逐一拆解这些技术细节。
🔤 Token:大模型眼中的世界
为什么不直接处理字符
人类阅读文字是一个个汉字、一个个字母地看,但大模型内部既不处理"字符",也不直接处理"单词",而是处理一种介于两者之间的单位——Token(词元)。原因很直接:
Token 化就是在这两个极端之间找一个平衡点:用一个规模适中(几万到几十万)的词表,把文本切成"子词"(subword)单元,常见词整体是一个 token,生僻词或新词被拆成几个小片段的组合。
BPE:从字节对开始"贪心合并"
目前主流大模型(GPT 系列、LLaMA 等)大多使用 BPE(Byte Pair Encoding,字节对编码) 或其变体(如 GPT-4 用的 BPE 改进版、SentencePiece 里的 Unigram)来构建词表。BPE 的训练过程可以理解为一种"贪心合并"算法:
用一个极简的伪代码可以说明这个思路:
\`\`\`text
语料(已按字符切分):
l o w l o w l o w e s t n e w e r w i d e r
第 1 轮:统计相邻字符对频率,(l, o) 和 (o, w) 出现最多 → 合并 "lo" 和 "ow"
第 2 轮:在新序列上再统计一次,"low" 整体出现频率变高 → 合并成 "low"
第 3 轮:继续合并 "er"、"est" 等高频片段
...
最终词表里既有完整单词 "low",也有常见后缀 "est"、"er",
还保留单个字符作为兜底,保证任何字符串都能被切分。
\`\`\`
这样训练出来的分词器有一个很实用的特性:常见词是一个完整 token,生僻词会被拆成几个子词的拼接,比如 "unhappiness" 可能被拆成 "un" + "happi" + "ness",模型依然能通过学过的词根组合理解和生成这个词,而不会因为"没见过整词"就束手无策。
中英文 token 切分的差异
中文和英文在 token 化上的表现差别很大,这也是很多人感觉"中文输入更贵"的直接原因:
这也解释了一个常见现象:中文用户写同样意思的一句话,往往会比英文用户"更快用完"上下文窗口和 API 额度。
为什么 API 按 token 计费
大模型的两项核心成本——显存占用和计算量——都直接和序列的 token 数挂钩:Transformer 的自注意力计算复杂度与序列长度的平方成正比,输入越长的 token 序列,每一层的计算和显存开销越大;同时 API 厂商的算力成本本质上是"处理了多少个 token、生成了多少个 token",而不是"处理了多少个字"或"多少次请求"。因此按 token 计费(通常输入和输出 token 分开计价,输出更贵,因为生成需要逐 token 自回归解码,成本更高)是最贴近真实算力消耗的计费方式,也是为什么理解 token 化对成本控制很重要——精简 prompt、减少不必要的重复内容,本质上都是在减少 token 消耗。
🌐 预训练:用整个互联网玩文字接龙
"预测下一个词"这一个任务
预训练阶段模型要做的事情说起来极其朴素:给定前面一段文字,预测下一个 token 是什么。这个任务在学术上叫自回归语言建模(Autoregressive Language Modeling),本质上是一种自监督学习——不需要人工标注任何"正确答案",因为语料本身就是答案:一句话的第 N+1 个词,就是训练时的标签。
具体训练过程可以简化理解为:
为什么"文字接龙"能学出语言、常识、甚至推理
这是最反直觉但也是预训练最迷人的地方:只靠"猜下一个词"这么简单的目标,为什么模型能学会语法、常识、甚至一定程度的推理?
原因在于,要把"下一个词"预测得准,模型被迫在内部隐式地建立起远超"词语接龙"的知识结构:
换句话说,"预测下一个词"只是一个训练信号(loss 的来源),但要把这个信号优化到极致,模型不得不把语言背后的结构性规律都压缩进自己的参数里。这也是为什么预训练语料的多样性和质量至关重要:网页文本教会常识和语言习惯,书籍教会长程逻辑和叙事结构,代码教会精确的形式化推理,学术论文教会专业知识。
预训练的规模量级
预训练之所以被称为"大力出奇迹"的工程,是因为它同时在三个维度上堆到极限:
这三者不是孤立的,业界发现它们之间存在可以量化的相互制约关系,这一点会在本文最后一节详细展开。
🎓 从 GPT 到 ChatGPT:SFT 与 RLHF 对齐
预训练模型只会"文字接龙"
一个刚做完预训练的模型(业内常称为 base model 或 pretrained model),本质上只学会了一件事:把一段文本自然地续写下去。如果你问它"如何做番茄炒蛋?",它并不理解这是一个需要被"回答"的问题——它更可能把这句话当成一篇作文的标题,接着续写出"这是一道简单又好吃的家常菜,很多人小时候就学过……"这样的文本,而不是直接给出步骤。它甚至可能续写出更多类似的问题,因为在预训练语料里,问题后面经常跟着更多问题(比如 FAQ 列表)。
这就是预训练模型和"能对话的助手"之间的核心差距:预训练模型学到的是"语言的统计规律",但不知道当前场景下用户期待的是什么样的回应格式和态度。要跨越这个差距,需要两个关键步骤:SFT 和 RLHF。
第一步:监督微调 SFT
SFT(Supervised Fine-Tuning,监督微调)的做法是:找人工标注员(或者用更强模型辅助生成后再人工审核)写出大量"问题 - 高质量回答"的示例对,用这些数据继续训练预训练模型,让它学会"看到一个问题(指令),就应该输出一个直接、有帮助的回答"这种格式和行为模式,而不是继续做单纯的文字接龙。
SFT 数据的典型形式是"指令 + 回答",比如:
\`\`\`text
指令:解释一下什么是光合作用,给小学生听。
回答:光合作用就像植物在做饭。植物的叶子里有一种绿色的小工厂,
它们用阳光当"火",用空气里的二氧化碳和从根部吸来的水当"食材",
做出植物自己需要的"食物"(也就是糖),同时还会呼出我们需要的氧气……
\`\`\`
经过 SFT 之后,模型已经初步具备了"听得懂指令、给出对应格式回答"的能力,这也是为什么很多早期开源模型只做完 SFT 就可以拿来对话使用。但 SFT 有一个局限:它只能教会模型"模仿标注员写过的那些回答",无法处理"同一个问题有好答案和坏答案之分,但标注员没写全"的情况,也很难精细地引导模型在语气、安全性、有用性之间做权衡。这就需要下一步 RLHF。
第二步:RLHF——用人类偏好继续调教模型
RLHF(Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)是让模型更"像人"、更懂得权衡的关键一步,完整流程通常分为两个阶段:
阶段一:训练奖励模型(Reward Model)
阶段二:用强化学习(通常是 PPO)优化策略模型
用一句话概括这个循环:奖励模型学会了"人类更喜欢什么",PPO 则让语言模型学会"如何生成人类更喜欢的内容"。这一步之所以关键,是因为人类的偏好(比如"更礼貌一点""不要啰嗦""拒绝危险请求但语气要委婉")很难写成规则或直接的监督样本,但很容易通过"两个回答哪个更好"这种相对比较来表达,而 RLHF 恰恰是把这种"相对偏好"转化成可优化目标的工程方法。经过 RLHF 之后的模型,通常会在有用性、安全性、语气自然度上有明显提升,这也是 ChatGPT 相比早期只做过 SFT 的模型体验更好的核心原因之一。
需要补充的是,近年也出现了 DPO(Direct Preference Optimization)等更简化的对齐方法,跳过了显式训练奖励模型和强化学习采样的过程,直接用偏好数据优化策略模型,工程复杂度更低,但核心思想仍然是"利用人类偏好数据引导模型对齐"。
🎲 温度与采样:AI 为什么每次回答不一样
模型输出的本质是一个概率分布
很多人以为大模型"计算"出一个确定的答案,但实际上,模型在每一步生成时,输出的是词表中每一个 token 成为下一个词的概率。比如给定"今天天气真",模型可能输出:"好"的概率 45%,"不错"的概率 20%,"糟糕"的概率 8%,其余概率分散在词表里的其他几万个 token 上。模型本身并不会自动选择概率最高的那个词作为最终答案——具体怎么从这个概率分布里"抽"出一个词,是由解码策略(decoding strategy)决定的,这也是为什么同一个问题问两次,模型给出的措辞常常不完全一样。
贪心解码 vs 随机采样
Temperature:控制概率分布的"平滑度"
Temperature(温度)是采样前对概率分布做的一步再加工,公式上是把模型输出的原始分数(logits)除以温度值 T,再做 softmax 归一化:
Top-k 与 Top-p(nucleus sampling)
单纯调温度还不够,因为即使压平了分布,词表尾部那些几乎不可能合理的 token 依然有极小的非零概率被抽到,可能导致偶尔输出完全不合逻辑的词。因此实践中通常会先用一种"截断策略"缩小候选范围,再做采样:
一段简化的采样伪代码可以直观展示这个流程:
\`\`\`python
def sample_next_token(logits, temperature=0.7, top_p=0.9):
# 1. 用温度调整分布的平滑度
scaled_logits = logits / temperature
probs = softmax(scaled_logits)
# 2. 按概率从高到低排序,累加直到超过 top_p 阈值,构成候选集
sorted_probs, sorted_idx = sort_descending(probs)
cumulative = cumsum(sorted_probs)
cutoff = first_index_where(cumulative > top_p)
candidate_idx = sorted_idx[: cutoff + 1]
# 3. 在候选集内重新归一化后随机抽取一个 token
candidate_probs = normalize(probs[candidate_idx])
next_token = random_choice(candidate_idx, weights=candidate_probs)
return next_token
\`\`\`
实际使用中,temperature 和 top-p/top-k 经常组合使用:先用 top-p 圈定一个"合理候选池",再用 temperature 微调池内的抽样倾向性,这也是为什么很多 API 会同时暴露 \`temperature\` 和 \`top_p\` 两个参数给开发者调节。
📈 Scaling Laws 与涌现:大力出奇迹
Scaling Laws:性能和规模的幂律关系
一个足够朴素但影响深远的发现是:大模型的最终性能(用预训练阶段的损失值衡量)与三个规模变量——参数量、数据量、算力——之间存在相当稳定、可预测的幂律关系(power law)。简单说就是:在其他条件不变的情况下,把参数量或数据量扩大若干倍,模型的损失会按照一条可以拟合出来的曲线持续下降,而且这条曲线在很大的规模范围内都成立,没有明显的"提前失效"。
这个发现的意义在于,它把"要不要训一个更大的模型"从一件靠直觉赌一把的事情,变成了一件可以提前用小规模实验外推、预估收益的工程决策——研究者可以先在较小规模上训练一系列模型,观察损失随规模变化的曲线,再推算出"如果把规模扩大到 N 倍,损失大概会降到多少",从而决定资源该往参数、数据还是算力哪个方向倾斜。
Chinchilla:不是参数越大越好,是要按比例来
早期业界的普遍做法是拼命堆参数量,但相对而言训练数据量没有等比例跟上。DeepMind 在 Chinchilla 相关研究中提出了一个重要修正:在给定的算力预算下,很多此前发布的大模型其实是"参数过多、数据量不够"的次优组合——如果把同样的算力预算,改成用一个更小的参数规模但配上多得多的训练数据来训练,反而能达到更低的损失、更好的效果。这一发现推动了后续大模型训练策略的转变:不再单纯追求参数量的"军备竞赛",而是更重视参数量和数据量按照最优比例同步扩大,用有限算力换取更高的性价比。这也是为什么此后很多新发布的模型即便参数量不再一味增大,效果依然能持续提升——因为训练数据量和数据质量在同步跟上。
涌现能力:规模跨过某个阈值后"突然会了"
比 Scaling Laws 更有意思、也更难解释的现象是涌现能力(Emergent Abilities):有一些能力(比如多步算术、复杂的链式推理、遵循复杂指令、某些小样本任务)在模型规模比较小的时候几乎完全不具备,表现和随机瞎猜差不多;但当参数量、算力跨过某个规模阈值之后,这些能力会在相对很窄的规模区间内迅速跳变,从"几乎不会"变成"明显掌握",而不是像整体损失曲线那样平滑连续地提升。
这和整体的 Scaling Laws 曲线(预训练 loss 的下降是平滑的)形成了有意思的对比:宏观的语言建模能力是连续改善的,但很多具体的、离散的下游任务能力却表现出"量变到质变"的跳跃特征。对这一现象的解释在学术界仍有讨论——一种观点认为这是评测指标本身的离散性造成的错觉(如果换成连续的评分方式,很多"涌现"曲线其实也是平滑的),另一种观点则认为确实存在某些任务需要模型同时具备多个子能力才能完成,而这些子能力各自独立地在不同规模跨过门槛,叠加起来就表现为看似突然的跃升。无论哪种解释更准确,涌现现象都提醒我们:仅凭小规模模型的表现,很难可靠地预测大规模模型会不会具备某些复杂能力,这也是大模型研发中"规模是否值得投入"这一判断天然带有不确定性的原因之一。
🧬 Tokenizer 深入:四大分词算法怎么选
前面用 BPE 打了个样,但工业界其实有四套主流的子词分词方案,它们的合并/切分逻辑、对未登录词的处理、以及对多语言的友好程度差别很大。搞清楚它们的区别,才能理解为什么 GPT 用 BPE、BERT 用 WordPiece、而 T5/LLaMA 又偏爱 SentencePiece。
为什么要有这么多种分词器
核心矛盾始终是那句老话:词表太大浪费显存和计算、词表太小序列变长、切分不合理又会让语义碎裂。不同算法只是在"如何决定把哪些片段合并成一个 token"这件事上给出了不同的答案:BPE 靠频率贪心合并,WordPiece 靠似然增益合并,Unigram 靠概率化地"删减"一个超大词表,SentencePiece 则是把前面几种算法直接架在原始字节流上、连空格都当普通字符处理,从而做到真正的语言无关。
四大分词算法横向对比
| 维度 | BPE | WordPiece | Unigram | SentencePiece |
| --- | --- | --- | --- | --- |
| 核心思路 | 频率最高的相邻对贪心合并 | 使似然增益最大的对合并 | 从大词表迭代删除低贡献 token | 框架,可承载 BPE 或 Unigram |
| 训练方向 | 自底向上合并 | 自底向上合并 | 自顶向下剪枝 | 取决于所选算法 |
| 是否需要预分词 | 通常需要按空格预切 | 需要,且用双井号标续接 | 需要 | 不需要,直接吃原始字符流 |
| 空格处理 | 依赖预分词 | 依赖预分词 | 依赖预分词 | 空格编码为特殊符号下划线 |
| 未登录词 | 拆成更小子词兜底 | 拆成子词,前缀标续接 | 概率最优切分兜底 | 字节级兜底,理论零未登录 |
| 多语言友好度 | 中等 | 中等 | 较好 | 最好 |
| 典型使用者 | GPT 系列、RoBERTa | BERT、DistilBERT | ALBERT、部分 T5 | LLaMA、T5、多数多语言模型 |
| 切分是否唯一 | 确定性合并顺序 | 确定性最长匹配 | 可概率采样多种切分 | 取决于算法 |
一个容易被忽略的点是 Unigram 支持子词正则化(subword regularization):训练时可以对同一个词随机采样不同的切分方式,相当于给模型做数据增强,这在低资源语言上往往能带来额外收益。BPE 的切分则是确定性的,同样的输入永远切成同样的 token 序列。
动手训练一个 BPE 分词器
下面用 HuggingFace 的 \`tokenizers\` 库训练一个可运行的 BPE 分词器,覆盖归一化、预分词、训练、特殊 token、保存和加载全流程:
\`\`\`python
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, normalizers, decoders
from tokenizers.normalizers import NFKC, Lowercase, Sequence
from tokenizers.pre_tokenizers import ByteLevel
# 1. 用 BPE 模型初始化一个空的分词器,unk_token 是兜底的未登录词标记
tokenizer = Tokenizer(models.BPE(unk_token="[UNK]"))
# 2. 归一化:Unicode 规范化 + 小写化(是否小写取决于任务,代码任务通常不小写)
tokenizer.normalizer = Sequence([NFKC(), Lowercase()])
# 3. 预分词:ByteLevel 会把文本先转成字节,保证任何字符都能被表示,永不 OOV
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=True)
tokenizer.decoder = decoders.ByteLevel()
# 4. 配置训练器:目标词表大小、最小出现频率、以及必须保留的特殊 token
trainer = trainers.BpeTrainer(
vocab_size=32000,
min_frequency=2,
special_tokens=["[UNK]", "[PAD]", "[BOS]", "[EOS]", "[MASK]"],
initial_alphabet=ByteLevel.alphabet(), # 保证 256 个字节都在初始词表里
show_progress=True,
)
# 5. 在语料文件上训练(corpus.txt 每行一段文本即可)
files = ["corpus.txt"]
tokenizer.train(files, trainer)
# 6. 保存为单文件,方便部署和分发
tokenizer.save("my_bpe_tokenizer.json")
# 7. 加载并测试切分效果
tokenizer = Tokenizer.from_file("my_bpe_tokenizer.json")
output = tokenizer.encode("Tokenization is the very first step of an LLM pipeline.")
print("tokens :", output.tokens)
print("ids :", output.ids)
print("回原文 :", tokenizer.decode(output.ids))
print("词表大小:", tokenizer.get_vocab_size())
\`\`\`
运行后你会看到常见词被切成完整 token,生僻词被切成子词碎片。想训练 WordPiece 只需把 \`models.BPE\` 换成 \`models.WordPiece\`、训练器换成 \`trainers.WordPieceTrainer\`;想要 Unigram 则用 \`models.Unigram\` 配 \`trainers.UnigramTrainer\`。而 SentencePiece 通常用 Google 官方的 \`sentencepiece\` 库单独训练:
\`\`\`python
import sentencepiece as spm
# character_coverage 对中日韩这类大字符集语言建议设 0.9995,英文可设 1.0
spm.SentencePieceTrainer.train(
input="corpus.txt",
model_prefix="spm_unigram",
vocab_size=32000,
model_type="unigram", # 也可以设为 "bpe"
character_coverage=0.9995,
pad_id=0, unk_id=1, bos_id=2, eos_id=3,
)
sp = spm.SentencePieceProcessor(model_file="spm_unigram.model")
print(sp.encode("大模型的分词器训练", out_type=str))
\`\`\`
Tokenizer 常见坑
🧹 预训练数据工程:清洗、去重、配比与课程学习
有一句在业界流传很广的话:"垃圾进,垃圾出"。模型能力的上限很大程度上是被训练数据的质量决定的,而不是模型结构。一条工业级的预训练数据管线,往往比模型代码本身还复杂。
数据处理的典型流水线
一条完整的预训练数据管线通常包含这些串行阶段:
质量过滤的启发式规则代码
下面是一段可运行的启发式质量过滤示例,实现了几条业界常用的规则:
\`\`\`python
import re
def is_high_quality(text: str) -> bool:
"""基于启发式规则判断一段文本是否值得保留,返回 True 表示保留。"""
words = text.split()
n_words = len(words)
# 规则 1:长度过滤,太短的片段信息量低,太长的可能是爬虫拼接
if n_words < 50 or n_words > 100000:
return False
# 规则 2:平均词长异常(过长往往是乱码或 base64,过短往往是符号堆砌)
mean_word_len = sum(len(w) for w in words) / max(n_words, 1)
if mean_word_len < 3 or mean_word_len > 10:
return False
# 规则 3:符号占比过高(省略号、井号堆砌通常是低质页面)
symbol_ratio = len(re.findall(r"[#.]", text)) / max(len(text), 1)
if symbol_ratio > 0.1:
return False
# 规则 4:重复行过多(导航栏、模板页常常整行重复)
lines = [l for l in text.split("\n") if l.strip()]
if lines:
unique_ratio = len(set(lines)) / len(lines)
if unique_ratio < 0.5:
return False
# 规则 5:必须包含一定比例的常见停用词,纯关键词堆砌的 SEO 垃圾页会被滤掉
stop_words = {"the", "be", "to", "of", "and", "a", "in", "that", "have", "it"}
stop_hits = sum(1 for w in words if w.lower() in stop_words)
if n_words > 0 and stop_hits / n_words < 0.02:
return False
return True
\`\`\`
去重:MinHash + LSH 近似去重
精确去重只能去掉一模一样的文档,但网页世界里更多的是"改了几个字的近似重复"。工业界普遍用 MinHash + LSH(局部敏感哈希) 做近似去重:
\`\`\`python
from datasketch import MinHash, MinHashLSH
def build_minhash(text: str, num_perm: int = 128) -> MinHash:
m = MinHash(num_perm=num_perm)
# 用 5-gram 词组作为特征,比单词粒度更能捕捉局部相似
words = text.split()
for i in range(len(words) - 4):
shingle = " ".join(words[i:i + 5]).encode("utf-8")
m.update(shingle)
return m
def dedup(documents, threshold: float = 0.8):
"""基于 Jaccard 相似度阈值做近似去重,保留每个相似簇里的第一篇。"""
lsh = MinHashLSH(threshold=threshold, num_perm=128)
kept = []
for idx, doc in enumerate(documents):
mh = build_minhash(doc)
# 查询是否已有足够相似的文档
if not lsh.query(mh):
lsh.insert(f"doc-{idx}", mh)
kept.append(doc)
return kept
corpus = ["the cat sat on the mat", "the cat sat on the mat!!", "quantum physics is hard"]
print(f"去重前 {len(corpus)} 篇,去重后 {len(dedup(corpus))} 篇")
\`\`\`
去重的收益经常被低估:大规模重复内容不仅浪费算力,还会让模型对重复片段过拟合、加剧记忆泄露,甚至恶化下游任务表现。多篇研究都发现,充分去重后的较小数据集,效果反而优于未去重的更大数据集。
数据配比:不同来源的黄金比例
预训练数据不是随便混在一起,各来源的配比直接影响模型的能力画像。下面是一个偏工程经验、非官方的示意配比表(不同模型差异很大,仅作理解用):
| 数据来源 | 典型占比 | 主要贡献的能力 | 备注 |
| --- | --- | --- | --- |
| 网页文本 | 40 到 60 percent | 通用语言、常识、时事 | 需重度清洗去重 |
| 代码 | 10 到 20 percent | 形式化推理、结构化输出 | 提升非代码任务推理 |
| 书籍 | 5 到 15 percent | 长程连贯、叙事、深度知识 | 版权需谨慎 |
| 学术论文 | 5 到 10 percent | 专业知识、严谨表达 | 提升专业问答 |
| 百科问答 | 3 到 8 percent | 事实密度、结构化知识 | 质量高、噪声低 |
| 多语言 | 视目标而定 | 跨语言能力 | 低资源语言可上采样 |
一个反直觉但被反复验证的经验是:在预训练里加入相当比例的代码,即使是纯自然语言任务,推理能力也会提升。一种解释是代码强迫模型学习严格的长程依赖和形式化逻辑,这种能力会迁移到自然语言推理上。
课程学习:从易到难安排数据
课程学习(Curriculum Learning)借鉴了人类"先学简单后学难"的思路:训练早期喂相对简单、干净、高质量的数据,让模型先打好语言基础,后期再逐步引入更长、更难、更专业的数据。一种常见的实现是按数据质量分档、按训练进度切换配比:
\`\`\`python
def get_data_mixture(step: int, total_steps: int) -> dict:
"""根据训练进度返回不同数据源的采样权重。"""
progress = step / total_steps
if progress < 0.3:
# 早期:以高质量、易学的百科和书籍为主
return {"wiki": 0.4, "books": 0.3, "web": 0.2, "code": 0.1}
elif progress < 0.7:
# 中期:均衡混合
return {"wiki": 0.2, "books": 0.2, "web": 0.4, "code": 0.2}
else:
# 后期:加大代码和长文档比例,强化推理和长程能力
return {"wiki": 0.1, "books": 0.2, "web": 0.4, "code": 0.3}
\`\`\`
课程学习并非总有效,效果高度依赖任务和数据,但"训练末期用一小批极高质量数据做退火(annealing)"这个做法在近年的开源模型里越来越常见。
⚙️ Transformer 训练要点:让训练既快又稳
有了数据,接下来是真正把模型训起来。这一节聚焦四个几乎每个训练脚本都绕不开的工程要点:混合精度、梯度累积、梯度裁剪、学习率调度。
混合精度训练:用一半的显存跑更快
混合精度(Mixed Precision) 的核心思想是:大部分计算用 16 位浮点(FP16 或 BF16)来做,既省显存又能利用现代 GPU 的 Tensor Core 加速,但在容易发生数值下溢的地方(比如梯度累加、优化器状态)保留 32 位精度。FP16 动态范围小、容易下溢,需要配合损失缩放(loss scaling);BF16 动态范围和 FP32 一样大、不需要损失缩放,是目前大模型训练的首选。
梯度累积:用小显存模拟大 batch
大 batch 通常能让训练更稳定,但显存放不下怎么办?梯度累积(Gradient Accumulation) 的做法是:把一个大 batch 拆成若干个 micro-batch 依次前向反向,梯度先累加不更新,累够了再统一执行一次优化器更新。等效 batch size = micro batch size × 累积步数 × 数据并行数。
梯度裁剪:给梯度设一个天花板
训练大模型时偶尔会遇到某个 batch 梯度突然爆炸,导致 loss 变成 NaN 直接毁掉训练。梯度裁剪(Gradient Clipping) 通过把梯度的全局范数限制在一个阈值(常用 1.0)以内来防止这种情况,是稳定训练的重要保险丝。
学习率调度:warmup + cosine 衰减
大模型训练几乎标配 warmup + 余弦衰减 的学习率曲线:训练开始的几百到几千步里学习率从 0 线性爬升到峰值(warmup,避免初期大步长把随机初始化的参数带偏),之后按余弦曲线平滑衰减到一个很小的值。下面把这四个要点整合进一段可运行的 PyTorch 训练循环:
\`\`\`python
import math
import torch
from torch.optim import AdamW
from torch.optim.lr_scheduler import LambdaLR
def get_cosine_schedule(optimizer, warmup_steps, total_steps, min_ratio=0.1):
"""warmup 线性升温 + 之后余弦衰减到 min_ratio 倍峰值学习率。"""
def lr_lambda(step):
if step < warmup_steps:
return step / max(1, warmup_steps)
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
cosine = 0.5 (1.0 + math.cos(math.pi progress))
return min_ratio + (1 - min_ratio) * cosine
return LambdaLR(optimizer, lr_lambda)
def train(model, dataloader, total_steps,
accum_steps=8, max_grad_norm=1.0, lr=3e-4, warmup_steps=2000):
device = "cuda"
model.to(device)
optimizer = AdamW(model.parameters(), lr=lr, betas=(0.9, 0.95), weight_decay=0.1)
scheduler = get_cosine_schedule(optimizer, warmup_steps, total_steps)
# BF16 不需要 GradScaler;若用 FP16 则需 torch.cuda.amp.GradScaler()
autocast_dtype = torch.bfloat16
model.train()
optimizer.zero_grad()
step = 0
for micro_step, batch in enumerate(dataloader):
input_ids = batch["input_ids"].to(device)
labels = batch["labels"].to(device)
# 混合精度前向
with torch.autocast(device_type="cuda", dtype=autocast_dtype):
outputs = model(input_ids=input_ids, labels=labels)
# 累积时把 loss 除以累积步数,保证梯度尺度不变
loss = outputs.loss / accum_steps
loss.backward() # 梯度累加,不清零
# 累够 accum_steps 个 micro-batch 才真正更新一次
if (micro_step + 1) % accum_steps == 0:
# 梯度裁剪,防止梯度爆炸
torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
optimizer.step()
scheduler.step()
optimizer.zero_grad()
step += 1
if step % 100 == 0:
cur_lr = scheduler.get_last_lr()[0]
print(f"step {step} | loss {loss.item() * accum_steps:.4f} | lr {cur_lr:.2e}")
if step >= total_steps:
break
\`\`\`
这段代码里几个数值都有讲究:AdamW 的 \`betas\` 第二个动量常用 0.95 而非默认 0.999(大模型训练更稳),\`weight_decay\` 常设 0.1,warmup 通常取总步数的 1 到 5 percent。
🧵 分布式训练:单卡放不下时的并行策略
当模型大到单张 GPU 放不下,或者想用更多卡加速时,就必须引入分布式并行。主流有四大类策略,实际大模型训练往往是多种组合使用(即所谓 3D 并行)。
四大并行策略对比
| 策略 | 切分对象 | 显存收益 | 通信开销 | 适用场景 |
| --- | --- | --- | --- | --- |
| 数据并行 DP | 复制模型,切分数据 | 无(每卡全量模型) | 梯度全归约 | 模型能放进单卡,想加速 |
| 张量并行 TP | 切分单层权重矩阵 | 高 | 每层前反向都通信,需高速互联 | 单层太大,卡间带宽高 |
| 流水线并行 PP | 按层切分到不同卡 | 高 | 层间激活传递,有气泡 | 层数多,跨节点 |
| ZeRO / FSDP | 切分优化器状态梯度参数 | 极高 | 参数按需 all-gather | 想用数据并行的简单性省显存 |
ZeRO 的三个阶段
ZeRO(Zero Redundancy Optimizer)分三级,省显存逐级增强、通信也逐级增多:
| 阶段 | 分片内容 | 相对省显存 | 通信增加 |
| --- | --- | --- | --- |
| ZeRO-1 | 优化器状态 | 约 4 倍 | 基本不变 |
| ZeRO-2 | 优化器状态 + 梯度 | 约 8 倍 | 略增 |
| ZeRO-3 | 优化器状态 + 梯度 + 参数 | 与卡数成正比 | 明显增加 |
PyTorch FSDP 代码片段
PyTorch 原生的 FSDP(Fully Sharded Data Parallel) 就是 ZeRO-3 思想的官方实现:
\`\`\`python
import torch
import functools
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp import MixedPrecision, ShardingStrategy
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
from transformers.models.llama.modeling_llama import LlamaDecoderLayer
# 混合精度策略:计算与通信用 bf16,梯度归约用 bf16
mp_policy = MixedPrecision(
param_dtype=torch.bfloat16,
reduce_dtype=torch.bfloat16,
buffer_dtype=torch.bfloat16,
)
# 按 Transformer 层自动切分,每个 DecoderLayer 作为一个分片单元
auto_wrap_policy = functools.partial(
transformer_auto_wrap_policy,
transformer_layer_cls={LlamaDecoderLayer},
)
model = FSDP(
model,
auto_wrap_policy=auto_wrap_policy,
mixed_precision=mp_policy,
sharding_strategy=ShardingStrategy.FULL_SHARD, # 等价 ZeRO-3
device_id=torch.cuda.current_device(),
)
\`\`\`
如果用 DeepSpeed,则通过一份 JSON 配置开启 ZeRO:
\`\`\`text
{
"train_micro_batch_size_per_gpu": 4,
"gradient_accumulation_steps": 8,
"bf16": { "enabled": true },
"zero_optimization": {
"stage": 3,
"offload_optimizer": { "device": "cpu" },
"offload_param": { "device": "cpu" },
"overlap_comm": true,
"contiguous_gradients": true
}
}
\`\`\`
其中 \`offload_optimizer\` 和 \`offload_param\` 可以把优化器状态和参数临时挪到 CPU 内存,进一步省显存,代价是 CPU 与 GPU 之间的数据搬运开销。
📝 SFT 实战:指令数据、chat template 与 SFTTrainer
回到对齐流程,现在把前面讲过的 SFT 落到可运行的代码上。SFT 阶段最关键的两件事:指令数据的组织格式和只对回答部分计算损失。
指令数据格式与 chat template
现代对话模型的训练数据是多轮对话结构,每条消息带一个角色(system/user/assistant)。为了让模型分清角色边界,需要用对话模板(chat template)把结构化消息渲染成带特殊标记的纯文本。下面是一个类 ChatML 风格的示例:
\`\`\`text
<|im_start|>system
你是一个乐于助人、诚实且无害的 AI 助手。<|im_end|>
<|im_start|>user
用一句话解释什么是梯度下降。<|im_end|>
<|im_start|>assistant
梯度下降是一种沿着损失函数下降最快的方向反复微调参数、从而逐步找到最小值的优化方法。<|im_end|>
\`\`\`
关键细节是:训练时只对 assistant 回答部分计算损失,system 和 user 部分要用 -100 这个特殊标签屏蔽掉(PyTorch 交叉熵会忽略 label 为 -100 的位置)。否则模型会去学着"生成用户的提问",这不是我们想要的。
\`\`\`python
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("some-base-model")
def build_sft_example(messages):
"""把多轮对话转成 input_ids 和只在 assistant 段落有效的 labels。"""
input_ids, labels = [], []
for msg in messages:
# 渲染单条消息为带角色标记的 token
header = tokenizer.encode(f"<|im_start|>{msg['role']}\n", add_special_tokens=False)
body = tokenizer.encode(msg["content"] + "<|im_end|>\n", add_special_tokens=False)
input_ids += header + body
if msg["role"] == "assistant":
# 只有 assistant 的正文参与 loss;角色头依然屏蔽
labels += [-100] * len(header) + body
else:
labels += [-100] * (len(header) + len(body))
return {"input_ids": input_ids, "labels": labels}
\`\`\`
用 TRL SFTTrainer 一键微调
手写训练循环虽然清晰,但工业界更常用 HuggingFace 的 TRL 库,它的 \`SFTTrainer\` 把数据打包、chat template、loss 屏蔽都封装好了:
\`\`\`python
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import SFTTrainer, SFTConfig
model_name = "meta-llama/Llama-3.1-8B"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype="bfloat16")
# 数据集每条是 {"messages": [{"role": ..., "content": ...}, ...]} 的对话格式
dataset = load_dataset("your/instruct-dataset", split="train")
config = SFTConfig(
output_dir="./sft-output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=8,
learning_rate=2e-5,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
bf16=True,
logging_steps=10,
save_strategy="epoch",
max_seq_length=4096,
packing=True, # 把多条短样本打包进一个序列,提升训练效率
assistant_only_loss=True, # 只对 assistant 回答算 loss
)
trainer = SFTTrainer(
model=model,
args=config,
train_dataset=dataset,
processing_class=tokenizer,
)
trainer.train()
trainer.save_model("./sft-final")
\`\`\`
SFT 阶段的经验值:学习率比预训练小一到两个数量级(常用 1e-5 到 2e-5),训练 1 到 3 个 epoch 就够,训太多会过拟合导致回答模板化、多样性下降。
🏆 奖励模型训练:pairwise ranking loss
RLHF 的第一步是训练奖励模型。它的结构通常是在一个语言模型骨干上换掉输出层,改成一个输出单个标量分数的回归头。训练用的是成对排序损失(pairwise ranking loss):给定同一个 prompt 下人类标注的"更好回答 chosen"和"更差回答 rejected",让模型给 chosen 打的分高于 rejected。
损失函数是 \`-log(sigmoid(r_chosen - r_rejected))\`,直观理解就是:两者分差越大、方向越对,损失越小。
\`\`\`python
import torch
import torch.nn as nn
from transformers import AutoModel
class RewardModel(nn.Module):
def __init__(self, base_model_name):
super().__init__()
self.backbone = AutoModel.from_pretrained(base_model_name)
hidden = self.backbone.config.hidden_size
# 输出一个标量作为奖励分数
self.score_head = nn.Linear(hidden, 1, bias=False)
def forward(self, input_ids, attention_mask):
out = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
# 取最后一个非 padding token 的隐状态作为整段序列的表示
last_hidden = out.last_hidden_state
seq_len = attention_mask.sum(dim=1) - 1
pooled = last_hidden[torch.arange(last_hidden.size(0)), seq_len]
return self.score_head(pooled).squeeze(-1) # 形状 [batch]
def reward_loss(model, chosen, rejected):
"""chosen / rejected 各含 input_ids 和 attention_mask。"""
r_chosen = model(chosen["input_ids"], chosen["attention_mask"])
r_rejected = model(rejected["input_ids"], rejected["attention_mask"])
# pairwise ranking loss:让 chosen 分数高于 rejected
loss = -torch.nn.functional.logsigmoid(r_chosen - r_rejected).mean()
# 顺便统计一下"排序正确"的准确率,方便监控
accuracy = (r_chosen > r_rejected).float().mean()
return loss, accuracy
\`\`\`
用 TRL 则更简洁,直接用 \`RewardTrainer\` 配 \`RewardConfig\`,数据集里带 \`chosen\` 和 \`rejected\` 两列即可。奖励模型训练的常见现象是很快就在训练集上过拟合(准确率飙到 0.9 以上但泛化差),因此通常只训 1 个 epoch,并密切监控验证集上的排序准确率。
🎮 RLHF/PPO:更细的流程与 KL 惩罚
有了奖励模型,就可以用强化学习优化策略模型了。PPO 阶段实际上同时在内存里维护四个模型,这也是它工程复杂度高、显存吃紧的根本原因:
| 模型 | 作用 | 是否更新 |
| --- | --- | --- |
| 策略模型 policy | 被优化的主角,生成回答 | 是 |
| 参考模型 ref | 冻结的 SFT 模型,算 KL 惩罚 | 否 |
| 奖励模型 RM | 给回答打分 | 否 |
| 价值模型 critic | 估计每步的期望回报 | 是 |
KL 惩罚:别为了高分跑偏
RLHF 的关键约束是 KL 惩罚:最终奖励不只是奖励模型给的分,还要减去"策略模型相对参考模型偏离了多少"的 KL 散度。这防止模型为了骗取奖励模型的高分而生成语无伦次、脱离 SFT 分布的"奖励黑客"文本。有效奖励 = RM 分数 - β × KL(policy || ref)。
PPO 训练伪代码
\`\`\`python
def ppo_step(policy, ref_model, reward_model, critic, prompts, tokenizer, beta=0.1):
# 1. rollout:策略模型对一批 prompt 采样生成回答
responses = policy.generate(prompts, do_sample=True, top_p=0.9, temperature=1.0)
seqs = concat(prompts, responses)
# 2. 打分:奖励模型给完整回答一个标量奖励(只在最后一个 token 处给)
scores = reward_model(seqs)
# 3. 计算逐 token 的 KL 惩罚:策略与参考模型对数概率之差
logp_policy = policy.log_probs(seqs)
logp_ref = ref_model.log_probs(seqs)
kl = logp_policy - logp_ref
# 每一步的奖励 = 末尾的 RM 分数 - 逐 token 的 KL 惩罚
rewards = -beta * kl
rewards[:, -1] += scores
# 4. 用 critic 估计价值,计算优势函数(GAE)
values = critic(seqs)
advantages, returns = compute_gae(rewards, values, gamma=1.0, lam=0.95)
# 5. PPO 多个 epoch 更新:带裁剪的重要性采样比
for _ in range(ppo_epochs):
new_logp = policy.log_probs(seqs)
ratio = exp(new_logp - logp_policy.detach())
clipped = clip(ratio, 1 - 0.2, 1 + 0.2)
# 取裁剪前后较小值,限制每步更新幅度,保证"近端"
policy_loss = -min(ratio advantages, clipped advantages).mean()
value_loss = mse(critic(seqs), returns)
loss = policy_loss + 0.1 * value_loss
loss.backward()
optimizer.step()
optimizer.zero_grad()
\`\`\`
PPO 的调参是出了名的难:β 太小模型容易跑偏、输出崩坏,β 太大又限制得太死学不到东西;奖励尺度、优势归一化、学习率都很敏感。这也是后来 DPO 这类"跳过强化学习"的方法迅速流行的原因。
🎯 DPO 实战与对齐方法对比
DPO(Direct Preference Optimization,直接偏好优化) 的洞见是:其实不需要显式训练奖励模型、也不需要在线采样做强化学习,可以用一个巧妙的数学变换,把"最大化奖励 + KL 约束"这个 RLHF 目标直接改写成一个在偏好数据上的分类损失。这样训练就退化成了类似 SFT 的监督学习,稳定、省显存、易复现。
DPO 的直觉
DPO 直接在 chosen/rejected 偏好对上优化:让策略模型相对参考模型,提高 chosen 的相对对数概率、压低 rejected 的相对对数概率。它隐式地把语言模型自己当成了奖励模型,省掉了单独训练 RM 和在线采样这两个最麻烦的环节。
用 TRL DPOTrainer 训练
\`\`\`python
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import DPOTrainer, DPOConfig
model_name = "./sft-final" # 从 SFT 之后的模型出发
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype="bfloat16")
# 偏好数据集每条含 prompt / chosen / rejected 三个字段
dataset = load_dataset("your/preference-dataset", split="train")
config = DPOConfig(
output_dir="./dpo-output",
num_train_epochs=1,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
learning_rate=5e-7, # DPO 学习率要非常小
lr_scheduler_type="cosine",
warmup_ratio=0.1,
bf16=True,
beta=0.1, # KL 约束强度,越大越贴近参考模型
logging_steps=10,
max_length=2048,
max_prompt_length=1024,
)
trainer = DPOTrainer(
model=model,
ref_model=None, # 设 None 时 TRL 会自动复制一份冻结模型作参考
args=config,
train_dataset=dataset,
processing_class=tokenizer,
)
trainer.train()
trainer.save_model("./dpo-final")
\`\`\`
DPO 的学习率极小(常在 5e-7 量级),\`beta\` 通常取 0.1 到 0.5。如果发现模型训练后 chosen 和 rejected 的概率一起下降(这是 DPO 常见现象),说明 β 可能偏小或数据质量有问题。
DPO vs PPO 全面对比
| 维度 | PPO(RLHF) | DPO |
| --- | --- | --- |
| 是否需要奖励模型 | 需要单独训练 | 不需要 |
| 是否在线采样 | 需要,训练中实时生成 | 不需要,用离线偏好数据 |
| 内存中的模型数 | 四个(policy/ref/RM/critic) | 两个(policy/ref) |
| 训练稳定性 | 调参敏感,易崩 | 稳定,接近监督学习 |
| 工程复杂度 | 高 | 低 |
| 数据利用 | 可反复采样探索 | 受限于给定偏好数据 |
| 效果上限 | 探索充分时可能更高 | 受偏好数据覆盖面限制 |
| 典型代表 | InstructGPT、早期 ChatGPT | 大量开源对齐模型 |
其它对齐方法简介
🪶 参数高效微调:LoRA 与 QLoRA
全参数微调一个大模型需要的显存极其惊人(参数、梯度、优化器状态三份)。参数高效微调(PEFT) 的思路是:冻结绝大部分原始参数,只训练极少量新增参数,就能达到接近全量微调的效果。
LoRA 的原理
LoRA(Low-Rank Adaptation) 观察到:微调时权重的更新量 ΔW 往往是"低秩"的,于是不直接更新原权重 W,而是在旁边并联一个低秩分解 ΔW = B × A,其中 A 和 B 是两个瘦长的小矩阵(秩 r 远小于原维度)。训练时只更新 A、B,原始 W 保持冻结,可训练参数量往往只有原模型的 0.1 到 1 percent。
\`\`\`python
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, base_linear: nn.Linear, r=8, alpha=16, dropout=0.05):
super().__init__()
self.base = base_linear
for p in self.base.parameters():
p.requires_grad = False # 冻结原始权重
in_f, out_f = base_linear.in_features, base_linear.out_features
self.lora_A = nn.Parameter(torch.randn(r, in_f) * 0.01)
self.lora_B = nn.Parameter(torch.zeros(out_f, r)) # B 初始化为 0,保证起始等于原模型
self.scaling = alpha / r
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# 原始输出 + 低秩旁路
return self.base(x) + self.dropout(x) @ self.lora_A.T @ self.lora_B.T * self.scaling
\`\`\`
QLoRA:4bit 量化 + LoRA
QLoRA 更进一步:把冻结的基座模型量化到 4bit 存储(大幅省显存),再在其上做 LoRA 微调,让在单张消费级显卡上微调百亿参数模型成为可能。用 HuggingFace PEFT 库落地非常简洁:
\`\`\`python
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
import torch
# 1. 4bit 量化配置(NF4 是专为正态分布权重设计的量化格式)
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True, # 二次量化,进一步省显存
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-8B",
quantization_config=bnb_config,
device_map="auto",
)
model = prepare_model_for_kbit_training(model)
# 2. LoRA 配置:作用在注意力和 MLP 的投影矩阵上
lora_config = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
bias="none", task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 典型输出:trainable params 约占全部参数的 0.5 percent 左右
\`\`\`
LoRA 训练完成后,可以把低秩旁路合并(merge) 回原权重,推理时零额外开销;也可以保留成独立的适配器文件(通常只有几十到几百 MB),实现"一个基座 + 多个任务适配器"的灵活部署。
🔡 采样与解码进阶:不止 temperature 和 top-p
正文前半已经讲过 temperature、top-k、top-p,这里补充几种生产环境高频使用的进阶解码技术。
重复惩罚三兄弟
自回归生成最常见的毛病是重复啰嗦,有三种惩罚可以缓解:
| 参数 | 作用机制 | 特点 |
| --- | --- | --- |
| repetition penalty | 对已出现 token 的 logits 除以一个大于 1 的系数 | 惩罚力度与出现次数无关 |
| frequency penalty | 按 token 已出现次数线性扣分 | 出现越多惩罚越重 |
| presence penalty | 只要出现过就扣固定分 | 鼓励引入新话题 |
\`\`\`python
import torch
def apply_penalties(logits, generated_ids,
repetition=1.1, frequency=0.0, presence=0.0):
"""在采样前对 logits 施加三种重复惩罚。"""
counts = torch.bincount(generated_ids, minlength=logits.size(-1))
appeared = counts > 0
# repetition penalty:对已出现 token 缩放(正 logit 除、负 logit 乘)
logits = torch.where(
appeared & (logits > 0), logits / repetition,
torch.where(appeared, logits * repetition, logits),
)
# frequency penalty:按出现次数线性扣分
logits = logits - frequency * counts.to(logits.dtype)
# presence penalty:出现过就扣固定分
logits = logits - presence * appeared.to(logits.dtype)
return logits
\`\`\`
beam search:多路径搜索最优序列
Beam Search(束搜索) 不是每步只保留一个候选,而是同时保留概率最高的 k 条部分序列(beam),逐步扩展并剪枝,最后选整体概率最高的一条。它在机器翻译、摘要这类"有明确最优答案"的任务上效果好,但在开放式对话里容易生成保守、重复、乏味的文本,因此对话产品反而更偏爱随机采样。
对比解码:用小模型当"陪衬"
对比解码(Contrastive Decoding) 让一个强的大模型和一个弱的小模型同时预测,取两者对数概率之差作为打分。直觉是:小模型也爱说的套话(重复、泛泛而谈)会被减掉,只有大模型特别有把握、小模型跟不上的高质量 token 才胜出,从而提升生成质量、减少重复。
投机采样:让生成更快
投机采样(Speculative Decoding) 是一种无损加速推理的技巧:用一个便宜的小模型(草稿模型)一口气猜出接下来的 k 个 token,再让大模型一次前向并行验证这 k 个猜测,接受匹配的前缀、从第一个不匹配处纠正。由于大模型一次前向能验证多个 token,整体吞吐可提升数倍,而输出分布和大模型单独解码完全一致(无损)。
\`\`\`python
def speculative_decode(target_model, draft_model, prompt, k=4, max_new=128):
tokens = prompt
while len(tokens) - len(prompt) < max_new:
# 1. 草稿模型自回归地猜 k 个 token
draft = draft_model.generate(tokens, num_tokens=k)
draft_tokens = draft[len(tokens):]
# 2. 目标模型一次前向,拿到这 k+1 个位置的概率分布
target_probs = target_model.forward(tokens + draft_tokens)
# 3. 逐个验证:以概率比接受或拒绝每个草稿 token
accepted = []
for i, tok in enumerate(draft_tokens):
p_target = target_probs[i][tok]
p_draft = draft_model.prob(tokens + accepted, tok)
if random() < min(1.0, p_target / p_draft):
accepted.append(tok) # 接受
else:
# 4. 首个被拒处,用修正后的分布重采样一个 token 后停止本轮
accepted.append(sample_from(target_probs[i], reject=tok))
break
else:
# 全部接受,额外用目标分布再采一个,白赚一个 token
accepted.append(sample_from(target_probs[k]))
tokens = tokens + accepted
return tokens
\`\`\`
📊 评估:困惑度与基准测试
模型训完怎么判断好坏?评估分两大类:内在指标(如困惑度)和外在基准(如 MMLU)。
困惑度 perplexity
困惑度(Perplexity, PPL) 衡量模型对一段文本"有多惊讶",数值上等于交叉熵损失的指数,越低说明模型对该文本的预测越自信、语言建模能力越强。它无需人工标注,是预训练阶段最常用的监控指标,但它只反映语言建模能力,不直接等同于下游任务表现。
\`\`\`python
import torch
@torch.no_grad()
def compute_perplexity(model, tokenizer, text, stride=512, max_len=2048):
"""用滑动窗口计算长文本的困惑度,避免上下文截断带来的偏差。"""
encodings = tokenizer(text, return_tensors="pt")
input_ids = encodings.input_ids.to(model.device)
seq_len = input_ids.size(1)
nll_sum, n_tokens = 0.0, 0
prev_end = 0
for begin in range(0, seq_len, stride):
end = min(begin + max_len, seq_len)
trg_len = end - prev_end # 本窗口真正计入 loss 的 token 数
ids = input_ids[:, begin:end]
target = ids.clone()
target[:, :-trg_len] = -100 # 只对新出现的部分算 loss
out = model(ids, labels=target)
# out.loss 是均值,乘以有效 token 数还原为总 NLL
num_valid = trg_len - 1
nll_sum += out.loss.item() * num_valid
n_tokens += num_valid
prev_end = end
if end == seq_len:
break
return torch.exp(torch.tensor(nll_sum / n_tokens)).item()
\`\`\`
主流基准测试
内在指标之外,还需要用标准基准衡量真实能力。常见基准及其考察重点:
| 基准 | 考察能力 | 形式 |
| --- | --- | --- |
| MMLU | 57 学科的综合知识与推理 | 四选一多项选择 |
| GSM8K | 小学数学多步推理 | 应用题,需给出推理过程 |
| HumanEval | 代码生成正确性 | 写函数,用单测判对 |
| HellaSwag | 常识推断、句子续接 | 多项选择 |
| TruthfulQA | 抗幻觉、是否说真话 | 开放/选择 |
| MT-Bench | 多轮对话综合质量 | 由强模型当裁判打分 |
| IFEval | 指令遵循精确度 | 可自动校验的约束 |
评测有几个坑要特别注意:一是数据污染,如果测试题泄漏进了训练集,分数会虚高;二是prompt 敏感,同一模型换个提问格式分数能差好几个点,所以对比时必须统一评测协议;三是多选题的作答方式(看选项字母的概率 vs 让模型自由生成再解析)会显著影响分数,跨报告对比要看清口径。
📐 Scaling Laws 数字与算力估算
正文已定性讲过 Scaling Laws 和 Chinchilla,这里给出可以直接用来估算的数字和公式。
6ND 算力公式
训练一个稠密 Transformer 所需的浮点运算量有一个广为使用的经验估算:C ≈ 6 × N × D,其中 C 是总计算量(FLOPs),N 是模型参数量,D 是训练 token 数。系数 6 的来历是:前向每个参数每个 token 约 2 次浮点运算,反向约为前向的两倍,合计约 6 次。
\`\`\`python
def estimate_train_flops(n_params, n_tokens):
"""返回训练所需的总 FLOPs 估算值。"""
return 6 n_params n_tokens
# 例:一个 70 亿参数模型,训练 2 万亿 token
N = 7e9
D = 2e12
flops = estimate_train_flops(N, D)
print(f"总计算量约 {flops:.2e} FLOPs") # 约 8.4e22 FLOPs
def gpu_days(flops, gpu_peak_flops=1e15, mfu=0.4):
"""给定单卡峰值算力和实际利用率 MFU,估算需要多少 GPU-天。"""
effective = gpu_peak_flops * mfu # 实际可用算力
seconds = flops / effective
return seconds / 86400
print(f"约需 {gpu_days(flops):.0f} GPU-天")
\`\`\`
其中 MFU(Model FLOPs Utilization,模型算力利用率) 是个关键现实系数:理论峰值算力永远达不到,大规模训练能做到 0.3 到 0.5 的 MFU 就算不错了,剩下的都损耗在通信、访存、气泡上。
Chinchilla 最优比例
Chinchilla 给出的经验法则是:在固定算力预算下,参数量和训练 token 数应当大致同比例增长,最优配比大约是每 1 个参数配 20 个训练 token。据此可以反推给定预算下的最优模型规模:
| 模型参数量 | Chinchilla 最优 token 数 | 大致训练算力(FLOPs) |
| --- | --- | --- |
| 1B | 约 20B | 约 1.2e20 |
| 7B | 约 140B | 约 5.9e21 |
| 70B | 约 1.4T | 约 5.9e23 |
| 175B | 约 3.5T | 约 3.7e24 |
需要强调的是,Chinchilla 最优是针对"训练算力最省"这一目标的。现实里很多模型会故意"过度训练"(token 数远超 20 倍),因为它们更在意推理成本:把一个较小的模型喂更多数据训练得更充分,推理时更便宜,长期部署反而更划算。所以你会看到很多几十亿参数的模型用了远超 20 倍的 token。
💰 训练成本估算案例
把上面的公式串起来算一个具体例子。假设要训练一个 70 亿参数、2 万亿 token 的模型,用 H100 集群:
| 规模 | 参数量 | token 数 | 约需 GPU-天(H100) | 256 卡耗时 | 粗估云成本(美元) |
| --- | --- | --- | --- | --- | --- |
| 小 | 1B | 300B | 约 52 | 约 5 小时 | 约 4 千 |
| 中 | 7B | 2T | 约 2430 | 约 9.5 天 | 约 17.5 万 |
| 大 | 70B | 2T | 约 24300 | 约 95 天 | 约 175 万 |
这些数字只算了成功的那一次训练,真实项目里还要叠加大量失败的实验、超参搜索、数据处理、评估的成本,实际总花费往往是单次训练的数倍。这也解释了为什么真正从零预训练大模型是极少数机构才玩得起的游戏,而绝大多数团队的最优选择是在开源基座上做 SFT / LoRA 微调。
🕳️ 常见坑清单
训练流水线里踩过的坑,几乎每一个都能让几天甚至几周的算力打水漂:
| 坑 | 现象 | 常见原因 | 应对 |
| --- | --- | --- | --- |
| loss 爆炸 | loss 突然变 NaN 或飙升 | 学习率过大、FP16 溢出、脏数据 | 梯度裁剪、降 lr、换 BF16、查数据 |
| loss 不降 | 训半天几乎不动 | lr 太小、数据/标签错位、loss 屏蔽错 | 检查 label 对齐、调 lr、看 warmup |
| 模式坍塌 | 反复输出同一句话 | 采样退化、RLHF 过度优化 | 加重复惩罚、调 KL、降训练轮数 |
| 奖励黑客 | RM 分很高但回答很怪 | 策略钻奖励模型漏洞 | 加大 KL、提升 RM 质量、混合奖励 |
| 灾难性遗忘 | 微调后通用能力大跌 | 微调数据太窄、训得太狠 | 混入通用数据、降 lr、少 epoch、用 LoRA |
| 过拟合 | 训练集好、验证集差 | epoch 过多、数据太少 | 早停、减 epoch、扩数据 |
| 数据污染 | 基准分虚高但实用差 | 测试集泄漏进训练集 | 严格去重、去污染检查 |
| 分词错位 | 输出乱码 | 训练/推理分词器或特殊 token 不一致 | 严格配套保存加载 |
| OOM 显存溢出 | 训练启动即崩 | batch/seq 过大、没开分片 | 梯度累积、ZeRO/FSDP、激活检查点 |
| 重复性不可控 | 输出啰嗦重复 | 缺重复惩罚、beam 退化 | 调 repetition/frequency penalty |
🌟 最佳实践清单
把散落在各环节的经验汇成一份可执行的清单:
✅ 小结
把这条流水线串起来看:Token 化解决了"文字如何变成模型能处理的符号"这个基础问题;预训练用"预测下一个词"这一个简单到极致的自监督任务,在海量数据和算力的堆叠下,逼出了语言、常识乃至推理能力;SFT 教会模型"应该以什么格式、什么角度回应用户",RLHF 则用人类偏好数据进一步把模型调教得更有用、更安全、更像人;temperature 与 top-p/top-k 采样策略决定了同一个模型在"确定性"和"多样性"之间如何权衡;而 Scaling Laws 与涌现能力则解释了为什么"堆规模"在大模型这个领域格外有效,也格外难以精确预测。理解这些具体环节,不仅有助于看懂"大模型是怎么来的",也能在实际使用 API、调参数、评估模型能力边界时,做出更有依据的判断。