大模型炼成记:Token、预训练与 RLHF 对齐

中等 🟡AI 学习
10 个标签
预计阅读时间:81 分钟
大语言模型Token化预训练SFTRLHFDPOLoRA分布式训练Scaling Laws采样解码

大模型炼成记:Token、预训练与 RLHF 对齐

一个能流畅聊天、写代码、做推理的大模型,是怎么从"一堆参数"变成今天的样子的?很多介绍只会说一句"先预训练再微调",但真正决定模型能力和体验的,恰恰藏在这条流水线的每一个具体环节里:文字是怎么被切碎喂给模型的、"预测下一个词"这个朴素任务为什么能催生出智能、ChatGPT 是怎么从一个只会接话茬的模型变得"听话"的、同一个问题为什么每次回答都不一样、以及为什么大家都在拼命堆参数和数据。本文按照训练流水线的真实顺序,逐一拆解这些技术细节。

🔤 Token:大模型眼中的世界

为什么不直接处理字符

人类阅读文字是一个个汉字、一个个字母地看,但大模型内部既不处理"字符",也不直接处理"单词",而是处理一种介于两者之间的单位——Token(词元)。原因很直接:

如果按字符切分,英文一个句子会被拆成几十个字符,序列长度暴涨,模型要花更多算力才能学到"字符组合成单词"这种浅层规律,效率很低。
如果按单词切分,英文词表要覆盖所有单词、所有变形(如 run/runs/running/ran)、所有新造词和拼写错误,词表会大到不可维护,而且永远会遇到"没见过的词"(OOV,Out-Of-Vocabulary)问题。

Token 化就是在这两个极端之间找一个平衡点:用一个规模适中(几万到几十万)的词表,把文本切成"子词"(subword)单元,常见词整体是一个 token,生僻词或新词被拆成几个小片段的组合。

BPE:从字节对开始"贪心合并"

目前主流大模型(GPT 系列、LLaMA 等)大多使用 BPE(Byte Pair Encoding,字节对编码) 或其变体(如 GPT-4 用的 BPE 改进版、SentencePiece 里的 Unigram)来构建词表。BPE 的训练过程可以理解为一种"贪心合并"算法:

1.先把训练语料里的所有文本拆成最小单元(字节或字符),作为初始词表。
2.统计语料中相邻两个单元同时出现的频率,把出现频率最高的那一对合并成一个新的 token,加入词表。
3.重复第 2 步,每次都合并当前最频繁的相邻对,直到词表达到预设大小(比如 5 万、10 万)为止。

用一个极简的伪代码可以说明这个思路:

\`\`\`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 化上的表现差别很大,这也是很多人感觉"中文输入更贵"的直接原因:

英文:单词之间天然有空格分隔,BPE 学到的很多 token 接近"完整单词"或"常见词根",一个英文单词平均约等于 1.3 个 token。
中文:没有空格分隔,且汉字本身信息密度高,分词器往往需要把常见的双字词、三字词合并成一个 token,但覆盖不到的组合就会退化成单字甚至更细的 UTF-8 字节级切分。同样语义信息量的一段话,中文消耗的 token 数通常比英文更多(不同模型的分词器差异较大,粗略经验值是中文字符数与 token 数比例接近 1.5~2 : 1,具体因分词器训练语料而异)。

这也解释了一个常见现象:中文用户写同样意思的一句话,往往会比英文用户"更快用完"上下文窗口和 API 额度。

为什么 API 按 token 计费

大模型的两项核心成本——显存占用计算量——都直接和序列的 token 数挂钩:Transformer 的自注意力计算复杂度与序列长度的平方成正比,输入越长的 token 序列,每一层的计算和显存开销越大;同时 API 厂商的算力成本本质上是"处理了多少个 token、生成了多少个 token",而不是"处理了多少个字"或"多少次请求"。因此按 token 计费(通常输入和输出 token 分开计价,输出更贵,因为生成需要逐 token 自回归解码,成本更高)是最贴近真实算力消耗的计费方式,也是为什么理解 token 化对成本控制很重要——精简 prompt、减少不必要的重复内容,本质上都是在减少 token 消耗。

🌐 预训练:用整个互联网玩文字接龙

"预测下一个词"这一个任务

预训练阶段模型要做的事情说起来极其朴素:给定前面一段文字,预测下一个 token 是什么。这个任务在学术上叫自回归语言建模(Autoregressive Language Modeling),本质上是一种自监督学习——不需要人工标注任何"正确答案",因为语料本身就是答案:一句话的第 N+1 个词,就是训练时的标签。

具体训练过程可以简化理解为:

1.从海量文本中截取一段,比如"今天天气很"。
2.模型基于已经看到的内容,输出一个覆盖整个词表的概率分布,预测下一个 token 最可能是什么(比如"好"的概率最高)。
3.和语料里真实出现的下一个 token(比如确实是"好")计算损失(交叉熵损失),根据误差反向传播更新参数。
4.在数万亿 token 规模的语料上,对每一个位置都重复这个过程。

为什么"文字接龙"能学出语言、常识、甚至推理

这是最反直觉但也是预训练最迷人的地方:只靠"猜下一个词"这么简单的目标,为什么模型能学会语法、常识、甚至一定程度的推理?

原因在于,要把"下一个词"预测得准,模型被迫在内部隐式地建立起远超"词语接龙"的知识结构:

要接对语法,模型必须学会主谓宾搭配、时态、语序等句法规律
要接对常识,比如"太阳从____升起",模型必须在参数里编码"东方"这样的世界知识
要接对逻辑链条,比如一段包含"因为...所以..."的推理文本,模型要接得通顺,就必须隐式学会因果和推理模式
要在代码语料上把下一行代码接对,模型必须学会编程逻辑

换句话说,"预测下一个词"只是一个训练信号(loss 的来源),但要把这个信号优化到极致,模型不得不把语言背后的结构性规律都压缩进自己的参数里。这也是为什么预训练语料的多样性和质量至关重要:网页文本教会常识和语言习惯,书籍教会长程逻辑和叙事结构,代码教会精确的形式化推理,学术论文教会专业知识。

预训练的规模量级

预训练之所以被称为"大力出奇迹"的工程,是因为它同时在三个维度上堆到极限:

数据量:主流大模型的预训练语料通常在数万亿(trillion 级别)token 量级,来源包括网页爬取(如 Common Crawl)、书籍、代码仓库、百科、论坛问答等,且需要经过去重、质量过滤、有害内容清洗等复杂的数据处理流程。
参数量:从早期的百万级参数,到今天主流模型动辄百亿、千亿参数,参数越多,模型能够存储的知识和模式就越丰富。
算力:训练一个千亿参数级别的模型,往往需要数千张高端 GPU/TPU 持续训练数周到数月,消耗的算力以 FLOPs(浮点运算次数)计算,通常在 10^23 ~ 10^25 量级。

这三者不是孤立的,业界发现它们之间存在可以量化的相互制约关系,这一点会在本文最后一节详细展开。

🎓 从 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)

1.让 SFT 之后的模型对同一个问题生成多个不同的回答(比如 4 个候选)。
2.请人类标注员对这几个回答按质量从好到差排序(而不是直接打分,排序比打分更容易保持一致性)。
3.用这些排序数据训练一个奖励模型:输入"问题 + 一个回答",输出一个标量分数,训练目标是让奖励模型给排序中更靠前的回答打更高的分。

阶段二:用强化学习(通常是 PPO)优化策略模型

1.把 SFT 模型作为初始的"策略模型"(policy)。
2.让策略模型针对新的问题生成回答。
3.把生成的回答交给上一步训练好的奖励模型打分,作为强化学习中的"奖励信号"。
4.PPO(Proximal Policy Optimization,近端策略优化)算法,根据奖励信号调整策略模型的参数,让它更倾向于生成能拿高分的回答。
5.同时会加入一个"和原始 SFT 模型的差异惩罚"(KL 散度约束),防止模型为了骗取高分而生成语言不通顺、脱离常识的"奖励黑客"(reward hacking)内容。

用一句话概括这个循环:奖励模型学会了"人类更喜欢什么",PPO 则让语言模型学会"如何生成人类更喜欢的内容"。这一步之所以关键,是因为人类的偏好(比如"更礼貌一点""不要啰嗦""拒绝危险请求但语气要委婉")很难写成规则或直接的监督样本,但很容易通过"两个回答哪个更好"这种相对比较来表达,而 RLHF 恰恰是把这种"相对偏好"转化成可优化目标的工程方法。经过 RLHF 之后的模型,通常会在有用性、安全性、语气自然度上有明显提升,这也是 ChatGPT 相比早期只做过 SFT 的模型体验更好的核心原因之一。

需要补充的是,近年也出现了 DPO(Direct Preference Optimization)等更简化的对齐方法,跳过了显式训练奖励模型和强化学习采样的过程,直接用偏好数据优化策略模型,工程复杂度更低,但核心思想仍然是"利用人类偏好数据引导模型对齐"。

🎲 温度与采样:AI 为什么每次回答不一样

模型输出的本质是一个概率分布

很多人以为大模型"计算"出一个确定的答案,但实际上,模型在每一步生成时,输出的是词表中每一个 token 成为下一个词的概率。比如给定"今天天气真",模型可能输出:"好"的概率 45%,"不错"的概率 20%,"糟糕"的概率 8%,其余概率分散在词表里的其他几万个 token 上。模型本身并不会自动选择概率最高的那个词作为最终答案——具体怎么从这个概率分布里"抽"出一个词,是由解码策略(decoding strategy)决定的,这也是为什么同一个问题问两次,模型给出的措辞常常不完全一样。

贪心解码 vs 随机采样

贪心解码(Greedy Decoding):每一步都直接选择概率最高的 token。优点是结果完全确定、可复现;缺点是容易陷入重复、生硬、缺乏变化的表达(比如反复生成同一个短语),因为它完全放弃了"次优但也合理"的可能性。
随机采样(Sampling):按照概率分布"抽签",概率越高的 token 被抽中的机会越大,但不是必然。这样生成的文本更自然、更有多样性,是目前对话类产品的主流选择,代价是结果不可完全复现。

Temperature:控制概率分布的"平滑度"

Temperature(温度)是采样前对概率分布做的一步再加工,公式上是把模型输出的原始分数(logits)除以温度值 T,再做 softmax 归一化:

温度较低(如 0.2):相当于把概率分布"变尖",原本领先的高概率 token 会被进一步放大优势,采样结果更接近贪心解码,输出更保守、更确定、更适合需要精确性的任务(如代码生成、数学计算、事实问答)。
温度较高(如 1.2):把概率分布"压平",原本较低概率的 token 也有更大机会被选中,输出更有创造性、更多样,但也更容易出现不连贯或跑题的内容,适合创意写作、头脑风暴一类的场景。
温度为 0:数学上等价于贪心解码,每次都选概率最高的 token,结果完全确定。

Top-k 与 Top-p(nucleus sampling)

单纯调温度还不够,因为即使压平了分布,词表尾部那些几乎不可能合理的 token 依然有极小的非零概率被抽到,可能导致偶尔输出完全不合逻辑的词。因此实践中通常会先用一种"截断策略"缩小候选范围,再做采样:

Top-k 采样:只保留概率最高的 k 个 token(比如 k=40),把候选范围限制在这 k 个之内,再按概率重新归一化后采样。缺点是 k 值是固定的,对于概率分布很集中或很分散的场景不够灵活。
Top-p 采样(nucleus sampling,核采样):不固定候选个数,而是从概率最高的 token 开始累加,直到累计概率达到设定阈值 p(比如 0.9)为止,把这个"核心集合"作为候选范围。这样在分布很确定时(比如只有一两个词概率极高)候选集会自动收窄,在分布很分散时候选集会自动放宽,比 top-k 更自适应,是目前工业界最常用的采样策略之一。

一段简化的采样伪代码可以直观展示这个流程:

\`\`\`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 常见坑

训练语料和推理语料分布不一致:如果分词器只在英文维基上训练,却拿去处理大量代码或中文,会出现大量单字节切分,序列暴涨、效果下降。分词器语料应尽量贴近最终模型的数据分布。
忘记加特殊 token 或对不齐:\`[BOS]\`、\`[EOS]\`、\`[PAD]\` 的 id 一旦在训练和推理之间对不上,模型会输出乱码。分词器和模型必须严格配套保存、配套加载。
数字切分不友好:很多分词器会把 \`12345\` 切成不规律的碎片,导致算术能力受损。有些新模型专门把数字按单个数字(digit-level)切分来缓解这个问题。
换分词器等于换模型:预训练好的模型和它的分词器是绑定的,随意替换分词器会让所有 token id 失去意义,必须重新训练。

🧹 预训练数据工程:清洗、去重、配比与课程学习

有一句在业界流传很广的话:"垃圾进,垃圾出"。模型能力的上限很大程度上是被训练数据的质量决定的,而不是模型结构。一条工业级的预训练数据管线,往往比模型代码本身还复杂。

数据处理的典型流水线

一条完整的预训练数据管线通常包含这些串行阶段:

1.抽取:从 Common Crawl 等原始网页里抽取正文,去掉 HTML 标签、导航栏、广告。
2.语言识别:用 fastText 之类的语言分类器过滤出目标语言,或按配比保留多语言。
3.质量过滤:用启发式规则(文本长度、符号比例、重复行比例)加上模型打分,滤掉低质内容。
4.去重:文档级和子串级去重,这是对最终效果影响最大的步骤之一。
5.有害内容与隐私清洗:过滤色情、暴力、仇恨言论,脱敏手机号、邮箱、身份证等 PII。
6.数据配比与混合:按设计好的比例混合网页、书籍、代码、论文等不同来源。
7.分词与打包:切成 token,按上下文窗口长度打包成训练样本。

质量过滤的启发式规则代码

下面是一段可运行的启发式质量过滤示例,实现了几条业界常用的规则:

\`\`\`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 | 想用数据并行的简单性省显存 |

数据并行(DP) 最简单:每张卡都放一份完整模型,各自处理不同数据,反向后把梯度做 all-reduce 求平均。缺点是每张卡都要放下整个模型和优化器状态,省不了显存。
张量并行(TP) 把单层的大矩阵按行或列切到多张卡上并行计算,再把结果拼起来。通信极其频繁,只适合卡间有 NVLink 这类高速互联的同一节点内。
流水线并行(PP) 把模型的不同层放到不同卡上,像流水线一样让 micro-batch 依次流过。挑战是流水线"气泡"(首尾阶段有卡空闲),需要用 micro-batch 交错来减小气泡。
ZeRO / FSDP 是数据并行的显存优化版:把优化器状态、梯度、参数分片到各卡,用到时才临时聚合,兼顾了数据并行的易用性和张量并行的省显存,是当下最流行的方案。

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 | 大量开源对齐模型 |

其它对齐方法简介

GRPO(Group Relative Policy Optimization):对同一个 prompt 采样一组回答,用组内相对得分(减去组均值)作为优势,从而省掉了单独的 critic 价值模型,显存更省、更稳定,在推理模型(如强调数学与代码的模型)训练中很流行。
KTO(Kahneman-Tversky Optimization):灵感来自前景理论,不需要成对偏好,只需要给每个回答一个"好/坏"的二元标签即可优化,数据标注成本比 DPO 更低,适合难以构造成对偏好的场景。
ORPO:把 SFT 和偏好对齐合并成一步、无需参考模型,进一步简化流程。

🪶 参数高效微调: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 集群

总算力:6 × 7e9 × 2e12 ≈ 8.4e22 FLOPs。
单卡 H100 的 BF16 峰值约 1e15 FLOPs/s,按 MFU = 0.4 估算实际算力约 4e14 FLOPs/s。
单卡耗时:8.4e22 / 4e14 ≈ 2.1e8 秒 ≈ 2430 GPU-天。
若用 256 张 H100:2430 / 256 ≈ 9.5 天。
成本:按云上 H100 约每卡每小时 2 到 4 美元估算,256 卡 × 9.5 天 × 24 小时 × 3 美元 ≈ 17.5 万美元。

| 规模 | 参数量 | 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 |

🌟 最佳实践清单

把散落在各环节的经验汇成一份可执行的清单:

数据优先于模型:花在数据清洗、去重、配比上的精力,回报往往高于折腾模型结构。垃圾进垃圾出。
默认用 BF16:动态范围大、不用损失缩放,比 FP16 省心得多。
小规模先跑通:用小模型、小数据把整条流水线(数据、训练、评估、保存加载)端到端跑通,再放大规模,别一上来就烧几百卡。
监控要全:loss、梯度范数、学习率、显存、吞吐、验证集指标都要盯,异常早发现早止损。
勤存 checkpoint:大训练必然会中断,定期存档并验证能否恢复,别等崩了才发现存档是坏的。
SFT 学习率要小、epoch 要少:1e-5 量级、1 到 3 个 epoch,训过头会模板化。
对齐先试 DPO:工程复杂度远低于 PPO,多数场景效果够用,实在需要在线探索再上 PPO/GRPO。
微调优先 LoRA/QLoRA:省显存、可插拔、易回滚,除非有充分理由才做全参数微调。
评估要防污染、统一协议:去污染检查、固定 prompt 模板和作答口径,否则分数没有可比性。
算成本再动手:用 6ND 公式和 MFU 预估算力和美元成本,避免中途才发现预算不够。
保留可复现性:固定随机种子、记录数据版本和超参、版本化配置,方便复现和 A/B。

✅ 小结

把这条流水线串起来看:Token 化解决了"文字如何变成模型能处理的符号"这个基础问题;预训练用"预测下一个词"这一个简单到极致的自监督任务,在海量数据和算力的堆叠下,逼出了语言、常识乃至推理能力;SFT 教会模型"应该以什么格式、什么角度回应用户",RLHF 则用人类偏好数据进一步把模型调教得更有用、更安全、更像人;temperature 与 top-p/top-k 采样策略决定了同一个模型在"确定性"和"多样性"之间如何权衡;而 Scaling Laws 与涌现能力则解释了为什么"堆规模"在大模型这个领域格外有效,也格外难以精确预测。理解这些具体环节,不仅有助于看懂"大模型是怎么来的",也能在实际使用 API、调参数、评估模型能力边界时,做出更有依据的判断。