作者: 引线小白-本文永久链接:https://www.limoncc.com/post/ac9b442b90c51cd5/
知识共享许可协议: 本博客采用署名-非商业-禁止演绎4.0国际许可证
一、映射到 Unicode 可见字符
直接对原始 Unicode 字符集进行分词会面临一个根本性难题:基础字符数量极其庞大,且实际应用中总会出现训练时从未见过的罕见汉字、表情符号等,造成严重的 OOV(Out-Of-Vocabulary)问题。
当前主流大模型的分词器普遍采用字节级子词分词来彻底解决这一难题。其核心思路是:任何 Unicode 文本都可以用 UTF-8 编码为一个字节序列,每个字节的取值范围是 0x00–0xFF(即 0–255)。既然只有 256 种基础字节,就可以把“字节”当作最小的词汇单元——这样一来,任何文本都能用这 256 个符号的组合来表示,永远不会出现未知字符。
但单纯把字节当作字符存在工程问题:0x00–0x1F 等属于控制字符,直接放进文本中会带来各种麻烦。因此,常见的做法会额外引入一个无损映射,具体分为两步:
- 编码:
f(文本) = utf8_bytes(文本),将任意文本转换为 UTF-8 字节序列。 - 映射:
g(byte) = byte + 256,将每个字节值(0–255)映射到 Unicode 码点区间 [U+0100, U+01FF](即码点值 256–511)。
这一步骤的必要性可以从两方面理解:
- 数学上:
byte → byte + 256是整数区间 [0, 255] 到 [256, 511] 的双射,可逆且无碰撞,保证了原始信息能够被完全还原。 - 工程上:[U+0100, U+01FF] 区间内的字符全部是可见字符(如 Ā、ā、Ă 等拉丁扩展字母),既避开了控制字符带来的处理麻烦,又能让分词算法把它们当作普通文本来工作。
评述:这个 +256 映射是所有字节级 BPE[^1][^2]算法中最安静也最关键的一步。它没有改变任何信息量,只是让后续的 BPE 算法可以“假装”自己在处理普通文本。早期我自己照着论文实现时,曾觉得这步可有可无,直到在日志里看到那些控制字符把终端搞乱,才明白工程上避开控制字符有多重要。
经过这两步转换,任意一段文本都变成了由 [U+0100, U+01FF] 区间内字符构成的序列。接下来在这个序列上运行 BPE(字节对编码)算法。BPE 根本不需要知道这些字符背后代表的是“字节”,它看到的仅仅是 256 个初始符号。它会反复统计这些符号的共现频率,将最常相邻出现的符号对合并成一个新的 token,不断扩充词表。训练完成后,词表中的 token 既可能是单个字节映射符,也可能是多个字节合并而成的子词——比如一个完整汉字所对应的多字节组合(汉字在 UTF-8 中通常占 3 个字节,合并后就可能用一个 token 表示)。
在推理时,处理流程完全对称:先将输入文本经 UTF-8 编码和 +256 映射转换为字符序列,再利用训练好的词表将其切分为 token ID;解码时,将 token 对应字符串中的每个字符减去 256,恢复为原始字节序列,最后用 UTF-8 解码,即可完美还原文本。这样,无论输入中包含多么罕见或全新的符号,都能被表示为若干字节级子词的组合,从而从根源上消灭了 OOV 问题。
1 | text = "中" |
二、Byte-level BPE 分词原理
Byte Pair Encoding(BPE,字节对编码)原本是一种数据压缩算法,2016 年被应用到自然语言处理中的子词切分,成为现代大模型分词的核心方法之一。它的核心思想很简单:用当下最频繁的相邻符号对,不断合并生成新的子词,直到词汇表达到预定大小。这样既能保留常见词的整体性,又能把罕见词拆成可理解的片段,大幅度缓解未登录词问题。
下面给出一个最小的 MVP 例子,方便大家理解。
2.1、准备训练语料(模拟词频)
1 | corpus_words = { |
2.2、字节级 BPE 训练
1 | from collections import Counter |
评述:BPE 的合并顺序本身就是一个有向无环图的构建过程。训练完成后,合并规则是按优先级排列的,推理时严格按此顺序应用,这保证了无论输入何种文本,切分结果都是确定且唯一的。实际使用中,你会发现词表大小通常在 32k 到 100k 之间,过小会导致切分过碎,过大则浪费参数。选多少取决于你的主要语言和计算预算,没有一个绝对标准,但可以从 50k 开始作为一个合理的起点。
2.3、推理:用训练好的规则切分新词 lowest
1 | def apply_bpe_to_text(text, merge_rules, vocab): |
三、Byte-level BPE 分词实战
实际分词器训练,一般都使用 Hugging Face 出品的 tokenizers 库。它是一个基于 Rust 的高性能、可定制的子词分词器库。这个库的使用要注意如下几点。
3.1、tokenizer_config 的配置
先了解一下 tokenizer_config.json 关于特殊 token 的配置。
| 配置项 | 作用 | 是否控制自动添加? |
|---|---|---|
| bos_token / eos_token | 定义哪个 token 是开始/结束标记 | ❌ 仅定义 |
| add_bos_token / add_eos_token | 控制 add_special_tokens=True 时,是否自动在序列首尾插入 BOS/EOS |
✅ 是,核心开关 |
| pad_token / unk_token | 定义填充/未知词的 token | ❌ 仅定义标记,不控制 encode 自动添加行为 |
| additional_special_tokens | 声明额外的特殊标记列表,防止被分词器拆分,并可被生成逻辑识别 | ❌ |
| added_tokens_decoder | 所有特殊 token 的完整注册表(角色、是否特殊、前后缀行为等) | ❌ |
- 想让 encode(…, add_special_tokens=True) 自动加上 BOS/EOS,只需保证 add_bos_token=True 和 add_eos_token=True。
- 把
<|im_start|>、<|im_end|>放进additional_special_tokens是好的实践,但不会改变 encode 的自动追加行为。
另外 Hugging Face 的 transformers 库中,各类 Tokenizer 的 from_pretrained 会丢弃 add_bos_token/add_eos_token,必须在加载后手动设置。否则 encode(..., add_special_tokens=True) 不会生效。
1 | # tokenization_utils_base.py:1787-178 |
评述:这里是个经典的坑。很多新人在本地训练完分词器、一切正常,但推到 Hub 上再
from_pretrained加载后,发现encode再也不自动加BOS/EOS了。原因就是transformers在加载时主动丢弃了这两个开关。最简单的解法:加载后马上执行tokenizer.add_bos_token = True和tokenizer.add_eos_token = True。建议手写tokenizer_config.json时保留这两个字段,但别指望自动加载会尊重它们——这是目前官方行为,不是 bug,是设计。
3.2、Control Tokens 配置
3.2.1、特殊词元速查表
控制词元是插入到序列中的特殊标记,用于传递元信息。例如,在预训练阶段,多个文档可能会被打包进单个序列。对 Qwen 而言,控制词元 <|endoftext|> 会被插入在每个文档之后,以表示该文档已结束、新文档即将开始。常见的控制标记及其在 Qwen 中的状态可参见下表:
Qwen 控制令牌(Control Tokens)速查表
- eod token(文档结束符)
<|endoftext|>:文档结束标记。在打包的训练序列中,插入在文档之间,用于分隔不同文档。 - bot token(回合开始符)
<|im_start|>:回合开始标记。在对话微调时,添加到每个对话回合的开头。 - eot token(回合结束符)
<|im_end|>:回合结束标记。在对话微调时,添加到每个对话回合的末尾。 - unk token(未知词符) 无:Qwen 使用的 BBPE 分词算法确保了不会出现未知词符,因此无需此标记。
- pad token(填充符) 无:Qwen 在训练中不使用填充序列。推理时如需填充,可结合分词器返回的注意力掩码,将任意特殊标记用作 pad_token,通常设置为与 eod 相同的
<|endoftext|>。 - bos token(序列开始符) 无:Qwen 不会在打包的训练序列开头添加固定标记。
- eos token(序列结束符) 无:Qwen 不会在打包的训练序列末尾添加固定标记。然而,由于多数框架没有 eot 的概念,在推理时需要使用 eos_token 作为停止条件,因此最终将 eos_token 设置为与 eot_token 相同的
<|im_end|>。
评述:Qwen 对 pad_token 的处理是一个实用技巧:训练时不填充,推理时随意指定一个已经存在的特殊 token 充当 pad(并用 attention_mask 屏蔽)。这样不会额外占用词表位置,也不干扰其他 token 的语义。类似的设计在 LLaMA 等模型中也常见,背后的哲学是:填充只是工程需要,不值得为它专门训练一个嵌入向量。
3.2.2、预训练阶段的特殊词元
特殊 token 设置,主要是看训练目标。从千问情况看,千问的设置是:
1 | { |
这意味着预训练数据格式,即输入模型的数据大致是这样的结构:
1 | 文档1的文本内容...<|endoftext|>文档2的文本内容...<|endoftext|>... |
预训练特殊词元重要结论:通过在无数文档间插入
<|endoftext|>进行训练,模型学会了用它来划分语义边界,防止把不同文档的知识混淆。也就是说在预训练阶段,模型完全没见过<|im_start|>和<|im_end|>,对“对话”这种形式没有概念。
评述:预训练阶段不用对话标记,是刻意的。这保证了基础模型的通用性——它理解的是“文档”概念,而非特定应用格式。所有对话能力都通过后训练注入,这样的好处是基础模型可以复用给不同下游任务(翻译、摘要、代码生成等),而不会被对话格式束缚。
3.2.3、后训练阶段的特殊词元
后训练阶段 <|im_start|> 和 <|im_end|> 才作为核心角色登场。Qwen 采用了 ChatML 格式来结构化对话数据。一个典型的训练样本会被组织成这样:
1 | <|im_start|>system |
后训练特殊词元重要结论:
- 角色与认知转变:在这个全新的数据结构中,
<|im_end|>被模型重新学习为对话的结束标记。因为它总出现在每个对话的结尾,模型逐渐认知到,当生成这个标记时,就代表一次对话的结束。- 预训练阶段很重要的
<|endoftext|>不再出现,其“结束符”的意义被<|im_end|>所取代。- 分词器配置的最终形态:训练完成后,为了适配这种新的认知,分词器的配置会被更新。eos_token 被设置为
<|im_end|>,pad_token 被设置为<|endoftext|>让它退居二线负责填充(padding)等辅助工作,bos_token 保持为 null,因为 ChatML 模板中的<|im_start|>已经承载了“开始”的职责。
所以特殊 token 应该根据你训练目的灵活设置,例如有人就直接这样设置,在预训练中直接使用 <|im_start|> 和 <|im_end|>:
1 | { |
评述:选择在哪个阶段引入对话标记,没有绝对正确。如果在预训练阶段就加入,模型从第一天起就把
<|im_end|>当作“生成结束”信号,省去后训练的重新认知过程,但代价是预训练数据也要打包成类似对话的格式,成本更高。Qwen 的做法属于“先通用、后专用”的工程折中,是目前的主流路线。
3.3、BBPE 预分词技巧
Unicode 标准有很多规范形式,把所有 Unicode 字符统一规范为 NFC 形式。比如 é 既可以用单个字符 U+00E9 表示,也可以用 e + 组合重音符表示。NFC 会统一成前者,避免同一个文本有两种底层表示,从而避免不必要的 OOV 或子词分裂。
正则拆解预分词:
1 | GPT2_SPLIT_PATTERN = ( |
评述:预分词正则看起来复杂,但对英文这种空格分隔的语言来说,主要影响标点和缩写的切分粒度。我自己的经验是,对于中文语料,这套正则的前两条依然有用(标点+词),但数字单独切分(
\p{N})有时会过于激进,尤其是遇到日期、小数时会把它们拆散,可以视情况调整。预分词之后,再走字节级映射,就能稳定处理中英混合文本。
3.4、BBPE 核心代码
3.4.1、训练设定
1 | import tokenizers as tk |
3.4.2、训练
1 | texts = ["你好", '你好,我来自地球'] |
评述:
train_from_iterator是内存友好设计,你可以直接传一个生成器,处理 TB 级数据也不会爆内存。另外注意initial_alphabet传入了ByteLevel.alphabet(),这会在词表中显式保留所有 256 个基础字节映射符,确保哪怕是单字节的罕见二进制数据也能被编码。如果没有这一步,某些模型在遇到非常规字节时会直接退化成 unk,反而违背了字节级 BPE 的初衷。
3.4.3、配置 tokenizer_config.json
Hugging Face 的 tokenizers 库只保存算法和数据,不能自动生成 tokenizer_config.json,这并非疏漏,而是因为它和 transformers 库有明确的分工。简单来说,tokenizers 库专注于“如何分词”(即核心算法),只保存算法和数据;而 transformers 库需要处理“如何使用分词器”这类更高级的配置,所以这些额外的配置工作就落在了它的肩上。
如何生成 tokenizer_config.json 文件?一种是自己手动写。
1 | config = { |
还有一种最简单、最标准的做法。你只需用 PreTrainedTokenizerFast 包装已训练好的 tokenizers.Tokenizer 对象,再调用它的 save_pretrained() 方法即可自动生成所有文件。但是这种方法保存的 tokenizer_config.json 格式不如手写:
1 | from tokenizers import Tokenizer |
评述:两种方法我用得最多的是手写,因为可以精确控制
add_bos_token这类容易被忽视的字段,以及chat_template这个对对话模型至关重要的模板。自动保存虽然方便,但生成的配置往往缺少chat_template,而缺少它的话,apply_chat_template会直接报错。所以如果打算公开发布分词器,建议最终还是用手写方式补全tokenizer_config.json。
3.4.4、测试
1 | # 测试对话编码 |
评述:最后这段测试务必保留。无论你多信任自己的配置,都建议跑一遍
decode(encode(text))的往返测试,特别是包含特殊 token 和不同语言混合的情况下。历史上因为add_bos_token丢失或chat_template错误导致的 silent failure 非常常见——编码解码看似正常,实际多了一个空格或少了一个换行,模型表现就完全不同。
参考文献
[^1]: Sennrich, R., Haddow, B., & Birch, A. (2016). Neural machine translation of rare words with subword units. arXiv. https://doi.org/10.48550/arXiv.1508.07909
[^2]: Radford, A., Wu, J., Child, R., Luan, D., Amodei, D., & Sutskever, I. (2019, February 14). Language models are unsupervised multitask learners. OpenAI. https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf
| 版权声明 | ![]() |
| 由引线小白创作并维护的柠檬CC博客采用署名-非商业-禁止演绎4.0国际许可证。 本文首发于柠檬CC [ https://www.limoncc.com ] , 版权所有、侵权必究。 | |
| 本文永久链接 | https://www.limoncc.com/post/ac9b442b90c51cd5/ |
| 如果您需要引用本文,请参考: |
| 引线小白. (May. 11, 2026). 《大语言模型研究11——分词器从原理到实战》[Blog post]. Retrieved from https://www.limoncc.com/post/ac9b442b90c51cd5 |
| @online{limoncc-ac9b442b90c51cd5, title={大语言模型研究11——分词器从原理到实战}, author={引线小白}, year={2026}, month={May}, date={11}, url={\url{https://www.limoncc.com/post/ac9b442b90c51cd5}}, } |
