1. 为什么需要分词

1.1 分词的核心矛盾

自然语言是连续字符序列,模型需要离散输入单元。分词面临三重矛盾:

策略词表大小OOV 问题序列长度语义完整性
字符级~100极长
词级100K+严重
子词级32K-128K较好

子词分词是最佳折中:高频词保持完整,低频词拆为有意义的子词单元。

1.2 分词影响一切

分词不仅是预处理步骤,它直接决定:

  • 模型能力边界:词表决定模型能处理的语言和字符
  • 推理成本:相同文本,不同 Tokenizer 产生的 token 数不同
  • 多语言公平性:中文分词效率直接影响中文任务成本

2. BPE(Byte Pair Encoding)

2.1 算法原理

BPE 最初是数据压缩算法,被 Sennrich 等人(2016)引入 NLP。核心思想:从字符级开始,迭代合并最高频的相邻字符对。

训练算法

1. 将所有词拆为字符序列,词尾加 </w>
   "low" → ['l', 'o', 'w', '</w>']
2. 统计相邻字符对频率
3. 合并最高频字符对为新 token
4. 重复步骤 2-3 直到达到目标词表大小

2.2 代码实现

from collections import Counter, defaultdict

def train_bpe(corpus, vocab_size):
    """BPE 训练算法"""
    # 初始化:每个词拆为字符
    word_freqs = Counter(corpus.split())
    splits = {word: list(word) + ['</w>'] for word in word_freqs}
    
    merges = []
    vocab = set(char for word in word_freqs for char in list(word) + ['</w>'])
    
    while len(vocab) < vocab_size:
        # 统计相邻对频率
        pair_freqs = defaultdict(int)
        for word, freq in word_freqs.items():
            split = splits[word]
            for i in range(len(split) - 1):
                pair_freqs[(split[i], split[i+1])] += freq
        
        if not pair_freqs:
            break
        
        # 合并最高频对
        best_pair = max(pair_freqs, key=pair_freqs.get)
        merges.append(best_pair)
        new_token = best_pair[0] + best_pair[1]
        vocab.add(new_token)
        
        # 更新所有词的分词
        for word in word_freqs:
            split = splits[word]
            i = 0
            while i < len(split) - 1:
                if (split[i], split[i+1]) == best_pair:
                    split = split[:i] + [new_token] + split[i+2:]
                else:
                    i += 1
            splits[word] = split
    
    return vocab, merges

def bpe_encode(text, merges):
    """BPE 编码"""
    tokens = list(text) + ['</w>']
    for pair in merges:
        i = 0
        while i < len(tokens) - 1:
            if (tokens[i], tokens[i+1]) == pair:
                tokens = tokens[:i] + [tokens[i]+tokens[i+1]] + tokens[i+2:]
            else:
                i += 1
    return tokens

2.3 BPE 的特点

  • 确定性:相同训练数据 → 相同词表
  • 贪婪合并:高频对优先,可能不是全局最优
  • 可逆性:token 拼接即可还原原文
  • 增量性:可以从已有词表继续训练

3. WordPiece

3.1 与 BPE 的区别

WordPiece(Google, 2012)用于 BERT,与 BPE 类似但有关键区别:

特征BPEWordPiece
合并准则最高频率最大似然增益
子词标记后缀无特殊标记非首子词加 ## 前缀
词尾标记</w>

WordPiece 选择合并 $(a, b)$ 时,最大化:

$$\frac{P(ab)}{P(a) \cdot P(b)}$$

即合并后新 token 的概率与独立概率乘积之比最大。

3.2 示例

"playing" → ["play", "##ing"]
"unhappiness" → ["un", "##happ", "##iness"]

## 前缀表示该子词不能出现在词首,保留词边界信息。

4. SentencePiece

4.1 设计动机

BPE 和 WordPiece 都需要预分词(先按空格切词),这对中文、日文等无空格语言不友好。SentencePiece 直接在原始字符序列上工作。

4.2 核心特性

特性描述
语言无关不需要预分词,直接处理原始文本
可逆性编码/解码无损
空格处理空格转为特殊符号 (U+2581)
支持 BPE/Unigram两种分词算法可选

4.3 Unigram Language Model

SentencePiece 的 Unigram 模式不同于 BPE:

  1. 初始化大词表(如所有子串的频繁子集)
  2. 用 EM 算法估计每个 token 的概率
  3. 基于似然贡献迭代删除低频 token
  4. 最终词表通过前缀树 (Trie) + Viterbi 解码找最优分词

$$P(x) = \prod_{t \in \text{tokens}(x)} P(t)$$

选择使 $P(x)$ 最大的分词方案。

4.4 工程使用

import sentencepiece as spm

# 训练
spm.SentencePieceTrainer.train(
    input='corpus.txt',
    model_prefix='tokenizer',
    vocab_size=32000,
    model_type='bpe',  # 或 'unigram'
    character_coverage=0.9995,
    normalization_rule_name='nmt_nfkc',
)

# 加载使用
sp = spm.SentencePieceProcessor(model_file='tokenizer.model')
tokens = sp.encode('你好世界', out_type=str)
ids = sp.encode('你好世界', out_type=int)
print(tokens)  # ['▁你好', '世界']
print(ids)     # [12345, 6789]

5. tiktoken

5.1 OpenAI 的方案

tiktoken 是 OpenAI 开源的高性能 BPE 分词器,用于 GPT-3.5/4。核心特点:

  • 字节级 BPE:在 UTF-8 字节上操作,天然支持所有语言
  • 正则预切分:用正则表达式将文本切为块,块内做 BPE
  • 极致性能:Rust 实现,比同等 Python 快 10-100×

5.2 正则预切分

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode("Hello, 你好世界!")
print(tokens)        # [9906, 11, 220, 57668, 53901, 31663, 8346]
print(len(tokens))   # 7

GPT-4 使用的正则模式:

<\|.*?\|>|'s|'t|'re|'ve|'m|'ll|'d|[\p{L}]+|[\p{N}]+|[^\s\p{L}\p{N}]+

该正则将文本切为:特殊标记、英文缩写、连续字母、连续数字、其他字符块。每块独立做 BPE,避免跨块合并。

6. 多语言分词对比

6.1 压缩效率

同一段文本在不同 Tokenizer 下的 token 数(越少越高效):

文本GPT-4 (cl100k)LLaMA-3Qwen-2中文专用
“The quick brown fox” (EN)4446
“敏捷的棕色狐狸” (ZH)6544
“素早い茶色の狐” (JA)985
“Быстрый коричневый лис” (RU)786

关键发现:英文在所有 Tokenizer 中效率最高,因为训练语料以英文为主。中文在 LLaMA-3 和 Qwen-2 中效率显著优于 GPT-4,因为它们在中文语料上做了专门优化。

6.2 公平性问题

# 同一段话的 token 成本差异
text_zh = "人工智能是计算机科学的一个分支"
text_en = "Artificial intelligence is a branch of computer science"

enc_gpt4 = tiktoken.get_encoding("cl100k_base")
print(f"中文 tokens: {len(enc_gpt4.encode(text_zh))}")  # ~18
print(f"英文 tokens: {len(enc_gpt4.encode(text_en))}")  # ~8

中文 token 数是英文的 2-3 倍,直接导致中文用户 API 调用成本更高、推理速度更慢。

7. Token 成本优化策略

7.1 选择合适的 Tokenizer

场景推荐原因
英文为主GPT-4 / LLaMA英文压缩率最优
中文为主Qwen / GLM中文压缩率最优
多语言混合LLaMA-3多语言均衡
代码+文本DeepSeek-Coder代码压缩率优

7.2 减少 Token 的工程技巧

  1. 去除冗余信息:去掉重复的系统提示、精简指令
  2. 结构化压缩:用 JSON 替代自然语言描述
  3. 缓存前缀:利用 KV Cache 避免重复计算
  4. 批处理:合并多次请求为一次

8. Tokenizer 训练实践

8.1 从头训练

# 使用 HuggingFace tokenizers 库
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel

tokenizer = Tokenizer(BPE(unk_token="<unk>"))
tokenizer.pre_tokenizer = ByteLevel(add_prefix_space=True)

trainer = BpeTrainer(
    vocab_size=50000,
    special_tokens=["<unk>", "<s>", "</s>", "<pad>", "<mask>"],
    initial_alphabet=ByteLevel.alphabet(),
)

tokenizer.train(files=["corpus.txt"], trainer=trainer)
tokenizer.save("custom-tokenizer.json")

8.2 关键参数

参数推荐值说明
vocab_size32K-128K太小→序列长,太大→嵌入层参数多
character_coverage0.9995覆盖的字符比例
min_frequency2最小出现频率,过滤噪声
model_typeunigram / bpeUnigram 通常更灵活

9. 总结

分词是 LLM 管线的第一道关卡,直接影响模型能力、推理效率和用户成本。BPE 是当前主流算法,SentencePiece 解决了无空格语言的问题,tiktoken 将性能推向极致。多语言分词效率差异是真实的公平性问题,选择与目标语言匹配的 Tokenizer 可以显著降低成本。在实践中,理解 Tokenizer 的行为——它如何切分文本、哪些 token 更常见——是优化 LLM 应用的基本功。

加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。