跳到主要内容

数据截至 (上游 commit eb980a5c9eea)

从零训一个 2.15 亿参数的模型 — 分词器、数据、损失掩码、训练循环

这一章讲三件事: 文字进模型之前要先过哪一道工序、那道工序自己也要训; 语料怎么变成一条条「输入 + 目标」的样本;以及一个训练循环里除了「算损失、调参数」还塞了哪些必需品。 全章最重要的一个零件叫「损失掩码」——预训练和有监督微调的全部差别就在它身上。 读完你会知道:训一个能对话的小模型,一共要做几件事、每件事会在哪儿出错。

1. 先看现象:模型不认字,它认编号

第 03 章讲过,查表层是拿编号去查表的。那这个编号是谁给的?

是分词器。 它把你打的那句话切成一小段一小段,每一段配一个编号。

这道工序有两个不显眼但要命的性质:

  1. 它是训出来的,不是写死的规则。 一份语料训一个分词器,换一份语料就该重训;
  2. 它切错了,后面全错。 模型永远只能看到切完的结果——被切碎的东西,它拼不回来。

一个你可能亲手撞过的例子: 你问模型「strawberry 里有几个 r」,它数错。 原因之一就在这里——它看到的不是 s-t-r-a-w-b-e-r-r-y 十个字母, 而是可能只有两三个片段。 数它没见过的东西,当然容易错。 (第 09 章会看到,书里的智能体正是靠调用一个外部函数来解决这道题。)

这一章从这道工序开始。

2. 顶层全景:训一个模型,一共四件事

① 训分词器 拿语料训出一张「片段 → 编号」的表(书里训到 6144 个片段)

② 造数据集 文本 → 编号序列 → 拆成「输入 X」和「目标 Y」,并标好哪些位置算分

③ 写训练循环 读一批 → 算损失 → 反着推每个参数该怎么调 → 挪一小步 → 下一批
│ (中间还塞着:学习率调度、混合精度、梯度累积、定期存盘)

④ 让它吐字 给个开头,一个标记一个标记往下接

图说:①②③④ 就是这一章的四节。真正容易出错的是 ②。

书里的目标产物:一个 2.15 亿参数的模型, 分两个版本——只做过预训练的「底座」,和再做过有监督微调的「对话版」1

主走查:一条最短的对话,走完这四件事

四件事各讲各的,读者读完只会记住四个名词。所以这一章挑一条最短的样本,从头跟到尾:

用户说「你好」,模型该答「你好呀」。 这正是第 6.2 节书里真实跑出来的第一条对话——这一章就是把它是怎么被训出来的走一遍。

后面每一节末尾都有一小段这条样本走到这一步,写出它此刻是哪一串编号、哪一串 0/1:

第 3 节 分词器 → 「你好呀」被切成几个片段,各是几号
第 4 节 造数据 → 套上模板 → 拉成一串编号 → 错开一位切成 X 和 Y → 逐位标好掩码
第 5 节 训练循环 → 这一批走一步,学习率是多少、攒几批才更新一次
第 6 节 吐字 → 给它「你好」,它一个标记一个标记接出「你好呀」

图说:这四步就是第 2 节那张图,只不过每一步旁边都换成了具体的数。

先声明:下面这些小段里的编号(比如「你 = 1024 号」)全是为演示编的,不是真实数值。 书里没有公开这张表的内容。词表大小、模板、掩码规则、开销数字都来自书里。

3. 第一件事:分词器

3.1 三条路线,各有各的死法

书里给了三种切法2:

切法怎么切死在哪
按词切按空格和标点分开:Hello, world!["Hello", ",", "world", "!"]遇到词表里没有的词就没辙(行话叫「未登录词」);缩写、复合词处理不好;中文日文没有空格,根本没法切
按字符切Hello["H","e","l","l","o"]什么语言都能处理,但序列变得极长,计算量和训练时间飙升;而且丢掉了词一级的语义
按子词切介于两者之间这是今天所有模型的选择

「未登录词」(OOV,out-of-vocabulary)就是「词表里没有这个词」。 第 01 章讲过这个问题,这里是它的正式解法。

3.2 子词切分的三种算法

书里列了三种3下表里有一个词先说清:字节就是计算机里存一个英文字符那么大的一小块数据。

算法核心思路例子
BPE(字节对编码)自底向上合并:从字符开始,反复把出现频率最高的一对合并成新片段,直到词表满lowerlow + er;newestnew + est
WordPiece和 BPE 类似,但合并时挑「能让整句概率最大」的那一对unhappinessun + ##happiness(## 表示这是后缀)
Unigram自顶向下:先给每个候选片段一个概率,再按概率找最优切法unhappinessun + happiness

书里选了 BPE,理由是「简单而有效,能处理未登录词和罕见词,同时保持较小的词典大小」4

3.2.1 把 BPE 真的走一遍:四轮合并

上面那三行表只有结论,读者复述不出「自底向上合并」到底怎么合。这里走一遍。

拿一份只有五个词的迷你语料:low low lower newest widest(这份语料是为演示编的,不是书里的;下面每一步的计数都可以自己数着核。)

第 0 轮:先把每个词拆到字符,一个字符就是一个片段。

low ×2 → l | o | w
lower ×1 → l | o | w | e | r
newest ×1 → n | e | w | e | s | t
widest ×1 → w | i | d | e | s | t

此时词表 = { l, o, w, e, r, n, s, t, i, d } 共 10 个

接下来每一轮做同一件事:数一遍「哪两个相邻片段挨在一起的次数最多」,把那一对并成一个新片段。

第几轮数出来最多的那一对次数怎么数出来的合并后词表新增
1l + o3low 出现 2 次,lower 1 次lo
2lo + w3同上三处,这时前半截已经是 lolow
3e + s2newest 1 次,widest 1 次es
4es + t2同上两处est

四轮之后,这五个词被切成了这样:

low → low
lower → low | e | r
newest → n | e | w | est
widest → w | i | d | est

图说:注意 low 和 lower 现在共用了 low 这一截,newest 和 widest 共用了 est 这一截。
没有人告诉过它「low 是词根、est 是后缀」——
它只是数了数谁和谁总黏在一起,词根和后缀就自己浮出来了。

上面那张表里 lower → low + erer 为什么这一轮没出来: 因为在这份只有五个词的语料里,e+r 只挨着出现过 1 次,排不进前列。 换成一份真实语料,er 作为英文最常见的后缀之一,会在很靠前的轮次被合并掉。 合并到什么时候停?停在你定的词表大小上——书里定的是 8192 个片段(见下一节)。

判断(我们的,不是书里的): 这三种算法书里各给了一段描述和一个例子,但没有给出处。 补上:BPE 用于自然语言的原始出处是 2015 年的机器翻译论文; Unigram 的出处是 2018 年那篇讲「同一个词允许有多种切法」的子词论文; WordPiece 这个名字最早出现在 Google 2016 年的神经机器翻译系统论文里5为什么值得补: 这三个名字在今天的模型卡片和配置文件里天天出现, 但如果你不知道它们各自要解决什么问题,就没法判断该选哪个。 如果错,会错在: 如果读者只需要跟着教程跑通、不做技术选型,那这些出处对他确实是负担。

3.3 真正训一个:五步

书里用 Hugging Face 的 tokenizers 库训了一个6,训练语料和预训练用的是同一份7

Step 1 装库
Step 2 加载语料(出门问问序列猴子开源数据集)
Step 3 写配置文件 —— 定义特殊标记和对话模板
Step 4 真正训练 —— 目标词表大小 8192
Step 5 测一测:编码、解码、套对话模板

图说:注意 Step 3 在 Step 4 之前。配置不是训完再补的,是训之前就定死的。

3.4 特殊标记:五个人造的记号

这是全章第一个容易被跳过、后面却会反复出现的东西。

书里配了五个特殊标记8:

标记干什么
<unk>遇到实在切不出来的东西时的兜底
<s> / </s>序列开始 / 序列结束
<|im_start|> / <|im_end|>一轮对话的开始 / 结束

后面两个是关键。 书里给的理由是:它们「可以帮助模型更好地理解文本数据, 提高模型的泛化能力和效果」8

更具体地说: 模型内部只是一串编号,它凭什么知道「这句是用户说的、那句是我该说的」? 靠的就是这两个记号。

3.5 对话模板:一段固定的排版

对话模板(书里叫 chat template)规定了怎么把一段对话排成一条文本序列。 书里直接沿用了 Qwen2.5 的模板9:

<|im_start|>system
你是一个有用的助手<|im_end|>
<|im_start|>user
你好<|im_end|>
<|im_start|>assistant
你好!有什么我可以帮你的吗?<|im_end|>

图说:三个角色轮流出场,每一段用起止标记包住。
「assistant」后面那一段,才是模型该学着写的东西。

这个模板会在第 08 章第 5.3 节再出现一次,而且那时它决定了有监督微调能不能做对。

主走查:「你好呀」这三个字被切成什么

分词器训完之后,手上就是一张「片段 → 编号」的表(书里训到 6144 个片段)。 把主走查那条样本查一遍:

中文这一档,常见字基本各占一个片段:
你 → 1024 号
好 → 1531 号
呀 → 2790 号
特殊标记也各占一个编号,而且被钉在很靠前的位置:
<|im_start|> → 4 号 <|im_end|> → 5 号
模板里的角色名是普通文字,照样要切:
user → 812 号 assistant → 903 号
换行 → 198 号

于是「你好呀」这三个字 = [1024, 1531, 2790]

图说:模型从头到尾看到的就是这一串数字,没有别的。
第 1 节那个「strawberry 里有几个 r」数不对,原因就在这一行——
它看到的是编号,不是字母。
(这些编号是为演示编的,不是真实数值;6144 这个词表大小来自书里。)

3.6 一个书里主动暴露的问题

书里在测试分词器时,把结果原样贴了出来,其中有两行是「否」10:

=== 编码解码测试 ===
Decoded text matches original: False ← 编码再解码,和原文不一样

=== 特殊token处理 ===
Original: <|im_start|>user
Decoded: <|im_start|> user ← 多出来一个空格
Special tokens preserved: False

书里没有对这两个 False 做任何解释。

判断(我们的,不是书里的): 这两行值得单独指出来,因为它们是真问题,不是无关紧要的细节。 「编码再解码不等于原文」意味着分词器丢了信息——最常见的原因是空格处理: 切分时把空格吃掉了,还原时按固定规则补回去,遇到原本没空格的地方就补错。 后果是模型看到的文本和你以为它看到的文本不是同一个东西, 而这会在代码、表格、缩进这类对空格敏感的内容上直接变成错误。 如果错,会错在: 如果这两个 False 只是因为测试脚本比较的方式过于严格 (比如把特殊标记也当普通文本比),那它就不构成实际问题—— 但书里没给测试脚本的比较逻辑,我们无从判断。

4. 第二件事:把语料变成样本

4.1 数据从哪来

书里用了两份公开数据集11:

数据集用在哪规模
出门问问序列猴子通用文本数据集预训练约 100 亿标记,来自网页、百科、博客、问答、代码、书籍、报刊、专利、教材、考题
BelleGroup有监督微调350 万条中文对话

预处理时把长文本切成 512 个字符一段12

4.2 预训练的样本长什么样:错一位

这是这一章的第一个关键点。

接龙这道题要求「给前面的,猜下一个」。具体到一条数据上,做法是把同一串编号错开一位13:

主走查那三个字上一节已经变成 [1024, 1531, 2790] 了,前后各加一个人造记号:

原始编号序列(5 个): [ <s>, 1024, 1531, 2790, </s> ]
└ 头 你 好 呀 尾

输入 X: [ <s>, 1024, 1531, 2790 ] ← 去掉最后一个
目标 Y: [ 1024, 1531, 2790, </s> ] ← 去掉第一个

逐位对齐着念:
第 1 位:看到 <s> → 该说 1024(你)
第 2 位:看到 <s> 你 → 该说 1531(好)
第 3 位:看到 <s> 你 好 → 该说 2790(呀)
第 4 位:看到 <s> 你 好 呀 → 该说 </s>(说完了)

图说:一条 5 个位置的序列里藏着 4 道题,靠掩码可以并行做完(第 02 章讲过)。
书里真正喂的一条是 512 个位置,里面就藏着 511 道题。
(1024/1531/2790 是上一节为演示编的编号;512 这个长度来自书里。)

一句话:输入是原序列去尾,目标是原序列去头。 这就是接龙训练的全部数据处理。

4.3 损失掩码:全章最重要的一个零件

「损失掩码」是一串和序列等长的 0 和 1,标记哪些位置要算分、哪些不算13

预训练时这串 0/1 几乎全是 1——除了补位的地方,每个位置都算:

接着上一节那条 4 个位置的样本:

目标 Y: [ 1024, 1531, 2790, </s> ]
掩码: [ 1, 1, 1, 1 ] ← 四道题全算分

要是这一批里还有更短的句子,得补齐到同样长度:
目标 Y: [ 1024, 1531, 2790, </s>, <pad>, <pad> ]
掩码: [ 1, 1, 1, 1, 0, 0 ] ← 补出来的两位不算分

图说:预训练是「整段文本都要学」,所以掩码基本全是 1,只有补位那几格是 0。

「补位」(padding)是什么: 一批里的句子长短不一,而计算要求形状一致, 所以短的后面要补一串没有意义的填充标记凑齐长度。 补位的地方当然不该算分,所以掩码在那里是 014

4.4 有监督微调:同样的错位,不同的掩码

这一节是第 05 章那句「input 不参与损失计算」的具体实现。

书里说得很干脆:有监督微调的数据集和预训练的 X、Y 是一样的, 差别只在于要生成一个损失掩码来标记哪些位置算损失15

规则也很干脆15:

遇到 <|im_start|>assistant 就开始算损失,遇到 <|im_end|> 就停。

把主走查那条样本按第 3.5 节的模板排好、按第 3.3 节的表换成编号,逐位摆出来:

位置: 1 2 3 4 5 6 7 8 9 10 11 12
内容: <im_s> user \n 你 好 <im_e> <im_s> asst \n 你 好 呀
编号: 4 812 198 1024 1531 5 4 903 198 1024 1531 2790
掩码: 0 0 0 0 0 0 0 0 0 1 1 1
└──── 从这里开始算分

位置 13 是 <im_e>(编号 5),掩码也是 1 —— 它得学会「说完了要收尾」。

图说:左边九格全是 0,盖住的正是「你好」这句用户说的话;
右边从第 10 格起是 1,盖住的正是「你好呀」这句它该说的话。
规则就一句:遇到 <|im_start|>assistant 开始记 1,遇到 <|im_end|> 记完最后一格。
(编号是第 3.3 节为演示编的;模板和这条规则来自书里。)

多轮的时候,这套规则原样再来一遍:

<|im_start|>user 你好<|im_end|> 掩码 = 0 0 0 0 0 0 0 ← 用户说的,不学
<|im_start|>assistant 你好呀<|im_end|> 掩码 = 0 0 1 1 1 1 1 ← 模型该说的,学
<|im_start|>user 再见<|im_end|> 掩码 = 0 0 0 0 0 0 0
<|im_start|>assistant 再见<|im_end|> 掩码 = 0 0 1 1 1 1 1

图说:同一条样本里藏着两道题,而且互不干扰——
这正是第 05 章说的「第三种多轮对话构造方式」。

这里把两件事连起来了: 第 3.4 节那两个对话标记,存在的全部理由就是让这里的规则能写出来。 没有它们,你没法在一串编号里找到「模型该说的话」的起点和终点。

判断(我们的,不是书里的): 损失掩码是这一章最值得单独记住的一个概念, 因为它出错不会报错。 掩码写错了,训练照跑、损失照降、看起来一切正常, 只是训出来的模型会去模仿用户提问,而不是回答问题。 在这一行,「静默失败」比「崩溃」危险得多——崩溃至少告诉你哪里错了。 如果错,会错在: 如果某个框架在掩码全 0 或者掩码长度对不上时会主动报错, 那这类错误就不是静默的——但书里的手写实现没有这类检查。

5. 第三件事:训练循环

训练循环的骨架只有四步:读一批 → 算损失 → 反着推每个参数该怎么调 → 挪一小步。 但真正的代码里塞了另外四样东西,每一样都不是可选项。

5.1 学习率调度:先慢慢加速,再慢慢刹车

「学习率」是每次调整参数时迈的步子有多大。

书里用的是预热 + 余弦退火(退火就是「让它慢慢降下来」,余弦退火就是照着余弦曲线降),分三段16:

① 预热阶段: 学习率从 0 线性涨到目标值
② 余弦退火: 按余弦曲线平滑衰减到最小值(目标值的十分之一)
③ 超出总步数:保持最小值

步子
│ ╭─────╮
│ ╱ ╲___
│ ╱ ╲______
└─╱──────────────────────── 训练步数
① ② ③

图说:开头小步走,是因为参数还是随机的,大步容易把模型带偏;
结尾小步走,是因为已经接近好答案了,大步会跨过去。

「余弦退火」的意思就是:按余弦函数那条从高到低的平滑曲线来降。 不是拍脑袋选的形状——它开头降得慢、中间降得快、结尾又降得慢,正好符合上面那个需求。

第 03 章提过,Pre-Norm 结构的一个好处就是可以去掉预热; 但书里仍然保留了它——在小模型上这是稳妥的做法。

主走查:这条样本被训练循环处理的那一步

上一节那条 13 个位置的样本,现在被喂进去了。它这一步经历的是这些数:

这一批装 4 条样本(其中一条就是「你好 → 你好呀」),每条 512 个位置

① 算损失:模型对每个位置猜下一个是什么,
只有掩码是 1 的那 4 个位置(你/好/呀/收尾记号)算进损失里,其余 9 个位置不罚
└ 假设这一步算出来的损失是 5.42

② 这是第 300 步,总共要跑 3 万步,预热定为前 1000 步:
└ 还在预热里 → 学习率 = 目标值 × 300 ÷ 1000 = 3e-4 × 0.3 = 9e-5
└ 等跑到第 1000 步涨满 3e-4,之后按余弦曲线一路降到 3e-5

③ 混合精度:先把 5.42 乘上一个大数(比如 65536)再往回推,
免得那些极小的调整量在低精度下被直接算成 0;更新参数前再除回来

④ 梯度累积 8 步:这一步算出的调整量先攒着不动,
攒到第 8 批才真正更新一次参数 —— 等效于一次喂 4 × 8 = 32 条

图说:所以「训练了 3 万步」这句话里的一步,不是「喂了一条样本」,
而是「喂了 32 条、攒完一起挪了一小步」。
(5.42、3e-4、1000 步、3 万步、65536 这几个数是为演示编的,不是真实数值;
书里只给了这套做法,没有公开这一组具体取值。)

5.2 用两种精度算同一件事

「精度」指的是一个数用多少位来存。 位数越多越准,也越占显存、算得越慢。 所以精度不是越高越好,而是一笔要算的账。

这笔账的答案叫混合精度,就是:大部分运算用低精度(快、省), 但关键的地方(比如参数的累加)保留高精度(准)17

为什么需要一个「缩放器」: 低精度能表示的最小数有下限, 而训练时那些调整量常常小到会被直接算成 0(叫「下溢」)。 所以先把损失乘上一个大数,让调整量落进能表示的范围,更新参数前再除回来17

5.3 梯度累积:用时间换显存

问题: 批量(一次同时喂进去多少条)开大了训练效果好(第 04 章 RoBERTa 那一节证明过),但显存装不下。

做法: 连算好几个小批,把每一批算出来的调整量攒起来,攒够了才真正更新一次参数18

显存只够批 = 4 想要的效果 = 批 32
└ 连算 8 个小批,调整量累加 ← 累积 8 步
└ 第 8 步之后才更新一次参数
└ 等效于一次批 32 的更新

图说:结果几乎等价,代价是慢了 8 倍。这是标准的「拿时间换显存」。

5.4 定期存盘

书里每隔固定步数记一次日志(日志就是程序一边跑一边往外写的那份流水账),存一次模型19

为什么必须有: 第 05 章说过,大规模训练中断难以避免。 没有存盘,一次断电就是几十小时白跑。

5.5 有监督微调的训练循环:几乎没变

书里一句话带过:有监督微调的代码和预训练基本一样,只是换了数据集20

这句话值得记住——它是第 05 章那个结论的具体印证: 两个阶段用的是同一道题(接龙)、同一个损失函数(就是把「答得离正确答案有多远」算成一个数的那条算式),差别只在数据和掩码。

6. 第四件事:让它吐字,以及真实的结果

6.1 生成

第 06 章讲过生成函数在干什么。这一节多的是把训好的模型真的跑起来: 加载模型文件、加载分词器、套上对话模板、然后一个标记一个标记地往下接21

主走查最后一步:训完之后,「你好」进去,「你好呀」出来

注意这一步和第 4.4 节那条样本的关系:训练时整条都摆在那儿,生成时右半截是空的。

你打的:你好

① 套上模板、换成编号,末尾停在 assistant 那一格后面:
[ 4, 812, 198, 1024, 1531, 5, 4, 903, 198 ] ← 9 个位置,后面什么都没有

② 整条流水线跑一遍,只看最后一个位置吐出来的 6144 个分数
└ 挑一个(第 06 章那两个旋钮:温度 0.6、top-k)→ 挑中 1024(你)
③ 把 1024 接到句尾,回到 ② → 挑中 1531(好)
④ 再来 → 挑中 2790(呀)
⑤ 再来 → 挑中 5(<|im_end|>)→ 停

拿到 [1024, 1531, 2790],查表反过来 → 「你好呀」

图说:第 4.4 节那条训练样本的右半截,就是这四步一个一个吐出来的。
训练时它是「照着抄」,生成时它是「自己接」——同一串编号,两种用法。
(编号沿用第 3.3 节为演示编的那套;温度 0.6 和 6144 来自书里。)

6.2 结果:两个版本,差别巨大

书里把真实输出原样贴了出来,这是它最诚实、也最有价值的一段。

对话版(做过有监督微调)22:

Q: 你好呀
A: 你好!有什么我可以帮你的吗?

Q: 中国的首都是哪里?
A: 中国的首都是北京。

Q: 1+1等于多少?
A: 1+1等于2。

底座版(只做过预训练)22——给它一个开头,让它往下写:

「北京大学是中国最早建立的研究型大学之一,是我国最早设置研究生院的高校之一, 是第一、二国教育委员会师资培训基地;北京大学是第一、二所国立大学, 其校名与北京大学相同。北京大学录取标准:本科三批1万元,本科一批1万元……」

读一遍就能看出:句子通顺、格式像模像样,但内容大量胡说。 「第一、二所国立大学」「校名与北京大学相同」「录取标准本科三批1万元」全是编的。

这两段输出并排放着,把第 05 章那三段训练的意义直接演示出来了:

底座版对话版
会不会说人话(通顺、有格式感)
会不会回答问题不会(只会接着往下写)
说得对不对大量胡说简单事实基本对

中间那一栏的差别,就是有监督微调干的活。 而第三栏那个「胡说」,2.15 亿参数这个体量下,再多的微调也补不上——那是预训练的活。

6.3 真实开销

书里给的数字非常具体,值得完整记住23:

项目数字
模型规模2.15 亿参数
作者的配置8 张 4090
预训练耗时46 小时
有监督微调耗时24 小时(350 万条中文指令)
单卡最低配置批大小设为 4 时,只要 7GB 显存——但预计要跑 533 小时

最后那一行是这一章最实在的一句话:

这件事一个人做得起,但要么有八张卡跑两天,要么有一张卡跑三周。

书里也给了退路:模型已经上传到公开平台,没有设备的人可以直接下载来体验24

7. 作者的判断与证据

说法书里给了什么该怎么看
三种分词路线各自的优缺点给了机制说明和例子可以采信,但没给任何出处
选 BPE 的理由给了三条(简单有效、能处理生词、词表小)成立,但没有和另外两种做对比实验
特殊标记能提高泛化能力只给了结论说法笼统;真正的作用是「让模型能分辨谁在说话」,书没讲透
第三种多轮对话构造最合理在第四章给了机制论证,这一章直接实现了它理论和实现对得上,这是这本书做得好的地方
混合精度、梯度累积只有代码和注释,正文一句没讲需要读者自己补
分词器测试的两个 False原样贴出,不做解释这是问题,不是细节——见第 3.6 节
真实训练开销给了完整数字(卡数、时长、最低显存)全书最有用的一段实操信息

判断(我们的,不是书里的): 这一章的价值排序和它的篇幅分配是反过来的。 篇幅最多的是代码,而最有价值的是三处非代码内容: ① 损失掩码的规则;② 两个版本输出的并排对照;③ 真实开销的数字。 前者能从任何仓库里读到,后三者只有真的训过才写得出来。 如果错,会错在: 如果读者的目标是照着改一个自己的训练脚本,那代码就是最有价值的部分, 这个排序对他要倒过来。

8. 边界与局限

① 分词器那两个 False 没有交代

见第 3.6 节。书把问题原样贴出来却不解释,是这一章最大的遗憾。

② 没讲的:词表大小怎么定

书里训分词器时用了 8192,而模型配置里写的是 614425两个数不一样,书里没有说明为什么。

更重要的是,「词表该多大」本身是一个有真实取舍的决定:

词表大词表小
同一段文字切出的标记更少 → 序列更短 → 算得快序列更长 → 算得慢
查表层参数变多(第 04 章 ALBERT 那一节算过)参数少
罕见词更可能被完整保留罕见词被切得更碎

书里完全没有讨论这个取舍,只是给了两个数。

③ 没讲的:怎么知道训得好不好

整章没有验证集,没有困惑度(一个衡量「模型对这段文字有多意外」的常用指标,越低越好),没有任何评测。 判断标准就是「跑几个例子看看输出」。

这在教学上可以接受,在实践上不行——你没法知道模型是在进步还是已经过拟合。 (评测是第 09 章的内容,但那一章讲的是基准数据集,不是训练过程中的监控。)

④ 没讲的:数据混合比例

第 05 章明说了「数据配比是核心秘籍」,但这一章只用了一份预训练语料,没有混合。 书里也没有提醒读者这是一个简化。

⑤ 代码里的一处不一致

书里正文说预训练数据集用的是**「能一条条往外吐」的那种底座**(适合一边读一边喂的超大文件), 但贴出来的代码用的是普通那种——一次性全装进内存26这两种在用法上有实际差别(前者不支持随机取样、要配不同的加载方式)。

判断(我们的,不是书里的): 这类「正文说 A、代码写 B」的不一致, 在这本书里已经出现第三次了(第 03 章两处、这里一处)。 它们都不影响道理,但都会让照着做的人卡住。 读法仍然是那一条:正文讲道理,代码当伪代码,要跑去配套仓库拿。 如果错,会错在: 如果配套仓库里确实用的是「能一条条往外吐」的那种实现,那正文没错、只是代码块摘错了版本。

9. 可带走的

  1. 模型不认字,认编号;给编号的是分词器,而分词器是训出来的;
  2. 按词切死在未登录词和中文,按字符切死在序列太长;所以今天全用子词切分;
  3. BPE 自底向上合并高频对,WordPiece 按「让整句概率最大」合并,Unigram 自顶向下按概率找最优切法;
  4. 特殊标记的真正作用是「让模型分辨谁在说话」,不只是「提高泛化」;
  5. 对话模板规定了一段对话怎么排成一条文本序列;它决定了后面损失掩码能不能写对;
  6. 预训练样本 = 原序列去尾当输入,原序列去头当目标——错开一位,一条序列里藏着 N 道题;
  7. 损失掩码标记哪些位置算分;预训练几乎全算,有监督微调只算模型该说的那部分;
  8. 有监督微调和预训练用的是同一道题、同一个损失函数,差别只在数据和掩码;
  9. 掩码写错不会报错——训练照跑、损失照降,只是训出个会模仿提问的模型。静默失败比崩溃危险;
  10. 学习率调度分三段:预热线性上升 → 余弦退火平滑下降 → 保持最小值;
  11. 混合精度用低精度算、高精度存,配一个缩放器防止调整量被算成 0;
  12. 梯度累积 = 攒够几个小批再更新一次,拿时间换显存;
  13. 必须定期存盘,大规模训练中断是常态;
  14. 只做过预训练的模型:话说得通顺,但只会接着往下写,而且大量胡说;
  15. 真实开销:2.15 亿参数,8 张 4090 跑 46 小时;单卡 7GB 显存也能跑,但要 533 小时。

10. 原文地图

主题原书章原文位置
三种分词路线5.2 训练 Tokenizertext/12-ch05-02-5-2-tokenizer.txt:28(搜「Word-based Tokenizer」) · text/12-ch05-02-5-2-tokenizer.txt:30(搜「OOV」) · text/12-ch05-02-5-2-tokenizer.txt:42(搜「Character-based Tokenizer」) · text/12-ch05-02-5-2-tokenizer.txt:57(搜「Subword Tokenizer」)
BPE / WordPiece / Unigram5.2 训练 Tokenizertext/12-ch05-02-5-2-tokenizer.txt:63(搜「反复合并频率最⾼」) · text/12-ch05-02-5-2-tokenizer.txt:80(搜「WordPiece 是另」) · text/12-ch05-02-5-2-tokenizer.txt:93(搜「Unigram 分词」)
训练分词器的五步5.2 训练 Tokenizertext/12-ch05-02-5-2-tokenizer.txt:112(搜「BPE 是⼀种简单⽽有效」) · text/12-ch05-02-5-2-tokenizer.txt:140(搜「序列猴⼦」) · text/12-ch05-02-5-2-tokenizer.txt:166(搜「Qwen2.5」) · text/12-ch05-02-5-2-tokenizer.txt:219(搜「特殊的 token」)
分词器测试里的两个 False5.3.1 数据下载text/13-ch05-03-5-3-1.txt:6(搜「Decoded text matches original」) · text/13-ch05-03-5-3-1.txt:13(搜「Special tokens preserved」)
两份数据集5.3.1 数据下载text/13-ch05-03-5-3-1.txt:23(搜「序列猴⼦通⽤⽂本数据集」) · text/13-ch05-03-5-3-1.txt:27(搜「BelleGroup」) · text/13-ch05-03-5-3-1.txt:41(搜「chunk_size=512」)
预训练样本的错位与损失掩码5.3.3 Datasettext/15-ch05-03-5-3-3-dataset.txt:59(搜「n-1」) · text/15-ch05-03-5-3-3-dataset.txt:66(搜「BOS, T1, T2」) · text/15-ch05-03-5-3-3-dataset.txt:76(搜「仅对T1-EOS计算损失」) · text/15-ch05-03-5-3-3-dataset.txt:50(搜「loss_mask = [1]」)
有监督微调的掩码规则5.3.4 预训练text/16-ch05-03-5-3-4.txt:13(搜「im_start」) · text/16-ch05-03-5-3-4.txt:18(搜「X 和 Y 是⼀样的」)
学习率调度5.3.4 预训练text/16-ch05-03-5-3-4.txt:79(搜「余弦退⽕调度策略」) · text/16-ch05-03-5-3-4.txt:82(搜「Warmup阶段」) · text/16-ch05-03-5-3-4.txt:94(搜「最⼩学习率」)
混合精度、梯度累积、存盘5.3.4 预训练text/16-ch05-03-5-3-4.txt:140(搜「混合精度」) · text/16-ch05-03-5-3-4.txt:152(搜「scaler.scale」) · text/16-ch05-03-5-3-4.txt:154(搜「accumulation_steps」) · text/16-ch05-03-5-3-4.txt:189(搜「save_interval」)
有监督微调只换数据集5.3.5 SFT 训练text/17-ch05-03-5-3-5-sft.txt:35(搜「导⼊的 Dataset 不⼀样」)
生成与真实结果5.3.6 使用模型生成文本text/18-ch05-03-5-3-6.txt:25(搜「TextGenerator」) · text/18-ch05-03-5-3-6.txt:207(搜「你好呀」) · text/18-ch05-03-5-3-6.txt:225(搜「北京⼤学是中国最早建」)
真实开销5.3.6 使用模型生成文本text/18-ch05-03-5-3-6.txt:204(搜「215.127」) · text/18-ch05-03-5-3-6.txt:242(搜「7G 显存」) · text/18-ch05-03-5-3-6.txt:242(搜「8卡4090」)

Footnotes

  1. 出处:「5.3.1 数据下载」第 17 段(text/13-ch05-03-5-3-1.txt:17,搜「⼋千万参数」)与「5.3.6 使用模型生成文本」第 204 段(text/18-ch05-03-5-3-6.txt:204,搜「215.127」)。注意书里在 5.3 节开头说的是「训练一个八千万参数的 LLM」,而最后跑出来的模型是 2.15 亿参数——书里没有解释这个不一致,从代码里的配置看,最终版本用的是更大的一组超参数(1024 维、18 层)。

  2. 出处:「5.2 训练 Tokenizer」第 28 段(text/12-ch05-02-5-2-tokenizer.txt:28,搜「Word-based Tokenizer」)、第 30 段(text/12-ch05-02-5-2-tokenizer.txt:30,搜「OOV」)、第 42 段(text/12-ch05-02-5-2-tokenizer.txt:42,搜「Character-based Tokenizer」)、第 57 段(text/12-ch05-02-5-2-tokenizer.txt:57,搜「Subword Tokenizer」)。

  3. 出处:「5.2 训练 Tokenizer」第 63 段(text/12-ch05-02-5-2-tokenizer.txt:63,搜「反复合并频率最⾼」)、第 80 段(text/12-ch05-02-5-2-tokenizer.txt:80,搜「WordPiece 是另」)、第 93 段(text/12-ch05-02-5-2-tokenizer.txt:93,搜「Unigram 分词」)。

  4. 出处:「5.2 训练 Tokenizer」第 112 段(text/12-ch05-02-5-2-tokenizer.txt:112,搜「BPE 是⼀种简单⽽有效」)。

  5. 补充(不在书里):BPE 用于自然语言的原始出处是《Neural Machine Translation of Rare Words with Subword Units》,第一作者 Rico Sennrich,首次公开于 2015 年 8 月 31 日,来源:https://arxiv.org/abs/1508.07909(查阅于 2026-08-25)。Unigram 的出处是《Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates》,作者 Taku Kudo,首次公开于 2018 年 4 月 29 日;摘要说它「提出了一种基于一元语言模型的新的分词算法」,来源:https://arxiv.org/abs/1804.10959(查阅于 2026-08-25)。同一作者后来做的 SentencePiece 把这些算法打包成了工具:《SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing》,作者 Taku Kudo 与 John Richardson,首次公开于 2018 年 8 月 19 日,摘要强调它「可以直接从原始句子训练子词模型」,来源:https://arxiv.org/abs/1808.06226(查阅于 2026-08-25)。WordPiece 的出处见第 04 章脚注。

  6. 出处:「5.2 训练 Tokenizer」第 113 段(text/12-ch05-02-5-2-tokenizer.txt:113,搜「tokenizers 库」)与第 223 段(text/12-ch05-02-5-2-tokenizer.txt:223,搜「vocab_size: int = 8192」)。

  7. 出处:「5.2 训练 Tokenizer」第 140 段(text/12-ch05-02-5-2-tokenizer.txt:140,搜「序列猴⼦」)。原文明说训分词器用的是「与预训练相同的数据集」。

  8. 出处:「5.2 训练 Tokenizer」第 219 段(text/12-ch05-02-5-2-tokenizer.txt:219,搜「特殊的 token」)与第 221 段(text/12-ch05-02-5-2-tokenizer.txt:221,搜「提⾼模型的泛化能⼒」)。 2

  9. 出处:「5.2 训练 Tokenizer」第 166 段(text/12-ch05-02-5-2-tokenizer.txt:166,搜「Qwen2.5」)与第 184 段(text/12-ch05-02-5-2-tokenizer.txt:184,搜「im_start」)。补充(不在书里):Qwen2.5 的技术报告首次公开于 2024 年 12 月 19 日,摘要里给的规模是 18 万亿标记的预训练数据、超过 100 万条样本的有监督微调。来源:https://arxiv.org/abs/2412.15115(查阅于 2026-08-25)。

  10. 出处:「5.3.1 数据下载」第 6 段(text/13-ch05-03-5-3-1.txt:6,搜「Decoded text matches original」)与第 13 段(text/13-ch05-03-5-3-1.txt:13,搜「Special tokens preserved」)。这两行是书里贴出的分词器测试实际输出,同样的内容在「5.3.3 Dataset」第 9 段(text/15-ch05-03-5-3-3-dataset.txt:9,搜「Decoded text matches original」)又出现了一次。

  11. 出处:「5.3.1 数据下载」第 23 段(text/13-ch05-03-5-3-1.txt:23,搜「序列猴⼦通⽤⽂本数据集」)与第 27 段(text/13-ch05-03-5-3-1.txt:27,搜「BelleGroup」)。

  12. 出处:「5.3.1 数据下载」第 41 段(text/13-ch05-03-5-3-1.txt:41,搜「chunk_size=512」)。

  13. 出处:「5.3.3 Dataset」第 59 段(text/15-ch05-03-5-3-3-dataset.txt:59,搜「n-1」)、第 66 段(text/15-ch05-03-5-3-3-dataset.txt:66,搜「BOS, T1, T2」)、第 76 段(text/15-ch05-03-5-3-3-dataset.txt:76,搜「仅对T1-EOS计算损失」)。原文:X 为编号序列的前 n-1 个元素,Y 为后 n-1 个元素;损失掩码用来标记哪些位置需要计算损失。 2

  14. 出处:「5.3.3 Dataset」第 50 段(text/15-ch05-03-5-3-3-dataset.txt:50,搜「loss_mask = [1]」)。代码里掩码是「文本长度个 1」加上「补位长度个 0」。补充(不在书里,来自通用知识):补位(padding)指为了让一批样本形状一致而在短序列后面填充无意义标记,书里用了这个概念但没有解释。

  15. 出处:「5.3.4 预训练」第 13 段(text/16-ch05-03-5-3-4.txt:13,搜「im_start」)与第 18 段(text/16-ch05-03-5-3-4.txt:18,搜「X 和 Y 是⼀样的」)。原文:掩码的生成规则是「当遇到 <|im_start|>assistant\n 时开始计算损失,直到遇到 <|im_end|> 为止」;并明说「SFT Dataset 和 Pretrain Dataset 的 X 和 Y 是一样的,只是在 SFT Dataset 中需要生成一个 loss_mask」。 2

  16. 出处:「5.3.4 预训练」第 79 段(text/16-ch05-03-5-3-4.txt:79,搜「余弦退⽕调度策略」)、第 82 段(text/16-ch05-03-5-3-4.txt:82,搜「Warmup阶段」)、第 94 段(text/16-ch05-03-5-3-4.txt:94,搜「最⼩学习率」)。「为什么开头要小步走」这层解释是我们补的(补充,不在书里,来自通用知识)。

  17. 出处:「5.3.4 预训练」第 140 段(text/16-ch05-03-5-3-4.txt:140,搜「混合精度」)与第 152 段(text/16-ch05-03-5-3-4.txt:152,搜「scaler.scale」)。书里只有代码注释,没有正文说明;「精度是什么」「缩放器为什么必要」这两层解释是我们补的(补充,不在书里,来自通用知识)。 2

  18. 出处:「5.3.4 预训练」第 154 段(text/16-ch05-03-5-3-4.txt:154,搜「accumulation_steps」)与第 145 段(text/16-ch05-03-5-3-4.txt:145,搜「args.accumulation_steps」)。代码里的做法是把每一批的损失先除以累积步数,再累加。

  19. 出处:「5.3.4 预训练」第 169 段(text/16-ch05-03-5-3-4.txt:169,搜「log_interval」)与第 189 段(text/16-ch05-03-5-3-4.txt:189,搜「save_interval」)。

  20. 出处:「5.3.5 SFT 训练」第 35 段(text/17-ch05-03-5-3-5-sft.txt:35,搜「导⼊的 Dataset 不⼀样」)。

  21. 出处:「5.3.6 使用模型生成文本」第 25 段(text/18-ch05-03-5-3-6.txt:25,搜「TextGenerator」)与第 96 段(text/18-ch05-03-5-3-6.txt:96,搜「add_generation_prompt=True」)。

  22. 出处:「5.3.6 使用模型生成文本」第 207 段(text/18-ch05-03-5-3-6.txt:207,搜「你好呀」)与第 225 段(text/18-ch05-03-5-3-6.txt:225,搜「北京⼤学是中国最早建」)。对话版用的温度是 0.6、最多生成 128 个标记;底座版用的温度是 0.75、最多 120 个标记(text/18-ch05-03-5-3-6.txt:180,搜「temperature=0.75」)。 2

  23. 出处:「5.3.6 使用模型生成文本」第 242 段(text/18-ch05-03-5-3-6.txt:242,搜「7G 显存」)与第 242 段(text/18-ch05-03-5-3-6.txt:242,搜「8卡4090」)。原文完整的交代是:预训练批大小为 4 时只需要 7GB 显存、训练时长预计 533 小时;作者是在 8 卡 4090 上训练的,预训练共耗时 46 小时,有监督微调阶段在 350 万条中文指令上训练 24 小时。

  24. 出处:「5.3.6 使用模型生成文本」第 245 段(text/18-ch05-03-5-3-6.txt:245,搜「设备不⾜以训练」)。

  25. 出处:「5.3.2 训练 Tokenizer」第 124 段(text/14-ch05-03-5-3-2-tokenizer.txt:124,搜「vocab_size: int = 8192」)与第 230 段(text/14-ch05-03-5-3-2-tokenizer.txt:230,搜「vocab_size=6144」);模型配置里的词表大小见「5.1 动手实现一个 LLaMA2 大模型」第 29 段(text/11-ch05-01-5-1-llama2.txt:29,搜「vocab_size: int = 6144」)。

  26. 出处:「5.3.3 Dataset」第 23 段(text/15-ch05-03-5-3-3-dataset.txt:23,搜「IterableDataset」)与第 28 段(text/15-ch05-03-5-3-3-dataset.txt:28,搜「class PretrainDataset(Dataset)」)。正文说用的是可迭代式数据集(IterableDataset),代码里用的是普通数据集(Dataset)。