LLM Tokenizer 深度工程实战:从 BPE 合并算法、预分词正则到压缩率与多语言词表设计的生产级全解
执行摘要:Tokenizer 是大模型系统里最被低估的一环。它不产生任何智能,却直接决定三件事:你的上下文窗口真实能装多少内容、每百万 token 的推理成本、以及模型在多语言和数字推理上的天花板。工程上真正难的不是 BPE 训练本身,而是预分词正则的选择、字节级回退的边界处理、并行化时的块边界一致性,以及词表规模与压缩率之间那条陡峭的收益曲线。本文拆开这套系统:给出可运行的 BPE 内核实现、tiktoken 风格的并行预分词骨架、压缩率/生育率的量化评估方法,以及一份生产环境踩过的坑位清单。
一、先建立直觉:Tokenizer 是系统的成本杠杆
一个常见误解是"分词只是预处理,训练完就固定了"。实际上分词器在整条链路上有乘数效应:
| 维度 | 影响 | 量级 |
|---|---|---|
| 压缩率(chars/token) | 同样 128K 上下文能装多少文本 | 英文 3.8~4.5,中文 1.2~1.8,差 2~3 倍 |
| 序列长度 | Prefill 算力 ∝ N²,Decode KV Cache ∝ N | 长度翻倍,显存翻倍 |
| 词表大小 | Embedding/Logits 参数与显存 | V=128K × d=8192 × 4B ≈ 4GB(fp32 主权重) |
| 数字切分方式 | 算术与代码能力 | 一位一切 vs 三位一组,GSM8K 可差 10+ 点 |
| 边界稳定性 | 工具调用 JSON / 代码补全 | 空格归属错一位,输出直接不可解析 |
一个具体的账:同样一本 30 万字的技术书,GPT-4 系词表约需 75K token,而中文 fertility 差的词表可能要 200K+ token。在长文档 RAG 场景里,这直接决定了你切不切得进上下文、以及每次检索要花多少钱。
结论先行:选择或训练分词器时,真正该盯的指标不是"词表多大",而是目标语料上的 chars/token、单位语义的 token 数(fertility),以及边界可预测性。
二、BPE 内核:从朴素实现到可用实现
BPE 的训练过程极简:从字符级(或字节级)词表出发,反复统计相邻符号对频次,把最高频的一对合并成新符号,直到达到目标词表大小。朴素实现是 O(V × N),几 MB 语料就跑不动了。
import regex as re # 注意:\p{L} / \p{N} 是 Unicode 属性类,标准库 re 不支持,用 regex
from collections import defaultdict, Counter
from typing import List, Tuple
# GPT-2 / GPT-4 风格预分词正则
PAT = re.compile(r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}{1,3}| ?[^\s\p{L}\p{N}]+|\s+""")
def pretokenize(text: str) -> List[str]:
"""GPT-2 风格预分词:先切块,BPE 只在块内合并,绝不跨块"""
return PAT.findall(text)
def train_bpe(corpus: List[str], vocab_size: int, special: List[str]):
# 1) 词表初始化为 UTF-8 字节集合(字节级 BPE,天然无 UNK)
vocab = {bytes([i]): i for i in range(256)}
for i, s in enumerate(special):
vocab[s.encode()] = 256 + i
# 2) 预分词 -> 按块统计频次,后续合并只在这个词频表上做
word_freq = Counter()
for text in corpus:
for chunk in pretokenize(text):
word_freq[tuple(chunk.encode('utf-8'))] += 1
# 3) 每块表示为字节序列,维护 pair -> count 的增量计数
words = {w: list(w) for w in word_freq}
pairs = defaultdict(int)
for w, freq in word_freq.items():
for a, b in zip(w, w[1:]):
pairs[(a, b)] += freq
merges: List[Tuple[bytes, bytes]] = []
while len(vocab) < vocab_size:
if not pairs:
break
best = max(pairs, key=pairs.get)
if pairs[best] < 2: # 频次 <2 的合并没有泛化价值,早停
break
a, b = best
new_id = len(vocab)
vocab[a + b] = new_id
merges.append((a, b))
# 增量更新:只重算受影响块的 pair 计数
new_pairs = defaultdict(int)
for w, freq in word_freq.items():
seq = words[w]
if not any(seq[i] == a and seq[i+1] == b for i in range(len(seq)-1)):
continue
for x, y in zip(seq, seq[1:]):
pairs[(x, y)] -= freq
i, out = 0, []
while i < len(seq):
if i < len(seq) - 1 and seq[i] == a and seq[i+1] == b:
out.append(a + b); i += 2
else:
out.append(seq[i]); i += 1
words[w] = out
for x, y in zip(out, out[1:]):
new_pairs[(x, y)] += freq
for k, v in new_pairs.items():
pairs[k] += v
del pairs[best]
return vocab, merges
这段代码里有两个工程要点值得单独说:
pairs[best] < 2早停。很多实现会硬跑满 vocab_size,结果尾部加入大量频次为 1 的合并,这些 token 在训练语料里几乎不出现,embedding 训不好,还白白占词表预算。- 增量更新而非全量重扫。全量重扫的复杂度是 O(V·N),用"受影响的词频块"做局部更新后,实际耗时下降一个数量级。真正的生产实现(tiktoken、HF tokenizers)会更进一步用双向链表 + 堆来维护 pair 优先级。
编码阶段则是按 merge 的优先级顺序贪心合并,而不是按频次:
def encode(text: str, vocab, merges) -> List[int]:
rank = {pair: i for i, pair in enumerate(merges)} # 优先级 = 训练顺序
ids = []
for chunk in pretokenize(text):
seq = list(chunk.encode('utf-8'))
while len(seq) > 1:
best, best_rank = None, float('inf')
for i in range(len(seq) - 1):
r = rank.get((seq[i], seq[i+1]))
if r is not None and r < best_rank:
best, best_rank = i, r
if best is None:
break
seq[best:best+2] = [seq[best] + seq[best+1]]
ids.extend(vocab[s] for s in seq) # 每个元素已是 bytes,直接查词表
return ids
注意这里是 O(n²) 的内层扫描。生产实现会把它换成一次线性扫描 + 优先队列,或直接查一张 (left_id, right_id) -> merged_id 的哈希表——这才是 tiktoken 能跑到 GB/s 级别的原因。
三、预分词正则:真正决定边界质量的地方
BPE 只在预分词块内部合并,所以正则怎么写,比 BPE 算法本身更影响最终行为。GPT-2/4 的那条经典正则拆开看:
| 片段 | 作用 | 不这么做的后果 | |||
|---|---|---|---|---|---|
| `'(?:[sdmt] | ll | ve | re)` | 英文缩写整体成块 | 's 被切成 ' + s,浪费一个 token |
?\p{L}+ | 字母连写,前导空格并入 | "hello" 与 " hello" 变成两个不相关 token,模型要重复学语义 | |||
?\p{N}{1,3} | 数字最多三位一组 | 见下 | |||
?[^\s\p{L}\p{N}]+ | 连续标点成块 | ...、!!! 各自成 token | |||
\s+ | 连续空白合并(除单行最后一处) | 缩进/换行被打散成一堆空格 token |
数字切 1~3 位是整条正则里最巧妙的设计。它让 4 位以上的数必然被切开,避免 2024、2025 各自霸占一个 id 却毫无泛化能力;同时三位一组恰好匹配人类读数习惯(1,234,567)。副作用是模型做多位数乘法时必须跨 token 合成,这正是"让模型显式写出中间步骤"能提升算术准确率的分词层原因。后来的 Llama 3 与部分新词表改成单位数字切分,换取更强的算术与代码能力,代价是数字序列长度膨胀约 3 倍——这是一个明确的能力 vs 成本权衡,不是谁更先进的问题。
给中文场景的一条实践建议:不要用纯 \p{L}+ 去切中文。中文没有空格,"深度学习模型"会连成一整块,BPE 只能在这一个超长块内部学习合并,泛化极差。实践中通常先做一次轻量语言学切分(或直接用字级粒度起步),让 BPE 有足够多的"块"可统计。
四、压缩率与生育率:该怎么量化评估
不要凭感觉判断词表好坏,至少跑这三个指标:
def eval_tokenizer(enc_fn, samples: dict):
report = {}
for name, texts in samples.items():
n_char = sum(len(t) for t in texts)
n_tok = sum(len(enc_fn(t)) for t in texts)
n_word = sum(len(t.split()) for t in texts) # 中文需换成分词后的词数
report[name] = {
'chars_per_token': round(n_char / n_tok, 3),
'fertility': round(n_tok / n_word, 3), # 每词多少 token
'oov_rate': 0.0, # 字节级 BPE 恒为 0
}
return report
经验参考区间(英文):chars_per_token 低于 3.5 偏浪费,4.0~4.5 是合理区间,高于 5 通常是词表偏大或语料与词表不匹配。中文场景 1.2~1.8 chars/token 属于正常,若低于 1.0,说明你的中文基本是逐字编码,上下文成本会非常高。
评估时的一个致命细节:必须用真实业务语料而不是通用语料。日志、JSON、代码、SQL、markdown 表格这些结构的压缩率与散文差 30%~50%。如果你的 Agent 大量吐 JSON,一定要单独测 JSON 样本的 fertility——这直接决定工具调用要花多少 token。
五、生产实现:并行化与块边界
tiktoken 之所以快,核心在两件事:合并规则用哈希表查(O(1)),预分词用 Rayon 并行。并行预分词的难处在于块边界:按字节偏移把大文本切成 N 段后,一个词可能被切在两段之间。
// tiktoken 风格:按字节并行切块,回退到"安全边界"再切
fn parallel_pretokenize(text: &str, n_chunks: usize) -> Vec<Vec<Range<usize>>> {
let bytes = text.as_bytes();
let chunk_size = (bytes.len() / n_chunks).max(1);
(0..n_chunks).map(|i| {
let start = i * chunk_size;
let mut end = ((i + 1) * chunk_size).min(bytes.len());
// 关键:向后扫描到最近的 UTF-8 字符边界与空白/标点处
while end < bytes.len() && !is_boundary(bytes[end]) {
end += 1;
}
let s = std::str::from_utf8(&bytes[start..end]).unwrap();
pretokenize(s).into_iter().map(to_range).collect()
}).collect()
}
fn is_boundary(b: u8) -> bool {
b.is_ascii_whitespace() || b.is_ascii_punctuation() || (b & 0xC0) != 0x80
}
三个必须守住的点:
- 边界必须落在 UTF-8 字符起点(
(b & 0xC0) != 0x80判断),否则from_utf8直接 panic。 - 边界优先落在空白或标点,保证不会把
deep切成de+ep。 - 编码结果必须与串行版本逐 token 相等。这是唯一不能妥协的验收标准——推理时分词不一致会静默产出垃圾,而且极难排查。建议把"并行 vs 串行 diff"做成 CI 里的常驻测试。
六、生产坑位清单
| 现象 | 根因 | 解法 |
|---|---|---|
| 输出 JSON 偶尔不可解析 | 空格归属不稳定,或 stop token 恰好是 " 的一部分 | 用约束解码(grammar / JSON schema);不要把特殊符号设为 stop |
| 中文上下文成本远高于预估 | 词表中文覆盖不足,逐字编码 | 换词表或做词表扩充后必须重训/继续预训练 embedding |
| 大数运算经常性出错 | 数字被切成不规则片段 | CoT 显式分步;或改用单位数字切分的词表 |
| 出现"复读乱码" | 触发 glitch token(训练频次极低的孤立 token) | 输入侧过滤;对 embedding 做范数检查 |
| 换词表后模型效果崩塌 | tokenizer 与 embedding 强绑定,不可热替换 | 换词表 = 重训,或做 embedding 映射初始化后长程退火 |
| 多轮对话 token 悄悄变多 | 聊天模板每轮重复注入 system 与分隔符 | 做前缀缓存(prefix caching),并审计模板开销 |
| 推理成本对不齐账单 | 只算了输出 token,忽略了 prompt 与工具返回 | 按 请求级 全量计量,分 prefill/decode 两段核算 |
其中最贵的一条是"换词表"。Tokenizer 决定了 token id 空间,也决定了 embedding 矩阵每一行的语义。词表一旦更换,旧 embedding 全部失效。社区里那些"给 Llama 换中文词表"的方案,本质上都必须配合继续预训练——只换词表不训练,等于把模型的输入表示整体打乱。
七、结论
- Tokenizer 是成本函数,不是预处理脚本。压缩率的 20% 差异,在亿级请求规模上就是真金白银。
- 正则优先于算法。BPE/Unigram/WordPiece 之间的差距,远小于预分词策略与词表规模带来的差距。
- 用业务语料评估,用 CI 守一致性。 fertility 必须在你的真实 JSON / 代码 / 日志分布上测;并行实现的逐 token 等价性必须有自动化测试兜底。
- 数字与空格是隐藏的能力开关。这两处的切分方式会向上影响到算术、代码与工具调用,值得在选型时专门验证。
- 词表不可热替换。选型阶段多花一周做评估,远好于上线后发现上下文装不下。
如果你正在做垂直领域模型(法律、医疗、代码、金融),值得认真考虑用领域语料训练一个专用词表:同等信息量下序列长度减少 20%~40%,对长上下文场景是纯粹的收益。前提是——把评估做在前面,把一致性测试做进 CI。

发表评论 取消回复