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 及其功能
| Token | ID | 角色 | 说明 |
|---|---|---|---|
## | 0 | PAD / UNK | 填充和未知词占位符 |
<|im_start|> | 1 | BOS | 对话消息开始标记 |
<|im_end|> | 2 | EOS | 对话消息结束标记 |
<|image_pad|> | 12 | IMAGE | 图像数据占位符 |
<|audio_pad|> | 16 | AUDIO | 音频数据占位符 |
<|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|> 等 |
| 工具调用 | 4 | ERY、INU(XML 标签) |
| 思维链 | 2 | KIN、G(思考/回答分隔) |
| 预留 Buffer | 9 | <|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_token | false | 编码时不自动添加 BOS(由 Chat Template 管理) |
add_eos_token | false | 编码时不自动添加 EOS(由 Chat Template 管理) |
model_max_length | 131072 | 理论上限 128K(实际可用长度由模型配置决定) |
bos_token | <|im_start|> | 句首标记 = 对话消息开始 |
eos_token | <|im_end|> | 句尾标记 = 对话消息结束 |
pad_token | ## | 填充标记 = 未知词占位 |
unk_token | ## | 未知词标记 |
tokenizer_class | PreTrainedTokenizerFast | 使用 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])}")
思考题
- 每个中文字对应几个 Token?为什么不是一个汉字一个?
- 计算压缩率:
len("你好世界") / len(ids),结果是几? - 试试编码
"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|>"))
思考题
- 为什么
im_start和im_end的数量相同? - 如果把
tokenize=False改成True,返回值变成了什么? - 尝试添加一条
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}")
思考题
- 哪种类型的压缩率最高?为什么?
- Emoji 的压缩率为什么可能比普通中文还低?
- 如果压缩率低于 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 全部精华