tokenizers 库把分词拆成五个可独立替换的阶段——Normalizer、Pre-tokenizer、Model、Post-processor、Decoder——只有 Model 带训练参数,其余四段是纯确定性变换;理解这条管线,等于同时掌握了自定义训练、跨语言序列化和 offsets 对齐三件事。

五段管线

阶段职责常见实现
NormalizerUnicode 层的字符串改写,可改变长度NFC NFKC Lowercase StripAccents Replace BertNormalizer
Pre-tokenizer切出互不重叠的初始片段,定义 Model 不可跨越的边界ByteLevel Metaspace Whitespace Punctuation Digits Split
Model片段内部的子词切分,唯一需要训练BPE WordPiece Unigram WordLevel
Post-processor拼特殊 token、写 type_ids、修正 offsetsTemplateProcessing ByteLevel BertProcessing
Decodertoken 序列还原为字符串ByteLevel Metaspace WordPiece ByteFallback

前四段全部支持 Sequence([...]) 组合。数据流:

"Hello World"
  → Normalizer      "Hello World"
  → Pre-tokenizer   [("Hello",(0,5)), ("ĠWorld",(5,11))]   # ByteLevel 把空格编成 Ġ
  → Model           ["Hello", "ĠWor", "ld"]
  → Post-processor  ["<s>", "Hello", "ĠWor", "ld", "</s>"]
  → Decoder         "Hello World"

三条不变式决定了大部分调参直觉:

  • Pre-tokenizer 的边界是硬边界。加 Digits(individual_digits=True) 后词表里永远不会出现 2024 这种多位数 token,无论语料里它多高频。
  • Decoder 必须与 Pre-tokenizer 配对。ByteLevel 对 ByteLevel、Metaspace 对 Metaspace,错配的直接症状是解码结果里残留 Ġ 或 ▁。
  • Normalizer 是有损的。decode(encode(x)) == x 只在归一化为恒等或输入已处于目标范式时成立。

从零训练一个 byte-level BPE

完整可运行示例,语料内联,不依赖网络。Model 选 BPE 算法原理 的 merge 机制,Pre-tokenizer 选 BBPE 字节级分词 的字节方案,因此不需要 unk_token。

from tokenizers import Tokenizer, models, normalizers, pre_tokenizers, decoders, processors, trainers
 
corpus = [
    "Tokenizer 把文本切成离散单元,是大模型的唯一入口。",
    "byte-level BPE 用 256 个字节符号兜底,不需要 unk token。",
    "vocab_size 越大,压缩比越好,embedding 层也越重。",
    "offsets 让 token 级预测能映射回原文字符区间。",
    "Normalizer、Pre-tokenizer、Model、Post-processor、Decoder 五段可独立替换。",
]
 
def iter_corpus(repeat=200):
    """train_from_iterator 接受任意可迭代对象;真实场景换成逐行读文件的生成器即可。"""
    for _ in range(repeat):
        yield from corpus
 
tok = Tokenizer(models.BPE())                              # unk_token 留空:字节级方案无 OOV
tok.normalizer = normalizers.NFC()                         # 只做规范化,不 lowercase、不去重音
tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tok.decoder = decoders.ByteLevel()
 
trainer = trainers.BpeTrainer(
    vocab_size=1000,
    min_frequency=2,
    special_tokens=["<|endoftext|>"],
    initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),   # 预置全部 256 字节,保证任意输入可编码
    show_progress=False,
)
tok.train_from_iterator(iter_corpus(), trainer=trainer)
 
# ByteLevel 后处理不加 token,只把 Ġ 造成的 offset 前移一位修回来
tok.post_processor = processors.ByteLevel(trim_offsets=True)
 
text = "byte-level BPE 不需要 unk token。"
enc = tok.encode(text)
print(enc.tokens)
print(enc.ids)
print(enc.offsets[:4])
assert tok.decode(enc.ids) == text                         # 字节级方案下严格可逆
 
tok.save("bbpe.json")

要点:

  • special_tokens 交给 trainer,它们会占据最小的 id(0 起),便于后续 pad_id 硬编码。
  • min_frequency 是 merge 的准入门槛,小语料上设 2,大语料上通常留默认 0 让 vocab_size 单独控制规模。
  • vocab_size 达不到时不报错,只是提前停止合并——词表比目标值小往往意味着语料不足,而非参数写错。
  • add_prefix_space=False 让 "World" 与 " World" 编成不同 token。做 NER 或任何 is_split_into_words=True 的任务要改成 True,否则除首词外每个词都会丢掉本该存在的空格前缀,与预训练分布不一致。

换 Unigram 只需替换 Model、Pre-tokenizer、Decoder 与 trainer 四行,算法差异见 WordPiece 与 Unigram LM:

tok = Tokenizer(models.Unigram())
tok.normalizer = normalizers.Sequence([normalizers.Nmt(), normalizers.NFKC()])
tok.pre_tokenizer = pre_tokenizers.Metaspace()      # 空格 → ▁,与 SentencePiece 同协议
tok.decoder = decoders.Metaspace()
trainer = trainers.UnigramTrainer(
    vocab_size=1000,
    unk_token="<unk>",                               # Unigram 必须有 unk:概率模型无字节兜底
    special_tokens=["<unk>", "<s>", "</s>"],
    shrinking_factor=0.75,                           # 每轮 EM 剪掉 25% 候选
    max_piece_length=16,
)
tok.train_from_iterator(iter_corpus(), trainer=trainer)

Metaspace 的空格前缀开关在不同版本间改过名(早期 add_prefix_space,后来统一为 prepend_scheme),跨版本代码不要硬写该参数;其语义与 SentencePiece 的 --add_dummy_prefix 完全一致。

需要 BERT 式的 [CLS] ... [SEP] 结构时,用 TemplateProcessing 声明式描述,而不是在业务代码里手工拼 id:

tok.post_processor = processors.TemplateProcessing(
    single="<s> $A </s>",
    pair="<s> $A </s> $B:1 </s>:1",                  # :1 指定该段的 type_id
    special_tokens=[("<s>", tok.token_to_id("<s>")), ("</s>", tok.token_to_id("</s>"))],
)

序列化:单个 tokenizer.json

tok.save() 输出的 tokenizer.json 是自包含的:五段组件的类型与参数、完整 vocab 与 merges、added_tokens、以及 truncation / padding 配置全在里面。加载不需要任何原始训练代码:

from tokenizers import Tokenizer
tok = Tokenizer.from_file("bbpe.json")

进一步接入 transformers 生态要包一层:

from transformers import PreTrainedTokenizerFast
 
hf = PreTrainedTokenizerFast(
    tokenizer_object=tok,
    eos_token="<|endoftext|>",
    pad_token="<|endoftext|>",
)
hf.save_pretrained("./my-tokenizer")   # 产出 tokenizer.json + tokenizer_config.json + special_tokens_map.json

tokenizer.json 只描述编解码;AutoTokenizer.from_pretrained 还依赖 tokenizer_config.json 里的 tokenizer_class 与 chat_template,这部分机制见 特殊 Token 与 Chat Template。反向路径是 convert_slow_tokenizer,把 SentencePiece 的 .model protobuf 翻译成等价的五段配置——转换后值得在真实语料上做一次全量 id 比对,历史上归一化环节出现过偏差。

offsets 与对齐

fast tokenizer 相对 slow 实现的核心增量不是速度,而是每个 token 都携带回溯到原始字符串的字符区间。Encoding 对象暴露一组双向映射:

enc = tok.encode("Hugging Face 在巴黎")
 
enc.offsets          # [(0,7), (7,12), ...] 每个 token 的 (start, end) 字符下标
enc.word_ids         # 每个 token 属于哪个 pre-token 片段,特殊 token 为 None
enc.token_to_chars(2)
enc.char_to_token(9) # 第 9 个字符落在哪个 token 里
enc.word_to_tokens(1)

典型用途是把词级标签摊到 subword 上,只对每词首个 token 计损失:

words  = ["Hugging", "Face", "在", "巴黎"]
labels = [1, 2, 0, 3]
 
enc = hf(words, is_split_into_words=True)
prev, aligned = None, []
for wid in enc.word_ids():
    if wid is None:
        aligned.append(-100)            # 特殊 token 不参与损失
    elif wid != prev:
        aligned.append(labels[wid])     # 词首 token 取真实标签
    else:
        aligned.append(-100)            # 词内后续 subword 屏蔽
    prev = wid

两个易踩的坑:

  • trim_offsets 必须开。ByteLevel 把空格并入下一个 token 的 Ġ,未修正时 " Face" 的 offset 会从空格开始,抽取出的 span 前面多一个空格。
  • offsets 精度受 Normalizer 限制。库通过 NormalizedString 维护归一化前后的字符对齐,但 StripAccents、Replace 这类删改字符的规则只能给出近似区间。要求 span 与原文严格 byte 级一致的抽取任务,Normalizer 应退化为 NFC 或恒等。

批量编码走 tok.encode_batch(list_of_texts),Rust 侧释放 GIL 并按 rayon 线程池并发,比 Python 循环快一个量级。在 DataLoader 里 fork 子进程时会出现 The current process just got forked 警告——正确做法是显式设 TOKENIZERS_PARALLELISM=false,把并行交给 worker 进程,而不是忽略警告。整条管线在模型输入侧的位置见 Tokenizer 概述。