数据截至 (上游 commit d5827816baed)
01 · 四段管线:归一化、预分词、模型、后处理
这一章讲什么: tokenizers 全库的骨架——为什么分词要拆成四段、四段各自的最小职责、它们怎么被
TokenizerImpl串成一次encode,以及一个容易被忽略但极其重要的设计:特殊 token(<s>、[MASK])为什么永远不会被 BPE 切碎。
1. 它要解决的小问题
如果让你写一个分词器,最自然的想法是一步到位:给个字符串,直接输出 id。但真实需求会立刻把这个函数撑爆:
- 小写化、Unicode 归一化(
é是一个码点还是e + ◌́两个?)要在切分之前做; - GPT-2 要先按「字母/数字/空白/标点」粗切,BERT 要按空白粗切,SentencePiece 干脆不粗切;
- 切出来的词怎么变成子词,BPE、WordPiece、Unigram 是三种算法;
- 最后
[CLS]/[SEP]插在哪、pair 句子的 type_id 怎么标,每个模型又不一样。
写成一个函数就是四种模型 × 四种组合的意大利面。tokenizers 的答案是:每个变化点一个 trait,管线本身不变。
2. 思路:变化点抽象成槽位
直觉一句话:把「一定会变的」做成槽位,把「永远不变的」做成骨架。
永远不变的是这条顺序:
原文 ──► 归一化 ──► 粗切成词 ──► 词切成子词+id ──► 插特殊token/定长 ──► Encoding
会变的是每一步用什么算法。于是定义五个 trait(tokenizers/src/tokenizer/mod.rs:56-208):
| trait | 职责 | 方法签名核心 |
|---|---|---|
Normalizer | 改文本(小写/Unicode 范式),维护对齐表 | normalize(&self, &mut NormalizedString) |
PreTokenizer | 粗切成词 | pre_tokenize(&self, &mut PreTokenizedString) |
Model | 词 → 子词 token | tokenize(&self, &str) -> Vec<Token> |
PostProcessor | 插特殊 token、合并 pair | process(enc, pair, add_special) -> Encoding |
Decoder | 反向还原文本(decode 时用) | decode_chain(Vec<String>) -> Vec<String> |
TokenizerImpl 用泛型把五段钉死成一条流水线(tokenizers/src/tokenizer/mod.rs:544-558):
// 摘自 tokenizers/src/tokenizer/mod.rs:544(字段注释精简)
pub struct TokenizerImpl<M, N, PT, PP, D> {
normalizer: Option<N>, // 可没有
pre_tokenizer: Option<PT>, // 可没有
model: M, // 必须有
post_processor: Option<PP>, // 可没有
decoder: Option<D>, // 可没有
added_vocabulary: AddedVocabulary,
truncation: Option<TruncationParams>,
padding: Option<PaddingParams>,
}
于是「BERT tokenizer」= BertNormalizer + BertPreTokenizer + WordPiece + BertProcessing;「GPT-2 tokenizer」= 无 + ByteLevel + BPE + ByteLevel 处理。差异全部变成配置,而配置可以整个塞进 tokenizer.json(序列化见 tokenizers/src/tokenizer/serialization.rs:15)。
3. 图示:一次 encode 的完整动线
"Hello <s>world"
│
▼
① AddedVocabulary::extract_and_normalize
trie 扫未归一化原文 → 命中 "<s>" 直接出 Split(带id)
其余片段过 Normalizer(小写化…)→ 第二个 trie 再扫一遍
│ PreTokenizedString = [Split("hello "), Split("<s>", id=7), Split("world")]
▼
② PreTokenizer.pre_tokenize
跳过已有 token 的 Split,把纯文本 Split 按空白/标点再切
│ [Split("hello"), Split("<s>", id=7), Split("world")]
▼
③ Model::tokenize(对每个无 token 的 Split)
BPE/WordPiece/Unigram → Split 里填上 Vec<Token>
│ 每个 Token = (id, 字符串, 词内 offset)
▼
④ PreTokenizedString::into_encoding
沿 alignments 把词内 offset 换算回原文字节/字符 offset
│
▼
⑤ post_process:截断(预留特殊位) → 插 [CLS]/[SEP]、合并 pair → 填充
│
▼
Encoding
4. 原理演示(示意代码)
下面这段是管线骨架的「教学版」伪代码,与真实结构同构(非源码):
# 示意 ,非源码
class Tokenizer:
def encode(self, text, add_special_tokens=True):
# ① 特殊 token 先被提取;剩余片段顺手归一化
splits = self.added_vocab.extract_and_normalize(self.normalizer, text)
# ② 粗切(只切没有 token 的片段)
self.pre_tokenizer?.pre_tokenize(splits)
# ③ 精切(同上,跳过已带 token 的片段)
for split in splits:
if split.tokens is None:
split.tokens = self.model.tokenize(split.text)
# ④ offset 回算 + ⑤ 后处理
enc = splits.into_encoding()
return self.post_processor?.process(enc, add_special_tokens) or enc
注意两件事:「跳过已有 token 的片段」在 ②③ 各出现一次——这是 added token 不被切碎的机制核心;后处理拿到的是已经算好 offset 的 Encoding,它只做结构性修改(插入、拼接)。
5. 真实实现
5.1 主入口 encode 只做三件事
encode(tokenizers/src/tokenizer/mod.rs:871-887)的函数体短到可以全录:
// 摘自 tokenizers/src/tokenizer/mod.rs:875-887
let (sequence, pair) = match input.into() {
EncodeInput::Single(s1) => (s1, None),
EncodeInput::Dual(s1, s2) => (s1, Some(s2)),
};
let encoding = self.encode_single_sequence(sequence, 0, OffsetType::Byte)?;
let pair_encoding = pair
.map(|sequence| self.encode_single_sequence(sequence, 1, OffsetType::Byte))
.transpose()?;
self.post_process(encoding, pair_encoding, add_special_tokens)
单序列和 pair 走同一条路,只是 type_id 一个 0 一个 1。encode_char_offsets(同文件 914 行起)是同一个函数的 OffsetType::Char 版本——Python 绑定的 encode 用的就是它(见第 4 章)。
5.2 encode_single_sequence:管线的真面目
encode_single_sequence(tokenizers/src/tokenizer/mod.rs:762-802)把 §3 图里的 ①~⑤ 压缩成一个闭包:
// 摘自 tokenizers/src/tokenizer/mod.rs:768-783
let encode = |is_pre_tokenized, subseq_idx, subseq| -> Result<Encoding> {
let normalized = self
.added_vocabulary
.extract_and_normalize(self.normalizer.as_ref(), subseq);
let pre_tokenized = self.do_pre_tokenize(normalized)?;
let subseq_encoding = self.do_tokenize(pre_tokenized, type_id, ...)?;
Ok(subseq_encoding)
};
注意第一步:归一化不是直接调 normalizer,而是先过 AddedVocabulary。这就是下一节的主角。
do_pre_tokenize(tokenizers/src/tokenizer/mod.rs:1247-1260)和 do_tokenize(tokenizers/src/tokenizer/mod.rs:1178-1200)都只是「有部件就调、没有就跳过」的薄封装;do_tokenize 里多做一件事:把截断参数(max_length, direction)传给模型层,让 BPE 在超限时能提前退出。
5.3 PreTokenizedString:管线中段的通用货币
四段之间传的不是 String,而是 PreTokenizedString(tokenizers/src/tokenizer/pre_tokenizer.rs:55-58):
// 摘自 tokenizers/src/tokenizer/pre_tokenizer.rs:55-58
pub struct PreTokenizedString {
original: String,
splits: Vec<Split>,
}
每个 Split(同文件 21-28 行)= 一个 NormalizedString + 可选的 Vec<Token>。「tokens 是否为 Some」就是这个 Split 是否已被定案的标志位:split() 方法(同文件 73-103)在切分时直接跳过 tokens.is_some() 的 Split(83-86 行),pre-tokenizer 和 model 因此天然不会碰特殊 token。
5.4 AddedVocabulary:两遍扫描的特殊 token 提取器
extract_and_normalize(tokenizers/src/tokenizer/added_vocabulary.rs:523-565)是全局最容易被低估的函数。它做两轮 :
// 摘自 tokenizers/src/tokenizer/added_vocabulary.rs:529-554
// 1. 先在【未归一化】的串上找
pretokenized
.split(|_, sequence| Ok(self.split_with_indices(sequence, &self.split_trie)))
.expect("AddedVocabulary bad split");
// 2. 再对每个剩余片段归一化,然后在【归一化后】的串上找
pretokenized
.split(|_, mut sequence| {
normalizer.map(|n| n.normalize(&mut sequence));
Ok(self.split_with_indices(sequence, &self.split_normalized_trie))
})
.expect("AddedVocabulary bad split");
两个 trie 分别装 normalized=false 和 normalized=true 的 added token。匹配本身在 find_matches(同文件 429-490)里:trie 的 leftmost 匹配之外,还按 AddedToken 的三个开关过滤——single_word(两侧不能紧贴字母数字)、lstrip/rstrip(把相邻空白吸进 token 范围)。开关定义在 AddedToken(同文件 17-28 行)。
这个设计同时满足两种互相矛盾的需求:
<s>必须原样匹配(设normalized=false,special 默认如此)——小写化不能把它变成<s>的某种变形再被切碎;- 自定义追加词
yesterday应该能匹配到Yesterday(设normalized=true)——先小写化再找。
源码里 tokenizers/src/tokenizer/added_vocabulary.rs:535-560 的注释给了四组「输入 → 切分结果」的对照例子,值得直接读原文。
5.5 PostProcessor:模板化插特殊 token
post_process(tokenizers/src/tokenizer/mod.rs:1265-1325)内部严格按三步走:
- 先截断,且截断长度要预先扣掉后处理会插入的特殊 token 数(
trunc.max_length - n_added_tokens,同文件 1282-1287 行)——否则[CLS]/[SEP]会把总长度顶超; - 调具体 processor(没有则用
default_process纯合并); - 最后填充。
最常用的 processor 是 TemplateProcessing(tokenizers/src/processors/template.rs):用 "[CLS] $A [SEP] $B:1 [SEP]:1" 这样的模板字符串声明插入位置与 type_id,$A/$B 指两条输入序列。它的 process_encodings(同文件 655-686)按「一条还是两条 Encoding」选模板套用。added_tokens(is_pair)(同文件 647-653)就是上一步「预留位置」的信息来源。
5.6 Decoder:管线的逆运算
decode(tokenizers/src/tokenizer/mod.rs:933-949)几乎 是对 encode 主线的逐行反转:先查 added vocabulary 再查 model 词表把 id 换回字符串,skip_special_tokens 时滤掉特殊 token,最后有 decoder 就 decode_chain(如 ByteLevel 把字节级字符映回原始字节),没有就空格拼接。
6. 坑与注意点
- 特殊 token 的 id 不来自 model 词表。 它们活在
AddedVocabulary的独立 id 空间(通常排在 model 词表之后)。decode先查 added 再查 model(tokenizers/src/tokenizer/mod.rs:937-940)的顺序不能反。 encode_special_tokens=false时特殊 token 会被当普通文本。find_matches里直接跳过 special token 的匹配(tokenizers/src/tokenizer/added_vocabulary.rs:451-453),于是[CLS]会被 normalizer 小写化、被 BPE 切碎。这个开关在Tokenizer::encode_special_tokens(false),用于「我想把模型输出文本再喂回去」的场景。stride < max_length是硬断言。 截断时Encoding::truncate直接assert!(tokenizers/src/tokenizer/encoding.rs:319),配置写错就 panic,不是返回 Err。- pair 编码的 word_idx 各自从 0 重新计数,靠
type_id/sequence_id区分;合并发生在PostProcessor::process默认实现里(tokenizers/src/tokenizer/mod.rs:122-149)。 - 预分词输入(is_pretokenized)跳过的是「按词再切」而不是管线本身:
encode_single_sequence对每个子串仍走 完整 ①~⑤,只是word_idx用外部给定的下标(tokenizers/src/tokenizer/mod.rs:775-782)。 - 管线四段的顺序不可换。 尤其「归一化在预分词前」是 alignments 设计的前提;自定义 Normalizer 必须维护对齐表,否则 offset 静默错乱(约定见
tokenizers/src/tokenizer/pre_tokenizer.rs:63-72)。
7. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 五个 trait | tokenizers/src/tokenizer/mod.rs | Normalizer、PreTokenizer、Model、PostProcessor、Decoder、Trainer |
| 管线持有者 | tokenizers/src/tokenizer/mod.rs | TokenizerImpl |
| 编码主线 | tokenizers/src/tokenizer/mod.rs | encode、encode_single_sequence、do_pre_tokenize、do_tokenize、post_process |
| 中段货币 | tokenizers/src/tokenizer/pre_tokenizer.rs | PreTokenizedString、Split、split、tokenize、into_encoding |
| 特殊 token 提取 | tokenizers/src/tokenizer/added_vocabulary.rs | AddedVocabulary::extract_and_normalize、find_matches、split_with_indices、AddedToken |
| 后处理模板 | tokenizers/src/processors/template.rs | TemplateProcessing、process_encodings、added_tokens |
| BERT 式后处理 | tokenizers/src/processors/bert.rs | BertProcessing |
| 截断/填充工具 | tokenizers/src/utils/truncation.rs、tokenizers/src/utils/padding.rs | truncate_encodings、pad_encodings |
| 具体归一化/预分词实现 | tokenizers/src/normalizers/、tokenizers/src/pre_tokenizers/ | BertNormalizer、Whitespace、ByteLevel、Metaspace 等 |