第 1 讲:课程总览与分词(Overview, Tokenization)
第 1 讲:课程总览与分词(Overview, Tokenization)
日期:3 月 30 日(周一,Spring 2026) | 讲师:Percy Liang | 材料:lecture_01.py(可执行讲义 + 代码)
概览
本讲先回答”这门课为什么存在”,梳理语言模型(LM)的历史脉络,然后进入第一个技术单元:分词(tokenization)——把原始文本(字节)转换成模型真正消费的整数序列的过程。讲义现场用 Python 实现并对比了字符级、字节级、词级以及 BPE(Byte-Pair Encoding,字节对编码) 分词器,其中包括一个从零写的 BPE 训练器。
核心概念与定义
- 语言模型(language model):一个定义在 token 序列上的概率分布。2018 年”语言模型”是拿来微调的东西(BERT);2020 年是拿来 prompt 的东西(GPT-3);2022 年是拿来对话的东西(ChatGPT);2026 年是可以自主行动的东西(agents)。底层原理(attention、kernel、优化)没变,但规格变了(更长的上下文、推理效率更关键)。
- 分词器(Tokenizer):一个提供两个方法的类——
encode(string) -> list[int]与decode(list[int]) -> string。它是”原始输入(字节)”与”模型操作的整数”之间的接口。- 类比:就像厨师备菜。你不能拿一整颗没洗的蔬菜下锅,得洗净、削皮、切成大小均匀的块(token),你的菜谱(模型)才好处理。不同的厨师(分词器)切法不同,切法直接决定菜好不好做。
- Unicode / 码点(code point):原始文本是 Unicode 字符序列;每个字符对应一个码点(例如
ord("a") == 97,ord("🌍") == 127757),用chr可以转回去。- 类比:码点就像 ISBN 书号——人类所有文字符号(书籍)都分到了一个全局唯一的编号,无论它属于哪种语言。
- UTF-8:主流编码方式,把字符映射为 1–4 个字节。ASCII 字符占 1 字节,
🌍占 4 字节(\xf0\x9f\x8c\x8d)。 - 压缩率(compression ratio):每个 token 对应的 UTF-8 字节数。压缩率越高,序列越短——这一点非常重要,因为 Transformer 的 attention 复杂度对序列长度是平方级的。
- 类比:压缩率就像打包行李。一件行李箱装 10 件衣服(高压缩率)远好过 10 个小包(低压缩率),因为航空公司(attention)是按”件数”(token 数)收费的。
- BPE(字节对编码):一种数据驱动的子词(subword)算法(1994 年 Philip Gage 为数据压缩提出,Sennrich 等人 2016 年引入 NLP,之后被 GPT-2 采用并成为现代模型的事实标准)。做法:以字节为初始 token,反复把出现频率最高的相邻 token 对合并成新 token,直到达到目标词表大小。
- 机制:
统计相邻对 → 合并最高频对 → 重复。结果是:常见的字节序列被压缩成单个 token,罕见的序列则被拆成很多 token。 - 类比:BPE 就像手机输入法的联想词——”brb”、”lol”、”omw” 因为在语料里高频出现而变成快捷词,而像 “supercalifragilisticexpialidocious” 这种罕见词只能一个字母一个字母打出来。
- 机制:
- 分词效率视角(本讲的核心论证):
- 缩短上下文长度(约 1000 字节 → 约 250 个 token);
- 自适应算力分配(把更多模型容量给输入中”更有信息量”的部分)。
- 无分词器架构(the dream):ByteT5、MegaByte、BLT 等模型直接在字节上操作——很有前景,但尚未扩展到前沿规模。
代码示例:Tokenizer 接口与三种朴素分词器
讲义定义了抽象接口,并给出三种具体(但次优)的分词器。
代码(Python):
from abc import ABC
class Tokenizer(ABC):
"""分词器的抽象接口。"""
def encode(self, string: str) -> list[int]:
raise NotImplementedError
def decode(self, indices: list[int]) -> str:
raise NotImplementedError
class CharacterTokenizer(Tokenizer):
"""把一个字符串表示成 Unicode 码点序列。"""
def encode(self, string: str) -> list[int]:
return list(map(ord, string))
def decode(self, indices: list[int]) -> str:
return "".join(map(chr, indices))
class ByteTokenizer(Tokenizer):
"""把一个字符串表示成字节序列。"""
def encode(self, string: str) -> list[int]:
string_bytes = string.encode("utf-8")
indices = list(map(int, string_bytes))
return indices
def decode(self, indices: list[int]) -> str:
string_bytes = bytes(indices)
string = string_bytes.decode("utf-8")
return string
代码做了什么:
CharacterTokenizer.encode用ord把每个 Unicode 字符映射成码点整数(一个字符一个整数);decode用chr转回。ByteTokenizer.encode先把整个字符串编码为 UTF-8 字节,再把每个字节(0–255)转成整数;decode把整数转回字节并做 UTF-8 解码。- 两者都能往返:
decode(encode(s)) == s。
实现深挖:
- 为什么说这两种是”两头不讨好”:字符分词器的词表巨大(约 15 万个 Unicode 字符,绝大多数极罕见),压缩率却约等于 1;字节分词器词表很小(256),但压缩率恰好等于 1(一个字节一个 token),序列很长,直接引爆 attention 开销。讲义用
"Hello, 🌍! 你好!"现场演示了字节分词器的压缩率恰好为 1.0。 - 为什么必须正确处理 UTF-8:并非所有字符都能用一个字节表示,
bytes("🌍", encoding="utf-8") == b"\xf0\x9f\x8c\x8d"。能否正确处理这一点,是”能用的分词器”和”坏掉的分词器”的分界线。 - 为什么 decode 要容忍非法字节:
bytes.decode("utf-8")遇到非法序列会抛异常;生产级分词器用errors="replace"(讲义中的output_tokenizer就是这么做的)来容忍任意字节串。
与作业的联系:作业 1 要求实现完整的 BPE 分词器。这里的 Tokenizer 抽象类正是你的 BPETokenizer 必须满足的接口,而 UTF-8 处理是你将要写的字节级 BPE(加上预分词、special token、高速合并)的基础。
代码示例:BPE 的 merge、训练与分词器
代码(Python):
def merge(indices: list[int], pair: tuple[int, int], new_index: int) -> list[int]:
"""返回 indices,但把所有 pair 的实例替换成 new_index。"""
new_indices = []
i = 0
while i < len(indices):
if i + 1 < len(indices) and indices[i] == pair[0] and indices[i + 1] == pair[1]:
new_indices.append(new_index)
i += 2
else:
new_indices.append(indices[i])
i += 1
return new_indices
def count_adjacent_pairs(indices: list[int]) -> dict[tuple[int, int], int]:
"""返回字典:每个相邻 token 对 -> 出现次数。"""
counts = defaultdict(int)
for index1, index2 in zip(indices, indices[1:]):
counts[(index1, index2)] += 1
return counts
def train_bpe(string: str, num_merges: int) -> BPETokenizerParams:
indices = list(map(int, string.encode("utf-8")))
merges: dict[tuple[int, int], int] = {} # 对 -> 合并后的新下标
vocab: dict[int, bytes] = {x: bytes([x]) for x in range(256)} # 下标 -> 字节
for i in range(num_merges):
counts = count_adjacent_pairs(indices) # 统计所有相邻对
pair = max(counts, key=counts.get) # 取最高频的 pair
new_index = 256 + i # 分配新下标
merges[pair] = new_index
vocab[new_index] = vocab[pair[0]] + vocab[pair[1]] # 字节串拼接
indices = merge(indices, pair, new_index)
return BPETokenizerParams(vocab=vocab, merges=merges)
@dataclass(frozen=True)
class BPETokenizerParams:
vocab: dict[int, bytes] # 下标 -> 字节
merges: dict[tuple[int, int], int] # (i1, i2) -> new_index
class BPETokenizer(Tokenizer):
def __init__(self, params: BPETokenizerParams):
self.params = params
def encode(self, string: str) -> list[int]:
indices = list(map(int, string.encode("utf-8")))
# 注意:这是一个非常慢的实现
for pair, new_index in self.params.merges.items():
indices = merge(indices, pair, new_index)
return indices
def decode(self, indices: list[int]) -> str:
bytes_list = list(map(self.params.vocab.get, indices))
return b"".join(bytes_list).decode("utf-8")
代码做了什么:
train_bpe先取训练字符串的字节序列,然后循环num_merges次:统计所有相邻对 → 取最高频者 → 分配全新下标(256 + i)→ 记录合并规则 → 在词表里把两段字节串拼接 → 用merge重写序列。BPETokenizer.encode把学到的合并规则按顺序应用到新输入的字节序列上。BPETokenizer.decode查词表拿到每个下标的字节串,拼接后做 UTF-8 解码。
实现深挖:
- 为什么是
256 + i:前 256 个下标留给单字节;每个新合并 token 拿下一个可用下标,保证映射是单射。 - 为什么必须按训练顺序应用 merge:BPE 是一个贪心的层次化过程;后学的合并可能包含先学的合并结果,只有按学习顺序应用,编码结果才与训练时的语义一致。
- 为什么 decode 不需要 merge 规则:词表为每个 token 存了完整的字节串,所以解码是纯查表;merge 规则只在编码时用到。这形成了清晰的”训练期数据结构(merges)”与”推理期数据结构(vocab)”分离。
- 复杂度问题(代码里明确标注):
encode对所有 merge 都遍历一遍序列,复杂度约 O(num_merges × 序列长度)。作业 1 要求你只应用”真正相关的” merge(例如序列里已不存在的对直接跳过)、支持 special token(如<\|endoftext\|>)、加入 GPT-2 风格的预分词 regex,并把整体做快。 - 为什么需要预分词与词尾标记:经典 Sennrich 版 BPE(以及你给的示例)会先按词切分并用
</w>标记词尾,防止跨词合并;讲义这里的字节级版本则直接合并原始字节,更简单,也和 GPT 系分词器一致。
与作业的联系:这是作业 1 第 2 节(BPE 分词器)的绝对基础。你要扩展的正是这套逻辑:在大语料上学习 merge、构建含 special token 的词表、做预分词(GPT-2 regex)、实现高效编码器。讲义明确列出了作业 1 要求的四项升级:(1) 只遍历有意义的 merge;(2) special token;(3) 预分词;(4) 提速。
关键要点
- 分词是 LM 流水线的关键第一步:它决定词表大小、序列长度以及罕见词的处理方式,并且必须完美往返(
decode(encode(s)) == s)。 - 字符/字节/词三种分词器都次优:字符词表巨大且压缩率低;字节词表小但压缩率恰好为 1;词级压缩率好但词表无界且存在未登录词(UNK)问题。
- BPE 是简单、数据驱动、被广泛使用的启发式算法:从字节出发,反复合并最高频相邻对,从而在词表大小与压缩率之间取得平衡。
- 本课的一切都是关于效率:分词之所以重要,是因为它缩短序列(attention 是平方复杂度!)并能自适应分配模型容量。
- 一个好的分词方案应当:① 让模型在”有意义的块(chunks)”上操作;② 让块是可变的,从而把更多容量分配给输入中有信息量的部分。
常见陷阱
- 没有正确处理 Unicode:多字节字符必须以 UTF-8 字节形式编解码;忽略它会让非 ASCII 文本无法往返。
- 不做往返测试:永远要断言
decode(encode(s)) == s,并覆盖 emoji、CJK、特殊空白等边界情况。 - 合并顺序错乱:编码必须按训练顺序应用 merge,否则结果与学到的词表不匹配。
- 编码复杂度接近 O(n²):朴素的
encode(对所有 merge 遍历整条序列)在真实语料上慢到不可用;作业 1 期望更聪明的做法。 - 字节解码失败:token 序列未必构成合法 UTF-8;生产环境要用
errors="replace"。 - 忽视压缩率:压缩率 1.0 意味着序列很长,而 attention 是平方复杂度——序列长度是第一位的成本。
复习题
- 问: 为什么字节分词器的压缩率恰好是 1?这为什么是问题?
- 答: 每个字节恰好映射成一个 token,所以”字节/token” = 1。这意味着 1000 字节的文档变成 1000 个 token;由于 attention 对序列长度是平方复杂度,模型开销会急剧膨胀——你希望大约 4 倍压缩(约 250 个 token)。
- 问: 在
train_bpe里,为什么decode用词表实现而不是用 merge 规则?- 答: 词表把每个 token 下标映射到它代表的完整字节串,所以解码就是直接查表;merge 规则描述的是”token 是如何被构造出来的”,只有编码新文本时才需要。此外,用词表解码能处理任意 token 序列,无需知道合并历史。
- 问: 如果 BPE 从不合并(词尾标记,下一个词的首字符)这样的跨词对——即在原始字节上做合并而不提供任何词边界信息——会发生什么?
- 答: 分词器可能学到跨越词边界的 token,使 token 的语义性变差,甚至把不同上下文混在一起;预分词加边界标记就是用来防止这种情况的。
