Tokenizer 分词器

阶段 1 · 让机器读懂人类语言的第一步

一个问题

你在 ChatGPT 里输入"你好,世界",模型真的收到了这六个字吗?

不是。模型收到的是一串数字,比如 [1, 47, 892, 15, 1024, 2]。

大语言模型的底层全是矩阵乘法和概率计算。它不认识汉字,不认识英文字母,甚至不认识空格。它只认识 数字。

那么问题来了:谁负责把"你好,世界"翻译成 [1, 47, 892, 15, 1024, 2]?谁又负责把模型输出的数字翻译回你能读懂的文字?

答案就是 Tokenizer——文本和数字之间的翻译官。

30 秒上手体验

加载 MiniMind 的 Tokenizer,亲手完成一次编码和解码

>>> from transformers import AutoTokenizer
>>> tok = AutoTokenizer.from_pretrained("./model/")

>>> ids = tok.encode("你好,世界")
>>> print("Token IDs:", ids)
Token IDs: [1, 47, 892, 15, 1024, 2]

>>> text = tok.decode(ids)
>>> print("还原:", text)
还原: 你好,世界

三行代码,你就看到了 Tokenizer 的全部职责:文字 → 数字 → 文字。接下来的整个章节,我们要搞清楚这背后的每一步是怎么工作的。

Part 1:桥梁

Tokenizer 在大模型流水线中的位置——它是训练和推理的第一道关卡。

不管你训练还是推理,文本都得先过 Tokenizer 这一关——它把人类可读的文字翻译成模型能消化的数字。这一节先把 Tokenizer 在整个流水线里的位置搞清楚,后面再深入它内部的运作机制。

  • 编码与解码 —— Tokenizer 同时承担两个方向的翻译:文本→数字(编码)和数字→文本(解码)
  • 训练与推理流程 —— 看一张流程图,理解 Tokenizer 在训练和推理两条路径中分别扮演什么角色

编码与解码

Tokenizer 同时承担两个方向的翻译

图:Tokenizer 编码与解码的双向翻译流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的水平流程图,展示文本经过 Tokenizer 编码进入模型、模型输出再经 Tokenizer 解码还原文字的完整双向流程 - 各元素关系:上半路径(编码):"你好世界" → Encode 编码 → Tokenizer → 数字序列 47,892,15,1024 → 输入模型 → 大语言模型;下半路径(解码):大语言模型 → 输出数字 203,567,42 → Decode 解码 → Tokenizer → 还原文字"我很高兴认识你"。Tokenizer 居中,同时承担两个方向的翻译 - 关键标注:编码路径旁标注"训练/推理输入";解码路径旁标注"推理输出";Tokenizer 节点标注"双向翻译官" - 配色建议:浅蓝/浅灰背景,文本节点用绿色,数字节点用蓝色,Tokenizer 用橙色圆角矩形突出,箭头用深灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

方向函数输入输出
编码encode()文字字符串Token ID 列表
解码decode()Token ID 列表文字字符串

无论训练还是推理,所有文本 必须 先经过 Tokenizer 编码。它是大模型流水线的第一道关卡,也是最后一道关卡。

训练与推理流程

图:Tokenizer 在训练与推理中的不同角色

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左右两栏对比布局,左栏为"训练阶段",右栏为"推理阶段",各自展示从文本到输出的完整流程 - 左栏(训练阶段)流程:原始文本语料 → Tokenizer 编码 → Token ID 序列 → 模型训练,用竖向箭头连接 - 右栏(推理阶段)流程:用户输入文字 → Tokenizer 编码 → Token ID 序列 → 模型自回归生成 → 输出 Token IDs → Tokenizer 解码 → 输出文字,用竖向箭头连接 - 关键标注:两栏之间用红色虚线连接"Tokenizer 编码"节点,旁标注"必须使用同一个 Tokenizer";左栏底部标注"批量处理语料";右栏底部标注"逐 Token 生成" - 配色建议:浅灰背景,左栏用浅蓝色边框,右栏用浅绿色边框,Tokenizer 节点用橙色强调 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

注意:训练和推理 必须使用同一个 Tokenizer。如果训练时"你好"编码为 [47, 892],推理时换了一个 Tokenizer 把它编码为 [331, 502],模型就完全看不懂了。

Part 2:Token

Tokenizer 不是把文本逐字符翻译——它有更聪明的拆分策略。

理解 Token 是理解整个 Tokenizer 的第一步。Token 不等于字符,也不等于单词——它介于两者之间,是模型处理文本的基本单位。这一节搞清楚 Token 的形态和编码粒度,后面学 BPE 算法时会轻松很多。

  • Token 的四种形态 —— 完整的词、词的一部分(子词)、单个字符、特殊标记,各有各的用途
  • Token ID 到向量 —— 每个 Token 有一个数字 ID,通过 Embedding 查表变成模型能处理的向量
  • 编码粒度对比 —— Word-level、Character-level、BPE 三种策略各有什么优缺点,为什么主流模型都选 BPE

Token 形态

一个 Token 可以是完整的词、词的一部分、单个字符或特殊标记

类型文本示例Token 切分说明
完整词"hello"[hello] → 1 个常见词一整个 Token
子词"unhappiness"[un, happi, ness] → 3 个罕见词被拆成已知片段
字符"世界"[世, 界] → 2 个中文字通常一字一 Token
特殊标记<|im_start|>[1] → 1 个标记对话结构,非文本

词表(Vocabulary) 是所有已知 Token 的集合。每个 Token 有唯一的整数编号(ID)。MiniMind 的词表大小是 6400。

为什么不是按"字"或按"词"切分?因为纯按词切,词表会膨胀到几十万;纯按字切,序列会拉得很长。BPE 算法在两者之间找到了平衡点。

ID 到向量

编号本身没有语义,必须通过 Embedding 查表变成向量

Token ID 只是一个整数编号,比如"你"的 ID 是 47。模型不能直接对整数做运算。

解决方法:Embedding 查表。模型内部有一张 6400 行 × d_model 列的矩阵。用 Token ID 当行号,取出对应的一行向量。

# 伪代码:Embedding 查表
embedding_matrix = torch.randn(6400, 512)  # 6400个Token, 每个512维
token_ids = [47, 892, 15, 1024]
vectors = embedding_matrix[token_ids]  # 取出4个512维向量
print(vectors.shape)  # torch.Size([4, 512])

图:Embedding Lookup 查表过程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左侧是一个 Token ID 序列 [47, 892, 15, 1024](纵向排列),中间是一个大的 Embedding 矩阵(6400 行 × 512 列),右侧是查表后得到的嵌入向量序列 - Embedding 矩阵:画成一个网格,每一行代表一个 Token 的 512 维向量,行号从 0 到 6399;用橙色高亮标记第 47、892、15、1024 四行 - 查表过程:从左侧每个 ID 画箭头指向矩阵中对应的行,再从该行画箭头指向右侧对应的向量;右侧显示 4 个向量,每个标注 "512维" - 关键标注:矩阵旁标注 "6400 × 512 = 3,276,800 个参数";右侧标注 "这些向量就是模型'理解'文字的方式" - 配色建议:浅灰背景,矩阵整体浅蓝色,高亮行橙色,ID 数字深灰色,向量块浅绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

编码粒度对比

不同分词策略对"unhappiness"的切分结果

按词切分 (Word-level)

["unhappiness"] → 1 个 Token

优点:序列短。

缺点:词表巨大(几十万),遇到新词直接 OOV(Out of Vocabulary)。

按字符切分 (Character-level)

["u","n","h","a","p","p","i","n","e","s","s"] → 11 个

优点:词表极小,永远不 OOV。

缺点:序列太长,模型难以学习长距离依赖。

BPE(Byte Pair Encoding) 在两者之间取平衡:["un", "happi", "ness"] → 3 个 Token。常见片段保持完整,罕见词自动拆分。

Part 3:BPE

MiniMind 使用的分词算法——也是 GPT、LLaMA、Qwen 的共同选择。

BPE(Byte Pair Encoding)是当今几乎所有主流大模型都在用的分词算法。它的核心思想出奇地简单:反复把最常见的相邻 Token 对合并成新 Token。这一节用一个完整的例子带你走一遍 BPE 的全过程。

  • BPE 核心思想 —— "高频相邻对优先合并",用最少的 Token 数覆盖最多的文本
  • 步骤演示 —— 以 low/lower/lowest 为例,一步步看 BPE 如何从字符级逐步合并出子词
  • 处理新词的逐级回退 —— 遇到训练时没见过的词,BPE 怎么把它拆成已知的子 Token
  • BPE 四大优势 —— 为什么它能在词级和字符级之间找到最佳平衡点

BPE 核心思想

反复把最常一起出现的相邻 Token 对合并成新的 Token

就像搭积木:先有最小的积木块(单个字节),然后把经常一起出现的粘在一起,变成更大的积木块。

卸甲:这个比喻只是为了建立直观感受。计算机内部没有"积木"和"胶水"——BPE 是通过统计字节对的出现频率,迭代地将最高频的相邻字节对合并为新 Token,本质是频率统计加合并操作。

图:BPE 算法的迭代合并流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个从上到下的流程图,展示 BPE 算法从原始文本到保存词表的完整迭代过程,其中包含一个循环回路 - 流程步骤:原始文本语料 → 按词频拆分为字符序列 → 统计所有相邻 Token 对频率 → 找到最高频 Token 对(菱形判断节点)→ 合并该 Token 对为新 Token → 词表达到目标大小?(菱形判断节点)→ 否则回到"统计"步骤形成循环 → 是则保存 tokenizer.json - 关键标注:循环箭头用橙色强调,旁标注"反复迭代直到词表达标";"找到最高频 Token 对"旁标注"贪心策略";最终节点旁标注"MiniMind 目标 6400" - 配色建议:浅灰背景,普通步骤用浅蓝色圆角矩形,判断节点用橙色菱形,循环箭头用深橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

BPE 演示一

以语料 "low lower lowest low low lower" 为例,逐步演示合并过程

第 1 步:拆成字符

low    → [l, o, w]
lower  → [l, o, w, e, r]
lowest → [l, o, w, e, s, t]
low    → [l, o, w]
low    → [l, o, w]
lower  → [l, o, w, e, r]

第 2 步:统计相邻对频率

(l, o) → 6次  ← 最高频
(o, w) → 6次  ← 并列最高
(w, e) → 3次
(e, r) → 2次
(e, s) → 1次
(s, t) → 1次

当频次相同时,通常按 先出现的对 或 字典序 选择。这里选 (l, o)。

BPE 演示二

逐步合并,观察词表和序列如何变化

第 3 步:合并 (l, o) → lo,词表新增 1 个 Token

low    → [lo, w]
lower  → [lo, w, e, r]
lowest → [lo, w, e, s, t]

第 4 步:重新统计,(lo, w) 出现 6 次,合并 → low

low    → [low]
lower  → [low, e, r]
lowest → [low, e, s, t]

第 5 步:(e, r) 出现 2 次(最高频),合并 → er

lower  → [low, er]
lowest → [low, e, s, t]

以此类推,直到词表增长到目标大小(MiniMind 是 6400)。

BPE 处理新词

任何文本都能被编码,永远不会出现"无法处理"的情况

图:BPE 处理新词的逐级回退策略

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个从左到右的决策树/流程图,展示 BPE 遇到新词时如何逐级回退查找匹配 - 决策路径:新词 lowestlow → 词表中有完整匹配?(是→直接作为一个 Token;否→有 lowest 和 low?)(是→拆为 lowest, low;否→有 low, e, s, t?)(是→拆为已知子词;否→退到字节级别拆分) - 关键标注:每个判断节点用菱形,旁标注"在词表中查找";每条"是"分支用绿色箭头,"否"分支用红色箭头;底部标注"字节级保证永不 OOV" - 配色建议:浅灰背景,判断节点用橙色菱形,成功匹配节点用浅绿色,字节级节点用浅蓝色,箭头用深灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

BPE 从字节级别开始构建,而字节只有 256 种。所以无论遇到什么文字——中文、日文、emoji、甚至火星文——都能编码。

BPE 四大优势

平衡粒度

常见词保持完整(序列短),罕见词拆成子词(词表小)。不会像按词切那样词表膨胀,也不会像按字符切那样序列过长。

天然泛化

遇到训练时没见过的新词,能自动拆分成已知的子词单元。比如学会了 play 和 ing,就能处理 playing。

多语言支持

基于字节级别构建,天然支持所有语言和符号。中文、英文、代码、emoji 混排也没问题。

数据驱动

合并策略完全从训练数据中自动学习,不需要人工编写规则。不同语料训练出的 Tokenizer 会自动适应不同的语言分布。

Part 4:训练

逐行拆解 trainer/train_tokenizer.py,看 MiniMind 如何训练自己的 Tokenizer。

前面的 Part 1-3 讲了 Tokenizer 的"是什么"和"为什么",这一节进入"怎么做"——跟着源码走一遍 MiniMind 训练 Tokenizer 的完整流程。你会看到从准备数据到保存模型,每一步都做了什么。

  • 整体流程时序图 —— 一张图看清从原始文本到保存 Tokenizer 的完整链路
  • 常量与入口配置 —— 词表大小、特殊 Token 等核心参数在哪里定义
  • 特殊 Token 定义 —— 三组共 36 个特殊 Token(控制标记、对话标记、功能标记)的用途
  • 训练核心三步 —— 创建 BPE 模型 → 配置训练器 → 执行训练,每一步的关键参数
  • 保存与后处理 —— 训练完的 Tokenizer 如何保存、为什么还需要修正特殊标记的属性

整体流程图

图:Tokenizer 训练脚本的时序交互流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个时序图(Sequence Diagram),展示训练脚本中四个参与者之间的调用顺序 - 参与者(从左到右排列):主脚本 Main、get_texts() 数据函数、BPE 模型、BpeTrainer 训练器、输出文件 - 交互时序:Main→get_texts:"读取 JSONL 数据"(返回文本迭代器)→ Main→BPE:"创建空 BPE 模型" → Main→Trainer:"配置 vocab_size=6400 + 特殊 Token" → Main→Trainer:"train_from_iterator(texts)" → Trainer→BPE:"反复合并最高频字节对"(循环箭头)→ Trainer→Main:"训练完成" → Main→Output:"保存 tokenizer.json" → Main→Output:"保存 tokenizer_config.json" - 关键标注:BpeTrainer 与 BPE 之间用双向箭头标注"迭代合并";get_texts 旁标注"生成器逐行读取" - 配色建议:浅灰背景,参与者用蓝色竖条,调用箭头用深灰色,返回箭头用虚线浅灰色,关键步骤用橙色标注 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

常量与入口

训练脚本的顶部配置

DATA_PATH = '../dataset/sft_t2t_mini.jsonl'
TOKENIZER_DIR = '../model_learn_tokenizer/'
VOCAB_SIZE = 6400        # 词表目标大小
SPECIAL_TOKENS_NUM = 36  # 特殊 Token 数量

if __name__ == '__main__':
    train_tokenizer(DATA_PATH, TOKENIZER_DIR, VOCAB_SIZE)
    eval_tokenizer(TOKENIZER_DIR)

为什么词表选 6400?

太小(比如 1000):压缩率低,一段话要编码成很长的序列,模型训练和推理都变慢。

太大(比如 100000):Embedding 矩阵参数过多,小模型扛不住。

6400 对 MiniMind 这种参数量级的小模型来说刚好合适——既保证了合理的压缩率,又不会让 Embedding 层占去太多参数。

数据准备函数

get_texts() 用生成器逐行读取,避免一次性加载全部文本

def get_texts(data_path):
    with open(data_path, 'r', encoding='utf-8', errors='ignore') as f:
        for i, line in enumerate(f):
            if i >= 10000: break  # 最多取10000行
            try:
                data = json.loads(line)
                contents = [item.get('content')
                            for item in data.get('conversations', [])
                            if item.get('content')]
                if contents:
                    yield "\n".join(contents)
            except json.JSONDecodeError:
                continue

关键设计:

  • 使用 yield 生成器而非 return 列表——10000 行文本可能有几百 MB,生成器逐条产出,内存友好。
  • errors='ignore' 跳过编码异常字节——真实数据总有脏数据。
  • try/except 跳过格式错误的行——保证流程不中断。

数据格式(JSONL,每行一条对话):

{"conversations": [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮你的吗?"}
]}

特殊 Token

36 个特殊 Token 分为三组,每组承担不同功能

# 第1组:核心对话 + 多模态标记(21个)
special_tokens_list = [
    "##", "<|im_start|>", "<|im_end|>",
    "<|object_ref_start|>", "<|object_ref_end|>",
    "<|box_start|>", "<|box_end|>",
    "<|quad_start|>", "<|quad_end|>",
    "<|vision_start|>", "<|vision_end|>", "<|vision_pad|>",
    "<|image_pad|>", "<|video_pad|>",
    "<|audio_start|>", "<|audio_end|>", "<|audio_pad|>",
    "", "", "", ""
]

# 第2组:工具调用 + 思维链标记(6个)
additional_tokens_list = [
    "≶", "⋩",
    "ERRY", "INU",
    "KIN", "G"
]

# 第3组:预留 Buffer(9个)= 36 - 21 - 6
num_buffer = 36 - len(special_tokens_list + additional_tokens_list)
buffer_tokens = [f"<|buffer{i}|>" for i in range(1, num_buffer + 1)]

三组 Token 合并后总共 36 个。它们在训练阶段就被"锁定",永远不会被 BPE 算法拆开或合并。

训练核心三步

创建模型 → 配置训练器 → 执行训练

# 第1步:创建空的 BPE 模型
tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)

# 第2步:配置训练器
trainer = trainers.BpeTrainer(
    vocab_size=6400,                                    # 目标词表大小
    show_progress=True,                                  # 显示训练进度
    initial_alphabet=pre_tokenizers.ByteLevel.alphabet(), # 256个UTF-8字节作为初始词表
    special_tokens=all_special_tokens                    # 36个特殊Token
)

# 第3步:执行训练
texts = get_texts(data_path)
tokenizer.train_from_iterator(texts, trainer=trainer)
tokenizer.decoder = decoders.ByteLevel()

三行关键代码解释:

models.BPE():创建一个空的 BPE 模型,词表为空。

pre_tokenizers.ByteLevel(add_prefix_space=False):预分词器。先把文本转成 UTF-8 字节序列,再按字节边界初步切分。add_prefix_space=False 表示不在文本开头加空格。

initial_alphabet=pre_tokenizers.ByteLevel.alphabet():初始词表就是 256 个可能的字节值。BPE 从这 256 个字节出发,逐步合并出 ~6144 个子词。

保存与后处理

训练完成后,需要保存 tokenizer.json 和 tokenizer_config.json

# 保存 tokenizer.json(BPE 模型文件)
os.makedirs(tokenizer_dir, exist_ok=True)
tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))

# 修正 added_tokens 的 special 标记
# additional_tokens(工具调用/思维链)不应该标记为 special
tokenizer_json_path = os.path.join(tokenizer_dir, "tokenizer.json")
with open(tokenizer_json_path, 'r', encoding='utf-8') as f:
    tokenizer_data = json.load(f)
for token_info in tokenizer_data.get('added_tokens', []):
    if token_info['content'] not in special_tokens_list:
        token_info['special'] = False  # 只有第1组才是真正的 special
with open(tokenizer_json_path, 'w', encoding='utf-8') as f:
    json.dump(tokenizer_data, f, ensure_ascii=False, indent=2)

为什么要修正 special 标记? 因为 additional_tokens(工具调用的 ERY/INU/KIN/G 等)在 HuggingFace 的约定中不属于"特殊 Token"——它们是功能性标记,但不应该被 skip_special_tokens=True 过滤掉。

Part 5:词表

6400 个 Token 不是随意分配的——它由三层构成,每层职责不同。

训练完 Tokenizer 后,词表里到底装了什么?MiniMind 的 6400 个 Token 并不是一锅粥,而是按照功能分成了三层。搞清楚这三层的结构,你就能理解 Token ID 的编号规律和每个特殊 Token 的作用。

  • 三层构成详解 —— ID 0~35 的特殊 Token、36~255 的基础字节、256~6399 的 BPE 子词,各占多少、各干什么
  • 核心特殊 Token —— <unk>、<s>、</s> 等控制标记的含义
  • 36 个特殊 Token 分组一览 —— 按功能分成控制标记、对话标记、功能标记三组,一目了然

三层构成

ID 0~35:特殊 Token(36 个)

对话标记(im_start、im_end)、多模态标记(image_pad、audio_pad)、工具调用标记、思维链标记、预留 Buffer。

这些 Token 不对应任何自然语言文本,它们是模型理解对话结构的"标点符号"。

ID 36~255:基础字节(~220 个)

来自 initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),即 UTF-8 编码中的 256 个可能字节值。

这是 BPE 合并的起点——所有更长的 Token 都是由这些基础字节逐步合并而来。

ID 256~6399:BPE 子词(~6144 个)

由 BPE 算法从训练数据中自动学习得到。常见的中文字、高频英文子词、常见标点组合等都在这一层。

图:MiniMind 词表 6400 的三层构成

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左侧是一个竖直的长条形(代表完整词表 0~6399),被分成三个颜色不同的区段;右侧是三个放大的独立区块,分别对应三个区段 - 三个区段:① 底部窄条(占比极小,约 0.6%)代表 ID 0~35 特殊 Token,用橙色填充;② 中部窄条(占比约 3.4%)代表 ID 36~255 基础字节,用浅绿色填充;③ 顶部宽条(占比约 96%)代表 ID 256~6399 BPE 子词,用浅蓝色填充 - 关键标注:每个区段旁标注 ID 范围、数量、简要说明;在左侧长条顶部标注"总计 6400";用虚线将右侧三个放大块与左侧对应区段相连 - 配色建议:浅灰背景,特殊 Token 用橙色,基础字节用浅绿色,BPE 子词用浅蓝色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

核心特殊 Token

最重要的 5 个特殊 Token 及其功能

TokenID角色说明
##0PAD / UNK填充和未知词占位符
<|im_start|>1BOS对话消息开始标记
<|im_end|>2EOS对话消息结束标记
<|image_pad|>12IMAGE图像数据占位符
<|audio_pad|>16AUDIO音频数据占位符

<|im_start|> 和 <|im_end|> 是最关键的两个——它们让模型知道:这句话是谁说的、每句话从哪里开始到哪里结束。

在 tokenizer_config.json 中,bos_token(句首)就是 <|im_start|>,eos_token(句尾)就是 <|im_end|>。

36 个特殊 Token

按功能分组一览

分组数量Token 示例
核心对话3##、<|im_start|>、<|im_end|>
目标检测6<|object_ref_start/end|>、<|box_start/end|>、<|quad_start/end|>
视觉相关5<|vision_start/end/pad|>、<|image_pad|>、<|video_pad|>
音频相关7<|audio_start/end/pad|>、<tts_pad|>、<tts_text_bos|> 等
工具调用4ERY、INU(XML 标签)
思维链2KIN、G(思考/回答分隔)
预留 Buffer9<|buffer1|> ~ <|buffer9|>

为什么预留 9 个 Buffer?未来如果需要新增特殊 Token(比如新增模态支持),不用重新训练整个 Tokenizer——直接用预留位即可。

Part 6:模板

模型需要理解对话结构——哪些是系统指令、哪些是用户输入、哪些是模型回复。

光把文本切成 Token 还不够,模型还得知道一段对话里"谁说了什么"。Chat Template 就是一套格式规则,用特殊标记把不同角色的内容包起来,让模型能区分系统提示、用户输入和自己的回复。

  • 对话格式化 —— 用 <|im_start|> 和 <|im_end|> 标记每一段对话的角色和内容
  • 格式规则解析 —— system / user / assistant 三种角色的模板写法和拼接顺序
  • 思维链模式 —— KIN/G 标记如何让模型在回答前先"想一想"
  • 工具调用格式 —— ERY/INU 标记如何支持模型调用外部工具

对话格式化

一条多轮对话经过 Chat Template 后的样子

messages = [
    {"role": "system", "content": "你是一个优秀的聊天机器人"},
    {"role": "user", "content": "你来自哪里?"},
    {"role": "assistant", "content": "我来自月球"},
]

格式化后:

<|im_start|>system
你是一个优秀的聊天机器人<|im_end|>
<|im_start|>user
你来自哪里?<|im_end|>
<|im_start|>assistant
我来自月球<|im_end|>

通用模板格式:<|im_start|>{role}\n{content}<|im_end|>\n

格式规则解析

图:Chat Template 消息格式解析

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的线性流程图,展示一条消息在 Chat Template 中的完整格式结构 - 流程节点依次为:开始标记 <|im_start|> → 角色名 system/user/assistant → 换行符 → 消息内容 → 结束标记 <|im_end|> → 换行符 → 下一消息 <|im_start|>...,形成循环 - 关键标注:开始标记节点用橙色强调,旁标注"标记谁在说话";角色名节点旁标注三种角色"system/user/assistant";消息内容节点用最大方框表示;结束标记旁标注"标记消息结束";箭头用不同颜色区分结构元素和内容 - 配色建议:浅灰背景,开始/结束标记用橙色圆角矩形,角色名用浅紫色,消息内容用浅蓝色大方框,换行符用浅灰色小圆角矩形 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

图:Chat Template 对话结构分解

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一段格式化后的完整对话文本,用不同颜色的背景色块标注每一段的结构角色 - 从上到下依次为三段:① system 段——浅紫色背景,左侧标注 "system 系统指令";② user 段——浅蓝色背景,左侧标注 "user 用户输入";③ assistant 段——浅绿色背景,左侧标注 "assistant 模型回复" - 关键标注:在每段的开头用箭头指向开始标记,标明 "开始标记 <|im_start|>";在每段末尾指向结束标记,标明 "结束标记 <|im_end|>" - 配色建议:浅灰整体背景,system 用浅紫色,user 用浅蓝色,assistant 用浅绿色,特殊标记用深橙色加粗 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

思维链模式

模型在回答之前先"思考",推理更准确

MiniMind 的 Chat Template 内置了思维链(Chain of Thought)支持。当开启 open_thinking 时:

<|im_start|>assistant
KIN
让我分析一下这个问题...
首先考虑...
所以答案是...

G

我来自地球<|im_end|>

KIN 和 G 是思维链的起止标记。模型在 KIN 和 G 之间的内容是"思考过程",之后才是最终回答。

图:思维链模式的内部结构

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从上到下的流程图,展示开启思维链后一条 assistant 消息的内部结构 - 流程节点:im_start assistant → KIN 标记(开始思考)→ 思考过程/内部推理(大块区域)→ G 标记(思考结束)→ 正式回答内容 → im_end - 关键标注:KIN 标记旁标注"开始思考,内容不对用户显示";思考过程区域用虚线框包裹,旁标注"模型内部推理链";G 标记旁标注"思考结束,以下是对用户的回答";正式回答旁标注"最终输出给用户的内容" - 配色建议:浅灰背景,结构标记(im_start/im_end)用橙色,思考过程区域用浅黄色虚线框,KIN/G 标记用紫色,正式回答用浅绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

工具调用格式

模型可以调用外部工具,结果通过特定格式嵌入对话

<|im_start|>assistant
ERY
{"name": "get_weather", "arguments": {"city": "北京"}}
INU<|im_end|>
<|im_start|>user
ERRY
{"temperature": 25, "condition": "晴"}
INU<|im_end|>

ERY 和 INU 标记 XML 样式的工具调用。模型输出调用请求,框架执行工具后把结果用 ERRY/INU 包裹,再交给模型继续生成。

标记功能出现在
ERY...INU工具调用请求assistant 的回复中
ERRY...INU工具返回结果模拟的 user 消息中

代码调用方式

使用 HuggingFace 的 AutoTokenizer 一键应用 Chat Template

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("./model_learn_tokenizer/")

messages = [
    {"role": "system", "content": "你是一个优秀的聊天机器人"},
    {"role": "user", "content": "你来自哪里?"},
]

# 应用模板(tokenize=False 返回格式化后的文本)
formatted = tokenizer.apply_chat_template(messages, tokenize=False)
print(formatted)

# 直接编码为 Token IDs(tokenize=True,默认行为)
ids = tokenizer.apply_chat_template(messages, tokenize=True)
print("Token IDs:", ids)

Chat Template 已经内嵌在 tokenizer_config.json 的 chat_template 字段中。apply_chat_template() 会自动读取并渲染 Jinja2 模板。你不需要手动拼接 <|im_start|> 和 <|im_end|>。

Part 7:配置

train_tokenizer.py 生成的两个核心配置文件——tokenizer.json 和 tokenizer_config.json。

训练完 Tokenizer 后,所有信息都保存在配置文件里。这一节拆解这两个 JSON 文件的结构——知道每个字段干什么,出了问题才知道去哪里查。

  • tokenizer_config.json 全局配置 —— 模型类型、词表大小、EOS/BOS Token 等顶层字段的含义
  • added_tokens_decoder —— 36 个特殊 Token 的 ID 映射关系,逐一对照

配置文件

HuggingFace Tokenizer 的全局配置文件

字段值含义
add_bos_tokenfalse编码时不自动添加 BOS(由 Chat Template 管理)
add_eos_tokenfalse编码时不自动添加 EOS(由 Chat Template 管理)
model_max_length131072理论上限 128K(实际可用长度由模型配置决定)
bos_token<|im_start|>句首标记 = 对话消息开始
eos_token<|im_end|>句尾标记 = 对话消息结束
pad_token##填充标记 = 未知词占位
unk_token##未知词标记
tokenizer_classPreTrainedTokenizerFast使用 HuggingFace 快速 Tokenizer(Rust 后端)

注意:model_max_length 是 Tokenizer 的理论上限。MiniMind 默认上下文窗口是 512,实际不能处理 128K 的输入。

解码器结构

36 个特殊 Token 的 ID 映射表

训练脚本在保存配置时,为每个特殊 Token 生成了详细的描述条目:

added_tokens_decoder = {}
for i, token in enumerate(all_special_tokens):
    idx = tokenizer.token_to_id(token)
    added_tokens_decoder[str(idx)] = {
        "content": token,
        "lstrip": False,        # 左侧不去空格
        "normalized": False,    # 不参与 BPE 归一化
        "rstrip": False,        # 右侧不去空格
        "single_word": False,   # 可以出现在词中间
        "special": True if token in special_tokens_list else False
    }

区分 special 标记:只有 special_tokens_list(第 1 组,核心对话 + 多模态)的 Token 的 special 字段为 True。additional_tokens(工具调用、思维链)设为 False——这样 decode(ids, skip_special_tokens=True) 时不会把它们过滤掉。

Part 8:测试

训练完 Tokenizer 后,怎么判断它好不好?四个维度的测试。

训练出来的 Tokenizer 不能"凭感觉"说好不好——得用测试来验证。MiniMind 设计了四项核心测试,从格式正确性到编码一致性再到压缩效率,全方位检验 Tokenizer 的质量。

  • Chat Template 格式检查 —— 验证生成的对话格式是否严格符合模板规范
  • 编解码一致性 —— 编码再解码,看能不能原样还原回原始文本
  • 压缩率测试 —— 衡量 Tokenizer 用多少个 Token 就能表示一段文本,压缩率越高越好
  • 流式解码与字节缓冲 —— 逐 Token 解码时如何处理不完整的 UTF-8 字符,避免乱码

四项核心测试

1. Chat Template 格式检查

构造多轮对话,检查 apply_chat_template() 的输出是否包含正确的 <|im_start|>/<|im_end|> 标记和角色标签。

2. 编解码一致性

对任意文本执行 encode → decode,还原结果必须与原文 完全一致(逐字符比较)。如果不一致,说明编解码有 bug。

3. 压缩率测试

压缩率 = 字符数 / Token 数。中文通常 1.5~3.0,英文 3.0~5.0。压缩率越高,一个 Token 承载的信息越多,编码效率越好。

4. 流式解码测试

逐 Token 解码时不会产生乱码(UTF-8 替换字符 �)。如果出现乱码,说明字节缓冲逻辑有问题。

模板测试

构造多轮对话,验证格式化输出正确

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir)
messages = [
    {"role": "system", "content": "你是一个优秀的聊天机器人"},
    {"role": "user", "content": "你来自哪里?"},
    {"role": "assistant", "content": "我来自月球"},
    {"role": "user", "content": "你到底来自哪里?"},
    {"role": "assistant", "content": "我来自地球"},
]

new_prompt = tokenizer.apply_chat_template(messages, tokenize=False)
print(new_prompt)

检查点:输出中每条消息都应该被 <|im_start|>role\ncontent<|im_end|> 正确包裹。

压缩率测试

衡量 Tokenizer 编码效率的核心指标

test_texts = [
    "人工智能是计算机科学的一个分支...",   # 中文样本
    "Large language models (LLMs) are...", # 英文样本
    "Python 是一种高级编程语言...",         # 中英混合
]

for i, text in enumerate(test_texts):
    encoded = tokenizer.encode(text)
    ratio = len(text) / len(encoded)
    print(f"样本 {i+1} | 字符数: {len(text):4} | Tokens: {len(encoded):3} | 压缩率: {ratio:.2f}")

压缩率怎么解读?

压缩率 2.5 表示平均每个 Token 承载 2.5 个字符。同一个 Tokenizer,英文的压缩率通常比中文高——因为英文有大量常见子词可以合并,而中文字相对独立。

如果压缩率低于 1.5,说明词表可能太小或者训练数据不够。

编解码一致性

encode → decode 必须原样还原

model_inputs = tokenizer(new_prompt)
response = tokenizer.decode(model_inputs['input_ids'],
                           skip_special_tokens=False)
print('一致性:', response == new_prompt)  # 必须为 True

如果结果是 False,说明编解码过程中有信息丢失或额外字符混入。常见原因:

  • Token 之间的空格被吃掉或多余
  • 特殊 Token 的编解码规则不一致
  • pre_tokenizer 和 decoder 不匹配

流式解码

解决逐 Token 生成时的 UTF-8 乱码问题

中文字符由 3 个 UTF-8 字节组成。如果模型每次只输出 1~2 个字节对应的 Token,直接 decode 会产生 �(乱码占位符)。

解决方法:字节缓冲。积累 Token,直到能完整解码为合法字符时才输出。

input_ids = model_inputs['input_ids']
token_cache = []
for tid in input_ids:
    token_cache.append(tid)
    current_decode = tokenizer.decode(token_cache)
    if current_decode and '�' not in current_decode:
        # 没有乱码 → 字符完整了,输出并清空缓冲区
        raw_tokens = [tokenizer.convert_ids_to_tokens(int(t)) for t in token_cache]
        print(f'Token IDs: {token_cache} -> {current_decode}')
        token_cache = []

图:流式解码中的字节缓冲区拼接

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:展示三个时间步,每步模型生成一个 Token,解码时如何通过字节缓冲区避免乱码 - 时间步 1:模型输出 Token A(包含"你"的前 2 个字节 E4 BD),缓冲区显示这两个字节,下方标注"不完整,等待",用浅橙色填充 - 时间步 2:模型输出 Token B(包含"你"的第 3 个字节 A0),缓冲区追加变为 [E4 BD A0],UTF-8 解码成功显示"你",用浅绿色标注"完整字符" - 时间步 3:缓冲区清空,继续等待下一个 Token - 关键标注:旁注"UTF-8 中文字符 = 3 字节";用虚线框标出"字节缓冲区" - 配色建议:白色背景,不完整缓冲区用浅橙色,完整字符用浅绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

Part 9:排错

训练和使用 Tokenizer 时最容易踩的坑。

Tokenizer 虽然代码量不大,但踩坑的概率不低——训练时 OOM、特殊 Token 被吞、解码出现乱码……这些都是新手经常遇到的问题。这一节把训练和使用两个阶段的常见错误整理成排查手册,出了问题直接对照查。

  • 训练阶段错误 —— BPE 训练 OOM、特殊 Token 被意外合并、Tokenizer 体积异常膨胀
  • 使用阶段错误 —— 编码-解码结果不一致、chat_template 格式异常、流式解码出现乱码

训练阶段错误

BPE 训练时 OOM

症状:MemoryError 或进程被系统 kill

原因:get_texts() 加载了过多文本,BPE 统计字节对频率时内存不够

修复:减小 if i >= 10000: break 的阈值,比如改成 5000;或者缩小单个文件大小

特殊 Token 被 BPE 合并

症状:<|im_start|> 等标记被拆成了 <、|、im 等子词

原因:special_tokens 参数没有传给 BpeTrainer

修复:确保 BpeTrainer(special_tokens=all_special_tokens),特殊 Token 在训练时会被锁定

tokenizer.json 体积异常大

症状:生成的文件远超预期(正常约 200KB)

原因:vocab_size 设置过大,或者训练数据有大量噪声

修复:检查 VOCAB_SIZE 配置;清洗训练数据中的 HTML 标签、特殊符号等噪声

使用阶段错误

decode 结果不一致

症状:解码后的文字和原文不同

排查:① 检查 skip_special_tokens 参数是否正确;② 确认编码和解码用的是同一个 Tokenizer 实例;③ 检查是否有 add_bos_token/add_eos_token 自动添加了额外 Token

chat_template 格式异常

症状:apply_chat_template 输出缺少标记或格式错乱

排查:① messages 的 role 字段是否为合法值(system/user/assistant/tool);② content 是否为字符串类型;③ tokenizer_config.json 中 chat_template 字段是否存在

流式解码出现乱码

症状:逐 Token 解码时出现 � 或问号

排查:① 确认使用了字节缓冲(参考 Part 8 的代码);② 检查 decoder 是否设置为 decoders.ByteLevel()

重要提醒

不建议重复训练 Tokenizer!

MiniMind 已自带训练好的 Tokenizer。基于不同词表训练的模型将导致 Token ID 不一致,输出完全不可用,也无法与社区的其他模型兼容。

train_tokenizer.py 仅供学习参考。理解它的工作原理比实际运行它更重要。

不要在有红色报错时继续执行!

如果 AutoTokenizer.from_pretrained() 报错 OSError: Can't load tokenizer,说明路径不对或文件缺失。先解决报错,再继续。

Part 10:练习

理论学完了,动手验证才是真正的理解。

前面九节讲了 Tokenizer 的方方面面,但光看不练等于没学。这一节准备了三个动手练习,从基础的编解码操作到 Chat Template 的使用再到压缩率的对比,帮你把知识真正变成肌肉记忆。

  • 编解码基础练习 —— 加载 MiniMind Tokenizer,手动编码-解码文本,观察 Token 的切分结果
  • Chat Template 练习 —— 构造多轮对话,用模板格式化后检查 Tokenizer 的输出是否正确
  • 压缩率对比练习 —— 对比不同文本(中文、英文、代码)的压缩率,理解 BPE 的编码特点

练习:编解码

任务:加载 MiniMind Tokenizer,完成以下操作并观察结果。

>>> from transformers import AutoTokenizer
>>> tok = AutoTokenizer.from_pretrained("./model/")

>>> # 1. 编码一段中文
>>> ids = tok.encode("你好世界")
>>> print("Token IDs:", ids)

>>> # 2. 解码回来
>>> text = tok.decode(ids)
>>> print("解码:", text)

>>> # 3. 逐个查看 Token
>>> for tid in ids:
...     print(f"ID {tid} -> {tok.decode([tid])}")

思考题

  1. 每个中文字对应几个 Token?为什么不是一个汉字一个?
  2. 计算压缩率:len("你好世界") / len(ids),结果是几?
  3. 试试编码 "Hello World",对比中英文的压缩率差异。

练习:模板

任务:构造一段包含 system、user、assistant 三种角色的对话,用 apply_chat_template 格式化,观察输出。

messages = [
    {"role": "system", "content": "你是一个翻译助手"},
    {"role": "user", "content": "请把'人工智能'翻译成英文"},
    {"role": "assistant", "content": "Artificial Intelligence"},
]

formatted = tok.apply_chat_template(messages, tokenize=False)
print(formatted)

# 数一数有多少个 <|im_start|> 和 <|im_end|>
print("im_start 数量:", formatted.count("<|im_start|>"))
print("im_end 数量:", formatted.count("<|im_end|>"))

思考题

  1. 为什么 im_start 和 im_end 的数量相同?
  2. 如果把 tokenize=False 改成 True,返回值变成了什么?
  3. 尝试添加一条 role: "tool" 的消息,观察模板如何处理。

练习:压缩率

任务:用下面的测试代码对比中文、英文、混合文本的压缩率。

test_texts = {
    "中文": "人工智能是计算机科学的一个分支,它企图了解智能的实质。",
    "英文": "Artificial intelligence is a branch of computer science.",
    "混合": "Python是一种高级编程语言,widely used in data science.",
    "代码": "def hello_world():\n    print('Hello, World!')",
    "Emoji": "你好👋世界🌍今天天气☀️真好",
}

for label, text in test_texts.items():
    encoded = tok.encode(text)
    ratio = len(text) / len(encoded)
    print(f"{label:4} | 字符: {len(text):3} | Tokens: {len(encoded):3} | 压缩率: {ratio:.2f}")

思考题

  1. 哪种类型的压缩率最高?为什么?
  2. Emoji 的压缩率为什么可能比普通中文还低?
  3. 如果压缩率低于 1.0,意味着什么?

关键要点回顾

翻译官角色

Tokenizer 是文本和数字之间的翻译官。所有文本必须先编码为 Token ID,所有输出必须解码回文字。

BPE 算法

从 256 个基础字节出发,通过反复合并最高频字节对构建词表。平衡了词表大小和序列长度。

词表 6400

三层结构:36 个特殊 Token + ~220 个基础字节 + ~6144 个 BPE 子词。适配 MiniMind 的小模型规模。

Chat Template

用 <|im_start|>/<|im_end|> 标准化对话结构。支持思维链和工具调用。

四项测试

Chat Template 格式检查、编解码一致性、压缩率、流式解码——缺一不可。

不要重新训练

MiniMind 自带训练好的 Tokenizer,重新训练会导致 ID 不一致,模型不可用。

延伸阅读

1. HuggingFace Tokenizers 官方文档

深入理解 tokenizers 库的 API:BPE、WordPiece、Unigram 三种算法的对比和配置方法。

适合阶段:想自己训练 Tokenizer 或调优词表时。

2. Andrej Karpathy 的 "Let's build the GPT Tokenizer" 视频

从零手写一个 BPE Tokenizer,直观理解每一步。看完这个视频,BPE 算法再也没有盲区。

适合阶段:想彻底搞懂 BPE 内部实现时。

3. MiniMind 源码 trainer/train_tokenizer.py

直接阅读训练脚本的每一行代码——本教程的所有知识点都能在 169 行源码中找到对应。

阶段 1 完成

Tokenizer 是连接人类语言和机器语言的桥梁

下一章:阶段 2 · 模型架构——287 行代码浓缩现代 LLM 全部精华

1 / X