对应:Stanford CS336 Spring 2026 Lecture 1(Overview, tokenization)。Spring 2025 的 Lecture 1 内容基本相同,并且 YouTube 上有完整录像。 配套代码:
code/lecture01/预计阅读时间:3–4 小时(第一遍通读 1.5 小时,第二遍对照代码 2 小时)
0. 本讲导航
0.1 学完本讲,你应该能够
- 说清楚这门课为什么要”从零开始”构建语言模型,以及小规模实验能学到什么、学不到什么。
- 正确理解”苦涩的教训”(The Bitter Lesson),解释为什么”效率”是贯穿整门课的主线。
- 大致说出语言模型从 n-gram 到今天的大模型的发展脉络,以及”闭源 / 开放权重 / 完全开源”三类模型的区别。
- 说出课程的五个单元(基础、系统、规模定律、数据、对齐)分别解决什么问题。
- 解释分词器(tokenizer)在语言模型中的位置和作用,用压缩率来评价一个分词器。
- 说出字符级、字节级、词级分词各自的优缺点,并解释 Unicode 和 UTF-8 的基本原理。
- 手算 BPE(Byte Pair Encoding)的训练过程;解释预分词、特殊 token、合并顺序为什么重要。
- 解释分词带来的各种”怪现象”:数字切分、多语言不公平、glitch token 等。
0.2 先修知识(不会也没关系,用到时会提醒)
| 知识点 | 用在哪里 | 最低要求 |
|---|---|---|
| Python 基础 | 配套代码 | 会写函数、for 循环、列表、字典。其余语法代码注释里都会讲 |
| 概率基础 | 第 3 节 | 知道条件概率 是”已知 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节 小结、与作业的关系、练习、延伸阅读、下一讲预告
第二部分的逻辑是一条”不断发现问题、不断改进”的链条:
每一种方案都是为了解决上一种方案的致命缺陷而出现的。理解了这条链,你就理解了今天几乎所有大模型分词器的设计动机。
第一部分:课程概述
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 的计算量大致分成两块:
- 与序列长度 无关的矩阵乘法(注意力的投影矩阵 + 前馈网络 MLP):约 次浮点运算;
- 注意力本身(每个 token 要和前面所有 token 打交道):约 次浮点运算。
其中 是模型的隐藏维度。两者之比是 。代入两个真实模型:
| 模型 | 注意力部分占比 | ||
|---|---|---|---|
| GPT-2 small(1.24 亿参数) | 768 | 1024 | 约 18% |
| GPT-3(1750 亿参数) | 12288 | 2048 | 约 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 的方向,而不是让它直接写实现。
这份中文讲义的用法建议:
- 先读讲义,建立直觉和动机;
- 运行配套代码,对照输出理解每个数字从哪里来;
- 自己动手重写关键函数(比如 BPE 训练),再和配套代码对比。
2. 苦涩的教训与”效率”思维
2.1 苦涩的教训说了什么
2019 年,强化学习先驱 Richard Sutton 写了一篇短文《苦涩的教训》(The Bitter Lesson)。核心观点是:回顾 70 年的 AI 研究,能充分利用计算资源的通用方法(搜索、学习)最终总是胜出;而那些把人类知识精心编码进系统的方法,短期内有效,长期来看总被前者超越。之所以”苦涩”,是因为研究者们倾注心血设计的巧妙方法,最后被”简单方法 + 更多算力”打败了。
2.2 常见的误读 vs 正确的理解
- ❌ 误读:“规模就是一切,算法不重要,堆算力就行。”
- ✅ 正确理解:“能随规模扩展的算法才重要。”
用一个公式来记:
资源(算力、数据)决定了上限能有多高,而效率决定了你能把资源转化成多少效果。算法的作用恰恰体现在”效率”这一项上。
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 序列 ,由概率的链式法则:
所以语言模型只需要学会一件事:给定前文,预测下一个 token 的概率分布。比如给定”今天天气很”,模型应该给”好""热""冷”较高的概率,给”桌子”很低的概率。生成文本时,就不断地”预测下一个 token → 采样 → 接到末尾 → 再预测”。
这个目标看起来很简单,为什么能训练出强大的模型?因为要把下一个词预测得很准,模型必须掌握语法、事实知识、常识,甚至推理能力。比如要预测”法国的首都是___“,模型得知道这个事实;要预测一道数学题解答的下一步,模型得会算。
一个有用的直觉:预测就是压缩。 香农的信息论告诉我们,一个能准确预测下一个符号的模型,可以用来把文本压缩得很小(越可预测的东西,需要的比特越少)。所以”训练更好的语言模型”和”找到更好的文本压缩方法”本质上是一回事。这也是后面评价语言模型常用”困惑度”(perplexity)和”每字节比特数”(bits per byte)的原因。
3.2 发展脉络
| 时期 | 里程碑 | 意义 |
|---|---|---|
| 1948 / 1951 | 香农(Shannon)用 n-gram 近似生成英文;用”让人猜下一个字母”的实验估计英语的熵 | 语言模型思想的起点 |
| 2000s | n-gram 模型广泛用于机器翻译、语音识别。2007 年 Google 的 Brants 等人在约 2 万亿 token 上训练 5-gram 模型,论文标题就叫 Large Language Models in Machine Translation | “大语言模型”这个词早就有了 |
| 1997 | LSTM | 能处理长序列的循环神经网络 |
| 2003 | Bengio 等人:第一个神经语言模型 | 用神经网络 + 词向量代替计数 |
| 2014 | Seq2seq(序列到序列);Adam 优化器 | 神经机器翻译;至今最常用的优化器家族 |
| 2014–2015 | Bahdanau 等人:注意力机制 | 让模型在生成时”回头看”输入的任意位置 |
| 2017 | Transformer(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 | “先在海量文本上预训练,再迁移到具体任务” |
| 2019 | GPT-2(15 亿参数) | 流畅的文本生成,初现零样本能力 |
| 2020 | 规模定律(Kaplan 等人) | 模型效果随规模可预测地提升(第 9 讲) |
| 2020 | GPT-3(1750 亿参数) | 上下文学习(in-context learning):在提示里给几个例子就能做新任务 |
| 2022 | PaLM(5400 亿参数);Chinchilla(700 亿参数) | Chinchilla 发现当时的大模型普遍”训练数据不足”,给出了计算最优的配比(约每个参数 20 个 token) |
3.3 开放程度:三类模型
| 类别 | 公开了什么 | 代表 |
|---|---|---|
| 闭源模型 | 只有 API | GPT-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
目标:让训练和推理在硬件上跑得尽可能快。先感受一下规模:
训练一个模型大约需要 次浮点运算( = 参数量, = 训练 token 数,第 2 讲会推导这个公式)。训练一个 700 亿参数的模型、用 1 万亿个 token:
一块 NVIDIA B200 GPU 的 BF16 稠密算力约为 次/秒。即使 100% 利用率,一块卡也要算 秒,约 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),词表的大小记作 。模型的嵌入层是一个 的表,输出层给出 个 token 上的概率分布——词表大小直接决定了模型的一部分结构。
为什么叫”token”而不叫”词”? 因为分词的单位不一定是词:可能是字符、字节、词的一部分(如 “ing”),甚至是词加前面的空格(如 ” world”)。token 是一个中性的词,意思是”模型处理的最小单位”。
5.2 分词不是模型的一部分,但影响巨大
分词器通常是单独训练的(在语言模型训练之前),训练完就固定下来。它不参与梯度下降,但它决定了:
- 模型”看到”的世界是什么样的(同一句话的不同切法,对模型来说是完全不同的输入);
- 序列有多长(直接决定计算成本);
- 模型有多少参数花在嵌入层上。
5.3 怎么评价一个分词器?
| 标准 | 含义 | 为什么重要 |
|---|---|---|
| 压缩率 | 平均每个 token 包含多少字节的原文: | 越高,序列越短,计算越省,上下文窗口”装”的内容越多 |
| 词表大小 | 有多少个不同的 token | 影响嵌入层和输出层的参数量 |
| 无损(可逆) | 对任何 成立 | 否则模型看到的和原文不一致,生成时也可能出错 |
| 无未知 token | 任何输入都能编码 | 否则遇到新词、新语言、表情符号就只能输出 <unk> |
| 词表利用率 | 每个 token 在训练数据里都足够常见 | 罕见的 token 学不好(第 12 节的 glitch token) |
这些标准互相冲突,没有完美的方案,只有折中。
压缩率用字节而不是字符来算,是为了让不同语言可以公平比较(一个汉字在 UTF-8 中是 3 个字节,一个英文字母是 1 个字节)。配套代码 02_simple_tokenizers.py 里的 get_compression_ratio 就是这个定义。
5.4 为什么序列长度这么重要?
- 注意力的计算量随序列长度平方增长。序列长 4 倍,注意力的计算量约为 16 倍(第 3 讲详细分析)。
- 上下文窗口是按 token 算的。一个 1024 token 的窗口,压缩率为 1 时只能装下约 1 KB 文本;压缩率为 4.4 时能装下约 4.5 KB。
- 训练和推理的成本都按 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。仔细观察,可以发现几个规律:
- 单词通常连同它前面的空格一起构成一个 token:
' upon'、' time'。 - 因此同一个词在句首和句中是不同的 token。在 GPT-2 中,
'hello'的 id 是 31373,' hello'(带前导空格)是 23748,'Hello'是 15496,而'HELLO'甚至被拆成了 3 个 token。 - 数字被切成不规则的片段:
'2026'→'20'+'26','1000000'→'1'+'000000'。 - 中文被切得很碎,一个汉字可能被拆成好几个”半个字”的 token(字节片段)。
用”同一个意思”的英文和中文比较三代 OpenAI 分词器(数据来自配套代码实验 6):
| 分词器 | 词表大小 | 英文句子 | 意思相同的中文句子 | 中文 / 英文 |
|---|---|---|---|---|
| GPT-2 | 50,257 | 25 个 token | 62 个 token | 2.48 |
| GPT-4(cl100k_base) | 100,277 | 25 | 42 | 1.68 |
| GPT-4o(o200k_base) | 200,019 | 25 | 24 | 0.96 |
同样的意思,GPT-2 处理中文要多花一倍半的 token;到了词表 20 万的 o200k_base,中文和英文基本持平。读完本讲,你就能解释这些现象的成因。
7. 方案一:字符级分词
7.1 背景:Unicode 是什么
计算机里的每个字符都有一个编号。Unicode 是一张全世界通用的”字符 → 编号”对照表,这个编号叫码位(code point),习惯写成 U+ 加十六进制数:
| 字符 | 码位(十进制) | 码位(U+ 写法) |
|---|---|---|
a | 97 | U+0061 |
é | 233 | U+00E9 |
你 | 20320 | U+4F60 |
🌍 | 127757 | U+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 问题
- 词表巨大:要覆盖所有码位,词表需要约 111 万个位置(只算已分配的也有约 15 万)。模型的嵌入层和输出层要为每个 token 准备参数。
- 大部分 token 极其罕见:大量字符(古文字、生僻字、特殊符号)在训练数据中几乎不出现。它们占据了词表位置,却学不好。在配套代码的小语料里(约 1.4 万个字符),452 种不同字符中有 161 种只出现过 1 次。
- 压缩率不高:英文每个字符只有 1 个字节,压缩率就是 1。
为什么”罕见 token 学不好”是个问题? 模型的每个 token 都有一个嵌入向量,只有当这个 token 出现在训练数据中时,它的向量才会被梯度更新。出现次数越少,向量越接近随机初始化,模型对它的”理解”就越差。
8. 方案二:字节级分词
8.1 背景:UTF-8 编码
码位只是编号,存储和传输时需要把它变成字节(byte,0~255 的整数)。UTF-8 是最流行的方案,它是变长的:
| 码位范围 | 字节数 | 字节模式(x 为码位的二进制位) | 典型字符 |
|---|---|---|---|
| U+0000 ~ U+007F | 1 | 0xxxxxxx | 英文字母、数字、ASCII 标点 |
| U+0080 ~ U+07FF | 2 | 110xxxxx 10xxxxxx | 带重音的拉丁字母、希腊字母、俄文 |
| U+0800 ~ U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx | 绝大多数汉字、日文假名 |
| U+10000 ~ U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | emoji、生僻汉字 |
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-8 | UTF-16 | UTF-32 |
|---|---|---|---|---|
| 英文句子 | 53 | 53 字节 | 106 | 212 |
| Python 代码 | 32 | 32 | 64 | 128 |
| 中文句子 | 25 | 75 | 50 | 100 |
英文和代码用 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 问题
- 词表无界。语言中的词遵循 Zipf 定律:少数词非常常见(the、a、and),而大量的词非常罕见。新词、人名、拼写错误、数字组合……永远有没见过的词。在配套代码的小语料上,只出现过 1 次的”词”占了词表的 60%。
- 未知词。训练时没见过的词只能映射成一个特殊 token
<unk>(unknown)。在我们的小语料上训练后,上面那句话被解码成"I'll <unk> <unk>!"——信息永久丢失,分词是有损的。 <unk>让评估失真。计算困惑度时,所有未知词都被当成”同一个词”,模型只需要预测”这里有个未知词”就能拿到不错的分数。- 对中文几乎不可用。中文词与词之间没有空格,
\w+会把一整句中文当成”一个词”:'我爱自然语言处理'→['我爱自然语言处理']。中文需要专门的分词工具(如 jieba),而切法本身就有歧义:经典例子”南京市长江大桥”,可以切成”南京市 / 长江 / 大桥”,也可以切成”南京 / 市长 / 江大桥”。
一个隐蔽的实现坑:在正则里,
.默认不匹配换行符\n。如果不加re.DOTALL标志,re.findall会悄悄丢掉所有换行符,分词结果无法还原原文。配套代码演示 3 专门演示了这个坑。
9.3 小结:我们想要什么?
| 方案 | 词表 | 压缩率 | 无损 | 未知词 |
|---|---|---|---|---|
| 字符级 | 巨大(约 15 万~111 万) | 低(英文 1.0) | ✅ | 几乎没有 |
| 字节级 | 256 | 1.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) | 2 | the, the |
| (h, e) | 2 | the, the |
| (e, ␣) | 2 | the␣, the␣ |
| (a, t) | 2 | cat, 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 个跨越了单词边界(把词尾和空格、标点粘在一起)。这有什么问题?
- 浪费词表。GPT-2 的论文指出:不加限制的 BPE 会学出
dog、dog.、dog!、dog?等大量”同一个词 + 不同标点”的变体,每一个都占一个词表位置,而它们的含义几乎一样。 - 切分不自然。
in the这样的 token 把两个词粘在一起,模型更难学到”词”这个层次的规律。 - 训练太慢。每次合并都要扫描整个语料。
解决办法:预分词(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 必须满足三点:
- 永远作为一个整体,对应唯一的一个 id,不能被 BPE 拆开;
- 不参与合并统计,否则会学出
'<|'、'endoftext'或跨文档的 token; - 训练时合并不能跨越它:它两侧的文本属于不同的文档。
实现方法:先按特殊 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,必须优化:
训练优化:
- 预分词 + 计数:只处理不同的预分词块,每个块带上出现次数。
- 增量更新:合并 (A, B) 只会影响包含 (A, B) 的那些块。维护一张”相邻对 → 包含它的块”的倒排索引,每次合并只更新受影响的块:先减去这个块旧的所有相邻对计数,再加上合并后新的相邻对计数。其他块的计数完全不用动。
- 并行预分词:预分词通常是最耗时的部分。把大文件切成若干块(切分点必须落在特殊 token 上,保证不会把一个词切成两半),用多进程并行处理,最后把计数加起来。
编码优化:
- 只看相关的规则:教学版对每条合并规则都扫描一遍序列(几万条规则,绝大多数和当前文本无关)。优化后,对每个预分词块,只看块内实际存在的几个相邻对,每次合并其中优先级最高(训练时最早学到)的那一对,直到没有可合并的对为止。可以证明这与”按顺序应用所有规则”的结果完全相同(证明思路见 04 号文件
BPETokenizer的文档字符串)。 - 缓存:自然语言里常见词反复出现,同一个预分词块编码一次后记住结果。
配套代码用一个”慢但显然正确”的参考实现与高效版对拍,验证两者得到完全相同的词表和合并规则——这是写算法时非常实用的技巧。更进一步,实验 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-2 | 50,257(256 字节 + 50,000 次合并 + 1 个特殊 token) |
| Llama 1 / 2 | 32,000 |
| GPT-4(cl100k_base) | 约 10 万 |
| Llama 3 | 128,256 |
| Qwen 2 / 2.5 | 约 15 万 |
| GPT-4o(o200k_base) | 约 20 万 |
| Gemma | 约 25.6 万 |
| CS336 作业 1 | TinyStories 上 10,000;OpenWebText 上 32,000 |
趋势很明显:词表越来越大。为什么?下面是权衡的两边。
词表大的好处:
- 压缩率更高 → 序列更短 → 计算更省、上下文装得更多;
- 能为更多语言分配足够的 token(多语言更公平,见第 12 节和第 6 节的表格)。
词表大的代价:
- 参数量:嵌入层和输出层都有 个参数。GPT-2 small 的嵌入矩阵是 万参数,约占全部 1.24 亿参数的 31%。对小模型来说,大词表会让大部分参数花在嵌入上;对几百亿参数的大模型,这部分占比就很小了——所以大模型才”负担得起”大词表。
- 输出层计算:每生成一个 token,都要在整个词表上算一次概率分布。
- 罕见 token:词表越大,越多 token 在训练数据中很少出现,学不好。
- 边际收益递减:配套代码实验 1 显示,词表从 257 增加到 600 时,测试集英文压缩率从 1.00 涨到 2.12;而从 1500 增加到 2000 时,只从 2.69 涨到 2.69——几乎不涨了(训练集还在涨,但测试集已经饱和,类似过拟合)。
| 设定词表 | 训练集 | 测试·英文 | 测试·中文 |
|---|---|---|---|
| 257 | 1.00 | 1.00 | 1.00 |
| 300 | 1.34 | 1.46 | 1.17 |
| 600 | 2.13 | 2.12 | 1.56 |
| 1000 | 2.77 | 2.37 | 1.79 |
| 2000 | 3.80 | 2.69 | 2.09 |
(这是在约 17 KB 的小语料上的结果,只为展示趋势。真实分词器在 TB 级语料上训练,英文压缩率约为 4。)
为什么测试集会饱和?把词表继续加大就能看清楚:BPE 在合并完所有常见的对之后,会开始合并只出现 1 次的对,最终把每个预分词块整个合并成一个 token——也就是退化成了词级分词,第 9 节的所有问题又回来了。在我们的小语料上,词表 2000 时最长的 token 已经是一整个中文分句 '非常喜欢在公园里和朋友们一起玩耍'(中文分句中间没有空格,整句就是一个预分词块);词表设为 3000 时,训练在约 2960 处就因为”没有相邻对可合并”而提前结束了。所以词表大小必须和训练语料的规模相匹配。
12. 分词带来的”怪现象”
分词器是在模型训练之前、用单独的算法和数据确定下来的,它的”偏见”会传递给模型。下面这些现象都能用本讲的知识解释。
12.1 数字与算术
GPT-2 的切法完全由训练语料中哪些数字片段常见来决定(数据来自配套代码实验 3):
| 数字 | GPT-2 | GPT-4(cl100k_base) |
|---|---|---|
| 1234 | 12 34 | 123 4 |
| 12345 | 123 45 | 123 45 |
| 1000000 | 1 000000 | 100 000 0 |
| 2026 | 20 26 | 202 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:只用英文训练 | 331 | 561 |
| B:英文 + 中文训练 | 348 | 360 |
只用英文训练的 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. 超越分词:无分词器模型的尝试
分词器有这么多问题,能不能干脆不要它,直接在字节上建模?这是一个活跃的研究方向:
| 工作 | 年份 | 思路 |
|---|---|---|
| ByT5 | 2021 | 直接在 UTF-8 字节上训练 T5 模型 |
| MEGABYTE | 2023 | 把字节分成固定大小的”块”,用大模型处理块之间的关系、小模型处理块内部 |
| BLT(Byte Latent Transformer) | 2024 | 根据”下一个字节有多难预测”(熵)动态地决定块的边界 |
| T-FREE | 2024 | 用字符三元组的稀疏表示代替词表 |
| H-Net | 2025 | 让模型端到端地学习如何分块(动态分块) |
这些方法很有前景:它们避免了第 12 节的各种怪现象,也让模型能”看到”每个字符。但到目前为止,它们还没有在前沿模型的规模上被验证并成为主流。核心困难仍然是效率:直接处理字节意味着序列长 4 倍左右,必须设计巧妙的架构来弥补。在那一天到来之前,BPE 仍然是最实用的选择。
CS336 对分词的定位:分词是一个”必要之恶”。它是一个与模型分开的、基于启发式规则的预处理步骤,理论上并不优雅,但在当前的算力条件下,它带来的效率提升是实实在在的。
14. 本讲小结
课程概述:
- 研究者离底层越来越远,但抽象会”漏”;要做基础研究,就要通过构建来理解整个技术栈。
- 前沿模型昂贵且不透明;小模型不是大模型的缩小版。能迁移的是机制和思维方式,直觉只能部分迁移。
- 苦涩的教训的正确理解:能随规模扩展的算法才重要。效果 = 效率 × 资源。
- 核心问题:给定计算和数据预算,能训练出的最好模型是什么? 效率驱动了从数据、分词、架构到训练、对齐的几乎所有设计决策。
- 五个单元:基础、系统、规模定律、数据、对齐。
分词:
- 分词器负责 字符串 ↔ 整数序列 的转换。评价标准:压缩率、词表大小、无损、无未知 token、词表利用率。
- 字符级:词表太大、罕见字符多。字节级:词表 256、永不遇到未知 token,但压缩率为 1、序列太长。词级:压缩率高,但词表无界、遇到未知词就有损,对中文几乎不可用。
- BPE:从字节出发,反复合并最常见的相邻对。常见片段 → 1 个 token,罕见片段 → 多个 token,最坏退化成字节。
- 关键细节:确定的平局规则;编码时按训练顺序应用合并;预分词防止跨词合并并大幅加速;特殊 token 不参与合并、整体映射。
- 词表越大,压缩率越高但收益递减,嵌入参数越多;大模型才负担得起大词表。
- 分词的”偏见”会传递给模型:数字切分影响算术、多语言不公平、glitch token……
15. 与作业 1 的关系
作业 1 的第一部分就是本讲的内容。你需要:
- 回答 Unicode 相关的小问题:
chr(0)是什么;为什么在 UTF-8 而不是 UTF-16/32 上训练分词器;为什么”逐字节解码”是错的;找一个不能解码的两字节序列。(本讲第 7、8 节,配套代码 01) - 实现
train_bpe(input_path, vocab_size, special_tokens),返回词表dict[int, bytes]和合并规则list[tuple[bytes, bytes]]。要求:使用 GPT-2 的预分词正则;特殊 token 不参与合并;平局时选字典序更大的对;速度和内存满足要求。(本讲第 10 节,配套代码 04) - 在 TinyStories(词表 10K)和 OpenWebText(词表 32K)上训练,回答:最长的 token 是什么?合不合理?训练花了多少时间和内存?哪一步最耗时?
- 实现
Tokenizer类:encode、decode、encode_iterable(流式编码大文件)、from_files(从文件加载)。要能正确处理重叠的特殊 token。 - 实验:计算两个分词器的压缩率;用 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,隐藏维度 ,输入嵌入和输出层不共享参数。这两部分共有多少参数?约占 80 亿总参数的多少?
参考答案
每部分 亿,两部分共约 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 讲)★:用 估算:在 1024 块 A100 GPU(每块 BF16 稠密算力约 312 TFLOP/s)上,以 50% 的利用率训练 GPT-3(1750 亿参数、3000 亿 token)大约需要多少天?
参考答案
总计算量 FLOPs。 有效算力 FLOP/s。 时间 秒 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?
- 一次矩阵乘法要多少次浮点运算? 公式是怎么来的?
- 训练一个模型需要多少显存?参数、梯度、优化器状态、激活值各占多少?
- 什么是 MFU(模型浮点利用率)?
下一讲之前,建议你:跑通 code/lecture01/ 下的 5 个脚本,完成练习 1、3、7,并尝试不看代码自己写一遍 BPE 训练函数。