模型看见世界之前,先由 tokenizer 决定世界如何被切开
{{ intro_card( points=[ "字符级 tokenizer 与 BPE 的最小实现(含完整训练循环)", "Hugging Face fast tokenizer 与 tiktoken 实测对比", "中英文 / 代码 / emoji 的切分差异可视化", "破坏实验:词表过大 / 过小 / 训推不一致会怎样", ], audience="想理解 LLM 输入端、需要做 tokenizer 选型或定制的开发者", prerequisites="Python 基础,了解什么是词表", time="30 分钟(含代码示例)", ) }}
项目目标
文本进入模型之前要先被切成 token,再被映射为整数 ID。这一步看似简单,但它决定了序列长度、词表大小、跨语言公平性,以及一部分难以排查的边界 bug。
本项目要做三件事:
- 写一个字符级 tokenizer,理解最简单的"切 + 映射"
- 写一个最小 BPE trainer,理解工业级 tokenizer 的构造思路
- 把自己写的、Hugging Face 的、tiktoken 的输出放在一起对比
做完这一项之后,看到任何一个 tokenizer,你应该能立即回答:它是按什么切的、词表多大、合并规则有多少、对中文 / 代码 / emoji 怎么处理。
背景与原理
tokenizer 的本质是一个确定性双向映射:
text ──encode──▶ list[int] ──decode──▶ text
encode 必须是确定的(同一输入永远同样的输出),decode(encode(x)) == x 是基本契约(少数 tokenizer 在 normalize 后不可逆,但应避免)。
主流方案差异:
| 方案 | 代表 | 思路 | 优点 | 缺点 |
|---|---|---|---|---|
| 字符级 | tiny GPT 教学 | 一字符一 token | 简单、无 OOV | 序列长,无亚词信息 |
| 词级 | 早期 NLP | 一词一 token | 直观 | 词表巨大、OOV 频繁 |
| BPE | GPT-2、Llama | 高频 byte pair 合并 | 平衡长度与覆盖 | 罕见词被切碎 |
| WordPiece | BERT | BPE 变体,按似然合并 | 略优于纯频次 | 跟 BPE 差异不大 |
| Unigram | XLNet、ALBERT | 概率模型剪枝词表 | 灵活,可控压缩 | 训练复杂 |
| SentencePiece | T5、Llama、Mistral | byte-level 输入 + BPE/Unigram | 跨语言公平 | 工程封装较重 |
| byte-level BPE | GPT-2/3/4、Qwen | 直接在 byte 上做 BPE | 无 OOV,处理任何字节 | 中文等多字节字符被多 token 分摊 |
主流 LLM 现在几乎都用 byte-level BPE 或 SentencePiece + BPE。差异主要在工程封装,核心算法都是 BPE。
动手实现
任务 A:字符级 tokenizer
最小版本只需要四件事:
text = "你好,世界 Hello world"
# 1) 收集词表(按字符排序保证确定性)
chars = sorted(set(text))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for ch, i in stoi.items()}
# 2) encode / decode
def encode(s: str) -> list[int]:
return [stoi[ch] for ch in s]
def decode(ids: list[int]) -> str:
return "".join(itos[i] for i in ids)
# 3) 验证可逆
assert decode(encode(text)) == text
print(f"词表大小: {len(stoi)}") # 15
print(f"encode('你好'): {encode('你好')}") # [13, 14] 或类似
字符级足够用于 tiny GPT 教学,但有两个问题:
- 序列长——"transformer" 11 个字符就是 11 个 token,模型每步只能看 1 个字符
- 无亚词信息——"running" / "runner" / "run" 之间没有任何共享 token
任务 B:最小 BPE trainer
BPE 的核心算法只有一个循环:反复找最高频的相邻 token 对,合并成一个新 token。
from collections import Counter
def train_bpe(corpus: list[str], num_merges: int = 100):
"""
corpus: 一批文本
返回: (vocab, merges),vocab 是最终词表,merges 是合并规则列表
"""
# 1) 初始化:先按字符切开,每个词后加 </w> 作为词尾标记
vocab = Counter()
for line in corpus:
for word in line.split():
tokens = list(word) + ["</w>"]
vocab[" ".join(tokens)] += 1
merges = []
for step in range(num_merges):
# 2) 统计所有相邻 pair 的频次
pairs = Counter()
for word, freq in vocab.items():
tokens = word.split()
for a, b in zip(tokens, tokens[1:]):
pairs[(a, b)] += freq
if not pairs:
break
# 3) 找最高频 pair
best_pair = max(pairs, key=pairs.get)
merges.append(best_pair)
# 4) 在 vocab 里把这个 pair 合并为一个新 token
new_token = "".join(best_pair)
new_vocab = Counter()
bigram = " ".join(best_pair)
replacement = new_token
for word, freq in vocab.items():
new_word = word.replace(bigram, replacement)
new_vocab[new_word] += freq
vocab = new_vocab
if step < 5 or step % 20 == 0:
print(f"step {step:>3}: merged {best_pair} (freq={pairs[best_pair]})")
return vocab, merges
# 跑一遍
corpus = [
"low low low low low",
"lower lower lower",
"newest newest newest newest newest newest",
"widest widest widest",
] * 10
vocab, merges = train_bpe(corpus, num_merges=20)
print(f"\n最终合并规则数: {len(merges)}")
print(f"前 5 条 merges: {merges[:5]}")
典型输出(前几步会先合并 e + s、es + t、l + o 这些高频组合):
step 0: merged ('e', 's') (freq=18)
step 1: merged ('es', 't') (freq=18)
step 2: merged ('l', 'o') (freq=80)
step 3: merged ('lo', 'w') (freq=80)
step 4: merged ('low', '</w>') (freq=50)
可以看到 BPE 自动学到了 low、est 这种亚词单位——这就是它能"既不长得离谱,又能覆盖罕见词"的核心。
任务 C:用工业级实现做对比
自己写的 BPE 是教学版。生产里用 tokenizers(Hugging Face 开源 Rust 实现)或 tiktoken(OpenAI 的实现)。
pip install tokenizers tiktoken
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
import tiktoken
# 1) Hugging Face 的 BPE
hf_tok = Tokenizer(BPE())
hf_tok.pre_tokenizer = Whitespace()
trainer = BpeTrainer(vocab_size=1000, special_tokens=["[UNK]", "[PAD]"])
hf_tok.train_from_iterator(corpus, trainer)
# 2) tiktoken 直接用 GPT-4 的词表
gpt4_tok = tiktoken.get_encoding("cl100k_base")
samples = [
"Hello, world!",
"你好,世界",
"def hello_world(): print('你好')",
"🤖 AI",
"lower newest widest",
]
print(f"{'文本':<35} | {'HF BPE':<8} | {'GPT-4':<8}")
print("-" * 60)
for s in samples:
hf_n = len(hf_tok.encode(s).ids)
gpt_n = len(gpt4_tok.encode(s))
print(f"{s:<35} | {hf_n:>5} 个 | {gpt_n:>5} 个")
典型输出:
文本 | HF BPE | GPT-4
------------------------------------------------------------
Hello, world! | 4 个 | 4 个
你好,世界 | 10 个 | 5 个
def hello_world(): print('你好') | 18 个 | 13 个
🤖 AI | 4 个 | 3 个
lower newest widest | 3 个 | 3 个
重要观察:
- 中文在 GPT-4 词表里更省 token:4 个汉字 5 个 token,因为 cl100k_base 训练数据里包含大量中文
- 不同 tokenizer 的 token 数差距经常 1.5~3 倍——这直接换算成 API 费用差距
- emoji
🤖在 byte-level BPE 里要占 2~3 个 token(UTF-8 编码后多字节)
任务 D:tokenizer visualizer
把切分结果可视化能让 tokenizer 行为变成"看得见"的。最小版:
def visualize(tokenizer, text):
"""把每个 token 用 [...] 包起来打印"""
if hasattr(tokenizer, 'encode'): # tiktoken
ids = tokenizer.encode(text)
pieces = [tokenizer.decode([i]) for i in ids]
else:
enc = tokenizer.encode(text)
ids = enc.ids
pieces = enc.tokens
print(f"\n原文: {text}")
print(f"token 数: {len(ids)}")
print(" ".join(f"[{p}]" for p in pieces))
visualize(gpt4_tok, "我喜欢 retrieval-augmented generation")
# 我[]喜[]欢[ ret][rie][val][-]aug[ment][ed][ generation]
看到每个 token 的实际边界,对 prompt 优化、上下文预算估算非常有用。
观察指标
跑完上面几段后,记录这些数字(写到一张表里,方便对比):
| 指标 | 含义 | 字符级 | 自训 BPE | GPT-4 cl100k |
|---|---|---|---|---|
vocab_size | 词表大小 | ~5K(含 CJK) | 1K | 100K |
| 同一段中文 token 数 | 100 字 | ~100 | ~80 | ~50 |
| 同一段英文 token 数 | 100 词 | ~500 | ~150 | ~130 |
| 词表占 embedding 参数比例 | vocab × dim | 极小 | 小 | 大 |
| 训练序列长度 | 同 1M token 数据 | 最长 | 中 | 最短 |
破坏实验
故意把 tokenizer 整坏,看模型怎么反应——这能让你建立"为什么这一步重要"的直觉。
实验 1:词表缩到极小
# 把 BPE 只训 10 个 merge
tok_tiny = train_bpe(corpus, num_merges=10)
# 序列会变得超长,模型上下文窗口很快被用完
实验 2:词表放到很大
# 训到 50000 merge
tok_huge = train_bpe(corpus, num_merges=50000)
# 低频词会被分到独立 token,但训练样本少,对应的 embedding 学不充分
实验 3:训练用 tokenizer A,推理换 tokenizer B
ids_train = tokA.encode("hello") # [5, 12, 8]
output = model(ids_train) # 训练时模型学的是这套映射
ids_infer = tokB.encode("hello") # [3, 7, 19] 完全不同的 ID
output = model(ids_infer) # 输出立刻乱码 —— ID 到 embedding 行的对应被破坏
实验 4:删除 special token
# 没有 [EOS] / [PAD] / [BOS] 时
# - 模型不知道句子结束 → 一直生成
# - batch 训练时 padding token 泄漏到输出
# - 多轮对话拼不出来
这四个实验在生产里都对应真实事故。理解过它们之后再调 tokenizer,就不会"看着不对劲不知道哪里出了问题"。
交付物
完成本项目,你应该有:
tokenizer_char.py——字符级 tokenizertokenizer_bpe.py——最小 BPE trainer + encode/decodecompare.py——和 HF / tiktoken 的输出对照脚本visualizer.py——单段文本的 token 边界可视化- 一张 markdown 表格:不同 tokenizer 在中文 / 英文 / 代码 / emoji 上的 token 数对比
- 一段 200 字短复盘:写清楚你对"为什么 GPT-4 中文比 GPT-2 省 token"的解释
与本站其他内容连接
- mini-gpt 02:把文字变成数字——字符级 tokenizer 的最小实现主线
- ai-for-python 番外 03:Token 的秘密——面向应用的 token 概念与成本估算
- llm-app/Embedding 嵌入向量——下一步:token ID 怎么变成可学习向量
延伸阅读
- Hugging Face NLP Course: BPE / WordPiece / Unigram
- tiktoken(OpenAI 实现)
- SentencePiece 论文
- Byte Pair Encoding 原始论文
- Karpathy: Let's build the GPT Tokenizer
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:项目 01:从零实现 tokenizer
本文链接:https://www.sshipanoo.com/blog/ai/llm-roadmap/项目01-tokenizer/
本文最后一次更新为 天前,文章中的某些内容可能已过时!