模型看见世界之前,先由 tokenizer 决定世界如何被切开

{{ intro_card( points=[ "字符级 tokenizer 与 BPE 的最小实现(含完整训练循环)", "Hugging Face fast tokenizer 与 tiktoken 实测对比", "中英文 / 代码 / emoji 的切分差异可视化", "破坏实验:词表过大 / 过小 / 训推不一致会怎样", ], audience="想理解 LLM 输入端、需要做 tokenizer 选型或定制的开发者", prerequisites="Python 基础,了解什么是词表", time="30 分钟(含代码示例)", ) }}

项目目标

文本进入模型之前要先被切成 token,再被映射为整数 ID。这一步看似简单,但它决定了序列长度、词表大小、跨语言公平性,以及一部分难以排查的边界 bug

本项目要做三件事:

  1. 写一个字符级 tokenizer,理解最简单的"切 + 映射"
  2. 写一个最小 BPE trainer,理解工业级 tokenizer 的构造思路
  3. 把自己写的、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 频繁
BPEGPT-2、Llama高频 byte pair 合并平衡长度与覆盖罕见词被切碎
WordPieceBERTBPE 变体,按似然合并略优于纯频次跟 BPE 差异不大
UnigramXLNet、ALBERT概率模型剪枝词表灵活,可控压缩训练复杂
SentencePieceT5、Llama、Mistralbyte-level 输入 + BPE/Unigram跨语言公平工程封装较重
byte-level BPEGPT-2/3/4、Qwen直接在 byte 上做 BPE无 OOV,处理任何字节中文等多字节字符被多 token 分摊

主流 LLM 现在几乎都用 byte-level BPESentencePiece + 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 + ses + tl + 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 自动学到了 lowest 这种亚词单位——这就是它能"既不长得离谱,又能覆盖罕见词"的核心。

任务 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 优化、上下文预算估算非常有用。

观察指标

跑完上面几段后,记录这些数字(写到一张表里,方便对比):

指标含义字符级自训 BPEGPT-4 cl100k
vocab_size词表大小~5K(含 CJK)1K100K
同一段中文 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——字符级 tokenizer
  • tokenizer_bpe.py——最小 BPE trainer + encode/decode
  • compare.py——和 HF / tiktoken 的输出对照脚本
  • visualizer.py——单段文本的 token 边界可视化
  • 一张 markdown 表格:不同 tokenizer 在中文 / 英文 / 代码 / emoji 上的 token 数对比
  • 一段 200 字短复盘:写清楚你对"为什么 GPT-4 中文比 GPT-2 省 token"的解释

与本站其他内容连接

延伸阅读

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:项目 01:从零实现 tokenizer

本文链接:https://www.sshipanoo.com/blog/ai/llm-roadmap/项目01-tokenizer/

本文最后一次更新为 天前,文章中的某些内容可能已过时!