跳到主要内容

数据截至 (上游 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词 → 子词 tokentokenize(&self, &str) -> Vec<Token>
PostProcessor插特殊 token、合并 pairprocess(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 只做三件事

encodetokenizers/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_sequencetokenizers/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_tokenizetokenizers/src/tokenizer/mod.rs:1247-1260)和 do_tokenizetokenizers/src/tokenizer/mod.rs:1178-1200)都只是「有部件就调、没有就跳过」的薄封装;do_tokenize 里多做一件事:把截断参数(max_length, direction)传给模型层,让 BPE 在超限时能提前退出。

5.3 PreTokenizedString:管线中段的通用货币

四段之间传的不是 String,而是 PreTokenizedStringtokenizers/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_normalizetokenizers/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=falsenormalized=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_processtokenizers/src/tokenizer/mod.rs:1265-1325)内部严格按三步走:

  1. 先截断,且截断长度要预先扣掉后处理会插入的特殊 token 数(trunc.max_length - n_added_tokens,同文件 1282-1287 行)——否则 [CLS]/[SEP] 会把总长度顶超;
  2. 调具体 processor(没有则用 default_process 纯合并);
  3. 最后填充

最常用的 processor 是 TemplateProcessingtokenizers/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:管线的逆运算

decodetokenizers/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. 代码地图

主题文件路径符号名
五个 traittokenizers/src/tokenizer/mod.rsNormalizerPreTokenizerModelPostProcessorDecoderTrainer
管线持有者tokenizers/src/tokenizer/mod.rsTokenizerImpl
编码主线tokenizers/src/tokenizer/mod.rsencodeencode_single_sequencedo_pre_tokenizedo_tokenizepost_process
中段货币tokenizers/src/tokenizer/pre_tokenizer.rsPreTokenizedStringSplitsplittokenizeinto_encoding
特殊 token 提取tokenizers/src/tokenizer/added_vocabulary.rsAddedVocabulary::extract_and_normalizefind_matchessplit_with_indicesAddedToken
后处理模板tokenizers/src/processors/template.rsTemplateProcessingprocess_encodingsadded_tokens
BERT 式后处理tokenizers/src/processors/bert.rsBertProcessing
截断/填充工具tokenizers/src/utils/truncation.rstokenizers/src/utils/padding.rstruncate_encodingspad_encodings
具体归一化/预分词实现tokenizers/src/normalizers/tokenizers/src/pre_tokenizers/BertNormalizerWhitespaceByteLevelMetaspace