学习 LLM 工程时,很多人会直接从 Transformer、Attention 或训练脚本开始。但如果从工程链路看,Tokenizer 才是文本进入模型的第一道门。
模型并不直接理解字符串。它接收的是一串整数,也就是 token id。Tokenizer 的职责,就是把自然语言文本转换成 token id,再在推理结束后把 token id 解码回文本。
本文以 MiniMind 工程为例,系统梳理 Tokenizer 的结构、BPE 分词、特殊 token、vocab_size 以及它们和模型计算之间的关系。
1. Tokenizer 在工程中的位置
MiniMind 中 tokenizer 的加载位置在 trainer/trainer_utils.py:
tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
默认路径是:
../model
因此实际读取的是:
model/tokenizer.json model/tokenizer_config.json
两个文件的职责不同:
| 文件 | 作用 |
|---|---|
tokenizer.json | 保存 tokenizer 的核心规则:词表、BPE merges、ByteLevel pre-tokenizer、decoder 等。 |
tokenizer_config.json | 保存运行配置:bos_token、eos_token、pad_token、chat_template、tokenizer_class 等。 |
可以简单理解为:
tokenizer.json -> 决定“怎么分词” tokenizer_config.json -> 决定“怎么用于聊天模型”
2. tokenizer.json 的核心结构
MiniMind 的 tokenizer.json 顶层结构包括:
version truncation padding added_tokens normalizer pre_tokenizer post_processor decoder model
最关键的是这几项:
added_tokens:额外加入的特殊 token。pre_tokenizer:正式 BPE 前如何预切分文本。decoder:如何把 token id 解码回文本。model:真正的分词模型,这里是 BPE。
MiniMind 当前 tokenizer 的核心配置是:
model.type = BPE vocab size = 6400 merges size = 6108 pre_tokenizer = ByteLevel decoder = ByteLevel
也就是说,它使用的是:
ByteLevel + BPE
3. BPE 是什么
BPE 是 Byte Pair Encoding,一种常见的 tokenizer 分词算法。
它解决的是一个折中问题:
- 如果按“词”做词表,词表会非常大,低频词也很多。
- 如果按“字符”切分,序列会变长,模型训练和推理成本升高。
BPE 的思路是:从小片段开始,反复合并语料中最常见的相邻片段,最终得到一个大小可控的词表。
训练过程可以概括为:
准备训练文本 -> 拆成基础片段 -> 统计相邻 pair 的频率 -> 合并最高频 pair -> 重复合并 -> 达到目标词表大小 -> 得到 vocab 和 merges
举个简化例子:
low lower lowest
一开始可以拆成:
l o w l o w e r l o w e s t
如果 l + o 经常一起出现,就合并成:
lo
如果 lo + w 经常一起出现,再合并成:
low
如果 e + r 经常一起出现,也可以合并成:
er
最终,lower 可能被切成:
low er
这就是 BPE 的直觉:常见片段变成更大的 token,低频词仍然可以拆成更小片段。
需要补充一点:主流 tokenizer 并不全是 BPE。BPE 很常见,但还有 WordPiece、SentencePiece Unigram、SentencePiece BPE 等方案。
| 方法 | 代表模型/系统 | 特点 |
|---|---|---|
| BPE | GPT-2、RoBERTa、Qwen、MiniMind、tiktoken 系列 | 高频 pair 合并,工程上非常常见。 |
| Byte-level BPE | GPT-2、RoBERTa、MiniMind、tiktoken | 先做字节级表示,再用 BPE 合并,字符覆盖能力强。 |
| WordPiece | BERT 系列 | 与 BPE 接近,但训练目标和打分方式不同,常见 ##suffix 风格。 |
| SentencePiece Unigram | T5、mT5、部分 LLaMA/ALBERT 系列 | 基于概率模型从候选子词集合中选择词表。 |
| SentencePiece BPE | 一些 LLaMA/多语言模型 | SentencePiece 框架下的 BPE 实现,不强依赖空格预分词。 |
所以更准确的说法是:BPE/Byte-level BPE 是当前 LLM 里非常常见的一类 tokenizer,但不是唯一主流路线。
4. MiniMind 如何训练 tokenizer
MiniMind 提供了参考脚本:
trainer/train_tokenizer.py
核心代码如下:
tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
trainer = trainers.BpeTrainer(
vocab_size=6400,
show_progress=True,
initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
special_tokens=all_special_tokens
)
texts = get_texts(data_path)
tokenizer.train_from_iterator(texts, trainer=trainer)
这里有几个关键点:
models.BPE():使用 BPE 作为分词模型。ByteLevel:先把文本转换成字节级基础片段。vocab_size=6400:最终词表大小。special_tokens:把对话、工具调用、思考标签等特殊 token 固定加入词表。
训练完成后,会保存:
tokenizer.json tokenizer_config.json
需要注意,Tokenizer 的 training 和神经网络训练不是一回事。
LLM 训练是:
forward -> loss -> backward -> 梯度下降
BPE tokenizer training 更像:
读语料 -> 统计频率 -> 贪心合并高频片段 -> 保存 vocab 和 merges
它没有反向传播,也没有神经网络参数,更接近无监督的统计压缩算法。
5. vocab 和 merges 怎么看
tokenizer.json 中最重要的两块是:
vocab merges
vocab 记录 token 到 id 的映射,例如:
"<|endoftext|>" -> 0 "<|im_start|>" -> 1 "<|im_end|>" -> 2
merges 记录 BPE 的合并规则。每一条 merge 都是:
["左片段", "右片段"]
意思是:
左片段 + 右片段 -> 合并后的新片段
例如:
["h", "e"] -> "he" ["i", "n"] -> "in" ["o", "n"] -> "on" ["e", "r"] -> "er" ["l", "ow"] -> "low" ["t", "he"] -> "the" ["hel", "lo"] -> "hello"
merges 的顺序也很重要:越靠前,优先级越高。
需要特别注意 Ġ。在 ByteLevel BPE 中,Ġ 通常表示前面有空格。因此:
"the" 和 " the"
可能对应不同 token:
"the" "Ġthe"
中文在 tokenizer.json 中可能显示成不直观的字节片段,例如:
ä½łå¥½
这不是乱码错误,而是 ByteLevel 的内部表示。正常 decode 时会还原成中文。
6. 用 transformers 看真实切分
比直接读 JSON 更直观的方法,是实际调用 tokenizer:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("model")
text = "lower"
ids = tok.encode(text, add_special_tokens=False)
tokens = tok.convert_ids_to_tokens(ids)
print(tokens)
print(ids)
MiniMind tokenizer 的一些实际结果:
| 文本 | tokens | ids |
|---|---|---|
hello | ["hello"] | [6170] |
the | ["Ġthe"] | [309] |
the | ["the"] | [4345] |
lower | ["low", "er"] | [1030, 311] |
你好 | ["ä½łå¥½"] | [1968] |
人工智能 | ["人工æĻºèĥ½"] | [2225] |
tokenizer.json | ["to", "k", "en", "iz", "er", ".", "j", "son"] | – |
这些例子可以帮助理解:
- 常见英文词可能是一个 token。
- 前面带空格的英文会出现
Ġ。 - 中文显示成 ByteLevel 中间表示,但可以正常解码。
- 一个词可能被拆成多个子词,例如
lower -> low + er。
7. 特殊 token:added_tokens
MiniMind tokenizer 中有 36 个 added_tokens,它们不是普通文本词汇,而是模型协议的一部分。
| id | token | special | 含义 |
|---|---|---|---|
| 0 | `< | endoftext | >` |
| 1 | `< | im_start | >` |
| 2 | `< | im_end | >` |
| 3 | `< | object_ref_start | >` |
| 4 | `< | object_ref_end | >` |
| 5 | `< | box_start | >` |
| 6 | `< | box_end | >` |
| 7 | `< | quad_start | >` |
| 8 | `< | quad_end | >` |
| 9 | `< | vision_start | >` |
| 10 | `< | vision_end | >` |
| 11 | `< | vision_pad | >` |
| 12 | `< | image_pad | >` |
| 13 | `< | video_pad | >` |
| 14 | `< | audio_start | >` |
| 15 | `< | audio_end | >` |
| 16 | `< | audio_pad | >` |
| 17 | <tts_pad> | true | TTS 相关 padding 占位。 |
| 18 | <tts_text_bos> | true | TTS 文本开始标记。 |
| 19 | <tts_text_eod> | true | TTS 文本结束或文档结束标记。 |
| 20 | <tts_text_bos_single> | true | TTS 单段文本开始标记。 |
| 21 | <tool_call> | false | 工具调用内容开始标记。 |
| 22 | </tool_call> | false | 工具调用内容结束标记。 |
| 23 | <tool_response> | false | 工具返回内容开始标记。 |
| 24 | </tool_response> | false | 工具返回内容结束标记。 |
| 25 | <think> | false | thinking/reasoning 内容开始标记。 |
| 26 | </think> | false | thinking/reasoning 内容结束标记。 |
| 27-35 | `< | buffer1 | >-< |
对当前文本模型最关键的是:
<|im_start|> <|im_end|> <tool_call> <tool_response> <think> </think>
8. BOS、EOS、PAD 是什么
MiniMind 当前配置:
bos_token = "<|im_start|>" bos_token_id = 1 eos_token = "<|im_end|>" eos_token_id = 2 pad_token = "<|endoftext|>" pad_token_id = 0
BOS 是 Beginning Of Sequence,表示序列开始。EOS 是 End Of Sequence,表示序列结束。
在 PretrainDataset 中,会手动给普通文本加上 BOS/EOS:
tokens = [self.tokenizer.bos_token_id] + tokens + [self.tokenizer.eos_token_id]
如果长度不足 max_length,会用 pad_token_id 补齐:
input_ids = tokens + [self.tokenizer.pad_token_id] * (self.max_length - len(tokens))
但是 padding 只是占位,不应参与训练 loss,所以 labels 中会把 pad 位置改成 -100:
labels[input_ids == self.tokenizer.pad_token_id] = -100
模型计算 loss 时再显式忽略 -100:
loss = F.cross_entropy(
x.view(-1, x.size(-1)),
y.view(-1),
ignore_index=-100
)
这条链路很重要:
pad token -> labels 中标为 -100 -> cross_entropy 忽略
9. vocab_size 为什么重要
vocab_size 不只是 tokenizer 的参数,它会直接进入模型结构。
MiniMind 默认:
vocab_size = 6400 hidden_size = 768 tie_word_embeddings = True
模型中有两块和 vocab_size 强相关:
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size) self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
9.1 Embedding 参数量
embedding 层负责把 token id 转成向量:
embedding 参数量 = vocab_size * hidden_size
MiniMind 默认:
6400 * 768 = 4,915,200 参数,约 4.92M
9.2 lm_head 参数量
lm_head 负责把 hidden state 映射到词表空间:
lm_head 参数量 = hidden_size * vocab_size
如果不共享权重:
总参数 = embedding + lm_head
= 2 * vocab_size * hidden_size
MiniMind 开启 tie_word_embeddings=True,因此:
self.model.embed_tokens.weight = self.lm_head.weight
即输入 embedding 和输出 head 共享同一份大矩阵。
9.3 logits 的大小
模型输出 logits 的形状是:
[batch_size, seq_len, vocab_size]
假设:
batch_size = 16 seq_len = 768
那么:
vocab_size=6400 -> 约 78,643,200 个元素 vocab_size=50000 -> 约 614,400,000 个元素 vocab_size=100000 -> 约 1,228,800,000 个元素
如果按 fp16/bf16 粗略估算,仅 logits 张量就大约是:
vocab_size=6400 -> 150 MB vocab_size=50000 -> 1172 MB vocab_size=100000 -> 2344 MB
训练时还会有梯度、中间缓存和 loss 计算开销,实际显存压力更大。
10. Embedding 是查表还是矩阵乘法
nn.Embedding 实际实现更像 lookup table。
内部参数是:
embedding.weight: [vocab_size, hidden_size]
每一行是一个 token id 对应的向量。
如果输入是:
input_ids = [1, 309, 4345]
那么输出就是:
[ embedding.weight[1], embedding.weight[309], embedding.weight[4345] ]
工程实现上,这是 gather/查表,不是普通矩阵乘法。
但数学上,它等价于:
one_hot(token_id) @ embedding.weight
只是实际不会构造巨大的 one-hot 矩阵。
可以总结为:
数学上:one-hot @ embedding_matrix 实现上:lookup / gather
11. 主流模型的词表大小
不同模型系列的词表大小差异很大:
| 模型/Tokenizer | 典型词表大小 | 备注 |
|---|---|---|
| MiniMind | 6,400 | 小模型场景下刻意控制较小。 |
| Llama 2 | 32,000 | 早期 Llama 系列常见大小。 |
| Mistral 7B v0.1/v0.2 | 32,000 | Mistral 早期版本常见大小。 |
| Phi-3 mini | 32,064 | 接近 32k。 |
| Falcon 7B | 65,024 | 比 Llama 2/Mistral 早期版本更大。 |
| Llama 3 / 3.1 / 3.2 | 128,256 | Llama 3 系列显著扩大词表。 |
| DeepSeek-V3 / V3.1 / V4 | 129,280 | DeepSeek V3/V4 常见配置。 |
| Qwen2 / Qwen2.5 / Qwen3 | 约 151k | Qwen 系列常见配置。 |
| Qwen3.5 | 248,320 | 新版本词表更大。 |
| Kimi K2 | 163,840 | 开源 Kimi K2 配置可查。 |
| Gemma / Gemma 2 | 256,000 | 很大的词表。 |
OpenAI cl100k_base | 约 100k | GPT-4/GPT-3.5 时代常见 encoding。 |
OpenAI o200k_base | 约 200k | GPT-4o/o 系列常见 encoding。 |
| Claude Opus / Sonnet | 未公开 | Anthropic 未公开完整 tokenizer 词表。 |
趋势很明显:
- 早期开源模型常见 32k 左右。
- 新一代多语言/代码模型常见 100k 以上。
- 小模型不适合盲目使用超大词表,因为 embedding 和 lm_head 会吃掉太多参数。
12. 小结
Tokenizer 看起来只是预处理工具,但它深度影响 LLM 的训练和推理:
- 它决定文本如何变成 token id。
- 它决定
vocab_size,进而影响 embedding、lm_head、logits 和 loss。 - 它通过特殊 token 定义了对话、工具调用和思考格式。
- 它的切分效果影响上下文长度利用率和训练效率。
MiniMind 使用 ByteLevel + BPE,词表大小为 6400。这是小模型场景下非常明确的工程取舍:牺牲一部分压缩率,换取更小的参数量和更轻的输出层计算。
理解 tokenizer 后,再去看 Dataset、模型 forward 和训练 loss,会顺畅很多,因为你已经知道了文本进入模型前到底发生了什么。