# 第 4 课:Tokenizer 与特殊标记 这一课只解决一个问题:MiniMind 如何把文本、角色和结构标记变成模型能处理的 token id。 ## 目录 - [0. 本节主线](#l04-mainline) - [1. 本节要懂的 5 个原理](#l04-principles) - [2. 变量流转](#l04-flow) - [3. 原理一:加载 tokenizer](#l04-load-tokenizer) - [4. 原理二:文本怎么被切成 token](#l04-token-splitting) - [5. 原理三:每个 token 怎么对应 id](#l04-token-id) - [6. 原理四:哪些 token 有特殊含义](#l04-special-tokens) - [7. 原理五:chat template 如何把 messages 变成 prompt](#l04-chat-template) - [8. 实验验证](#l04-experiment) - [9. 本节检查](#l04-check) - [10. 下一课](#l04-next) ## 0. 本节主线 Tokenizer 的本质是: ```text 加载 tokenizer 配置 -> 按 ByteLevel + BPE 规则切文本 -> 用 vocab 把 token 映射成 id -> 用特殊 token 表达消息边界、思考、工具调用等结构 -> 用 chat template 把 messages 变成 prompt -> encode 成 input_ids,必要时 decode 回文本 ``` 这条链对应本节的 5 个原理: ```text 加载 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 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 课里这行代码: ```python inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device) ``` 背后依赖的就是本节内容:tokenizer 负责把 prompt 字符串变成 `input_ids` 和 `attention_mask`。 ## 3. 原理一:加载 tokenizer ### 原理讲解 推理时,模型不能直接处理字符串。字符串必须先交给 tokenizer,转成一串整数 id。 所以 tokenizer 的职责是双向转换: ```text 文本 -> token id token id -> 文本 ``` 它不是模型权重,也不负责“理解问题”。它更像模型使用的字典和切分规则。模型训练时用哪套 tokenizer,推理时也必须用同一套 tokenizer。否则同一个 id 可能代表不同 token,模型输出就会乱。 MiniMind 里 tokenizer 的加载发生在推理入口: ```text eval_llm.py:13 ``` ### 源码证据 A:推理代码加载 tokenizer 文件:`eval_llm.py:13` 看它是为了理解:推理入口从哪个目录加载 tokenizer。 ```python 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 加载。 ```json { "tokenizer_class": "PreTrainedTokenizerFast" } ``` 这段配置说明: - MiniMind 使用 transformers 的 fast tokenizer 接口。 - 底层切分和映射由 `tokenizers` 库执行。 - 你不需要手动实例化 tokenizer 类,`AutoTokenizer` 会根据配置处理。 ### 理解到这一步就够 你应该能说清楚: ```text tokenizer 是文本和 token id 的转换协议; 模型权重必须和 tokenizer 的词表、特殊标记保持一致; MiniMind 通过 AutoTokenizer.from_pretrained(...) 加载 tokenizer。 ``` 暂时不用看: - `AutoTokenizer` 内部如何选择具体类。 - `tokenizers` Rust 底层实现。 ## 4. 原理二:文本怎么被切成 token ### 原理讲解 tokenizer 不是简单按“字”切,也不是简单按“空格”切。 MiniMind 的 tokenizer 大致分两步: ```text 第一步:ByteLevel 预切分 第二步:BPE 根据 merge 规则合并常见片段 ``` ByteLevel 的好处是覆盖面广。中文、英文、标点、空格、少见字符都能落到字节级表示,不容易出现完全无法处理的字符。 BPE 的作用是压缩常见片段。常见的字符组合可以合并成一个 token,这样同样一句话可以用更少 token 表示。 所以当你看到: ```text MiniMind 是什么? ``` 它不会简单变成 12 个字符,也不会只按空格切。它会经过 ByteLevel + BPE 变成一串 token。 ### 源码证据 A:tokenizer.json 记录切分组件 文件:`model/tokenizer.json:332-346` 看它是为了理解:已经训练好的 tokenizer 使用什么预切分器和模型类型。 ```json "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。 ```python 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` 是一致的。 ### 理解到这一步就够 你应该能说清楚: ```text MiniMind 的 tokenizer 不是按字或按空格切; 它先做 ByteLevel 处理,再用 BPE merge 把常见片段合并; 具体规则保存在 tokenizer.json 里。 ``` 暂时不用看: - 完整 `merges` 表。 - `trim_offsets`、`use_regex` 的细节。 - raw token 里 `Ġ` 或乱码样式片段的完整含义,先通过实验观察即可。 ## 5. 原理三:每个 token 怎么对应 id ### 原理讲解 模型不会直接预测文字。模型预测的是 token id。 tokenizer 维护一张词表: ```text token 字符串 -> 整数 id ``` 例如 MiniMind 里: ```text <|endoftext|> -> 0 <|im_start|> -> 1 <|im_end|> -> 2 ``` 模型最后一层输出 logits,最后一维长度就是 `vocab_size`。如果 `vocab_size=6400`,模型每个位置会输出 6400 个分数: ```text 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。 ```json "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 训练阶段确定的。 ```python 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 词表大小一致。 ```python self.vocab_size = kwargs.get("vocab_size", 6400) ``` 这段代码说明: - MiniMind 模型默认 `vocab_size` 也是 6400。 - 后面的 embedding 和 lm head 都依赖这个大小。 - 模型输出的 token 分数和 tokenizer 的 token id 是一一对应关系。 ### 理解到这一步就够 你应该能说清楚: ```text tokenizer.json 里保存 token -> id 的词表; 模型输出 logits 的最后一维对应这些 id; tokenizer vocab_size 和模型 vocab_size 必须一致。 ``` 暂时不用看: - 全部 6400 个 token。 - 每一条 merge 规则如何形成最终 vocab。 ## 6. 原理四:哪些 token 有特殊含义 ### 原理讲解 普通 token 表示文本片段;特殊 token 表示结构。 在聊天模型里,模型不仅要知道“文字内容”,还要知道: ```text 这是谁说的? 这条消息从哪里开始? 这条消息在哪里结束? 是否有 thinking 区间? 是否有 tool call 或 tool response? ``` 这些结构都要通过 token 表达。比如: ```text <|im_start|>user MiniMind 是什么?<|im_end|> ``` 这里 `<|im_start|>` 和 `<|im_end|>` 不是给人看的装饰,而是模型训练时反复见过的结构 token。 还有一个容易混淆的点: ```text 结构标记 != 一定 special=true ``` `` 是结构标记,但在当前配置里 `special=false`。这意味着它会作为一个完整 token 存在,但 `decode(..., skip_special_tokens=True)` 不会自动把它删掉。 ### 源码证据 A:消息边界 token 是 special=true 文件:`model/tokenizer_config.json:5-29` 看它是为了理解:哪些 token 会被 tokenizer 当作 special token。 ```json "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。 ```json "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 时就被固定进词表。 ```python special_tokens_list = [ "<|endoftext|>", "<|im_start|>", "<|im_end|>", ... ] additional_tokens_list = [ "", "", "", "", "", "" ] ``` 这段代码说明: - `<|im_start|>`、`<|im_end|>` 等核心标记不会被拆开。 - ``、`` 等结构标记也作为完整 token 进入词表。 - 后续模型才能稳定学习这些结构。 ### 理解到这一步就够 你应该能说清楚: ```text 特殊标记用于表达对话结构; special=true 会影响 skip_special_tokens; 这类结构标记虽然是完整 token,但当前不是 special=true。 ``` 暂时不用看: - 多模态 token:`vision_*`、`audio_*`。 - buffer token 的扩展用途。 - Tool call 的完整格式,后面 Tool Use 再讲。 ## 7. 原理五:chat template 如何把 messages 变成 prompt ### 原理讲解 模型不能直接吃 Python 里的 messages 列表。 比如: ```python [{"role": "user", "content": "MiniMind 是什么?"}] ``` 这只是程序里的结构化数据。模型真正能处理的是 token id,而 token id 来自字符串。因此在 tokenize 之前,要先把 messages 渲染成一个字符串 prompt。 chat template 就是这个渲染规则。 它把 user 消息变成: ```text <|im_start|>user MiniMind 是什么?<|im_end|> ``` 推理时还要在最后补上 assistant 开头: ```text <|im_start|>assistant ``` 这样模型才知道:接下来应该从 assistant 的位置继续生成。 ### 源码证据 A:user / assistant 消息如何被渲染 文件:`minimind-3/chat_template.jinja:25-45` 看它是为了理解:messages 里的 role 如何变成 prompt 中的角色标记。 ```jinja {%- 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\n' + reasoning_content.strip('\n') + '\n\n\n' + content.lstrip('\n') }} {{- '<|im_end|>\n' }} {%- endif %} {%- endfor %} ``` 这段模板说明: - 遍历每条 message。 - user 消息被包装成 `<|im_start|>user ... <|im_end|>`。 - assistant 消息被包装成 `<|im_start|>assistant ... <|im_end|>`,并带 `` 区域。 ### 源码证据 B:推理时添加 assistant 起始提示 文件:`minimind-3/chat_template.jinja:78-84` 看它是为了理解:`add_generation_prompt=True` 到底加了什么。 ```jinja {%- if add_generation_prompt %} {{- '<|im_start|>assistant\n' }} {%- if open_thinking is defined and open_thinking is true %} {{- '\n' }} {%- else %} {{- '\n\n\n\n' }} {%- endif %} {%- endif %} ``` 这段模板说明: - `add_generation_prompt=True` 会在末尾加 `<|im_start|>assistant\n`。 - `open_thinking=False` 时插入空 thinking 块。 - `open_thinking=True` 时只打开 `\n`,让模型自己生成 thinking 内容。 ### 源码证据 C:推理代码启用 generation prompt 文件:`eval_llm.py:76` 看它是为了理解:CLI 推理确实打开了这个模板行为。 ```python 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 起始提示。 ### 理解到这一步就够 你应该能说清楚: ```text chat template 是 messages 和 prompt 之间的桥; 推理时必须告诉模型“接下来由 assistant 续写”; 这就是 add_generation_prompt=True 的作用。 ``` 暂时不用看: - tools 分支。 - tool response 的多轮定位逻辑。 - reasoning_content 的所有兼容处理。 ## 8. 实验验证 ### 实验 A:观察 tokenizer 基本信息 这个实验验证: ```text token -> id id -> token special token 是否会被 skip 普通文本如何 encode/decode ``` 运行: ```bash cd /home/sun/minimind PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_tokenizer.py ``` 记录: ```text vocab_size = bos/eos/pad/unk token 和 id = "MiniMind 是什么?" 的 input_ids = "MiniMind 是什么?" 的 raw_tokens = decode(skip_special_tokens=False) = decode(skip_special_tokens=True) = 在 skip_special_tokens=True 时是否被移除 = ``` 你应该理解到: ```text 同一段文本有 input_ids,也有 raw_tokens; decode 是把 id 转回字符串; special=true 的 token 会受 skip_special_tokens 影响。 ``` ### 实验 B:观察 chat template 这个实验验证: ```text messages -> prompt add_generation_prompt -> assistant 起始格式 open_thinking -> prompt 末尾变化 ``` 运行默认版本: ```bash cd /home/sun/minimind PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_tokenizer.py ``` 再运行 open thinking 版本: ```bash PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_tokenizer.py --open_thinking ``` 记录: ```text 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|>`、`` 分别在对话格式中起什么作用。 5. 为什么 `apply_chat_template(..., tokenize=False)` 返回的是 prompt 字符串,而不是 token id。 6. `add_generation_prompt=True` 为什么对推理重要。 ## 10. 下一课 第 5 课继续数据格式和 chat template:我们会看 SFT 数据中的 `conversations` 如何被 `SFTDataset` 转成 prompt 和 labels。