从零构建分词器 本节摘要:第 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++。
阅读完本节,你应当能够:
你在第 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 | 模型看的是整数,不是字符串 |
第 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 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 包装层。
基础:把任意字符串转成字节序列,反向亦然。
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 字节。字节级分词器不在乎什么语言——字节就是字节。
用 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 永不跨这些边界合并。
第 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
特殊 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): # 返回 [(片段, 是否特殊)] 列表 ...
把一切串起来:归一化、按特殊 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
真正考验:扔给它英文、中文、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 代码是独立工具,接口稳定,可投入生产。
(Easy) 加一个 get_token_bytes(id) 方法,展示任意 token ID 的原始字节,用它检视你最常见的合并 token 到底代表什么。
(Medium) 实现 Llama 风格的预分词(按空白和数字切分但保留前导空格),在同一语料上对比它与 GPT-2 正则的词表差异。
(Hard) 加一个聊天模板方法,接收 [{"role":...,"content":...}] 列表,产出 Llama 3 聊天格式的正确 token 序列,并与 HuggingFace 实现对照测试。
下一节,我们离开分词,进入预训练数据流水线:如何清洗、去重、打包 TB 级文本,使其快到不拖慢 GPU 训练。