跳到主要内容

数据截至 (上游 commit d5827816baed)

Tokenizers — 架构与原理

30 秒导读: Hugging Face tokenizers 是整个 HF 生态(transformers 的 fast tokenizer、绝大多数 Hub 模型的 tokenizer.json)背后那个「真正干活的」分词库。它把分词拆成四段可自由组合的流水线——归一化(Normalizer)→ 预分词(PreTokenizer)→ 模型切分(Model:BPE / WordPiece / Unigram / WordLevel)→ 后处理(PostProcessor:插特殊 token)——每段都是一个 trait,换一段就换一种分词器。核心用 Rust 写成,靠一张「归一化字符串每个字节对应原串哪几个字节」的对齐表,让最终每个 token 都能精确指回原文;再用 PyO3 薄壳暴露给 Python,批编码时释放 GIL 并多线程并行。读它你能回答:为什么 fast tokenizer 比 Python 写的快一个数量级、offset 映射是怎么穿过小写化/Unicode 归一化还活着的、BPE 的「合并」在生产实现里到底怎么算的。


1. 这是什么(零基础也能懂)

一句话定义

tokenizers 是一个生产级文本分词库:给它一段文本,它输出一串 token id(外加每个 token 在原文里的位置);给它一批语料,它能从零训练出一个 BPE / WordPiece / Unigram 词表。

它要解决谁的什么问题

大模型只认数字。文本进模型前必须切成 token 并映射成 id。这件事听起来像「按空格 split」,实际上坑很多:

  • 归一化会改变长度:小写化、Unicode 归一化(NFC/NFKC)、去重音之后,字符串变长了或变短了,「第 5 个 token 对应原文哪几个字」就说不清了。
  • 不同模型切法不同:BERT 用 WordPiece,GPT-2 用字节级 BPE,T5/LLaMA 用 Unigram/SentencePiece。每换一个模型就要换一套实现,没法组合。
  • 特殊 token 要精确插入[CLS] x [SEP] y [SEP] 的位置和 type_id 每个模型都不一样。
  • 速度:预训练语料是 TB 级,Python 逐字符实现会成为整个训练管线的瓶颈。

tokenizers 就是把这四件事一次性解决掉的那一层。 下游(transformers、文本嵌入服务、推理引擎)只面对一个 tokenizer.json 文件和一个 encode() 调用。

它能做什么

能力具体支持
分词模型BPE(含字节级、byte_fallback、dropout)、WordPiece、Unigram、WordLevel(tokenizers/src/models/
归一化NFD/NFC/NFKD/NFKC、小写、strip、replace、预编译字符集、可串联成 Sequence(tokenizers/src/normalizers/
预分词空白、标点、数字、ByteLevel(GPT-2 风格)、Metaspace(SentencePiece 风格)、Split、Unicode Scripts、可串联(tokenizers/src/pre_tokenizers/
后处理BERT / RoBERTa 样式、任意模板(TemplateProcessing)、可串联(tokenizers/src/processors/
训练从文件或任意迭代器训练四种模型的词表(tokenizers/src/models/*/trainer.rs
工程批编码多线程并行、截断(三种策略 + stride 溢出窗)、填充、offset 映射(字节/字符两种)、单文件 tokenizer.json 序列化

用起来什么样

最小形态是三段式(Python 侧,摘自官方 quicktour 的等价形态):

# 示意(基于 bindings/python 公开 API)
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.trainers import BpeTrainer

tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()
tokenizer.train(["wiki.train"], BpeTrainer(vocab_size=30000, special_tokens=["[UNK]", "[CLS]", "[SEP]"]))
tokenizer.save("tokenizer.json")

out = tokenizer.encode("Hello, y'all! How are you?")
out.ids # token id 序列
out.tokens # 对应的字符串片段
out.offsets # 每个 token 在原文里的 (start, end)

读这段代码就能读出 tokenizers 的心智模型:一个 Tokenizer = 一个模型 + 四个可插拔部件,全部状态可以塞进一个 JSON 文件,加载后 encode 一条线走到底。

Rust 侧入口是 Tokenizer::new(model)tokenizers/src/tokenizer/mod.rs:569),真正的编码主线是 encodetokenizers/src/tokenizer/mod.rs:871)。

一句话直觉

把 tokenizers 想成一条「字符加工厂流水线」。 原料(原始字符串)先进清洗车间(归一化),再上粗切台(预分词切成词),然后进精切机(模型把词切成子词并贴上 id 标签),最后打包贴单(后处理插上 [CLS]/[SEP] 并统一长度)。每个工位都只干一件事,而且全程给每块料留着「原来在原料哪个位置」的跟踪标签——那张标签就是本库最值钱的 offset 映射。


2. 顶层全景(它大概怎么转)

2.1 仓库与运行时拓扑

先看「代码在哪、谁在调谁」:

┌─ bindings/python ─────────────┐ ┌─ bindings/node ────────┐
│ PyO3 薄壳 (py_src + src/) │ │ napi 薄壳 │
│ Tokenizer / Encoding / ... │ │ (本文不展开) │
└──────────┬────────────────────┘ └──────────┬─────────────┘
│ 同一套 Rust 核心 │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ tokenizers/src(Rust 核心,~60 文件) │
│ TokenizerImpl = normalizer? + pre_tokenizer? + model │
│ + post_processor? + decoder? │
│ + added_vocabulary + truncation + padding │
└──────────────────────────────────────────────────────────┘

│ 一个 JSON 文件(tokenizer.json)完整描述
serde 序列化/反序列化(tokenizers/src/tokenizer/serialization.rs:15)

怎么读这张图: 所有语言绑定共享同一个 Rust 核心;TokenizerImpltokenizers/src/tokenizer/mod.rs:544)就是全部运行时状态——五个部件里四个是 Option(只有 model 必须有),外加 added vocabulary、截断与填充参数。tokenizer.json 不是「词表文件」,而是整条流水线的完整快照

2.2 部件职责

部件干什么在哪个文件
TokenizerImpl持有五个部件 + added vocabulary + 截断/填充参数,串起整条线tokenizers/src/tokenizer/mod.rs:544
Normalizer(trait)归一化文本(小写、Unicode 范式、strip 等),维护对齐表tokenizers/src/tokenizer/mod.rs:56
PreTokenizer(trait)把字符串粗切成「词」并保留 offsettokenizers/src/tokenizer/mod.rs:65
Model(trait)把每个词切成子词 token(BPE/WordPiece/Unigram/WordLevel)tokenizers/src/tokenizer/mod.rs:70
PostProcessor(trait)插特殊 token、设 type_id、合并 pair 序列tokenizers/src/tokenizer/mod.rs:122
Decoder(trait)反向:token 字符串 → 可读文本tokenizers/src/tokenizer/mod.rs:183
AddedVocabulary特殊/追加 token 的独立词表,用 trie 在归一化前后各扫一遍原文tokenizers/src/tokenizer/added_vocabulary.rs:142
PreTokenizedString编码中期的载体:一串带 offset 跟踪的 Splittokenizers/src/tokenizer/pre_tokenizer.rs:55
NormalizedString原串 + 归一化串 + 逐字节对齐表tokenizers/src/tokenizer/normalizer.rs:105
Encoding最终产物:ids/tokens/offsets/type_ids/word_ids 等九组向量tokenizers/src/tokenizer/encoding.rs:11
Trainer(trait)从语料训练出 model 的词表tokenizers/src/tokenizer/mod.rs:193
截断/填充truncate_encodings / pad_encodings,在 post_process 阶段套用tokenizers/src/utils/truncation.rs:70tokenizers/src/utils/padding.rs:50

2.3 主线走一遍(一次 encode)

下面这条线对应 Tokenizer::encode("Hello there")tokenizers/src/tokenizer/mod.rs:871-887)。步骤号与源码调用层级一一对应:

① encode(input, add_special_tokens)
拆出单序列或 pair → 对每条序列调 encode_single_sequence
(tokenizers/src/tokenizer/mod.rs:762)


② extract_and_normalize() ← AddedVocabulary 先动手!
用 trie 在「未归一化」原文上找 added tokens(如 <s>、<img>),
命中段直接变成带 id 的 Split;剩余片段再过 normalizer,
归一化后再用第二个 trie 找「归一化后才匹配」的 added tokens
(tokenizers/src/tokenizer/added_vocabulary.rs:523)


③ do_pre_tokenize() ← 粗切
PreTokenizer 把每个 Split 再切成词级 Split(如按空白+标点)
(tokenizers/src/tokenizer/mod.rs:1247)


④ do_tokenize() ← 精切
Model 对每个词跑子词算法(BPE merge / WordPiece 最长匹配 / Unigram Viterbi),
产出 (id, token字符串, 词内offset)
(tokenizers/src/tokenizer/mod.rs:1178)


⑤ into_encoding() ← offset 回算
沿 alignments 把「词内 offset」换算成「原文字节 offset」(或字符 offset)
(tokenizers/src/tokenizer/pre_tokenizer.rs:198)


⑥ post_process(encoding, pair, add_special_tokens)
先截断(给特殊 token 预留位置)→ PostProcessor 插 [CLS]/[SEP] 并合并 pair
→ 最后填充
(tokenizers/src/tokenizer/mod.rs:1265)

这条线最值得记住的两点:

  1. added tokens 不走正常管线。 <s>[MASK] 这类 token 在第 ② 步就被 trie 直接匹配出来、贴上 id,之后 normalizer/pre-tokenizer/model 都对它们绕道(Split 里已有 tokens 的直接跳过,见 tokenizers/src/tokenizer/pre_tokenizer.rs:83-86)。这就是「为什么特殊 token 永远不会被 BPE 切碎」的实现答案。
  2. offset 是全程背着走的,不是事后重算的。 每一步切分都不新建字符串索引,而是维护「归一化串字节 → 原串字节」的对齐关系,第 ⑤ 步一次性换算回原文。这正是第 3 章的主角。

2.4 训练主线(一次 BPE 训练)

feed() 迭代语料 → 用 normalizer + pre_tokenizer 把语料切成词并计数
(trainer.feed,tokenizers/src/models/bpe/trainer.rs:645)


do_train():特殊 token 入表 → 初始字母表 → 词切成字符序列
→ 统计所有相邻对的频次 → 大顶堆反复「合并最高频对」
→ 每轮只增量更新受影响词的 pair 计数(并行,含 unsafe 指针技巧)
(tokenizers/src/models/bpe/trainer.rs:456)


词表 + merges 表灌回 model → tokenizer.save() 落成 tokenizer.json

细节在第 2 章。


3. 阅读地图(建议顺序)

四章由浅入深。时间有限的话,读 01 → 03 就能抓住 tokenizers 区别于「教学版 BPE」的全部要害。

顺序章节讲什么适合谁
101-pipeline.md四段管线的职责边界与串联方式,AddedVocabulary 的两遍提取所有人必读,这是全库骨架
202-bpe.mdBPE 推理(merge_all 优先队列)与训练(do_train 增量计数)双算法想真正搞懂 BPE 的人
303-encoding.mdalignments 对齐表、Encoding 九字段、stride 截断与 overflowing要用 offset 映射做序列标注/问答的人
404-python-bindings.mdPyO3 绑定、GIL 释放、序列化、与 transformers 的对接要在 Python 里用或扩展它的人

4. 巧妙之处

  1. 把「可组合性」做成类型系统的一部分。 五个 trait(tokenizers/src/tokenizer/mod.rs:56-208)+ TokenizerImpl 泛型,让「换一个归一化器」和「换一个模型」是同一量级的事情。BERT tokenizer 和 GPT-2 tokenizer 的差别只是五个槽位的具体填充物不同,而不是两套代码。
  2. AddedVocabulary 的「两遍扫描」。 第一遍在未归一化原文上找(保证 <s> 这类 token 不被小写化破坏),第二遍在归一化后的片段上找(保证加了 normalized=true 的 token 能匹配变形后的文本),见 tokenizers/src/tokenizer/added_vocabulary.rs:523-565 与文件内大段注释示例。这个设计同时保住了「特殊 token 神圣不可切」和「普通追加 token 参与归一化」两种需求。
  3. 逐字节 alignments 而不是区间映射。 归一化每改一个字符就记录「归一化串的每个字节来自原串的哪段(含 1→n、n→1、删除)」,所以 offset 映射能活着穿过 NFKC、小写化、strip 的任意组合。详见第 3 章。
  4. BPE 推理的「链表 + 最小堆 + 惰性失效」。 合并时不重建字符串,符号存在数组里用 prev/next 指针连;堆里的过期条目靠「取出来再校验」自然丢弃。O(n log n) 且常数极小(tokenizers/src/models/bpe/word.rs:163)。详见第 2 章。
  5. 训练循环的增量更新 + 并行。 每轮 merge 只重算「包含被合并对的那些词」的 pair 计数,并用 where_to_update 集合精确知道哪些词要动;更新多个词时用裸指针绕过别名检查做并行(tokenizers/src/models/bpe/trainer.rs:553-578,带 Safety 注释的 unsafe)。这是 TB 级语料上训 BPE 还能接受的关键。
  6. Python 侧的「全程释放 GIL」。 批编码、训练都在 py.detach 里跑 Rust 多线程;#[pymodule(gil_used = false)] 声明支持 free-threaded Python(bindings/python/src/lib.rs:50)。还注册了一个 fork 回调,防止 Python multiprocessing fork 后 Rayon 线程池死锁(bindings/python/src/lib.rs:36-46)——典型「踩过坑才有」的代码。

5. 边界与局限

  • 它不是 SentencePiece。 训练 Unigram/BPE 是自家实现,与 Google SentencePiece 在细节上(如 处理、采样)不完全等价;仓库里专门留了 parity 脚本(bindings/python/scripts/spm_parity_check.pytokenizers/src/models/bpe/parity_trainer.rs)说明「对齐 SentencePiece 行为」是刻意做过的工作而不是天然一致。
  • WordLevel 完全不做子词切分:整词查表,查不到回退 unk_token,词表里连 unk_token 都没有就直接报 MissingUnkToken 错(tokenizers/src/models/wordlevel/mod.rs:162-177)。它适合「词表即全部」的场景,不适合开放词表。
  • offset 映射在「归一化删除/折叠字符」处会给出带零宽或合并区间的近似——它保真到字节级,但不保证每个 token 边界都落在人类直觉的字符边界上(尤其 Python 默认要的 char offsets 是从字节 offsets 换算的,见 tokenizers/src/tokenizer/pre_tokenizer.rs:329)。
  • Tokenizer::from_pretrained 已废弃tokenizers/src/tokenizer/mod.rs:1609,自 0.14.0 起),官方让改用 hf-hub 下载 + from_file。Python 侧 from_pretrained 仍在(bindings/python/src/tokenizer.rs:789)。
  • 多线程并行是「尽力而为」maybe_par_iter 在样本少或环境变量 TOKENIZERS_PARALLELISM=false 时退化为串行(tokenizers/src/utils/parallelism.rs)。
  • 改造归一化器要懂 alignments 约定:自定义 Normalizer 必须正确维护对齐表,否则 offset 静默错乱——这是扩展本库最容易踩的坑(约定写在 tokenizers/src/tokenizer/pre_tokenizer.rs:63-72split 文档与 NormalizedString 各方法文档里)。
  • Node 绑定存在但本文不展开bindings/node/),机制与 Python 绑定同构。

6. 横向对比(同书架)

维度tokenizers(本篇)minbpetransformers
定位生产级分词引擎,HF 生态地基教学用最小 BPE(Karpathy,单文件级)模型库;分词只是入口层
语言Rust 核心 + Python/Node 绑定纯 PythonPython(fast tokenizer 直接内嵌本库的绑定)
抽象四段管线 trait + 可序列化 JSON一个 Tokenizer 基类 + BPE 训练/推理PreTrainedTokenizer(Fast) 统一 API
BPE 实现链表+优先队列+缓存+byte_fallback字典上反复 min(pair_counts)慢速用 Python、fast 用本库
offset 映射逐字节 alignments,穿过归一化仍精确fast 路径透传本库的 Encoding
训练并行Rayon 多线程 + 增量计数委托本库
适合读它为了知道生产分词怎么做、怎么扩展知道 BPE 算法本身是什么知道分词结果怎么进模型

一句话:minbpe 教你 BPE「是什么」,tokenizers 告诉你 BPE「要上线还差哪八百个细节」,transformers 展示这些 token 接下来去哪。


7. 代码地图(入口级)

每章末尾都有自己的细粒度地图,这里只列从零开始读源码的四个入口

主题文件路径符号名
全部 trait 与核心结构tokenizers/src/tokenizer/mod.rsTokenizerImplNormalizerPreTokenizerModelPostProcessorDecoderTrainer
编码主线tokenizers/src/tokenizer/mod.rsencodeencode_single_sequencedo_pre_tokenizedo_tokenizepost_process
BPE 推理算法tokenizers/src/models/bpe/word.rstokenizers/src/models/bpe/model.rsWord::merge_allBPE::merge_wordBPE::tokenize
Python 入口bindings/python/src/tokenizer.rsbindings/python/src/lib.rsPyTokenizerencodeencode_batchtrain_from_iterator