MiniMind 学习笔记:从 Tokenizer 开始理解 LLM

学习 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_tokeneos_tokenpad_tokenchat_templatetokenizer_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 等方案。

方法代表模型/系统特点
BPEGPT-2、RoBERTa、Qwen、MiniMind、tiktoken 系列高频 pair 合并,工程上非常常见。
Byte-level BPEGPT-2、RoBERTa、MiniMind、tiktoken先做字节级表示,再用 BPE 合并,字符覆盖能力强。
WordPieceBERT 系列与 BPE 接近,但训练目标和打分方式不同,常见 ##suffix 风格。
SentencePiece UnigramT5、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 的一些实际结果:

文本tokensids
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,它们不是普通文本词汇,而是模型协议的一部分。

idtokenspecial含义
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>trueTTS 相关 padding 占位。
18<tts_text_bos>trueTTS 文本开始标记。
19<tts_text_eod>trueTTS 文本结束或文档结束标记。
20<tts_text_bos_single>trueTTS 单段文本开始标记。
21<tool_call>false工具调用内容开始标记。
22</tool_call>false工具调用内容结束标记。
23<tool_response>false工具返回内容开始标记。
24</tool_response>false工具返回内容结束标记。
25<think>falsethinking/reasoning 内容开始标记。
26</think>falsethinking/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典型词表大小备注
MiniMind6,400小模型场景下刻意控制较小。
Llama 232,000早期 Llama 系列常见大小。
Mistral 7B v0.1/v0.232,000Mistral 早期版本常见大小。
Phi-3 mini32,064接近 32k。
Falcon 7B65,024比 Llama 2/Mistral 早期版本更大。
Llama 3 / 3.1 / 3.2128,256Llama 3 系列显著扩大词表。
DeepSeek-V3 / V3.1 / V4129,280DeepSeek V3/V4 常见配置。
Qwen2 / Qwen2.5 / Qwen3约 151kQwen 系列常见配置。
Qwen3.5248,320新版本词表更大。
Kimi K2163,840开源 Kimi K2 配置可查。
Gemma / Gemma 2256,000很大的词表。
OpenAI cl100k_base约 100kGPT-4/GPT-3.5 时代常见 encoding。
OpenAI o200k_base约 200kGPT-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,会顺畅很多,因为你已经知道了文本进入模型前到底发生了什么。

发表评论