本文使用 Hugging Face Transformers 库和 Qwen3-0.6B 模型,通过实际代码来深入理解大语言模型(LLM)中的 Tokenizer、BPE 分词、特殊 Token、Chat Template 等核心概念。
1. 为什么需要 Tokenizer?
大语言模型(LLM)本质上是一个巨大的数学概率模型,它看不懂文字,只能处理数字(向量)。Tokenizer 的核心任务,就是把我们输入的自然语言文本,切分并转换成模型能听得懂的数字序列。Tokenizer 会建立一本词表(Vocabulary),给每个常见字、词或者符号编上号(Token ID):
- 编码(Encode):把人类可读的文本 → 一串数字 ID
- 解码(Decode):把数字 ID → 人类可读的文本
没有 Tokenizer,你无法把自然语言的 你好 喂给模型,也无法把模型输出的数字变成你能读懂的文字。
基本概念
文本(Text)
文本(Text)是人类世界的语言,是人类日常交流所使用的原始字符串。它可以是一个词、一个句子、一篇文章,甚至是一整本书。特点:
- 人类友好: 充满语义、逻辑、情感和标点符号。
- 计算机不友好: 文本长度不固定,且直接看文字,计算机无法进行加减乘除等数学运算。
词元(Token)
词元(Token)则是文本被 Tokenizer(分词器)切分之后的语义碎片。Token 是模型处理语言时的最小语义单元。它的特点是:
- 承上启下: 它是文本到数字的中间过渡状态。
- 不一定是单词:在英文中,一个 Token 可能是完整单词(如 cool),也可能是前缀或子词(如 un、helpful);在中文中,它可能是一个词,也可能是不是一个合法的汉字
- 带有特殊标记:很多模型的分词器会用特殊符号来标记 Token 的空间结构
Token 不是自然语言的 词,也不是"字"。它是分词算法拆分出的最小单位。一个 token 可能是:
| 类型 | 例子 | 说明 |
|---|---|---|
| 完整词 | Hello, the, is |
高频词直接作为 token |
| 子词(subword) | un, belie, vable |
低频词被拆成多个子词 token |
| 单字符 | a, Z |
极罕见的字符 |
| 字节级编码 | ä½łå¥½ |
byte-level BPE 对 UTF-8 字节的特殊编码 |
| 特殊标记 | <|im_start|>, <|im_end|> | 用于标记对话结构 |
词表(Vocabulary)
词表(Vocabulary)就是模型 认识 的所有词元(Token)的字典,是所有合法 token 的集合,每个 token 有一个唯一数字 ID。词表大小会影响模型 embedding 的大小。从下文的 Demo 可以看出,Qwen3-0.6B 的词表大小是 151643。
词元 ID(Token ID)
词元 ID(Token ID)则是计算机世界的数学代号,它是 Token 在模型的词表(Vocabulary)中所对应的整数索引(Index):
- 每一个唯一的 Token 字符串,在字典里都有一个唯一固定的整数编号。大模型内部所有的矩阵乘法和概率计算,最初都是基于这些数字开始的
- 数字无实际语义: ID 本身的大小(比如数字 100 和数字 10000)没有任何高低贵贱之分,只是普通的字典页码。真正的语义是通过后面的 Embedding(嵌入向量)学到的
Tokenizer 的工作流程
从高层次来看,Tokenizer 的工作并不是孤立的,它是人类世界与大模型神经网络之间的 安检通道 和 翻译桥梁。我们可以把完整流程分为 输入(Encode) 和 输出(Decode) 两个宏观方向。
对于文本输入到模型(Encoding 流程):
- 规范化 (Normalization):统一大小写(有些模型需要)、将繁体转简体、标准化各种奇怪的标点符号或隐形空格
- 预切分 (Pre-tokenization):给出一个粗略的切分边界,通常是按空格和标点切分
- 子词切分 (Subword Tokenization):核心步骤,根据模型自带的词表算法,把上一步切开的词进一步细化,得到 Token 列表
- 编码 + 特殊 token:对照词表,把这些 Token 字符串变成数字 ID,同时添加特殊 Token
对于模型输出到人类(Decoding 流程):
- ID 查表:把数字 ID 映射回 Token
- 拼接与清理(Stream Decoding):Tokenizer 将其换回人类可读的文字,顺便清理掉不再需要的特殊符号
- 渲染输出:最后输出人类可读的文字
Demo:基本分词操作
接下来我们通过一个实际 Demo 来了解 Tokenizer 的基本用法,对分词操作有个直观的认识:
1 | """ |
运行结果:
1 | python 01_basic_tokenization.py |
- 通过 Hugging Face 的
AutoTokenizer自动去下载或加载本地的 Qwen(通义千问)分词器配置文件。 tokenizer.vocab_size用于查看词表大小tokenizer.model_max_length用于查看模型能够支持的最大上下文长度,这里是 128k。上下文是输入(Prompt)和输出(Response)的总和tokenizer.pad_token_id:使用<|endoftext|>作为 Pad Tokentokenizer.eos_token:使用<|im_end|>来标识聊天结束
我们也可以通过 tokenizer.convert_ids_to_tokens() 来将 id 转换为 token,使用 tokenizer.get_vocab() 获取整个词表
1 | tokenizer.convert_ids_to_tokens(0) |
分词算法
大模型每次处理或生成文本时,都是以 Token 为单位计费和消耗算力的。词表的大小直接决定了模型吃进一段话时,要把这段话切得多碎:
-
词表太小:如果词表过小,模型遇到复杂词汇时就需要将其进行拆分成多个 Token,这导致文本对应的 Token 序列变得极其漫长。因为大模型的 Attention(注意力机制)计算复杂度与序列长度的平方成正比,序列越长,模型不仅运行越慢,能容纳的上下文窗口(Context Window)也会缩水
-
词表太大:大模型的入口 Embedding 层和出口(LM Head 输出层)的大小是直接与词表大小(Vocab Size)挂钩的。这两层的矩阵面积等于:。如果词表过大,会导致模型 Embedding 层和输出层参数会显著增加
工程上一般取中间平衡点:兼顾序列长度、模型体积与语义表达。目前在整个大模型和自然语言处理(NLP)领域,主流的子词分词算法有:BPE、WordPiece、Unigram。它们出现的初衷,都是为了解决传统分词 词表太大 或 未知词(UNK)太多 的痛点。但它们的解题思路和数学逻辑截然不同。
BPE (Byte-Pair Encoding,双核字节编码)
BPE 最初是一种数据压缩算法,后来被引入到 NLP 分词中。它的逻辑非常像堆乐高积木:一开始手里全是零散的小方块,谁跟谁经常挨在一起,就把它们粘成一个稍大一点的组合积木。
工作流程:
- 初始化:把训练集里的所有文本拆成最基础的单个字符,这些字符组成初始词表
- 统计频率: 统计语料库中,哪两个相邻的 Token 同时出现的次数最多(频率最高)
- 合并: 把这个最高频的
双子星组合合并成一个新的 Token,并塞进词表 - 循环: 重复步骤 2 和 3,直到词表大小达到了你预设的上限(比如 5 万或 15 万)
该算法存在一种变体 Byte-level BPE (BBPE),连字符都不作为基础了,直接把文本转成 UTF-8 字节(Byte)。基础词表里雷打不动先躺着 0~255 这 256 个字节代号。因为世界万物皆可转字节,所以它彻底消除了 UNK(未知词)。
这就是 BPE 算法的精妙之处在于:
- 常用词保持完整:高频出现的单词经过几次迭代直接变成一个 Token,保证了模型的计算效率
- 罕见词拆成碎片:不常用的词也能被拆成基础碎片的组合,保证了模型不会遇到不认识的词
WordPiece 算法(基于最大概率合并)
WordPiece 的整体流程和 BPE 非常像,也是从单个字符开始,一步步把高频的片段合并成大词元。但它在决定“合并哪两个碎片”时,引入了更高级的统计语言模型概率:
- BPE 只看两个片段在一起出现的绝对次数:这会导致像 of the、in the 这种本身基数就大的词被强行合并
- WordPiece 看的是:合并这两个词,对整体语言模型的互信息(Mutual Information)提升有多大。如果两个词各自出现的概率很高,但凑在一起的概率并没有显著飙升,那么其 Score 就会很低。相反,如果两个词单独出现概率一般,但合在一起的概率却很高,算法就会优先合并它们
Unigram 算法
Unigram 算法与前两种(BPE 和 WordPiece)的思路完全不同。BPE 和 WordPiece 是自底向上地从小字符开始合并,而 Unigram 是自顶向下地从一个庞大的初始词汇表里,不断删掉 贡献不大 的子词。它的核心逻辑很简单,就两步:
- 评估每个人(子词)的不可或缺性:具体来说,就是计算删除某个子词后,模型解释整个语料库的损失会增加多少
- 留下骨干,裁掉冗余:一轮评估完成后,算法会给所有子词的重要性排序,一次性裁掉那些排名靠后、对效率影响最小的子词
不断重复这个 评估-裁员 的过程,直到团队规模精简到我们设定的目标大小。
查看模型的分词算法
我们可以使用如下示例代码查看模型所使用的分词算法信息:
1 | type(tokenizer) |
从模型文件中查看分词配置
我们可以从模型文件的 tokenizer_config.json 获取所使用的分词器类,这里是 Qwen2Tokenizer:
1 | ...... |
模型的 tokenizer.json 则是 tokenizer 的完整定义文件。这个 JSON 文件包含了 tokenizer 运行所需的全部信息,不需要任何 Python 代码就能重建 tokenizer。它的结构如下:
1 | { |
每个字段的作用:
| 字段 | 作用 |
|---|---|
| model.vocab | 151643 个 token 到 ID 的映射,即词表 |
| model.merges | 151387 条 BPE 合并规则,按优先级排列 |
| normalizer | 分词前先做 Unicode 标准化(NFC) |
| pre_tokenizer | 先正则切分、再转字节,决定分词粒度 |
| decoder | 编码还原为文本的逆过程 |
| added_tokens | 特殊 token,不在 BPE 词表内,额外插入 |
tokenizer.json 是 tokenizer 的 配方。HuggingFace 的 tokenizers 库(Rust 编写)读取这个 JSON 就能构建完整的 tokenizer,不需要任何 Python 逻辑。Qwen2Tokenizer 这个 Python 类只是在 tokenizers.Tokenizer 外面包了一层易用的 API。
1 | AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B") |
所以:
- tokenizer.json 定义
怎么分词(Rust 层,算法+词表+合并规则) - tokenizer_config.json 定义
怎么用这个 tokenizer(Python 层,类名+chat_template+特殊token声明)
聊天模版
大模型(LLM)其实是个纯文本续写机器,它根本不知道什么是 用户、什么是 AI。为了让模型明白这是一场对话,安全地分清谁说了什么,训练时会使用特殊的控制标记(Special Tokens)把对话包装成一段特定格式的纯文本。聊天模版Chat Template 的作用是:
把结构化的多轮对话消息 → 格式化为一个扁平的文本字符串 → 再 tokenize 喂给模型
不同模型的训练数据使用不同的对话格式,所以它们的 Chat Template 也不同。用错了 template,模型的表现会大幅下降。Chat Template 把每个模型独特的拼接规则写成一段 Jinja2 脚本,存在分词器(Tokenizer)里。你只需要给它标准的数据结构,它自动帮你拼好。
从模型文件的 tokenizer_config.json chat_template 配置里可以看到 Qwen3-0.6B 所使用的聊天模版 。或者通过 transformers 库,我们可以直接查看模型的 Chat Template:
1 | from transformers import AutoTokenizer |
我们可以使用 apply_chat_template() 方法查看渲染后的实际文本内容:
1 | messages = [ |
- add_generation_prompt=True 在末尾添加助手前缀,引导模型开始生成
- 如果 enable_thinking=True(或默认未定义):
模型会正常走 <think> 流程,边思考边输出,最后再给答案 - 如果 enable_thinking=False:模板通过提前闭合标签,
欺骗并强行制止了大模型的思考行为,让其直接输出结果
端到端推理测试
最后我们来通过实际一个端到端推理测试实例,来看下整个推理过程是如何一步一步完成的:
1 | import torch |
运行结果如下:
1 | ============================================================ |
整个示例的代码还是非常简单,唯一值得注意的代码就是 generated_ids = outputs[0][inputs['input_ids'].shape[1]:],它的作用是:把大模型输出的完整 Token 序列进行 切片,切掉前面的 问题(Input),只保留模型新生成的 回答(Output):
- 大模型(AutoRegressive LM)的推理逻辑是文字续写,模型返回的 outputs 并不是纯粹的答案,而是
原始问题 + 答案 - 如果不做切片直接解码,打印出来的文本就会包含原始问题,显得非常冗余
另外需要注意,解码时需要通过 skip_special_tokens=True 来跳过特殊 token。
小结
这篇文章结合可实际运行的 Demo,帮助我们直观且深入地理解 LLM 分词器的核心原理,掌握了 token、各种分词算法以及 Chat Template 等关键知识点,为我们进一步理解 LLM 的工作原理打下了基础。