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]llvere)`英文缩写整体成块'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
}

三个必须守住的点:

  1. 边界必须落在 UTF-8 字符起点((b & 0xC0) != 0x80 判断),否则 from_utf8 直接 panic。
  2. 边界优先落在空白或标点,保证不会把 deep 切成 de + ep。
  3. 编码结果必须与串行版本逐 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 换中文词表"的方案,本质上都必须配合继续预训练——只换词表不训练,等于把模型的输入表示整体打乱。

七、结论

  1. Tokenizer 是成本函数,不是预处理脚本。压缩率的 20% 差异,在亿级请求规模上就是真金白银。
  2. 正则优先于算法。BPE/Unigram/WordPiece 之间的差距,远小于预分词策略与词表规模带来的差距。
  3. 用业务语料评估,用 CI 守一致性。 fertility 必须在你的真实 JSON / 代码 / 日志分布上测;并行实现的逐 token 等价性必须有自动化测试兜底。
  4. 数字与空格是隐藏的能力开关。这两处的切分方式会向上影响到算术、代码与工具调用,值得在选型时专门验证。
  5. 词表不可热替换。选型阶段多花一周做评估,远好于上线后发现上下文装不下。

如果你正在做垂直领域模型(法律、医疗、代码、金融),值得认真考虑用领域语料训练一个专用词表:同等信息量下序列长度减少 20%~40%,对长上下文场景是纯粹的收益。前提是——把评估做在前面,把一致性测试做进 CI。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部