跳到主要内容

Tokenizer 基础

我们已经知道,神经网络内部的一切运算都是数值——矩阵乘法、激活函数、梯度更新,它们接受的输入和产生的输出,全都是数字。但我们每天使用的是自然语言,它由文字构成,不是数字。

这一节,我们从零开始实现 Tokenizer。依次构建字符级、词级和子词级三种方案,观察每种切分方式带来的取舍,理解子词级为什么最终成为了现代大语言模型共同的选择。

将文本转换成数值的机制称为 Tokenizer。Tokenizer 先将文本切成小片段,再给每个片段分配一个整数编号。这些小片段称为 token,整数编号称为 token ID。

举个例子,句子 "the cat sat" 被切成 ["the", "cat", "sat"] 三个 token,再通过一张编号表(称为词表)查到对应的 ID 为 [5, 1, 3]。经过这样的处理,文本就变成了整数序列,可以被模型处理了。

切分的粒度有好几种。按字符切,每个字母是一个 token,比如 "cat" 被切成 ["c", "a", "t"],序列会变得很长。按词切,每个单词是一个 token,比如 "the cat sat" 被切成 ["the", "cat", "sat"],序列短了,但词表会很大,而且遇到没见过的词就无法处理。子词级在这两者之间取得了平衡,也是现代大语言模型普遍采用的方案。

# 先用真实的 tokenizer 感受一下:文本是怎么变成 token 的
import tiktoken

tokenizer = tiktoken.get_encoding("gpt2")

text = "the cat sat on the mat"
ids = tokenizer.encode(text)
tokens = [tokenizer.decode([i]) for i in ids]

print(f"原文: {text}")
print(f"token: {tokens}")
print(f"token ID: {ids}")

本节要点

通过这一节的学习,以下问题应该能够回答:

  1. Tokenizer 在做什么?
  2. 字符级切分有什么优缺点?
  3. 词级切分有什么优缺点?
  4. 子词级为什么是现代 LLM 的默认选择?

1. Token 和 Tokenizer

前面提到,token 是模型处理文本的最小单位。具体到切分方式,token 可以是一个字符、一个词,也可以是一个子词片段:

字符级:  "cat" → ["c", "a", "t"]
词级: "the cat" → ["the", "cat"]
子词级: "playing" → ["play", "ing"]

Tokenizer 对外提供两个核心操作:encode(文本 → token ID 序列)和 decode(token ID 序列 → 文本)。

文本  --encode-->  token ID 序列
ID序列 --decode--> 文本

需要注意的是,token ID 只是编号,不代表大小关系。ID 为 12 的 token 并不"大于"ID 为 3 的 token——它只是排在第 12 个位置。

下面准备一份小语料,逐一实现三种切分方式。

实验语料

Tokenizer 需要从语料中统计规律、建立词表,因此先准备一小段文本。这里使用英文,因为英文有空格作为天然的词边界,方便观察切分过程。原理清楚之后,中文的情况只是边界标记更隐蔽,需要更细致的规则。

# 我们的语料库 — 故意选有重复模式的简单句子
# 这样 BPE 合并时能清楚看到高频 pair 如何被合并
corpus = [
"the cat sat on the mat",
"the dog sat on the log",
"the cat and the dog",
"i love my cat",
"i love my dog",
"the cat is cute",
"the dog is happy",
"the mat is soft",
"the log is hard",
"cats and dogs are friends",
]

print(f"语料库共 {len(corpus)} 条句子")
for i, s in enumerate(corpus):
print(f" [{i}] {s}")

total_chars = sum(len(s) for s in corpus)
print(f"\n总字符数: {total_chars}")
print(f"总词数(按空格算): {sum(len(s.split()) for s in corpus)}")
语料库共 10 条句子
[0] the cat sat on the mat
[1] the dog sat on the log
[2] the cat and the dog
[3] i love my cat
[4] i love my dog
[5] the cat is cute
[6] the dog is happy
[7] the mat is soft
[8] the log is hard
[9] cats and dogs are friends

总字符数: 175
总词数(按空格算): 46

计算机是怎样表示文字的

屏幕上显示的 the cat,在计算机内部是一串编号(这里以 ASCII 为例):

文本:    t    h    e         c    a    t
↓ ↓ ↓ ↓ ↓ ↓ ↓
ASCII: 116 104 101 32 99 97 116

文字到数字的转换,计算机本身就在做。Tokenizer 要回答的不是"能不能转换",而是"切到多细":

  • 按字符切:每个字母一个 token,最细。
  • 按词切:每个单词一个 token,最粗。
  • 按子词切:常见片段一个 token,粗细居中。

下面逐一尝试,每次关注同一个问题:这种切法解决了什么,又引入了什么新问题。

2. 字符级 Tokenizer

字符级 Tokenizer 的做法最直接:每个字符就是一个 token。

"the cat"
↓ 按字符切
['t', 'h', 'e', ' ', 'c', 'a', 't']
↓ 查表给编号
[5, 12, 3, 0, 6, 1, 8]

词表就是一张 token → ID 的映射表。在字符级场景下,每个 token 是一个单独的字符。这种方案的优点很明显:实现简单,词表稳定(几十个字符即可),而且基本不存在"没见过的字符"——Unicode 中的字符都可以纳入。

下面用 Python 实现一个字符级 Tokenizer。

class CharTokenizer:
"""
字符级 Tokenizer:一个字符 = 一个 token
只有两个核心方法:encode(编码)和 decode(解码)
"""

def __init__(self):
# stoi = string-to-id,把字符映射到数字
# itos = id-to-string,把数字映射回字符
self.stoi = {}
self.itos = {}

def train(self, texts):
"""
训练:从语料中收集所有出现过的字符,建立词表

步骤:
1. 把所有文本拼成一个长字符串
2. 用 set 去重 → 得到所有字符
3. 排序 → 给每个字符分配一个唯一 ID
"""
all_text = "".join(texts)
chars = sorted(list(set(all_text)))

self.stoi = {ch: i for i, ch in enumerate(chars)}
self.itos = {i: ch for i, ch in enumerate(chars)}

print(f"=== 字符级 Tokenizer 训练完成 ===")
print(f"词表大小: {len(self.stoi)} 个 token")
print(f"词表内容: {chars}")

def encode(self, text):
"""编码:文本 → token ID 列表"""
return [self.stoi[ch] for ch in text]

def decode(self, ids):
"""解码:token ID 列表 → 文本"""
return "".join([self.itos[i] for i in ids])

# 训练并测试
char_tokenizer = CharTokenizer()
char_tokenizer.train(corpus)

# 用一句来测试
text = "the cat"
ids = char_tokenizer.encode(text)
recovered = char_tokenizer.decode(ids)

print(f"\n=== 编码/解码测试 ===")
print(f"原文: '{text}'")
print(f"Token IDs: {ids}")
print(f"解码回来: '{recovered}'")
print(f"\n关键观察:原文 {len(text)} 个字符 → 产生 {len(ids)} 个 token,数量一致。")
print(f"每个字符都变成一个 token,没有压缩。")

字符级的不足

字符级方案有两个明显的缺点。

第一,序列太长。"the cat" 是 7 个字符,对应 7 个 token。一篇 1000 字符的文章就需要 1000 个 token。Self-Attention 机制对序列长度非常敏感,计算量和显存消耗随序列长度增长得很快。

第二,语义被打碎了。cat 在自然语言中是一个完整的概念,字符级却把它拆成了 c、a、t 三个独立的字母。模型需要从大量数据中自行学会这三个字母经常一起出现、合在一起才表示"猫"这个概念,这增加了学习负担。

这两个问题——序列过长和语义破碎——指向同一个改进方向:能不能用更大的单位来切分。一个自然的想法是按词切分。

3. 词级 Tokenizer

词级 Tokenizer 按空格切分,每个单词是一个 token。

"the cat sat on the mat"
↓ 按空格切
['the', 'cat', 'sat', 'on', 'the', 'mat']
↓ 查表
[0, 1, 2, 3, 0, 4]

与字符级相比,序列长度大幅缩短。"the cat" 只需要 2 个 token,而字符级需要 7 个。

不过,词级方案有一个根本性的问题:遇到词表外的词怎么办。假设词表中包含 cat,但不包含 cats——那么 Tokenizer 就无法为 cats 分配合法的 ID。这种情况称为 OOV(out of vocabulary)。实际文本中充满了各种变形——单复数、时态变化、派生词、拼写错误、网络新词。如果每种变体都单独纳入词表,词表会膨胀到难以管理;不纳入的话,又频繁遇到 OOV。这是一个两难的选择。

class WordTokenizer:
"""
词级 Tokenizer:按空格切分,一个词 = 一个 token
结构与 CharTokenizer 相同,只是切分粒度从「字符」变成了「词」
"""

def __init__(self):
self.stoi = {}
self.itos = {}

def train(self, texts):
"""从语料中收集所有出现过的词"""
all_words = set()
for text in texts:
words = text.split()
all_words.update(words)

all_words = sorted(list(all_words))
self.stoi = {w: i for i, w in enumerate(all_words)}
self.itos = {i: w for i, w in enumerate(all_words)}

print(f"=== 词级 Tokenizer 训练完成 ===")
print(f"词表大小: {len(self.stoi)} 个词")
print(f"词表内容: {all_words}")

def encode(self, text):
"""把文本按空格切开,每个词查表"""
return [self.stoi[w] for w in text.split()]

def decode(self, ids):
"""把 ID 查表变回词,用空格拼起来"""
return " ".join([self.itos[i] for i in ids])

# 训练并测试
word_tokenizer = WordTokenizer()
word_tokenizer.train(corpus)

text = "the cat sat on the mat"
ids = word_tokenizer.encode(text)
recovered = word_tokenizer.decode(ids)

print(f"\n=== 编码/解码测试 ===")
print(f"原文: '{text}'")
print(f"Token IDs: {ids}")
print(f"解码回来: '{recovered}'")
print(f"\n关键观察:原文 {len(text.split())} 个词 → 产生 {len(ids)} 个 token。")
print(f"同样的句子,字符级需要 7 个 token,词级只需要 6 个。")
# 词级 tokenizer 遇到生词(OOV)时的行为
print("=== OOV(Out Of Vocabulary)问题演示 ===")
print(f"当前词表: {list(word_tokenizer.stoi.keys())}")
print()

try:
test_text = "the elephant sat"
print(f"尝试编码: '{test_text}'")
ids = word_tokenizer.encode(test_text)
print(f"结果: {ids}")
except KeyError as e:
words = test_text.split()
for w in words:
if w in word_tokenizer.stoi:
print(f" '{w}' → ID {word_tokenizer.stoi[w]} (在词表中)")
else:
print(f" '{w}' → 不在词表中,抛出 KeyError")
print(f"\n结论:遇到生词 'elephant',编码过程报错。")
print(f"实际场景中,不可能预先把所有词都收入词表。")

4. 子词级 Tokenizer

字符级和词级各有各的问题。子词级方案试图在两者之间找到平衡。三种方案的对比:

方案词表大小序列长度OOV 问题特点
字符级很小很长几乎没有太细,语义碎
词级很大很短经常出现太粗,不灵活
子词级可控中等很少两者之间的折中

子词级的效果,看几个例子就清楚了:

"the cat sat"      → [the, cat, sat]       ← 常见词整体保留
"cats and dogs" → [cat, s, and, dog, s] ← 不常见的复数拆开
"unbelievable" → [un, believ, able] ← 罕见词拆成已知片段

思路是这样的:高频词整体放入词表,低频词或未见过的词拆成更小的、词表里已有的片段。这样一来,词表不会像词级那样膨胀,遇到新词也通常能拆成已知的子词片段。

BPE 算法简介

BPE(Byte Pair Encoding)是目前最常用的子词算法之一。它的核心操作只有一步:找到出现次数最多的一对相邻 token,把它们合并成一个新的 token。然后重复这个过程。

从一个具体例子来看:

初始:每个字符一个 token
['t', 'h', 'e', ' ', 'c', 'a', 't', ...]
↓ 统计所有相邻 pair 的出现次数
('t', 'h') 出现频率最高
↓ 把它们合并
新 token: 'th'
↓ 继续统计,继续合并……

经过多轮迭代,词表从最初的字符集逐步生长出 th、the、ing、tion 等常见片段。下一节会从零实现 BPE,每一步的统计和合并过程都会完整呈现。

小结

这一节所学的内容:

  • Tokenizer 是文本和 token ID 序列之间的双向转换——encode 和 decode
  • 词表是一张 token → ID 的映射表,每个 token 有唯一编号,编号本身不代表大小关系
  • 字符级 Tokenizer 词表小、稳定、几乎没有 OOV 问题,但序列长、语义被拆碎
  • 词级 Tokenizer 序列短,但词表容易膨胀,遇到词表外的词(OOV)无法处理
  • 子词级 Tokenizer 在词表大小和序列长度之间取得平衡,高频词整体保留、低频词拆成已知片段,是现代大语言模型的默认选择

下一节,我们从零实现 BPE 算法,看一张子词词表是怎样从字符开始逐步构建出来的。

附录:special token 与词表

实际的 Tokenizer 除了文本本身产生的 token 之外,还会使用一些人为约定的控制符号,称为 special token。最常见的三种:<BOS>(Beginning of Sequence)标记序列的开始,<EOS>(End of Sequence)标记序列的结束,<PAD>(Padding)用来补齐变长序列到相同长度。

这些 special token 在词表中有自己的固定 ID,和普通 token 看起来没什么区别——都只是一个编号。但它们有几处特殊的地方。

第一,special token 不是通过 BPE 的统计合并产生的。普通 token 是 tokenizer 从语料中统计高频 pair、一步步合并出来的;special token 则是在训练 tokenizer 时手动指定的——训练开始前就预留好位置,硬性加入词表,不参与任何统计过程。

第二,tokenizer 在 encode 时不会自动插入 special token。举个例子,把 "hello world" 交给 tokenizer,它只返回 hello 和 world 对应的 ID,不会自动在开头补一个 <BOS> 的 ID、在结尾补一个 <EOS> 的 ID。要不要加这些符号、加在什么位置,由准备训练数据的人自己决定——比如在预处理代码里,手动把每句话包成 [<BOS> ID] + 正文 ID + [<EOS> ID] 的形式,再交给模型。tokenizer 只管"每个 token 对应什么 ID",不管"这句话应该有哪些 token"——后者由 chat template 决定:它是一套规则,规定了对话中用户说的话前面加什么、模型回答前后加什么,作用在 tokenizer 之外。

第三,special token 在训练和推理中会被区别对待。<PAD> 只是占位符,计算 Attention 时模型通过 attention mask 把它的位置置零,计算损失时同样跳过,不让 padding 干扰真实内容。<EOS> 在文本生成中作为停止信号——模型逐 token 生成时,一旦输出 <EOS> 的 ID,解码立即终止。

这些 special token 的 embedding 和其他参数一样参与训练和梯度更新,只是在不同的计算环节中被选择性排除。更多细节会在后面实现 Mini-GPT 时展开。

作业

三道题覆盖两类内容:

  1. 核心机制:Tokenizer 的本质是文本 ↔ token ID,亲手操作一遍有助于加深理解。
  2. 实际用法:special token、attention mask、变长序列的 batching——这些都是模型训练和推理中的常规操作。

关于 AI 辅助:可以向 AI 提问思路、拆解步骤、检查方向是否正确,但不建议直接让 AI 完成题目。亲手完成后的理解和直接看答案的印象,差别是显著的。

作业 1:encode 的原理

把 token 查表转换为 ID——这是 Tokenizer 最核心的操作。

小提示stoi[token] 就是查表——把 token 映射到对应的编号。

# 作业 1:核心机制 encode 填空
stoi = {"the": 0, "cat": 1, "sat": 2}
tokens = ["the", "cat", "sat"]

# TODO:把下面三引号里的内容替换成你的代码
ids = """在这里把 tokens 里的每个 token 转成 ID"""

assert not isinstance(ids, str), "请先替换三引号里的占位内容"
assert ids == [0, 1, 2], ids
print("作业 1 通过:你理解了 encode 的核心就是 token → ID")

作业 2:special token 的使用

模型的输入不只有正文。<BOS> 标记序列开始,<EOS> 标记序列结束——模型依赖这些符号识别句子的边界。

小提示:常见的输入格式是 [BOS] + 正文 tokens + [EOS],边界符号在两端。

# 作业 2:special tokens 填空
stoi = {"<BOS>": 0, "<EOS>": 1, "the": 2, "cat": 3}
tokens = ["the", "cat"]

# TODO:把下面三引号里的内容替换成你的代码
input_ids = """在这里给正文前后加上 <BOS> 和 <EOS> 的 ID"""

assert not isinstance(input_ids, str), "请先替换三引号里的占位内容"
assert input_ids == [0, 2, 3, 1], input_ids
print("作业 2 通过:你理解了 special tokens 在输入序列中的作用")

作业 3:padding 与 attention mask

一个 batch 中的句子通常长短不一。为了组成整齐的张量,短句子末尾会补上 <PAD>,同时用 attention mask 标注哪些位置是真实 token、哪些是 padding。

小提示:真实 token 位置的 mask 值为 1,padding 位置为 0。模型在计算 Attention 时据此忽略 padding。

# 作业 3:padding + attention mask 填空
PAD_ID = 0
ids = [5, 6, 7]
max_len = 5

# TODO:把下面三引号里的内容替换成你的代码
padded_ids = """在这里把 ids 补到 max_len 长度"""
attention_mask = """在这里标出真实 token 和 PAD 位置"""

assert not isinstance(padded_ids, str), "请先替换 padded_ids 的占位内容"
assert not isinstance(attention_mask, str), "请先替换 attention_mask 的占位内容"
assert padded_ids == [5, 6, 7, 0, 0], padded_ids
assert attention_mask == [1, 1, 1, 0, 0], attention_mask
print("作业 3 通过:你理解了 padding 和 attention mask 的配合方式")