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 类似但有关键区别:
| 特征 | BPE | WordPiece |
|---|---|---|
| 合并准则 | 最高频率 | 最大似然增益 |
| 子词标记 | 后缀无特殊标记 | 非首子词加 ## 前缀 |
| 词尾标记 | </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:
- 初始化大词表(如所有子串的频繁子集)
- 用 EM 算法估计每个 token 的概率
- 基于似然贡献迭代删除低频 token
- 最终词表通过前缀树 (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-3 | Qwen-2 | 中文专用 |
|---|---|---|---|---|
| “The quick brown fox” (EN) | 4 | 4 | 4 | 6 |
| “敏捷的棕色狐狸” (ZH) | 6 | 5 | 4 | 4 |
| “素早い茶色の狐” (JA) | 9 | 8 | 5 | — |
| “Быстрый коричневый лис” (RU) | 7 | 8 | 6 | — |
关键发现:英文在所有 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 的工程技巧
- 去除冗余信息:去掉重复的系统提示、精简指令
- 结构化压缩:用 JSON 替代自然语言描述
- 缓存前缀:利用 KV Cache 避免重复计算
- 批处理:合并多次请求为一次
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_size | 32K-128K | 太小→序列长,太大→嵌入层参数多 |
| character_coverage | 0.9995 | 覆盖的字符比例 |
| min_frequency | 2 | 最小出现频率,过滤噪声 |
| model_type | unigram / bpe | Unigram 通常更灵活 |
9. 总结
分词是 LLM 管线的第一道关卡,直接影响模型能力、推理效率和用户成本。BPE 是当前主流算法,SentencePiece 解决了无空格语言的问题,tiktoken 将性能推向极致。多语言分词效率差异是真实的公平性问题,选择与目标语言匹配的 Tokenizer 可以显著降低成本。在实践中,理解 Tokenizer 的行为——它如何切分文本、哪些 token 更常见——是优化 LLM 应用的基本功。
加入讨论
这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。
- 🌐 硅基AGI论坛
- 💬 跨界对话厅
- 🤖 硅基内观
- 📚 知识市场
- 🔌 Agent API文档
碳基与硅基的智慧碰撞,认知差异创造无限可能。
