跳到主要内容
版本:1.0

分词器:文本如何变成数字

神经网络只吃数字。分词器(Tokenizer)负责把 "Hello, 世界" 变成 [15496, 11, 12314, 234] 这样的 token id 序列,并能无损还原。它是 LLM 的第一道工序,词表设计直接影响模型效率与能力上限。

为什么不是按字或按词?​

方案词表大小序列长度问题
按字符极小(几百)极长语义全靠自己组合,模型负担重;序列长 → 注意力开销爆炸
按词极大(百万+)短词表爆炸、OOV(未登录词)无法表示、稀疏
子词(subword)适中(3万~15万)适中高频词整体成 token,生僻词拆成片段,无 OOV

现代 LLM 几乎全部使用子词方案,主流算法是 BPE(Byte-Pair Encoding)。

BPE 训练算法:从字节开始合并​

核心思想一句话:反复把语料中出现频率最高的相邻 token 对合并成一个新 token,直到词表达到目标大小。

初始:把文本拆成字节(256 个基础 token,保证任何文本都能表示)
循环:
1. 统计全语料中所有相邻 token 对的频次
2. 取频次最高的对 (a, b),加入合并表,生成新 token ab
3. 把语料中所有 a b 替换为 ab
直到 len(vocab) == 目标词表大小

手写一个最小 BPE 训练器​

from collections import Counter

def get_pair_counts(token_seqs):
"""统计所有语料里相邻 token 对的频次"""
counts = Counter()
for seq in token_seqs:
counts.update(zip(seq, seq[1:]))
return counts

def merge_pair(seq, pair, new_id):
"""把序列中所有出现的 pair 替换为 new_id"""
out, i = [], 0
while i < len(seq):
if i < len(seq) - 1 and (seq[i], seq[i+1]) == pair:
out.append(new_id); i += 2
else:
out.append(seq[i]); i += 1
return out

def train_bpe(text, vocab_size=300):
# 1. 初始化为字节级 id
seqs = [list(line.encode("utf-8")) for line in text.split("\n") if line]
vocab = {i: bytes([i]) for i in range(256)}
merges = [] # 合并规则(有序,推理时要按顺序重放)

while len(vocab) < vocab_size:
counts = get_pair_counts(seqs)
if not counts:
break
pair, freq = counts.most_common(1)[0]
if freq < 2:
break
new_id = len(vocab)
vocab[new_id] = vocab[pair[0]] + vocab[pair[1]]
merges.append(pair)
seqs = [merge_pair(s, pair, new_id) for s in seqs]
return vocab, merges

编码(encode):按合并表重放​

推理时对新文本,按训练时学到的合并顺序依次应用:

def encode(text, merges):
ids = list(text.encode("utf-8"))
for pair in merges: # 顺序不能乱!
ids = merge_pair(ids, pair, 256 + merges.index(pair))
return ids

解码(decode):查表拼接​

def decode(ids, vocab):
return b"".join(vocab[i] for i in ids).decode("utf-8", errors="replace")

工程版 BPE 的关键优化​

上面的实现是教学版,生产级分词器(OpenAI 的 tiktoken、HF tokenizers)还做了:

  1. 预切分(pre-tokenization):先用正则把文本按"单词/数字/标点"切块,BPE 只在块内合并——保证 token 不会跨越词边界,语义更干净。GPT-2 的正则大致是:\s?\w+|\s?\d+|\s?[^\w\s]+|\s+。

  2. 字节级而非字符级:直接以 UTF-8 字节为基础,天然支持任意语言、emoji、控制字符,词表基础项固定 256 个,永不 OOV。

  3. 特殊 token:<|endoftext|>、<|im_start|> 等被分配固定 id,编码时优先匹配、绝不参与合并。

# 用 tiktoken 感受真实词表
import tiktoken
enc = tiktoken.get_encoding("cl100k_base") # GPT-4 的词表,约 10 万
print(enc.encode("Hello, 世界! 我喜欢编程")) # 中文通常 1~2 字一个 token

词表设计的经验结论​

  • 大小权衡:词表越大 → 序列越短(省算力)但嵌入层越大、低频 token 训练不充分。主流在 32k(LLaMA-2)~ 152k(Qwen2.5)。
  • 中文效率:英文平均 1 token ≈ 0.75 词;中文若词表未针对优化,可能 1 字 1.5 token,直接抬高训练与推理成本。国产模型词表会加大中文子词占比。
  • 数字切分影响算术:1234 若被切成 12 34 或 1 2 3 4,模型做算术的难易完全不同——这是早期模型算术差的部分原因,一些词表会强制数字逐位切分。
  • 压缩率可当质量信号:同一文本在不同模型分词器下的 token 数差异,直接影响你的 API 账单和上下文窗口利用率。

动手练习​

  1. 用上面的 train_bpe 在 1MB 中文维基文本上训练 vocab_size=1000,观察高频合并对是否对应常见词("的""是""我们")。
  2. 对比你的分词器与 tiktoken 对同一段文字的 token 数量差异。
  3. 尝试加入预切分正则,观察合并质量变化。

下一步:Transformer 逐层推导——token id 进入模型后发生了什么。