从零构建分词器


文档摘要

从零构建分词器 本节摘要:第 01 节给了你一个玩具,这一节给你一把武器。玩具 BPE 只会切英文,扔给它日文、emoji、Tab 与空格混用的 Python 代码,它就崩了。生产级分词器是一条五段流水线:归一化(NFKC)、预分词(正则切词边界)、BPE 合并、特殊 token 注入、ID 映射。本节带你把这套机制从零搭起来——字节级回退让任何输入(含 emoji、CJK、代码)都不产生未知 token;GPT-2 风格正则把前导空格黏在词上、收缩符号单独切出;聊天模板把多轮消息压成一条平坦 token 序列。你会理解为什么「模板写错,模型吐垃圾」,以及为什么生产分词器必须用 Rust/C++。

从零构建分词器

本节摘要:第 01 节给了你一个玩具,这一节给你一把武器。玩具 BPE 只会切英文,扔给它日文、emoji、Tab 与空格混用的 Python 代码,它就崩了。生产级分词器是一条五段流水线:归一化(NFKC)、预分词(正则切词边界)、BPE 合并、特殊 token 注入、ID 映射。本节带你把这套机制从零搭起来——字节级回退让任何输入(含 emoji、CJK、代码)都不产生未知 token;GPT-2 风格正则把前导空格黏在词上、收缩符号单独切出;聊天模板把多轮消息压成一条平坦 token 序列。你会理解为什么「模板写错,模型吐垃圾」,以及为什么生产分词器必须用 Rust/C++。

学习目标

阅读完本节,你应当能够:

  1. 构建生产级 BPE 分词器,处理 Unicode 归一化、空白规整与特殊 token。
  2. 实现字节级回退,使分词器能编码任何输入(emoji、CJK、代码)而不出未知 token。
  3. 添加预分词正则,在套用 BPE 前先按词边界切分文本。
  4. 在语料上训练自定义分词器,并在多语言文本上与 tiktoken 对比压缩比。

一、问题与直觉

你在第 01 节写的 BPE 能切英文。现在扔给它日文。或者 emoji。或者 Tab 与空格混用的 Python 代码。

它崩了。

不是因为 BPE 错——而是因为实现不完整。生产分词器要能处理任意编码的原始字节、在切分前归一化 Unicode、管理永不被合并的特殊 token、把预分词与子词切分串联起来,而且要快到不拖累处理 15 万亿 token 的训练流水线。

GPT-2 的分词器有 50,257 个 token,Llama 3 有 128,256 个,GPT-4 约有 10 万。这些都不是玩具数字。背后那张合并表是在数百 GB 文本上训练出来的,而它周围的机制——归一化、预分词、特殊 token 注入、聊天模板格式化——才是「能处理 hello world」与「能处理整个互联网」的分水岭。你要搭的就是这套机制。

完整流水线

生产分词器不是单一算法,而是五段流水线,每段解决不同问题。

阶段 做什么 为何重要
归一化 NFKC Unicode,可选小写、去重音 连字「fi」(U+FB01)变成「fi」两个字符;不做这步,同一个词会得到不同 token
预分词 在 BPE 前按规则切成块 阻止 BPE 跨词边界合并;「the cat」不该产出「e c」这种 token
BPE 合并 对字节序列套用学到的合并规则 核心压缩,把原始字节变成子词 token
特殊 token 注入 [BOS]、[EOS]、[PAD]、聊天模板标记 固定 ID,永不参与 BPE;模型靠它们识别结构
ID 映射 token 字符串转整数 ID 模型看的是整数,不是字符串

字节级 BPE

第 01 节的分词器在 UTF-8 字节上操作,方向对了。但漏了关键点:当这些字节不是合法 UTF-8 时怎么办?字节级 BPE 把每个可能字节值(0~255)都当作合法 token,基础词表恰好 256,任何文件——文本、二进制、损坏的——都能分词而无未知 token。GPT-2 还玩了个花样:把每个字节映射到一个可打印 Unicode 字符,让词表保持人类可读(纯装饰,算法不在乎)。

字节级 BPE 的真正威力:它能处理地球上任何语言。中文字符每个占 3 个 UTF-8 字节,日文 3~4 字节,阿拉伯文、天城体、emoji——都是字节序列。BPE 在这些字节序列里找模式,和在英文 ASCII 字节里找模式一模一样。

预分词

在 BPE 触碰文本前,得先把它切成块,以防合并算法造出跨词边界的 token。GPT-2 用一个正则模式来切:

'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+

它处理缩写(「don't」变成「don」+「't」)、带可选前导空格的词、数字、标点和空白。前导空格黏在词上——所以「the cat」变成 [" the", " cat"] 而非 ["the"," ","cat"]。Llama 用的 SentencePiece 完全跳过正则,把原始字节流当作一条长序列,让 BPE 自己找边界——更简单,但给了 BPE 更多造跨词 token 的自由。选择有影响:GPT-2 的正则阻止分词器学到「上一个词末尾的 the」与「下个词开头的 the」该合并;SentencePiece 允许,有时压缩更高效但 token 更难解读。

特殊 token

每个生产分词器都为结构标记保留 token ID:[BOS]/<s>(序列开始,Llama 3、GPT 用)、[EOS]/</s>(序列结束,所有模型)、[PAD](批对齐填充,BERT、T5)、<|im_start|>/<|im_end|>(聊天消息边界,ChatGPT、Qwen)、<|user|>/<|assistant|>(轮次标记,Llama 3)。特殊 token 永不被 BPE 切分——它们在合并算法运行前被精确匹配,替换为固定 ID,周围文本正常分词。

聊天模板(最容易出错处)

你往聊天模型发消息,API 接收的是消息列表 [{"role":"system",...},{"role":"user",...},{"role":"assistant",...}]。模型看到的不是 JSON,而是一条平坦的 token 序列。聊天模板用特殊 token 把消息转成那条平坦序列,每个模型做法都不同:

Llama 3: <|begin_of_text|><|start_header_id|>system<|end_header_id|> 你是有帮助的助手。<|eot_id|><|start_header_id|>user<|end_header_id|> 你好<|eot_id|><|start_header_id|>assistant<|end_header_id|> 你好!<|eot_id|>

⚠️ 设计警示:模板写错,模型吐垃圾。它是在一种精确格式上训练的,任何偏差——少个换行、换个 token、多个空格——都把输入推到训练分布之外。

速度

Python 对生产分词太慢。tiktoken(OpenAI)用 Rust 写带 Python 绑定,HuggingFace tokenizers 也是 Rust,SentencePiece 是 C++——比纯 Python 快 10~100 倍。给个量级:为 Llama 3 预训练分词 15 万亿 token,用快的 Python(每秒 100 万)要 174 天,用 Rust(每秒 1 亿)只要 1.7 天。我们用 Python 是为理解算法,生产中用编译实现,只碰 Python 包装层。

二、从零实现

Step 1:字节级编码

基础:把任意字符串转成字节序列,反向亦然。

def bytes_to_tokens(text): return list(text.encode("utf-8")) def tokens_to_text(token_bytes): return bytes(token_bytes).decode("utf-8", errors="replace")

「hello」是 5 字节,「你好」是 6 字节(每字符 3),火焰 emoji 是 4 字节。字节级分词器不在乎什么语言——字节就是字节。

Step 2:正则预分词

用 GPT-2 正则把文本切成块,每块独立由 BPE 分词。

import regex # 支持 \p{L} 等 Unicode 属性转义;标准库 re 不支持 GPT2_PATTERN = regex.compile( r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""" ) def pre_tokenize(text): return [m.group() for m in GPT2_PATTERN.finditer(text)]

试一下:pre_tokenize("Hello, world! Don't stop.") 得到 [' Hello', ',', ' world', '!', " Don", "'t", ' stop', '.']。前导空格黏在词上,缩写在撇号处切开,标点自成一块。BPE 永不跨这些边界合并。

Step 3:字节序列上的 BPE

第 01 节的核心算法,但现在作用在预分词后的每个块上,各自独立。

from collections import Counter def get_byte_pairs(chunks): pairs = Counter() for chunk in chunks: b = list(chunk.encode("utf-8")) for i in range(len(b) - 1): pairs[(b[i], b[i+1])] += 1 return pairs def apply_merge(byte_seq, pair, new_id): merged, i = [], 0 while i < len(byte_seq): if i < len(byte_seq)-1 and (byte_seq[i],byte_seq[i+1]) == pair: merged.append(new_id); i += 2 else: merged.append(byte_seq[i]); i += 1 return merged

Step 4:特殊 token 处理

特殊 token 需要精确匹配与固定 ID,完全绕过 BPE。

class SpecialTokenHandler: def __init__(self): self.special_tokens = {} self.pattern = None def add_token(self, token_str, token_id): self.special_tokens[token_str] = token_id # 按长度降序转义,避免短 token 是长 token 的前缀 escaped = [re.escape(t) for t in sorted(self.special_tokens, key=len, reverse=True)] self.pattern = re.compile("|".join(escaped)) def split_with_specials(self, text): # 返回 [(片段, 是否特殊)] 列表 ...

Step 5:完整分词器类

把一切串起来:归一化、按特殊 token 切分、预分词、BPE 合并、映射到 ID。

import unicodedata class ProductionTokenizer: def __init__(self): self.merges = {} self.vocab = {i: bytes([i]) for i in range(256)} self.special_handler = SpecialTokenHandler() self.next_id = 256 def normalize(self, text): return unicodedata.normalize("NFKC", text) def train(self, text, num_merges): text = self.normalize(text) chunks = pre_tokenize(text) chunk_bytes = [list(c.encode("utf-8")) for c in chunks] for i in range(num_merges): pairs = Counter() for seq in chunk_bytes: for j in range(len(seq)-1): pairs[(seq[j], seq[j+1])] += 1 if not pairs: break best = max(pairs, key=pairs.get) new_id = self.next_id; self.next_id += 1 self.merges[best] = new_id self.vocab[new_id] = self.vocab[best[0]] + self.vocab[best[1]] chunk_bytes = [apply_merge(s, best, new_id) for s in chunk_bytes] def encode(self, text): text = self.normalize(text) parts = self.special_handler.split_with_specials(text) ids = [] for part_text, is_special in parts: if is_special: ids.append(self.special_handler.special_tokens[part_text]) else: for chunk in pre_tokenize(part_text): b = list(chunk.encode("utf-8")) for pair, new_id in self.merges.items(): b = apply_merge(b, pair, new_id) ids.extend(b) return ids

Step 6:多语言测试

真正考验:扔给它英文、中文、emoji、代码。

tok = ProductionTokenizer(); tok.train(corpus, num_merges=50) tok.add_special_token("<|begin|>"); tok.add_special_token("<|end|>") for text in ["The quick brown fox.", "你好世界", "Hello 🌍 World", "def foo(x): return x + 1", "<|begin|>你好<|end|>"]: ids = tok.encode(text) print(f"{text} -> {len(ids)} ids -> 往返:{tok.decode(ids)==text}")

中文字符每字 3 字节,emoji 4 字节,都不崩,都不出未知 token——这就是字节级 BPE 的力量。

三、框架对比

对比真实分词器

加载 Llama 3、GPT-4、Mistral 的真实分词器,看各自如何处理同一段多语言文本:

import tiktoken gpt4 = tiktoken.get_encoding("cl100k_base") para = "机器学习很强大。 L'apprentissage automatique est puissant. 🤖💪" print(len(gpt4.encode(para))) # GPT-4 token 数 from transformers import AutoTokenizer llama = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B") print(len(llama.encode(para))) # Llama 3 token 数

你会看到同一段文字 token 数不同:Llama 3(128K 词表)合并常见模式更激进,GPT-4(100K)居中,Mistral(32K)产更多 token 但嵌入层更小。权衡永远一样:词表大则序列短但参数多。

四、可复用产物

本节产出 outputs/prompt-tokenizer-builder.md——一个用于构建与调试生产分词器的提示。Python 代码是独立工具,接口稳定,可投入生产。

五、练习

  1. (Easy) 加一个 get_token_bytes(id) 方法,展示任意 token ID 的原始字节,用它检视你最常见的合并 token 到底代表什么。

  2. (Medium) 实现 Llama 风格的预分词(按空白和数字切分但保留前导空格),在同一语料上对比它与 GPT-2 正则的词表差异。

  3. (Hard) 加一个聊天模板方法,接收 [{"role":...,"content":...}] 列表,产出 Llama 3 聊天格式的正确 token 序列,并与 HuggingFace 实现对照测试。

本节要点回顾

  1. 生产分词器是五段流水线:归一化 → 预分词 → BPE 合并 → 特殊 token → ID 映射,每段解决不同问题。
  2. 字节级 BPE 以 256 基础字节覆盖一切:任何输入(emoji、CJK、代码、二进制)都不产生未知 token。
  3. 预分词防止跨词合并:GPT-2 正则把前导空格黏在词上,处理缩写与标点;SentencePiece 跳过正则,让 BPE 自找边界。
  4. NFKC 归一化消除等价差异:连字「fi」变「fi」,全角「A」变「A」,否则同词得不同 token。
  5. 聊天模板必须精确:模型只认训练时的那一种格式,少个换行就出训练分布之外;每家模型格式不同。
  6. 特殊 token 绕过 BPE:固定 ID,在合并前精确匹配,模型靠它们识别序列结构。
  7. 生育率(Fertility)衡量效率:GPT-4 英文约 1.3 token/词,韩文 2~3,越高越浪费上下文。
  8. 生产必须用编译实现:tiktoken/HF tokenizers(Rust)、SentencePiece(C++),比纯 Python 快 10~100 倍;算法相同,语言是差距。
  9. 合并表即所学知识:有序的字节对合并列表,顺序不可乱——编码必须按学习顺序应用。
  10. 词表大序列短但参数多:128K 词表让非英文压缩更公平,但嵌入矩阵更大,是真实工程权衡。

下一节,我们离开分词,进入预训练数据流水线:如何清洗、去重、打包 TB 级文本,使其快到不拖慢 GPU 训练。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U