第 1 讲:课程概述与分词(Tokenization)

Language Modeling from Scratch · Lecture 01。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应:Stanford CS336 Spring 2026 Lecture 1(Overview, tokenization)。Spring 2025 的 Lecture 1 内容基本相同,并且 YouTube 上有完整录像。 配套代码:code/lecture01/ 预计阅读时间:3–4 小时(第一遍通读 1.5 小时,第二遍对照代码 2 小时)


0. 本讲导航

0.1 学完本讲,你应该能够

  1. 说清楚这门课为什么要”从零开始”构建语言模型,以及小规模实验能学到什么、学不到什么。
  2. 正确理解”苦涩的教训”(The Bitter Lesson),解释为什么”效率”是贯穿整门课的主线。
  3. 大致说出语言模型从 n-gram 到今天的大模型的发展脉络,以及”闭源 / 开放权重 / 完全开源”三类模型的区别。
  4. 说出课程的五个单元(基础、系统、规模定律、数据、对齐)分别解决什么问题。
  5. 解释分词器(tokenizer)在语言模型中的位置和作用,用压缩率来评价一个分词器。
  6. 说出字符级、字节级、词级分词各自的优缺点,并解释 Unicode 和 UTF-8 的基本原理。
  7. 手算 BPE(Byte Pair Encoding)的训练过程;解释预分词、特殊 token、合并顺序为什么重要。
  8. 解释分词带来的各种”怪现象”:数字切分、多语言不公平、glitch token 等。

0.2 先修知识(不会也没关系,用到时会提醒)

知识点用在哪里最低要求
Python 基础配套代码会写函数、for 循环、列表、字典。其余语法代码注释里都会讲
概率基础第 3 节知道条件概率 p(A∣B)p(A \mid B) 是”已知 B 时 A 的概率”
二进制第 8 节(选读)知道一个字节 = 8 个二进制位,取值 0~255
神经网络第 1、4 节不需要。提到的 Transformer、注意力等概念,第 2、3 讲会从头讲

0.3 本讲地图

本讲分成两个几乎独立的部分:

第一部分:课程概述(为什么学、学什么)
   第1节  为什么要"从零开始"构建语言模型?
   第2节  苦涩的教训与"效率"思维
   第3节  语言模型简史
   第4节  课程全景:五个单元

第二部分:分词(本课程动手实现的第一个组件)
   第5节  分词要解决什么问题?怎么评价分词器?
   第6节  先看一个真实的分词器(GPT 系列)
   第7节  方案一:字符级分词(Unicode 码位)
   第8节  方案二:字节级分词(UTF-8)
   第9节  方案三:词级分词
   第10节 方案四:BPE —— 让数据决定词表          ← 本讲重点
   第11节 词表大小的权衡
   第12节 分词带来的"怪现象"
   第13节 超越分词:无分词器模型的尝试
   第14–18节 小结、与作业的关系、练习、延伸阅读、下一讲预告

第二部分的逻辑是一条”不断发现问题、不断改进”的链条:

字符级⏟词表太大  →  字节级⏟序列太长  →  词级⏟未知词  →  BPE⏟折中:从字节出发,合并常见片段\underbrace{\text{字符级}}_{\text{词表太大}} \;\to\; \underbrace{\text{字节级}}_{\text{序列太长}} \;\to\; \underbrace{\text{词级}}_{\text{未知词}} \;\to\; \underbrace{\text{BPE}}_{\text{折中:从字节出发,合并常见片段}}

每一种方案都是为了解决上一种方案的致命缺陷而出现的。理解了这条链,你就理解了今天几乎所有大模型分词器的设计动机。


第一部分:课程概述

1. 为什么要”从零开始”构建语言模型?

1.1 一个趋势:研究者离底层越来越远

回顾过去十年,做 NLP 研究的人和”模型本身”之间的距离在不断拉大:

大约年份研究者的典型工作方式需要懂多少底层?
2016 年前后自己设计网络结构、自己写训练代码、自己训练模型全部
2018 年前后下载一个预训练好的 BERT,在自己的任务上微调训练循环、模型接口
2020 年以后调用 GPT 系列的 API,写提示词(prompt)几乎不用懂

这种趋势本身是好事:抽象让更多人能用上强大的技术,就像你开车不需要懂发动机。但抽象是会”漏”的(软件工程里叫 leaky abstraction)——当事情出错时,你需要知道下面发生了什么。举几个你用 API 时就会碰到的例子:

  • 为什么 API 按 token 而不是按字数收费?为什么同样意思的中文有时比英文贵?(第 12 节)
  • 为什么模型数不清 “strawberry” 里有几个 r?(第 12 节)
  • 为什么上下文窗口是 128K token,而不是 128K 字?(第 5 节)

如果你想做基础性的研究——改进模型架构、训练方法、数据处理——就必须理解整个技术栈。CS336 的核心理念是:

通过构建来理解(understanding via building)。 不用 Hugging Face 等现成的库,而是用 PyTorch 的基本操作,亲手实现分词器、Transformer、优化器、训练循环、分布式训练、数据处理、对齐算法……

1.2 另一个现实:前沿模型遥不可及

问题在于,前沿模型(frontier models,即当前最强的那批模型)已经”工业化”了:

  • 成本:OpenAI 的 CEO 公开表示 GPT-4 的训练花费超过 1 亿美元。
  • 规模:xAI 为训练 Grok 搭建了约 20 万块 GPU 规模的集群;各大公司宣布的 AI 基础设施投资动辄数千亿美元。
  • 保密:GPT-4 的技术报告明确表示,出于竞争和安全考虑,不公开模型大小、架构、硬件、训练计算量、数据集构建和训练方法等任何细节。

所以一门大学课程不可能真的训练一个前沿模型。我们能做的是训练小模型。那么问题来了:

1.3 小模型不是大模型的”缩小版”

在小模型上学到的东西,能直接搬到大模型上吗?不一定。有两个典型原因:

原因一:计算量的”构成”会随规模变化。 以 Transformer 为例(细节在第 3 讲),每一层对每个 token 的计算量大致分成两块:

  • 与序列长度 LL 无关的矩阵乘法(注意力的投影矩阵 + 前馈网络 MLP):约 24d224d^2 次浮点运算;
  • 注意力本身(每个 token 要和前面所有 token 打交道):约 4Ld4Ld 次浮点运算。

其中 dd 是模型的隐藏维度。两者之比是 4Ld24d2=L6d\frac{4Ld}{24d^2} = \frac{L}{6d}。代入两个真实模型:

模型ddLL注意力部分占比
GPT-2 small(1.24 亿参数)7681024约 18%
GPT-3(1750 亿参数)122882048约 2.7%

小模型里,注意力占了可观的一块;大模型里,几乎全部计算都在 MLP 上。如果你在小模型上优化注意力,得出”注意力是瓶颈”的结论,放到大模型上可能完全不成立。

原因二:涌现(emergence)。 有些能力(比如按照指令做多步推理、上下文学习)只有在模型大到一定程度之后才会明显出现。在小模型上你根本观察不到它们。

1.4 那这门课到底能学到什么?三种知识

CS336 把知识分成三类,它们在”能否从小规模迁移到大规模”上表现不同:

知识类型例子能迁移到前沿模型吗?
机制(mechanics)Transformer 是怎么工作的;模型并行如何利用多块 GPU;BPE 怎么训练✅ 能。原理不随规模改变
思维方式(mindset)尽可能榨干硬件性能;认真对待规模(scaling);做资源核算✅ 能。这恰恰是大模型团队最看重的
直觉(intuitions)哪种数据、哪种架构改动能带来更好的模型⚠️ 只能部分迁移。很多结论只在特定规模下成立

关于”直觉”,有一个著名的例子:现代大模型普遍使用的 SwiGLU 激活函数,来自 Noam Shazeer 2020 年的论文。他在实验中发现这类变体效果更好,但在论文结尾半开玩笑地说,他无法解释为什么,只能把成功归功于”神的恩典”(divine benevolence)。深度学习中很多设计选择就是这样:先有实验结果,后(也许)有解释。所以本课程会教你如何做实验来获得直觉,而不是背诵结论。

1.5 如何用好这份讲义(以及 AI 助手)

CS336 官方对 AI 编程助手的态度很值得借鉴:AI 可以帮你写完作业,但那样你什么也学不到。推荐的用法是把 AI 当家教:让它解释概念、回答”为什么”、帮你找 bug 的方向,而不是让它直接写实现。

这份中文讲义的用法建议:

  1. 先读讲义,建立直觉和动机;
  2. 运行配套代码,对照输出理解每个数字从哪里来;
  3. 自己动手重写关键函数(比如 BPE 训练),再和配套代码对比。

2. 苦涩的教训与”效率”思维

2.1 苦涩的教训说了什么

2019 年,强化学习先驱 Richard Sutton 写了一篇短文《苦涩的教训》(The Bitter Lesson)。核心观点是:回顾 70 年的 AI 研究,能充分利用计算资源的通用方法(搜索、学习)最终总是胜出;而那些把人类知识精心编码进系统的方法,短期内有效,长期来看总被前者超越。之所以”苦涩”,是因为研究者们倾注心血设计的巧妙方法,最后被”简单方法 + 更多算力”打败了。

2.2 常见的误读 vs 正确的理解

  • ❌ 误读:“规模就是一切,算法不重要,堆算力就行。”
  • ✅ 正确理解:“能随规模扩展的算法才重要。”

用一个公式来记:

模型效果  =  效率×资源\text{模型效果} \;=\; \text{效率} \times \text{资源}

资源(算力、数据)决定了上限能有多高,而效率决定了你能把资源转化成多少效果。算法的作用恰恰体现在”效率”这一项上。

2.3 证据:算法进步和硬件进步一样重要

OpenAI 在 2020 年的一项研究(Hernandez & Brown, AI and Efficiency)测量了:要在 ImageNet 上训练出 AlexNet 水平的模型,所需的计算量随时间如何变化。结论是:2012 到 2019 年间,所需计算量减少了 44 倍,大约每 16 个月减半。同一时期,摩尔定律带来的硬件提升只有约 11 倍。也就是说,算法进步贡献的效率提升比硬件还大。

2.4 为什么规模越大,效率越重要?

直觉很简单:如果一次训练花 1000 美元,浪费 10% 无所谓;如果一次训练花 1 亿美元,浪费 10% 就是 1000 万美元。而且大规模训练无法反复试错——你可能只有一次机会。所以在大规模下,每一个设计决策都要精打细算。

2.5 本课程的核心问题

整门课可以浓缩成一个问题:

给定固定的计算和数据预算,你能训练出的最好的模型是什么?

这是一个优化问题,目标是最大化效率。你会发现,课程中几乎每一个技术决策都能从这个角度理解。

2.6 效率如何驱动具体的设计决策

当前的大模型训练处于”计算受限”(compute-constrained)的状态:数据相对充足,算力是瓶颈。于是:

环节为了效率而做的决策对应讲次
数据过滤掉低质量数据,不把算力浪费在垃圾数据上第 13–14 讲
分词直接对原始字节建模很优雅,但序列太长、太贵 → 用 BPE 压缩本讲
架构很多改动是为了省显存或省计算(例如多个注意力头共享 KV 缓存、滑动窗口注意力)第 3–4 讲
训练数据多到只需要过一遍(单个 epoch)第 2 讲
规模定律在小模型上找超参数,再外推到大模型,省下大模型上的试错成本第 9、11 讲
对齐针对具体用途对齐后,较小的基座模型也能胜任第 15–16 讲

未来,高质量数据可能会先于算力耗尽,进入”数据受限”状态。那时的最优决策会改变(比如数据要重复多轮使用、模型要更大)。理解”为什么”比记住”是什么”更重要,因为”是什么”会随条件变化。


3. 语言模型简史

3.1 什么是语言模型?

语言模型(language model, LM)就是一个给文本序列分配概率的模型。把一段文本看成 token 序列 x1,x2,…,xnx_1, x_2, \dots, x_n,由概率的链式法则:

p(x1,x2,…,xn)  =  ∏t=1np(xt∣x1,…,xt−1)p(x_1, x_2, \dots, x_n) \;=\; \prod_{t=1}^{n} p(x_t \mid x_1, \dots, x_{t-1})

所以语言模型只需要学会一件事:给定前文,预测下一个 token 的概率分布。比如给定”今天天气很”,模型应该给”好""热""冷”较高的概率,给”桌子”很低的概率。生成文本时,就不断地”预测下一个 token → 采样 → 接到末尾 → 再预测”。

这个目标看起来很简单,为什么能训练出强大的模型?因为要把下一个词预测得很准,模型必须掌握语法、事实知识、常识,甚至推理能力。比如要预测”法国的首都是___“,模型得知道这个事实;要预测一道数学题解答的下一步,模型得会算。

一个有用的直觉:预测就是压缩。 香农的信息论告诉我们,一个能准确预测下一个符号的模型,可以用来把文本压缩得很小(越可预测的东西,需要的比特越少)。所以”训练更好的语言模型”和”找到更好的文本压缩方法”本质上是一回事。这也是后面评价语言模型常用”困惑度”(perplexity)和”每字节比特数”(bits per byte)的原因。

3.2 发展脉络

时期里程碑意义
1948 / 1951香农(Shannon)用 n-gram 近似生成英文;用”让人猜下一个字母”的实验估计英语的熵语言模型思想的起点
2000sn-gram 模型广泛用于机器翻译、语音识别。2007 年 Google 的 Brants 等人在约 2 万亿 token 上训练 5-gram 模型,论文标题就叫 Large Language Models in Machine Translation“大语言模型”这个词早就有了
1997LSTM能处理长序列的循环神经网络
2003Bengio 等人:第一个神经语言模型用神经网络 + 词向量代替计数
2014Seq2seq(序列到序列);Adam 优化器神经机器翻译;至今最常用的优化器家族
2014–2015Bahdanau 等人:注意力机制让模型在生成时”回头看”输入的任意位置
2017Transformer(Attention Is All You Need)今天几乎所有大模型的基础架构
2017混合专家(Mixture of Experts, MoE)参数很多但每次只激活一部分(第 4 讲)
2018–2019模型并行:GPipe、ZeRO、Megatron-LM让一个模型能分布在成百上千块 GPU 上训练(第 7–8 讲)
2018–2019基础模型:ELMo、BERT、T5“先在海量文本上预训练,再迁移到具体任务”
2019GPT-2(15 亿参数)流畅的文本生成,初现零样本能力
2020规模定律(Kaplan 等人)模型效果随规模可预测地提升(第 9 讲)
2020GPT-3(1750 亿参数)上下文学习(in-context learning):在提示里给几个例子就能做新任务
2022PaLM(5400 亿参数);Chinchilla(700 亿参数)Chinchilla 发现当时的大模型普遍”训练数据不足”,给出了计算最优的配比(约每个参数 20 个 token)

3.3 开放程度:三类模型

类别公开了什么代表
闭源模型只有 APIGPT-4 系列、Claude、Gemini
开放权重模型模型权重 + 论文/技术报告(通常不公开完整训练数据)Llama、Mistral、DeepSeek、Qwen、Kimi、GLM 等
完全开源模型权重 + 论文 + 训练代码 + 训练数据AI2 的 OLMo、Marin 等

早期的开放尝试包括 EleutherAI 的数据集 The Pile 和模型 GPT-J、Meta 复现 GPT-3 的 OPT(1750 亿参数)、BigScience 注重数据的 BLOOM。如今开放权重模型的能力已经非常接近闭源模型。对学习者来说,完全开源的模型最有价值:你能看到训练的每一个环节。

3.4 使用范式的演变

  • 2018:微调(fine-tune)——每个任务训练一个模型;
  • 2020:提示(prompt)——一个模型,用提示词完成各种任务;
  • 2022:对话(converse)——ChatGPT 式的多轮对话;
  • 2026:自主智能体(agents)——模型自己调用工具、写代码、完成长链条任务。

范式在变,但底层都是同一个东西:一个预测下一个 token 的模型。这门课学的就是如何把这个东西造出来。


4. 课程全景:五个单元

先看一张”大模型是怎么做出来的”流程图,课程的五个单元分别对应其中的环节:

  原始数据 ──► 数据处理 ──► 分词 ──► 模型架构 ──► 训练 ──► 基座模型 ──► 对齐 ──► 可用的模型
 (网页等)    (单元4:数据)  (单元1)   (单元1)    (单元1+2)              (单元5)
                                                  ▲
                              单元2(系统):让训练和推理跑得快
                              单元3(规模定律):在小规模上做实验,预测大规模的最优配置

4.1 单元一:基础(Basics)—— 作业 1

目标:从零实现训练一个标准 Transformer 语言模型所需的全部组件。

  • 分词器:实现 BPE(本讲);
  • 模型架构:Transformer,包括现代改进(RMSNorm、SwiGLU、RoPE 等,第 3 讲);
  • 训练:交叉熵损失、AdamW 优化器、学习率调度、训练循环;
  • 实践:在 TinyStories(简单的儿童故事,适合小模型)和 OpenWebText(网页文本)上训练,并参加排行榜:在固定的 GPU 时间内把验证集困惑度降到最低。

设计模型时要平衡三件事:表达力(能不能表示复杂的依赖关系)、稳定性(训练时梯度和参数的大小要”不多不少”)、效率(能不能在硬件上跑得快)。

4.2 单元二:系统(Systems)—— 作业 2

目标:让训练和推理在硬件上跑得尽可能快。先感受一下规模:

训练一个模型大约需要 6ND6ND 次浮点运算(NN = 参数量,DD = 训练 token 数,第 2 讲会推导这个公式)。训练一个 700 亿参数的模型、用 1 万亿个 token:

6×(70×109)×(1012)=4.2×1023 次浮点运算6 \times (70 \times 10^9) \times (10^{12}) = 4.2 \times 10^{23} \text{ 次浮点运算}

一块 NVIDIA B200 GPU 的 BF16 稠密算力约为 2.25×10152.25 \times 10^{15} 次/秒。即使 100% 利用率,一块卡也要算 4.2×1023/2.25×1015≈1.9×1084.2\times10^{23} / 2.25\times10^{15} \approx 1.9 \times 10^8 秒,约 5.9 年。用 1000 块卡、按现实中常见的 40% 利用率算,也要约 5.4 天。所以系统优化至关重要:

  • 核函数(kernels):GPU 上最昂贵的往往不是计算,而是数据搬运。通过算子融合(fusion)、分块(tiling,FlashAttention 就是典型)减少数据搬运。会用 Triton 写 GPU 核函数(第 5–6 讲)。
  • 并行:数据并行、张量并行、流水线并行、序列并行、专家并行……把一个训练任务拆到多块 GPU 上(第 7–8 讲)。
  • 推理:分成预填充(prefill,处理提示词,计算受限)和解码(decode,逐个生成 token,内存带宽受限)两个阶段。优化手段包括量化、蒸馏、推测解码、连续批处理等(第 10 讲)。

4.3 单元三:规模定律(Scaling Laws)—— 作业 3

核心思想:不要只想”在某个规模下怎么训练最好”,而是要找到一个配方:给定计算量(FLOPs),如何确定模型大小、数据量、学习率等超参数。

做法:在一系列较小的规模上做实验 → 拟合出规律(通常是幂律)→ 外推到目标规模,在花大钱之前就预测出大模型的效果和最优配置。

  • Chinchilla 的结论:计算最优时,训练 token 数约为参数量的 20 倍(例如 700 亿参数配 1.4 万亿 token);
  • 但这没有考虑推理成本:模型部署后要被调用无数次,更小的模型推理更便宜。所以实践中常常”过度训练”小模型(例如 Llama 3 的 80 亿参数模型用了约 15 万亿 token,远超 20 倍);
  • 超参数迁移:用合适的参数化方式(如 μP),让小模型上调好的超参数能直接用到大模型上(第 11 讲)。

4.4 单元四:数据(Data)—— 作业 4

核心问题:模型的能力很大程度上取决于数据。数据从哪来、怎么处理?

  • 评估:怎么判断模型好不好?困惑度、各类基准测试、测试集污染问题(第 12 讲);
  • 数据来源:网页爬取(Common Crawl)、书籍、论文、代码……以及版权问题(第 13 讲);
  • 处理流程:HTML → 纯文本;质量/有害内容过滤(训练分类器);去重(布隆过滤器、MinHash);数据配比;用语言模型改写/合成数据(第 14 讲)。

4.5 单元五:对齐(Alignment)—— 作业 5

为什么需要对齐:预训练得到的”基座模型”只会续写文本。你问它一个问题,它可能接着编出更多问题。对齐让模型变成有用、安全的助手。

  • 监督微调(SFT):在”指令–回答”数据上继续训练;
  • 从反馈中学习:生成回答 → 打分(人类、验证器或另一个模型)→ 更新模型。算法包括 PPO(InstructGPT 使用)、DPO(直接用偏好数据,更简单)、GRPO(去掉了价值函数);
  • 可验证奖励的强化学习(RLVR):在数学、代码等答案可自动验证的任务上用 RL 训练推理能力(第 15–16 讲)。

第二部分:分词(Tokenization)

5. 分词要解决什么问题?

5.1 模型看到的不是文字,而是整数

神经网络只会做数学运算。一段文字要送进模型,需要经过这样一条流水线:

 "Hello world"
      │  分词器 encode
      ▼
 ["Hello", " world"]        ← token(字符串片段)
      │  查词表
      ▼
 [15496, 995]               ← token id(整数)
      │  嵌入层(查表)
      ▼
 [[0.12, -0.3, ...],        ← 每个 id 对应一个向量
  [0.88, 0.05, ...]]
      │  Transformer
      ▼
 下一个 token 的概率分布(在整个词表上)

分词器(tokenizer)负责第一步和最后一步:

  • 编码(encode):字符串 → 整数序列(token id);
  • 解码(decode):整数序列 → 字符串。

所有可能的 token 组成词表(vocabulary),词表的大小记作 VV。模型的嵌入层是一个 V×dV \times d 的表,输出层给出 VV 个 token 上的概率分布——词表大小直接决定了模型的一部分结构。

为什么叫”token”而不叫”词”? 因为分词的单位不一定是词:可能是字符、字节、词的一部分(如 “ing”),甚至是词加前面的空格(如 ” world”)。token 是一个中性的词,意思是”模型处理的最小单位”。

5.2 分词不是模型的一部分,但影响巨大

分词器通常是单独训练的(在语言模型训练之前),训练完就固定下来。它不参与梯度下降,但它决定了:

  • 模型”看到”的世界是什么样的(同一句话的不同切法,对模型来说是完全不同的输入);
  • 序列有多长(直接决定计算成本);
  • 模型有多少参数花在嵌入层上。

5.3 怎么评价一个分词器?

标准含义为什么重要
压缩率平均每个 token 包含多少字节的原文:UTF-8 字节数token 数\dfrac{\text{UTF-8 字节数}}{\text{token 数}}越高,序列越短,计算越省,上下文窗口”装”的内容越多
词表大小有多少个不同的 token影响嵌入层和输出层的参数量
无损(可逆)decode(encode(s))=s\text{decode}(\text{encode}(s)) = s 对任何 ss 成立否则模型看到的和原文不一致,生成时也可能出错
无未知 token任何输入都能编码否则遇到新词、新语言、表情符号就只能输出 <unk>
词表利用率每个 token 在训练数据里都足够常见罕见的 token 学不好(第 12 节的 glitch token)

这些标准互相冲突,没有完美的方案,只有折中。

压缩率用字节而不是字符来算,是为了让不同语言可以公平比较(一个汉字在 UTF-8 中是 3 个字节,一个英文字母是 1 个字节)。配套代码 02_simple_tokenizers.py 里的 get_compression_ratio 就是这个定义。

5.4 为什么序列长度这么重要?

  1. 注意力的计算量随序列长度平方增长。序列长 4 倍,注意力的计算量约为 16 倍(第 3 讲详细分析)。
  2. 上下文窗口是按 token 算的。一个 1024 token 的窗口,压缩率为 1 时只能装下约 1 KB 文本;压缩率为 4.4 时能装下约 4.5 KB。
  3. 训练和推理的成本都按 token 计。同样一份数据,token 越少,训练越快;API 调用也越便宜。

所以我们希望压缩率尽量高。那为什么不把词表做得无限大(比如每个常见句子一个 token)?因为词表越大,嵌入参数越多,而且每个 token 越罕见、越学不好(第 11 节)。


6. 先看一个真实的分词器

在动手实现之前,先观察 OpenAI 的分词器是怎么切分文本的。你可以在网上搜索 “tiktokenizer” 找到交互式的在线工具,也可以运行配套代码 05_tokenizer_experiments.py(需要 pip install tiktoken)。

用 GPT-2 的分词器编码一句英文童话:

"Once upon a time, there was a little girl named Lily. She loved to play..."
→ ['Once', ' upon', ' a', ' time', ',', ' there', ' was', ' a', ' little', ' girl',
   ' named', ' Lily', '.', ' She', ' loved', ' to', ' play', ...]

这句英文共 109 字节,被切成 25 个 token,压缩率约 4.4。仔细观察,可以发现几个规律:

  1. 单词通常连同它前面的空格一起构成一个 token:' upon'、' time'。
  2. 因此同一个词在句首和句中是不同的 token。在 GPT-2 中,'hello' 的 id 是 31373,' hello'(带前导空格)是 23748,'Hello' 是 15496,而 'HELLO' 甚至被拆成了 3 个 token。
  3. 数字被切成不规则的片段:'2026' → '20' + '26','1000000' → '1' + '000000'。
  4. 中文被切得很碎,一个汉字可能被拆成好几个”半个字”的 token(字节片段)。

用”同一个意思”的英文和中文比较三代 OpenAI 分词器(数据来自配套代码实验 6):

分词器词表大小英文句子意思相同的中文句子中文 / 英文
GPT-250,25725 个 token62 个 token2.48
GPT-4(cl100k_base)100,27725421.68
GPT-4o(o200k_base)200,01925240.96

同样的意思,GPT-2 处理中文要多花一倍半的 token;到了词表 20 万的 o200k_base,中文和英文基本持平。读完本讲,你就能解释这些现象的成因。


7. 方案一:字符级分词

7.1 背景:Unicode 是什么

计算机里的每个字符都有一个编号。Unicode 是一张全世界通用的”字符 → 编号”对照表,这个编号叫码位(code point),习惯写成 U+ 加十六进制数:

字符码位(十进制)码位(U+ 写法)
a97U+0061
é233U+00E9
你20320U+4F60
🌍127757U+1F30D

Python 用 ord 和 chr 在字符和码位之间转换:ord('你') == 20320,chr(20320) == '你'。码位的合法范围是 0 到 0x10FFFF(约 111 万个位置),其中真正分配了字符的约有 15 万个,覆盖了世界上几乎所有文字、符号和表情。

7.2 做法

最直接的想法:Unicode 已经给每个字符编好号了,直接拿码位当 token id:

encode("Hello, 🌍! 你好!") = [72, 101, 108, 108, 111, 44, 32, 127757, 33, 32, 20320, 22909, 33]

这个例句有 13 个字符、20 个字节,所以压缩率 = 20 / 13 ≈ 1.54。

7.3 问题

  1. 词表巨大:要覆盖所有码位,词表需要约 111 万个位置(只算已分配的也有约 15 万)。模型的嵌入层和输出层要为每个 token 准备参数。
  2. 大部分 token 极其罕见:大量字符(古文字、生僻字、特殊符号)在训练数据中几乎不出现。它们占据了词表位置,却学不好。在配套代码的小语料里(约 1.4 万个字符),452 种不同字符中有 161 种只出现过 1 次。
  3. 压缩率不高:英文每个字符只有 1 个字节,压缩率就是 1。

为什么”罕见 token 学不好”是个问题? 模型的每个 token 都有一个嵌入向量,只有当这个 token 出现在训练数据中时,它的向量才会被梯度更新。出现次数越少,向量越接近随机初始化,模型对它的”理解”就越差。


8. 方案二:字节级分词

8.1 背景:UTF-8 编码

码位只是编号,存储和传输时需要把它变成字节(byte,0~255 的整数)。UTF-8 是最流行的方案,它是变长的:

码位范围字节数字节模式(x 为码位的二进制位)典型字符
U+0000 ~ U+007F10xxxxxxx英文字母、数字、ASCII 标点
U+0080 ~ U+07FF2110xxxxx 10xxxxxx带重音的拉丁字母、希腊字母、俄文
U+0800 ~ U+FFFF31110xxxx 10xxxxxx 10xxxxxx绝大多数汉字、日文假名
U+10000 ~ U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxxemoji、生僻汉字

UTF-8 有几个精妙的设计(配套代码 01_unicode_and_utf8.py 演示 3 手写了一个 UTF-8 编码器):

  • 兼容 ASCII:1 字节部分和 ASCII 完全一样,所有老的英文文件天然就是合法的 UTF-8。
  • 自同步:看首字节的开头几位就知道这个字符占几个字节;后续字节都以 10 开头,不会和首字节混淆。从任意位置开始读,都能找到下一个字符的开头。
  • 没有字节序问题:UTF-16/UTF-32 的一个字符由多个字节组成一个”整数”,需要约定大端还是小端,文件开头常带一个字节序标记(BOM);UTF-8 按字节定义,没有这个问题。

为什么大模型选 UTF-8,而不是 UTF-16 或 UTF-32?(这也是作业 1 的一道思考题)配套代码演示 4 测量了不同文本的编码长度:

文本字符数UTF-8UTF-16UTF-32
英文句子5353 字节106212
Python 代码323264128
中文句子257550100

英文和代码用 UTF-8 最省;中文用 UTF-8 反而比 UTF-16 长。但训练数据以英文网页和代码为主,而且互联网上绝大多数网页本来就用 UTF-8 存储,所以整体上 UTF-8 是最自然、最省的选择;UTF-32 则浪费太多(大部分字节是 0)。

8.2 做法

把文本编码成 UTF-8 字节,每个字节就是一个 token:

encode("Hello, 🌍! 你好!") = [72, 101, 108, 108, 111, 44, 32, 240, 159, 140, 141, 33, 32,
                              228, 189, 160, 229, 165, 189, 33]

优点非常诱人:

  • 词表只有 256 个,小而固定;
  • 永远不会遇到未知 token:任何语言、任何符号、甚至乱码,都能编码。

8.3 问题:序列太长

压缩率恒等于 1。中文每个字要 3 个 token,emoji 要 4 个。结合第 5.4 节:序列长度是 BPE 的 4 倍多,注意力的计算量就是约 19 倍(配套代码 02_simple_tokenizers.py 演示 5)。在当前”计算受限”的状态下,这太贵了。

8.4 一个重要的坑:字节不一定能解码

编码(字符串 → 字节)永远不会失败,但解码(字节 → 字符串)可能失败:随便一串字节不一定是合法的 UTF-8。比如 '你' 的 UTF-8 是 3 个字节 [228, 189, 160],如果只拿前两个 [228, 189] 去解码,就会报错——首字节说”我是 3 字节字符的开头”,但后面的字节不够。

这在大模型里是真实的问题:

  • 模型一次生成一个 token,而一个 token 可能只是”半个汉字”。如果每生成一个 token 就立刻解码显示,就会遇到非法字节。
  • 作业 1 有一道题给出了一个”逐字节解码”的错误函数:它对英文能用,对中文就会崩溃(配套代码演示 5 复现了它)。

正确做法是:先把所有 token 的字节拼起来,最后统一解码;遇到真正非法的字节,用 errors="replace" 把它替换成 �(U+FFFD),而不是让程序崩溃。

8.5 选读:看起来一样的文字,字节可能不一样

Unicode 允许同一个”视觉字符”有多种表示:é 可以是 1 个码位 U+00E9,也可以是 e + 组合尖音符 U+0301(2 个码位)。两者看起来完全一样,但字节不同、分词结果也不同。有的分词器(如 SentencePiece 的默认配置)会先做 Unicode 规范化(NFKC)统一表示;而 GPT-2 式的字节级 BPE 不做规范化,原样保留所有字节,以保证严格无损。两种选择各有利弊(配套代码演示 6)。


9. 方案三:词级分词

9.1 做法

用规则(正则表达式)把文本切成”词”,给每个不同的词分配一个编号。CS336 课上用的例子是正则 \w+|.(一串单词字符,或者任意一个其他字符):

"I'll say supercalifragilisticexpialidocious!"
→ ['I', "'", 'll', ' ', 'say', ' ', 'supercalifragilisticexpialidocious', '!']

8 个 token 装下了 44 个字节,压缩率 5.5,很高。

9.2 问题

  1. 词表无界。语言中的词遵循 Zipf 定律:少数词非常常见(the、a、and),而大量的词非常罕见。新词、人名、拼写错误、数字组合……永远有没见过的词。在配套代码的小语料上,只出现过 1 次的”词”占了词表的 60%。
  2. 未知词。训练时没见过的词只能映射成一个特殊 token <unk>(unknown)。在我们的小语料上训练后,上面那句话被解码成 "I'll <unk> <unk>!"——信息永久丢失,分词是有损的。
  3. <unk> 让评估失真。计算困惑度时,所有未知词都被当成”同一个词”,模型只需要预测”这里有个未知词”就能拿到不错的分数。
  4. 对中文几乎不可用。中文词与词之间没有空格,\w+ 会把一整句中文当成”一个词”:'我爱自然语言处理' → ['我爱自然语言处理']。中文需要专门的分词工具(如 jieba),而切法本身就有歧义:经典例子”南京市长江大桥”,可以切成”南京市 / 长江 / 大桥”,也可以切成”南京 / 市长 / 江大桥”。

一个隐蔽的实现坑:在正则里,. 默认不匹配换行符 \n。如果不加 re.DOTALL 标志,re.findall 会悄悄丢掉所有换行符,分词结果无法还原原文。配套代码演示 3 专门演示了这个坑。

9.3 小结:我们想要什么?

方案词表压缩率无损未知词
字符级巨大(约 15 万~111 万)低(英文 1.0)✅几乎没有
字节级2561.0✅没有
词级无界高(英文约 5)❌有

理想的方案应该:像字节级一样永远不会遇到未知 token、词表大小可控,同时像词级一样对常见文本有高压缩率。思路很自然:

常见的片段用一个 token 表示;罕见的片段拆开,最坏退化成字节。

而”哪些片段常见”不应该由人来规定,而应该从数据中统计出来。这就是 BPE。


10. 方案四:BPE —— 让数据决定词表

10.1 背景与历史

BPE(Byte Pair Encoding,字节对编码)最早是 Philip Gage 在 1994 年提出的一种数据压缩算法:反复把数据中最常见的相邻字节对替换成一个新的、未使用的字节。

2016 年,Sennrich 等人把它引入神经机器翻译,用来解决罕见词问题:把词拆成”子词”(subword),罕见词就能用常见子词拼出来(比如 “lowest” = “low” + “est”)。

2019 年,GPT-2 做了一个关键改进:从 UTF-8 字节开始做 BPE(字节级 BPE),而不是从字符开始。这样初始词表就是 256 个字节,任何文本都能编码,彻底消灭了未知 token。从此以后,GPT 系列、Llama 3、Qwen 等大多数大模型都使用字节级 BPE(或它的变体)。

10.2 直觉

BPE 的核心思想可以用一句话概括:

从字节出发,反复把最常一起出现的相邻两个 token 合并成一个新 token。

几个帮助理解的角度:

  • 像速记:一个人经常写”人工智能”,时间长了就会用”AI”来缩写。常用的东西值得一个短符号。
  • 像哈夫曼编码的精神:常见的东西用短编码,罕见的东西用长编码。BPE 让常见片段成为 1 个 token,罕见片段需要多个 token。
  • “训练”分词器:词表不是人规定的,而是在一个训练语料上统计出来的。所以分词器会偏向它的训练数据——这一点在第 12 节会变得非常重要。

10.3 训练算法

输入:训练文本,合并次数 num_merges
1. 把文本编码成 UTF-8 字节序列,每个字节是一个 token(id 为 0~255)
2. 初始化词表:{0: b'\x00', 1: b'\x01', ..., 255: b'\xff'}
3. 重复 num_merges 次:
     a. 统计序列中每一个"相邻 token 对"出现的次数
     b. 选出次数最多的那一对 (A, B)
     c. 分配一个新 id,词表中记录:新 token 的字节 = A 的字节 + B 的字节
     d. 把序列中所有的 (A, B) 替换成新 token
     e. 记录这条合并规则:(A, B) → 新 id
输出:词表(id → 字节串)+ 有序的合并规则列表

最终词表大小 = 256 + 合并次数(+ 特殊 token 数)。

10.4 手算例子:the cat in the hat

用 CS336 课上的例子,在字符串 "the cat in the hat" 上做 3 次合并(配套代码 03_bpe_from_scratch.py 演示 2 会逐步打印这个过程)。

初始状态:18 个字节,每个字节一个 token(下面用字符表示字节,␣ 表示空格):

t h e ␣ c a t ␣ i n ␣ t h e ␣ h a t

第 1 次合并。统计所有相邻对:

相邻对次数出现位置
(t, h)2the, the
(h, e)2the, the
(e, ␣)2the␣, the␣
(a, t)2cat, hat
其余各对1

四个对并列第一。需要一个确定的平局规则,否则同样的数据可能训练出不同的分词器。CS336 作业 1 的约定是选字典序最大的那一对(比较字节串):t > h > e > a,所以选 (t, h),得到新 token 256 = th:

th e ␣ c a t ␣ i n ␣ th e ␣ h a t        (16 个 token)

第 2 次合并。现在 (th, e)、(e, ␣)、(a, t) 都出现 2 次,字典序 th > e > a,选 (th, e),得到 257 = the:

the ␣ c a t ␣ i n ␣ the ␣ h a t          (14 个 token)

第 3 次合并。(the, ␣) 和 (a, t) 都出现 2 次,选 (the, ␣),得到 258 = the␣:

the␣ c a t ␣ i n ␣ the␣ h a t            (12 个 token)

学到的合并规则:

1. (t, h)     → 256 = "th"
2. (256, e)   → 257 = "the"
3. (257, ␣)   → 258 = "the "

注意新 token 可以由之前合并出的 token 继续合并而成,这就是 BPE 能学出越来越长的 token 的原因。

CS336 课堂代码用的平局规则是”取最先出现的那一对”(Python max 的默认行为)。在这个例子里两种规则恰好给出相同的结果,但一般情况下不同。

10.5 编码与解码

编码:把新文本变成字节,然后按训练时的顺序依次应用每一条合并规则。用上面训练出的小分词器编码 "the quick brown fox":

字节:t h e ␣ q u i c k ␣ b r o w n ␣ f o x    (19 个)
规则1:th e ␣ q u i c k ...
规则2:the ␣ q u i c k ...
规则3:the␣ q u i c k ␣ b r o w n ␣ f o x      (16 个 token)

训练时从没见过的字符(比如中文)会怎样?它们保持为单字节 token,照样能编码、照样能无损还原。这就是字节级 BPE 相对词级分词的根本优势。

解码:查词表把每个 id 变回字节串,全部拼起来后统一做 UTF-8 解码(原因见第 8.4 节)。

为什么必须按训练顺序应用规则? 假设学到的规则是”先 (a, b)→ab,再 (b, c)→bc”。编码 "abc":按正确顺序得到 ['ab', 'c'];如果顺序反了,得到 ['a', 'bc']。两种切法都能解码回 "abc",但它们是不同的 token 序列。模型训练时只见过按正确顺序切出的序列,推理时换一种切法,模型看到的就是”陌生的输入”。所以合并规则的顺序和词表一样,是分词器不可分割的一部分(配套代码演示 4)。

10.6 预分词:为什么要先”粗切”一刀

在一个真实一点的小语料上跑一下 03 号文件的教学版 BPE(300 次合并),学到的前几个 token 是:

'e ', 'd ', '. ', 'th', ' a', 'er', 't ', 's ', 'ed ', 'the ', ...

最长的几个 token 是 '," said '、'. They '、'in the '。在 300 个新 token 中,有 83 个跨越了单词边界(把词尾和空格、标点粘在一起)。这有什么问题?

  1. 浪费词表。GPT-2 的论文指出:不加限制的 BPE 会学出 dog、dog.、dog!、dog? 等大量”同一个词 + 不同标点”的变体,每一个都占一个词表位置,而它们的含义几乎一样。
  2. 切分不自然。in the 这样的 token 把两个词粘在一起,模型更难学到”词”这个层次的规律。
  3. 训练太慢。每次合并都要扫描整个语料。

解决办法:预分词(pre-tokenization)。在 BPE 之前,先用一个正则表达式把文本切成”类似单词”的小块(pre-token),BPE 只在每个小块内部合并,不跨越小块边界。GPT-2 用的正则表达式是:

'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+

逐段解释:

片段含义例子
'(?:[sdmt]|ll|ve|re)英文缩写单独成块I'm → I + 'm
?\p{L}+可选的一个空格 + 一串字母(\p{L} 包括任何语言的字母,也包括汉字)' world'、'你好'
?\p{N}+可选的一个空格 + 一串数字' 12345'
?[^\s\p{L}\p{N}]+可选的一个空格 + 一串标点/符号'!!'、' $'、' 🌍!!'
\s+(?!\S)一串空白,但如果后面紧跟非空白字符,就留下最后一个空白"a b" → a、' '、' b'
\s+剩下的空白

几个关键的设计:

  • 空格跟着后面的词走:"hello world" → ['hello', ' world']。这就是 GPT 词表里有大量以空格开头的 token 的原因,也解释了第 6 节”同一个词在句首和句中是不同 token”的现象。
  • \s+(?!\S) 的巧思:"a b"(3 个空格)切成 ['a', ' ', ' b'],让 b 总是以 ' b' 的形式出现,而不会因为前面空格数量不同而产生 ' b'、' b' 等变体。
  • 缩写只认小写和 ASCII 撇号:"DON'T" 会被切成 ['DON', "'", 'T'],而 "don't" 切成 ['don', "'t"]。这是 GPT-2 正则的一个小缺陷,后来 GPT-4 的正则加入了大小写不敏感的处理。
  • 中文:由于汉字属于 \p{L},而中文句子中间没有空格,一整个分句(两个标点之间的所有汉字)会成为一个预分词块,BPE 在块内部学习合并。

预分词还带来巨大的速度优势:真实语料里 ' the' 可能出现上亿次,但我们只需要把它当作”一个词、出现上亿次”来处理。

加入预分词后,在同样的语料上训练(配套代码 04_bpe_tokenizer.py 演示 3),前几条合并变成了:

'h'+'e'→'he',  ' '+'t'→' t',  ' '+'a'→' a',  ' '+'s'→' s',  'e'+'d'→'ed',
' '+'w'→' w',  ' t'+'he'→' the',  'n'+'d'→'nd',  ...

最长的 token 是 ' little'、' looked'、' friend'、'小明'、'妈妈' 这类完整的词,中间或末尾含空格的 token 数量降为 0。

边界:预分词必须是无损的——所有块拼起来一定等于原文。GPT-2 的正则保证了每个字符都会被某个分支匹配到。Python 标准库 re 不支持 \p{L} 写法,需要安装第三方的 regex 库;配套代码在没有 regex 时会使用一个用标准库写的近似版本,在英文和中文上结果完全相同。

10.7 特殊 token

特殊 token(special token)是人为规定的”控制符号”,最常见的是 <|endoftext|>,用来标记一篇文档的结束。训练数据中,不同文档被拼接成一个长序列,文档之间用它分隔,模型就能学会”一篇文章写完了”。对话模型还会用特殊 token 标记”系统 / 用户 / 助手”等角色。

特殊 token 必须满足三点:

  1. 永远作为一个整体,对应唯一的一个 id,不能被 BPE 拆开;
  2. 不参与合并统计,否则会学出 '<|'、'endoftext' 或跨文档的 token;
  3. 训练时合并不能跨越它:它两侧的文本属于不同的文档。

实现方法:先按特殊 token 把文本切开,特殊 token 直接映射到它的 id,普通文本再走”预分词 → BPE”的流程。

一个实现细节:如果有多个特殊 token 并且互相重叠(比如同时有 <|endoftext|> 和 <|endoftext|><|endoftext|>),切分时要让长的优先匹配,否则长的那个永远匹配不到(作业 1 的测试专门检查了这一点)。

安全提示:如果用户输入的文本里混进了 <|endoftext|>,而分词器把它当作特殊 token 处理,用户就能”伪造”文档边界或角色标记,这是一种注入攻击。所以 tiktoken 默认会拒绝编码用户文本中的特殊 token,需要调用者显式允许。

10.8 让它变快:训练与编码的优化

教学版 BPE 的训练时间 ∝ 合并次数 × 语料长度。按配套代码测得的速度粗略外推,要在约 40 GB 的文本上做 GPT-2 那样的 50,000 次合并,需要约 6 年。作业 1 要求你在 TinyStories 和 OpenWebText 上训练 BPE,必须优化:

训练优化:

  1. 预分词 + 计数:只处理不同的预分词块,每个块带上出现次数。
  2. 增量更新:合并 (A, B) 只会影响包含 (A, B) 的那些块。维护一张”相邻对 → 包含它的块”的倒排索引,每次合并只更新受影响的块:先减去这个块旧的所有相邻对计数,再加上合并后新的相邻对计数。其他块的计数完全不用动。
  3. 并行预分词:预分词通常是最耗时的部分。把大文件切成若干块(切分点必须落在特殊 token 上,保证不会把一个词切成两半),用多进程并行处理,最后把计数加起来。

编码优化:

  1. 只看相关的规则:教学版对每条合并规则都扫描一遍序列(几万条规则,绝大多数和当前文本无关)。优化后,对每个预分词块,只看块内实际存在的几个相邻对,每次合并其中优先级最高(训练时最早学到)的那一对,直到没有可合并的对为止。可以证明这与”按顺序应用所有规则”的结果完全相同(证明思路见 04 号文件 BPETokenizer 的文档字符串)。
  2. 缓存:自然语言里常见词反复出现,同一个预分词块编码一次后记住结果。

配套代码用一个”慢但显然正确”的参考实现与高效版对拍,验证两者得到完全相同的词表和合并规则——这是写算法时非常实用的技巧。更进一步,实验 6 把 GPT-2 真实的 50,000 条合并规则装进我们自己写的分词器,在整个语料上与 OpenAI 官方的 tiktoken 逐 token 完全一致。

10.9 其他分词算法(了解即可)

  • WordPiece(BERT 使用):与 BPE 类似,但选择合并时不看”出现次数”,而看合并后能让语言模型似然提升多少。
  • Unigram 语言模型分词(SentencePiece 支持):反过来,从一个很大的词表开始,逐步删掉”最不重要”的 token。
  • SentencePiece:一个分词工具库(不是算法),把空格当作普通字符(显示为 ▁),支持 BPE 和 Unigram。Llama 1/2 使用 SentencePiece BPE;Llama 3 改用了基于 tiktoken 的字节级 BPE。

11. 词表大小的权衡

真实模型的词表大小:

模型 / 分词器词表大小
GPT-250,257(256 字节 + 50,000 次合并 + 1 个特殊 token)
Llama 1 / 232,000
GPT-4(cl100k_base)约 10 万
Llama 3128,256
Qwen 2 / 2.5约 15 万
GPT-4o(o200k_base)约 20 万
Gemma约 25.6 万
CS336 作业 1TinyStories 上 10,000;OpenWebText 上 32,000

趋势很明显:词表越来越大。为什么?下面是权衡的两边。

词表大的好处:

  • 压缩率更高 → 序列更短 → 计算更省、上下文装得更多;
  • 能为更多语言分配足够的 token(多语言更公平,见第 12 节和第 6 节的表格)。

词表大的代价:

  • 参数量:嵌入层和输出层都有 V×dV \times d 个参数。GPT-2 small 的嵌入矩阵是 50,257×768≈3,86050{,}257 \times 768 \approx 3{,}860 万参数,约占全部 1.24 亿参数的 31%。对小模型来说,大词表会让大部分参数花在嵌入上;对几百亿参数的大模型,这部分占比就很小了——所以大模型才”负担得起”大词表。
  • 输出层计算:每生成一个 token,都要在整个词表上算一次概率分布。
  • 罕见 token:词表越大,越多 token 在训练数据中很少出现,学不好。
  • 边际收益递减:配套代码实验 1 显示,词表从 257 增加到 600 时,测试集英文压缩率从 1.00 涨到 2.12;而从 1500 增加到 2000 时,只从 2.69 涨到 2.69——几乎不涨了(训练集还在涨,但测试集已经饱和,类似过拟合)。
设定词表训练集测试·英文测试·中文
2571.001.001.00
3001.341.461.17
6002.132.121.56
10002.772.371.79
20003.802.692.09

(这是在约 17 KB 的小语料上的结果,只为展示趋势。真实分词器在 TB 级语料上训练,英文压缩率约为 4。)

为什么测试集会饱和?把词表继续加大就能看清楚:BPE 在合并完所有常见的对之后,会开始合并只出现 1 次的对,最终把每个预分词块整个合并成一个 token——也就是退化成了词级分词,第 9 节的所有问题又回来了。在我们的小语料上,词表 2000 时最长的 token 已经是一整个中文分句 '非常喜欢在公园里和朋友们一起玩耍'(中文分句中间没有空格,整句就是一个预分词块);词表设为 3000 时,训练在约 2960 处就因为”没有相邻对可合并”而提前结束了。所以词表大小必须和训练语料的规模相匹配。


12. 分词带来的”怪现象”

分词器是在模型训练之前、用单独的算法和数据确定下来的,它的”偏见”会传递给模型。下面这些现象都能用本讲的知识解释。

12.1 数字与算术

GPT-2 的切法完全由训练语料中哪些数字片段常见来决定(数据来自配套代码实验 3):

数字GPT-2GPT-4(cl100k_base)
123412 34123 4
12345123 45123 45
10000001 000000100 000 0
202620 26202 6

在 GPT-2 中,同一个数位在不同数字里可能落在不同 token 的不同位置,模型想”对齐个位、十位做加法”就很困难。改进的做法包括:GPT-4 的预分词正则把数字限制为最多 3 位一组(\p{N}{1,3});Llama 1/2 则把每个数字单独切开。

12.2 字符级任务

“strawberry 里有几个 r?“——模型看到的不是 10 个字母,而是几个 token(比如 str + aw + berry)。它必须”记住”每个 token 由哪些字母组成才能回答,这对模型来说并不自然。类似地,拼写、倒序、押韵等任务都受分词影响。

12.3 大小写与空格

'hello'、' hello'、'Hello'、'HELLO' 是完全不同的 token(在 GPT-2 中 id 分别是 31373、23748、15496,而 'HELLO' 被拆成 3 个 token)。模型必须分别学习它们,而且全大写的文本因为罕见,往往被切得很碎。另一个实际问题是:提示词末尾多一个空格可能导致切分方式与训练时不同(训练时空格总是和后面的词一起出现),从而影响生成质量。

12.4 多语言不公平

分词器在哪种语言上训练得多,就对哪种语言更友好。配套代码实验 2 在同一份小语料上训练了两个同样大小的分词器:

分词器英文测试 token 数中文测试 token 数
A:只用英文训练331561
B:英文 + 中文训练348360

只用英文训练的 A 从没见过汉字,每个汉字都只能拆成 3 个字节 token;加入中文后,中文的 token 数降了约 36%,英文只多了约 5%。

对真实用户而言,同样的意思用某些语言表达需要更多 token,就意味着:API 更贵、上下文装得更少、生成更慢。Petrov 等人(2023)系统测量了这种”分词器带来的语言不公平”。第 6 节的表格也显示,新一代词表更大的分词器在中文上改善了很多。

12.5 Glitch token:分词器与模型的”数据错位”

2023 年初,有人发现 GPT-3 看到 ' SolidGoldMagikarp' 这个字符串时行为非常离奇(比如拒绝重复它、给出莫名其妙的回答)。原因是:

  • 它在 GPT-2/GPT-3 的词表里是一个 token(GPT-2 中 id 为 43453),据分析来自分词器训练语料里频繁出现的一个 Reddit 用户名;
  • 但语言模型的训练数据经过了不同的清洗,这个 token 几乎从未出现;
  • 所以它的嵌入向量几乎没有被训练过,模型”不知道它是什么”。

配套代码实验 4 在小规模上复现了这个成因:在一个 1000 词表的分词器里,743 个合并产生的 token 中,有 113 个在编码后的训练语料中从未作为最终 token 出现,另有 110 个只出现 1~2 次。比如 '妈' 从未单独出现——因为它总是被继续合并成 '妈妈'。这些 token 占据词表位置,却几乎得不到训练信号。

教训:分词器的训练数据应该和模型的训练数据分布一致。

一个与中文相关的真实案例:2024 年 GPT-4o 发布后,有人检查它的新词表,发现其中最长的一批中文 token 里有不少来自垃圾网站(赌博、色情广告等)的固定短语。据分析,原因是分词器训练语料里的中文网页没有被充分清洗,这些垃圾短语重复出现的次数极多,于是被 BPE 合并成了整个 token;而模型训练数据经过了更严格的过滤,这些 token 很少出现、训练不足,用户输入它们时模型容易出现异常行为。这和 SolidGoldMagikarp 是同一个成因,也再次说明了第 14 讲”数据过滤”的重要性。

12.6 代码与空白

代码里大量的缩进空格如果每个都是一个 token,会严重浪费序列长度。GPT-2 的分词器对代码很不友好;后来的分词器专门加入了”连续多个空格”的 token,大幅提升了代码的压缩率。


13. 超越分词:无分词器模型的尝试

分词器有这么多问题,能不能干脆不要它,直接在字节上建模?这是一个活跃的研究方向:

工作年份思路
ByT52021直接在 UTF-8 字节上训练 T5 模型
MEGABYTE2023把字节分成固定大小的”块”,用大模型处理块之间的关系、小模型处理块内部
BLT(Byte Latent Transformer)2024根据”下一个字节有多难预测”(熵)动态地决定块的边界
T-FREE2024用字符三元组的稀疏表示代替词表
H-Net2025让模型端到端地学习如何分块(动态分块)

这些方法很有前景:它们避免了第 12 节的各种怪现象,也让模型能”看到”每个字符。但到目前为止,它们还没有在前沿模型的规模上被验证并成为主流。核心困难仍然是效率:直接处理字节意味着序列长 4 倍左右,必须设计巧妙的架构来弥补。在那一天到来之前,BPE 仍然是最实用的选择。

CS336 对分词的定位:分词是一个”必要之恶”。它是一个与模型分开的、基于启发式规则的预处理步骤,理论上并不优雅,但在当前的算力条件下,它带来的效率提升是实实在在的。


14. 本讲小结

课程概述:

  1. 研究者离底层越来越远,但抽象会”漏”;要做基础研究,就要通过构建来理解整个技术栈。
  2. 前沿模型昂贵且不透明;小模型不是大模型的缩小版。能迁移的是机制和思维方式,直觉只能部分迁移。
  3. 苦涩的教训的正确理解:能随规模扩展的算法才重要。效果 = 效率 × 资源。
  4. 核心问题:给定计算和数据预算,能训练出的最好模型是什么? 效率驱动了从数据、分词、架构到训练、对齐的几乎所有设计决策。
  5. 五个单元:基础、系统、规模定律、数据、对齐。

分词:

  1. 分词器负责 字符串 ↔ 整数序列 的转换。评价标准:压缩率、词表大小、无损、无未知 token、词表利用率。
  2. 字符级:词表太大、罕见字符多。字节级:词表 256、永不遇到未知 token,但压缩率为 1、序列太长。词级:压缩率高,但词表无界、遇到未知词就有损,对中文几乎不可用。
  3. BPE:从字节出发,反复合并最常见的相邻对。常见片段 → 1 个 token,罕见片段 → 多个 token,最坏退化成字节。
  4. 关键细节:确定的平局规则;编码时按训练顺序应用合并;预分词防止跨词合并并大幅加速;特殊 token 不参与合并、整体映射。
  5. 词表越大,压缩率越高但收益递减,嵌入参数越多;大模型才负担得起大词表。
  6. 分词的”偏见”会传递给模型:数字切分影响算术、多语言不公平、glitch token……

15. 与作业 1 的关系

作业 1 的第一部分就是本讲的内容。你需要:

  1. 回答 Unicode 相关的小问题:chr(0) 是什么;为什么在 UTF-8 而不是 UTF-16/32 上训练分词器;为什么”逐字节解码”是错的;找一个不能解码的两字节序列。(本讲第 7、8 节,配套代码 01)
  2. 实现 train_bpe(input_path, vocab_size, special_tokens),返回词表 dict[int, bytes] 和合并规则 list[tuple[bytes, bytes]]。要求:使用 GPT-2 的预分词正则;特殊 token 不参与合并;平局时选字典序更大的对;速度和内存满足要求。(本讲第 10 节,配套代码 04)
  3. 在 TinyStories(词表 10K)和 OpenWebText(词表 32K)上训练,回答:最长的 token 是什么?合不合理?训练花了多少时间和内存?哪一步最耗时?
  4. 实现 Tokenizer 类:encode、decode、encode_iterable(流式编码大文件)、from_files(从文件加载)。要能正确处理重叠的特殊 token。
  5. 实验:计算两个分词器的压缩率;用 TinyStories 的分词器去编码 OpenWebText 会怎样;估算分词器的吞吐量,以及编码 The Pile(825 GB)需要多久;把编码结果存成 uint16 数组——为什么 uint16 合适?(因为词表不超过 65,536,每个 token id 用 2 个字节就够了,比 Python 的整数省得多。)

建议:先独立完成,卡住时再看配套代码的思路。配套代码 04 号文件与作业要求的接口基本一致,但直接抄代码学不到东西。


16. 练习

练习 1(BPE 手算)★:在字符串 "aaabdaaabac" 上训练 3 次合并,平局时选字典序更大的对(比较字节串)。写出每一步的相邻对计数、选中的对和合并后的序列。

参考答案
  • 初始:a a a b d a a a b a c。相邻对计数:(a,a)=4(注意 aaa 中 (a,a) 重叠计了 2 次,出现两处 aaa)、(a,b)=2、(b,d)=1、(d,a)=1、(b,a)=1、(a,c)=1。选 (a,a) → aa。合并时从左往右不重叠:aaa → aa + a。序列变为 aa a b d aa a b a c(9 个)。
  • 第 2 步:(aa,a)=2、(a,b)=2 并列。比较字节串:b'aa' > b'a'(前缀更短的更小),所以选 (aa,a) → aaa。序列:aaa b d aaa b a c(7 个)。
  • 第 3 步:(aaa,b)=2 最多 → aaab。序列:aaab d aaab a c(5 个)。

可以用 03_bpe_from_scratch.py 中的 train_bpe_naive("aaabdaaabac", 3, verbose=True) 验证。 (维基百科上的经典例子得到的是 aa→ab→aaab,因为它的平局规则不同。这再次说明平局规则必须明确规定。)

练习 2(UTF-8):é(U+00E9)、€(U+20AC)、𝄞(U+1D11E)的 UTF-8 编码分别有几个字节?首字节的二进制开头分别是什么?

参考答案
  • é:2 字节,11000011 10101001,首字节以 110 开头;
  • €:3 字节,11100010 10000010 10101100,首字节以 1110 开头;
  • 𝄞:4 字节,11110000 10011101 10000100 10011110,首字节以 11110 开头。

可以用 01_unicode_and_utf8.py 中的 encode_utf8_manually 验证。

练习 3(预分词):用 GPT-2 的正则手动预分词 "I've got 3 cats!! Yes"(Yes 前有两个空格)。

参考答案

['I', "'ve", ' got', ' 3', ' cats', '!!', ' ', ' Yes']。 注意两个空格:\s+(?!\S) 只吃掉第一个空格(留下最后一个),第二个空格和 Yes 组成 ' Yes'。 可以用 04_bpe_tokenizer.py 中的 pretokenize 验证。

练习 4(解码陷阱):给出一个长度为 2、无法用 UTF-8 解码的字节序列,并解释原因。

参考答案

例如 b'\xc3\x28':首字节 0xC3(11000011)表示”这是一个 2 字节字符的开头”,要求第二个字节以 10 开头;但 0x28(00101000,即 ()不是。又如 b'\xe4\xbd':首字节表示 3 字节字符,但只给了 2 个字节。

练习 5(词表与参数):Llama 3 8B 的词表大小是 128,256,隐藏维度 d=4096d = 4096,输入嵌入和输出层不共享参数。这两部分共有多少参数?约占 80 亿总参数的多少?

参考答案

每部分 128,256×4096=525,336,576≈5.25128{,}256 \times 4096 = 525{,}336{,}576 \approx 5.25 亿,两部分共约 10.5 亿,约占总参数的 13%。对比 GPT-2 small 的 31%,可见模型越大,大词表的”参数负担”越轻。

练习 6(思考):如果训练 BPE 时不先按 <|endoftext|> 把文本切开,而是把它当作普通文字,会发生什么?

参考答案

(1) <|endoftext|> 本身会被拆成 <|、endoftext、|> 等片段,占用词表位置,而且编码时无法保证它是单个 token;(2) 如果不做预分词,还可能学出跨越文档边界的 token,比如把上一篇的结尾和下一篇的开头粘在一起;(3) 模型无法可靠地学习”文档结束”这一信号,生成时也不能用它作为停止条件。

练习 7(为什么按优先级合并是对的)★★:04 号文件的编码方法是”在块内找 rank 最小的相邻对并合并,重复直到无可合并”,而 03 号文件是”按顺序逐条应用所有合并规则”。试说明两者结果相同。

参考答案

关键观察:合并 (A, B)→C 之后,序列中新出现的相邻对只有 (左邻居, C) 和 (C, 右邻居),它们都包含 C。而任何包含 C 的合并规则,都一定是在 C 被创造出来之后才学到的,rank 一定大于 (A, B) 的 rank。所以合并 (A, B) 不会让任何 rank 更小的规则变得”可用”。 因此,“当前块中 rank 最小的相邻对”恰好就是”按顺序扫描规则表时,下一条真正会生效的规则”。两种方法每一步做的合并完全相同。

练习 8(动手):

(a) 修改 04_bpe_tokenizer.py 中的 FALLBACK_PATTERN,把数字部分 \d+ 改成 \d{1,3}(模仿 GPT-4),或者 \d(模仿 Llama 1/2),重新训练并观察数字的切分变化。(如果你装了 regex 库,要改的是 GPT2_PATTERN 中的 \p{N}+。) (b) 在 05_tokenizer_experiments.py 的实验 2 中,把词表大小从 600 改成 300 和 1500,中英文 token 数的差距如何变化? (c) 用 02_simple_tokenizers.py 的 WordTokenizer,把 max_vocab_size 设为 100,看看测试文本中有多少比例的词变成了 <unk>。

练习 9(预习第 2 讲)★:用 6ND6ND 估算:在 1024 块 A100 GPU(每块 BF16 稠密算力约 312 TFLOP/s)上,以 50% 的利用率训练 GPT-3(1750 亿参数、3000 亿 token)大约需要多少天?

参考答案

总计算量 6×175×109×300×109=3.15×10236 \times 175 \times 10^9 \times 300 \times 10^9 = 3.15 \times 10^{23} FLOPs。 有效算力 1024×312×1012×0.5≈1.6×10171024 \times 312 \times 10^{12} \times 0.5 \approx 1.6 \times 10^{17} FLOP/s。 时间 ≈3.15×1023/1.6×1017≈1.97×106\approx 3.15 \times 10^{23} / 1.6 \times 10^{17} \approx 1.97 \times 10^6 秒 ≈\approx 23 天。


17. 延伸阅读

  • CS336 Spring 2026 / 2025 Lecture 1 的课件(可执行的 Python 讲义,见 GitHub stanford-cs336/lectures)与录像。
  • Richard Sutton,The Bitter Lesson(2019,短文,15 分钟读完)。
  • Hernandez & Brown,Measuring the Algorithmic Efficiency of Neural Networks(2020)。
  • Sennrich, Haddow, Birch,Neural Machine Translation of Rare Words with Subword Units(2016):BPE 进入 NLP。
  • Radford et al.,Language Models are Unsupervised Multitask Learners(GPT-2 论文,2019):第 2.2 节介绍字节级 BPE 和预分词的动机。
  • Petrov et al.,Language Model Tokenizers Introduce Unfairness Between Languages(2023)。
  • Andrej Karpathy,Let’s build the GPT Tokenizer(视频)和 minbpe 代码库:另一个非常好的 BPE 从零实现教程。
  • Pagnoni et al.,Byte Latent Transformer: Patches Scale Better Than Tokens(2024):无分词器方向的代表工作。

18. 下一讲预告

本讲把文本变成了整数序列。下一讲(PyTorch 与资源核算)开始搭建模型的”地基”:

  • PyTorch 的张量(tensor)是什么?它在内存里如何存储?
  • 浮点数格式:FP32、FP16、BF16、FP8 有什么区别?为什么大模型训练用 BF16?
  • 一次矩阵乘法要多少次浮点运算?6ND6ND 公式是怎么来的?
  • 训练一个模型需要多少显存?参数、梯度、优化器状态、激活值各占多少?
  • 什么是 MFU(模型浮点利用率)?

下一讲之前,建议你:跑通 code/lecture01/ 下的 5 个脚本,完成练习 1、3、7,并尝试不看代码自己写一遍 BPE 训练函数。

← 返回课程目录