minimind/course/04_tokenizer_and_special_to...

18 KiB
Raw Blame History

第 4 课:Tokenizer 与特殊标记

这一课只解决一个问题:MiniMind 如何把文本、角色和结构标记变成模型能处理的 token id。

目录

0. 本节主线

Tokenizer 的本质是:

加载 tokenizer 配置
-> 按 ByteLevel + BPE 规则切文本
-> 用 vocab 把 token 映射成 id
-> 用特殊 token 表达消息边界、思考、工具调用等结构
-> 用 chat template 把 messages 变成 prompt
-> encode 成 input_ids,必要时 decode 回文本

这条链对应本节的 5 个原理:

加载 tokenizer -> 切文本 -> token/id 映射 -> 特殊标记 -> chat template

本节不训练 tokenizer。项目作者也明确不建议重复训练 tokenizer,因为 tokenizer 一旦变化,数据、权重、推理格式都会不兼容。

1. 本节要懂的 5 个原理

原理 要理解什么 源码证据
加载 tokenizer tokenizer 是文本和 token id 的转换器,不是模型本身 eval_llm.py:13, model/tokenizer_config.json:334
切文本 MiniMind 用 ByteLevel + BPE 切文本,不是按字或空格简单切 model/tokenizer.json:332-346, trainer/train_tokenizer.py:24-27
token/id 映射 tokenizer 词表规定每个 token 的 id,模型输出维度必须匹配词表 model/tokenizer.json:354-370, trainer/train_tokenizer.py:43-48, model/model_minimind.py:20
特殊标记 消息边界、padding、thinking、tool use 都是 tokenizer 能识别的 token model/tokenizer_config.json:5-29, model/tokenizer_config.json:317-325, trainer/train_tokenizer.py:28-48
chat template 对话列表必须先渲染成带角色标记的 prompt,推理时还要加 assistant 起始提示 minimind-3/chat_template.jinja:25-45, minimind-3/chat_template.jinja:78-84, eval_llm.py:76

学完本节,你不需要手写 BPE 算法,也不需要完整读懂 tokenizer.json 的所有字段。

2. 变量流转

把本节主线对应到变量:

text: 普通字符串,例如 "MiniMind 是什么?"
messages: [{"role": "user", "content": text}]
prompt: chat template 渲染后的字符串
raw_tokens: token id 对应的 token 字符串
input_ids: tokenizer 编码后的 token id
decoded_text: token id 解码回来的文本

第 3 课里这行代码:

inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device)

背后依赖的就是本节内容:tokenizer 负责把 prompt 字符串变成 input_ids 和 attention_mask。

3. 原理一:加载 tokenizer

原理讲解

推理时,模型不能直接处理字符串。字符串必须先交给 tokenizer,转成一串整数 id。

所以 tokenizer 的职责是双向转换:

文本 -> token id
token id -> 文本

它不是模型权重,也不负责“理解问题”。它更像模型使用的字典和切分规则。模型训练时用哪套 tokenizer,推理时也必须用同一套 tokenizer。否则同一个 id 可能代表不同 token,模型输出就会乱。

MiniMind 里 tokenizer 的加载发生在推理入口:

eval_llm.py:13

源码证据 A:推理代码加载 tokenizer

文件:eval_llm.py:13

看它是为了理解:推理入口从哪个目录加载 tokenizer。

tokenizer = AutoTokenizer.from_pretrained(args.load_from)

这段代码说明:

  • args.load_from 可以是 ./model,也可以是 ./minimind-3。
  • AutoTokenizer.from_pretrained(...) 会读取目录里的 tokenizer 文件。
  • 加载出来的 tokenizer 后面会负责 apply_chat_template、encode、decode。

源码证据 B:配置声明 tokenizer 类型

文件:model/tokenizer_config.json:334

看它是为了理解:transformers 应该按哪类 tokenizer 加载。

{
  "tokenizer_class": "PreTrainedTokenizerFast"
}

这段配置说明:

  • MiniMind 使用 transformers 的 fast tokenizer 接口。
  • 底层切分和映射由 tokenizers 库执行。
  • 你不需要手动实例化 tokenizer 类,AutoTokenizer 会根据配置处理。

理解到这一步就够

你应该能说清楚:

tokenizer 是文本和 token id 的转换协议;
模型权重必须和 tokenizer 的词表、特殊标记保持一致;
MiniMind 通过 AutoTokenizer.from_pretrained(...) 加载 tokenizer。

暂时不用看:

  • AutoTokenizer 内部如何选择具体类。
  • tokenizers Rust 底层实现。

4. 原理二:文本怎么被切成 token

原理讲解

tokenizer 不是简单按“字”切,也不是简单按“空格”切。

MiniMind 的 tokenizer 大致分两步:

第一步:ByteLevel 预切分
第二步:BPE 根据 merge 规则合并常见片段

ByteLevel 的好处是覆盖面广。中文、英文、标点、空格、少见字符都能落到字节级表示,不容易出现完全无法处理的字符。

BPE 的作用是压缩常见片段。常见的字符组合可以合并成一个 token,这样同样一句话可以用更少 token 表示。

所以当你看到:

MiniMind 是什么?

它不会简单变成 12 个字符,也不会只按空格切。它会经过 ByteLevel + BPE 变成一串 token。

源码证据 A:tokenizer.json 记录切分组件

文件:model/tokenizer.json:332-346

看它是为了理解:已经训练好的 tokenizer 使用什么预切分器和模型类型。

"pre_tokenizer": {
  "type": "ByteLevel",
  "add_prefix_space": false
},
"model": {
  "type": "BPE"
}

这段配置说明:

  • pre_tokenizer.type = ByteLevel:先按字节级规则处理文本。
  • model.type = BPE:再使用 BPE 模型进行 token 合并。
  • 这两个字段回答了“文本大致怎么被切”的问题。

源码证据 B:训练脚本里也是这样创建 tokenizer

文件:trainer/train_tokenizer.py:24-27

看它是为了理解:如果从零训练 tokenizer,作者也是显式选择 BPE + ByteLevel。

tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)

这段代码说明:

  • Tokenizer(models.BPE()):创建 BPE tokenizer。
  • pre_tokenizers.ByteLevel(...):设置 ByteLevel 预切分。
  • 训练脚本和最终 tokenizer.json 是一致的。

理解到这一步就够

你应该能说清楚:

MiniMind 的 tokenizer 不是按字或按空格切;
它先做 ByteLevel 处理,再用 BPE merge 把常见片段合并;
具体规则保存在 tokenizer.json 里。

暂时不用看:

  • 完整 merges 表。
  • trim_offsets、use_regex 的细节。
  • raw token 里 Ġ 或乱码样式片段的完整含义,先通过实验观察即可。

5. 原理三:每个 token 怎么对应 id

原理讲解

模型不会直接预测文字。模型预测的是 token id。

tokenizer 维护一张词表:

token 字符串 -> 整数 id

例如 MiniMind 里:

<|endoftext|> -> 0
<|im_start|>  -> 1
<|im_end|>    -> 2

模型最后一层输出 logits,最后一维长度就是 vocab_size。如果 vocab_size=6400,模型每个位置会输出 6400 个分数:

logits[..., 0] 对应 token id 0
logits[..., 1] 对应 token id 1
...
logits[..., 6399] 对应 token id 6399

因此 tokenizer 的词表大小必须和模型的 vocab_size 一致。否则模型预测出来的 id,tokenizer 无法按同一套含义 decode;训练好的权重也会和输入输出层不匹配。

源码证据 A:token 到 id 的映射表

文件:model/tokenizer.json:354-370

看它是为了理解:token 字符串如何对应具体 id。

"vocab": {
  "<|endoftext|>": 0,
  "<|im_start|>": 1,
  "<|im_end|>": 2
}

这段配置说明:

  • <|endoftext|> 的 id 是 0。
  • <|im_start|> 的 id 是 1。
  • <|im_end|> 的 id 是 2。
  • 这些 id 不是随便来的,而是 tokenizer 词表定义的。

源码证据 B:训练 tokenizer 时指定词表大小

文件:trainer/train_tokenizer.py:43-48

看它是为了理解:词表大小是在 tokenizer 训练阶段确定的。

VOCAB_SIZE = 6400

trainer = trainers.BpeTrainer(
    vocab_size=vocab_size,
    special_tokens=all_special_tokens
)

这段代码说明:

  • VOCAB_SIZE = 6400 是 MiniMind tokenizer 的目标词表大小。
  • BpeTrainer(... vocab_size=...) 会在训练 tokenizer 时控制词表规模。
  • special_tokens=all_special_tokens 保证特殊标记被放进词表。

源码证据 C:模型配置也使用同样的 vocab_size

文件:model/model_minimind.py:20

看它是为了理解:模型输出维度必须和 tokenizer 词表大小一致。

self.vocab_size = kwargs.get("vocab_size", 6400)

这段代码说明:

  • MiniMind 模型默认 vocab_size 也是 6400。
  • 后面的 embedding 和 lm head 都依赖这个大小。
  • 模型输出的 token 分数和 tokenizer 的 token id 是一一对应关系。

理解到这一步就够

你应该能说清楚:

tokenizer.json 里保存 token -> id 的词表;
模型输出 logits 的最后一维对应这些 id;
tokenizer vocab_size 和模型 vocab_size 必须一致。

暂时不用看:

  • 全部 6400 个 token。
  • 每一条 merge 规则如何形成最终 vocab。

6. 原理四:哪些 token 有特殊含义

原理讲解

普通 token 表示文本片段;特殊 token 表示结构。

在聊天模型里,模型不仅要知道“文字内容”,还要知道:

这是谁说的?
这条消息从哪里开始?
这条消息在哪里结束?
是否有 thinking 区间?
是否有 tool call 或 tool response?

这些结构都要通过 token 表达。比如:

<|im_start|>user
MiniMind 是什么?<|im_end|>

这里 <|im_start|> 和 <|im_end|> 不是给人看的装饰,而是模型训练时反复见过的结构 token。

还有一个容易混淆的点:

结构标记 != 一定 special=true

<think> 是结构标记,但在当前配置里 special=false。这意味着它会作为一个完整 token 存在,但 decode(..., skip_special_tokens=True) 不会自动把它删掉。

源码证据 A:消息边界 token 是 special=true

文件:model/tokenizer_config.json:5-29

看它是为了理解:哪些 token 会被 tokenizer 当作 special token。

"1": {
  "content": "<|im_start|>",
  "special": true
},
"2": {
  "content": "<|im_end|>",
  "special": true
}

这段配置说明:

  • <|im_start|> 和 <|im_end|> 都是 tokenizer 级别的 special token。
  • 当 decode(..., skip_special_tokens=True) 时,这类 token 会被跳过。

源码证据 B:bos/eos/pad/unk 的指定

文件:model/tokenizer_config.json:317-325

看它是为了理解:常用特殊角色分别绑定到哪个 token。

"bos_token": "<|im_start|>",
"eos_token": "<|im_end|>",
"pad_token": "<|endoftext|>",
"unk_token": "<|endoftext|>"

这段配置说明:

  • BOS 使用 <|im_start|>。
  • EOS 使用 <|im_end|>。
  • padding 和未知 token 都复用 <|endoftext|>。
  • 这会影响 padding、结束判断、decode 等行为。

源码证据 C:训练 tokenizer 时预留结构标记

文件:trainer/train_tokenizer.py:28-48

看它是为了理解:这些结构标记在训练 tokenizer 时就被固定进词表。

special_tokens_list = [
    "<|endoftext|>", "<|im_start|>", "<|im_end|>",
    ...
]

additional_tokens_list = [
    "<tool_call>", "</tool_call>",
    "<tool_response>", "</tool_response>",
    "<think>", "</think>"
]

这段代码说明:

  • <|im_start|>、<|im_end|> 等核心标记不会被拆开。
  • <think>、<tool_call> 等结构标记也作为完整 token 进入词表。
  • 后续模型才能稳定学习这些结构。

理解到这一步就够

你应该能说清楚:

特殊标记用于表达对话结构;
special=true 会影响 skip_special_tokens;
<think> 这类结构标记虽然是完整 token,但当前不是 special=true。

暂时不用看:

  • 多模态 token:vision_*、audio_*。
  • buffer token 的扩展用途。
  • Tool call 的完整格式,后面 Tool Use 再讲。

7. 原理五:chat template 如何把 messages 变成 prompt

原理讲解

模型不能直接吃 Python 里的 messages 列表。

比如:

[{"role": "user", "content": "MiniMind 是什么?"}]

这只是程序里的结构化数据。模型真正能处理的是 token id,而 token id 来自字符串。因此在 tokenize 之前,要先把 messages 渲染成一个字符串 prompt。

chat template 就是这个渲染规则。

它把 user 消息变成:

<|im_start|>user
MiniMind 是什么?<|im_end|>

推理时还要在最后补上 assistant 开头:

<|im_start|>assistant
<think>

</think>

这样模型才知道:接下来应该从 assistant 的位置继续生成。

源码证据 A:user / assistant 消息如何被渲染

文件:minimind-3/chat_template.jinja:25-45

看它是为了理解:messages 里的 role 如何变成 prompt 中的角色标记。

{%- for message in messages %}
    {%- if (message.role == "user") %}
        {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
    {%- elif message.role == "assistant" %}
        {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
        {{- '<|im_end|>\n' }}
    {%- endif %}
{%- endfor %}

这段模板说明:

  • 遍历每条 message。
  • user 消息被包装成 <|im_start|>user ... <|im_end|>。
  • assistant 消息被包装成 <|im_start|>assistant ... <|im_end|>,并带 <think> 区域。

源码证据 B:推理时添加 assistant 起始提示

文件:minimind-3/chat_template.jinja:78-84

看它是为了理解:add_generation_prompt=True 到底加了什么。

{%- if add_generation_prompt %}
    {{- '<|im_start|>assistant\n' }}
    {%- if open_thinking is defined and open_thinking is true %}
        {{- '<think>\n' }}
    {%- else %}
        {{- '<think>\n\n</think>\n\n' }}
    {%- endif %}
{%- endif %}

这段模板说明:

  • add_generation_prompt=True 会在末尾加 <|im_start|>assistant\n。
  • open_thinking=False 时插入空 thinking 块。
  • open_thinking=True 时只打开 <think>\n,让模型自己生成 thinking 内容。

源码证据 C:推理代码启用 generation prompt

文件:eval_llm.py:76

看它是为了理解:CLI 推理确实打开了这个模板行为。

inputs = tokenizer.apply_chat_template(
    conversation,
    tokenize=False,
    add_generation_prompt=True,
    open_thinking=bool(args.open_thinking)
)

这段代码说明:

  • conversation 会先被渲染成 prompt 字符串。
  • tokenize=False 表示这一步还不转成 token id。
  • add_generation_prompt=True 让模板加 assistant 起始提示。

理解到这一步就够

你应该能说清楚:

chat template 是 messages 和 prompt 之间的桥;
推理时必须告诉模型“接下来由 assistant 续写”;
这就是 add_generation_prompt=True 的作用。

暂时不用看:

  • tools 分支。
  • tool response 的多轮定位逻辑。
  • reasoning_content 的所有兼容处理。

8. 实验验证

实验 A:观察 tokenizer 基本信息

这个实验验证:

token -> id
id -> token
special token 是否会被 skip
普通文本如何 encode/decode

运行:

cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_tokenizer.py

记录:

vocab_size =
bos/eos/pad/unk token 和 id =
"MiniMind 是什么?" 的 input_ids =
"MiniMind 是什么?" 的 raw_tokens =
decode(skip_special_tokens=False) =
decode(skip_special_tokens=True) =
<think> 在 skip_special_tokens=True 时是否被移除 =

你应该理解到:

同一段文本有 input_ids,也有 raw_tokens;
decode 是把 id 转回字符串;
special=true 的 token 会受 skip_special_tokens 影响。

实验 B:观察 chat template

这个实验验证:

messages -> prompt
add_generation_prompt -> assistant 起始格式
open_thinking -> prompt 末尾变化

运行默认版本:

cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_tokenizer.py

再运行 open thinking 版本:

PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_tokenizer.py --open_thinking

记录:

open_thinking=False 时 prompt 末尾是什么?
open_thinking=True 时 prompt 末尾是什么?
两者的 prompt_token_count 是否变化?

9. 本节检查

如果你真懂了本节,应该能不看答案说清楚:

  1. tokenizer.json 和 tokenizer_config.json 分别主要解决什么问题。
  2. ByteLevel + BPE 大致是什么意思。
  3. token 到 id 的映射在哪里,为什么它必须和模型 vocab_size 匹配。
  4. <|im_start|>、<|im_end|>、<think> 分别在对话格式中起什么作用。
  5. 为什么 apply_chat_template(..., tokenize=False) 返回的是 prompt 字符串,而不是 token id。
  6. add_generation_prompt=True 为什么对推理重要。

10. 下一课

第 5 课继续数据格式和 chat template:我们会看 SFT 数据中的 conversations 如何被 SFTDataset 转成 prompt 和 labels。