一句话:BPE(Byte Pair Encoding)是一种把文本切分成"子词单元"的算法,是现代大模型 Tokenizer 的核心。tiktoken 是 OpenAI 实现的高性能 BPE 库,GPT 系列模型使用它。


一、为什么需要 Tokenizer?

模型不能直接处理文字,需要先把文字转成数字(token ID),再转成向量(embedding)。

原始文本:"今天天气很好"
    ↓ Tokenizer
token 序列:["今天", "天气", "很", "好"]
    ↓ 查词表
token ID:[1234, 5678, 910, 1112]
    ↓ Embedding 层
向量矩阵:[[...], [...], [...], [...]]   ← 模型真正处理的输入

二、三种切分粒度的对比

在 BPE 出现之前,有两种极端方案:

方案1:词级别(Word-level)

"unhappiness" → ["unhappiness"]   ← 一个词一个 token

问题

  • 词表会非常大(英语有几十万个词)
  • 遇到训练时没见过的新词(OOV,Out-of-Vocabulary)就不认识
  • 不同语言、不同形态的词需要分别存储(“run”、“running”、“ran” 是三个不同 token)

方案2:字符级别(Character-level)

"unhappiness" → ["u","n","h","a","p","p","i","n","e","s","s"]   ← 每个字符一个 token

问题

  • 序列太长,Attention 的 $O(T^2)$ 复杂度爆炸
  • 字符本身语义信息太少,模型难以学习

方案3:子词级别(Subword-level)—— BPE 的目标

"unhappiness" → ["un", "happi", "ness"]   ← 有意义的子词单元

优点

  • 词表大小可控(通常 3 万~10 万)
  • 没见过的词可以拆成已知子词组合,不会 OOV
  • 保留了一定的语义信息

三、BPE 算法详解

BPE 最初是数据压缩算法,被 Sennrich 等人(2016)引入 NLP。

核心思想

反复合并出现频率最高的相邻字节/字符对,直到达到目标词表大小。

训练过程(逐步演示)

初始语料(简化示例,括号内是出现频率):

"low"(5次)  "lower"(2次)  "newest"(6次)  "widest"(3次)

第0步:初始化,把每个词拆成字符,加上词尾标记 </w>(表示这是一个词的结尾):

l o w </w>        (5次)
l o w e r </w>    (2次)
n e w e s t </w>  (6次)
w i d e s t </w>  (3次)

统计所有相邻字符对的频率:

(l, o): 5+2 = 7
(o, w): 5+2 = 7
(w, </w>): 5
(w, e): 2+6 = 8    ← 最高!
(e, r): 2
(e, s): 6+3 = 9    ← 最高!
...

第1步:合并频率最高的对 (e, s)es

l o w </w>         (5次)
l o w e r </w>     (2次)
n e w es t </w>    (6次)   ← "e s" 合并为 "es"
w i d es t </w>    (3次)   ← "e s" 合并为 "es"

第2步:重新统计,合并频率最高的对 (es, t)est

l o w </w>          (5次)
l o w e r </w>      (2次)
n e w est </w>      (6次)   ← "es t" 合并为 "est"
w i d est </w>      (3次)   ← "es t" 合并为 "est"

第3步:合并 (w, e)we(频率 8):

l o w </w>          (5次)
l o we r </w>       (2次)
n e we st </w>      (6次)
w i d est </w>      (3次)

第4步:合并 (l, o)lo(频率 7):

lo w </w>           (5次)
lo we r </w>        (2次)
n e we st </w>      (6次)
w i d est </w>      (3次)

……持续合并,直到词表达到目标大小(如 50000 个 token)

最终词表包含所有合并规则,例如:

合并规则列表(按顺序):
  1. e + s → es
  2. es + t → est
  3. w + e → we
  4. l + o → lo
  5. lo + w → low
  ...

推理时如何切分新词

拿到新词,按照训练时学到的**合并规则列表(按顺序)**逐一应用:

新词:"lowest"
初始:l o w e s t </w>
应用规则1 (e+s→es):l o w es t </w>
应用规则2 (es+t→est):l o w est </w>
应用规则3 (w+e→we):无匹配(w 后面是 est,不是 e)
应用规则4 (l+o→lo):lo w est </w>
应用规则5 (lo+w→low):low est </w>

最终切分:["low", "est</w>"] → 对应 token ID

四、Byte-level BPE(字节级 BPE)

GPT-2 及之后的模型使用的是 Byte-level BPE,在字节(byte)而非字符上做 BPE。

动机

  • 普通 BPE 以 Unicode 字符为基本单元,词表初始化需要覆盖所有字符(中文就有几万个)
  • Byte-level BPE 以 256 个字节为基本单元,初始词表只有 256 个元素,可以表示任意语言、任意字符,永远不会 OOV
"你好" 的 UTF-8 字节:
  "你" → [0xE4, 0xBD, 0xA0]
  "好" → [0xE5, 0xA5, 0xBD]

Byte-level BPE 从这 6 个字节开始合并,最终可能得到:
  ["你好"](如果这个组合在训练数据中足够频繁)
  或 ["你", "好"]
  或更细的字节组合

五、tiktoken 是什么?

tiktoken 是 OpenAI 开源的高性能 BPE tokenizer 库,用 Rust 实现核心逻辑,Python 封装调用,速度比纯 Python 实现快 3~6 倍。

GPT 系列使用的编码方案

模型 编码方案 词表大小
GPT-2 gpt2 50,257
GPT-3.5 / GPT-4 cl100k_base 100,277
GPT-4o o200k_base 200,019
text-embedding-ada-002 cl100k_base 100,277

词表越大,同样的文本切分出的 token 数越少,处理效率越高,但模型的 embedding 层参数也越多。

tiktoken 安装和基本使用

# 安装
# pip install tiktoken

import tiktoken

# 加载编码方案
enc = tiktoken.get_encoding("cl100k_base")   # GPT-4 使用的编码
# 或者直接按模型名加载
enc = tiktoken.encoding_for_model("gpt-4")
<![CDATA[# 实际输出:
# token IDs:[9906, 11, 220, 57668, 53901, 6447, 33, 1777, 374, 8056, 13]
# token 数量:11]]>
# ── 解码:token ID → 文本 ──────────────────────────────
decoded = enc.decode(token_ids)
print(f"解码还原:{decoded}")   # "Hello, 你好!BPE is amazing."

# ── 查看每个 token 对应的文本 ──────────────────────────
for token_id in token_ids:
    token_bytes = enc.decode_single_token_bytes(token_id)
    print(f"  ID {token_id:6d}{token_bytes}")
# 实际输出:
#   ID   9906 → b'Hello'
#   ID     11 → b','
#   ID    220 → b' '
#   ID  57668 → b'\xe4\xbd\xa0'   ← "你" 的 UTF-8 字节
#   ID  53901 → b'\xe5\xa5\xbd'   ← "好" 的 UTF-8 字节
#   ID   6447 → b'\xef\xbc\x81'   ← "!" 的 UTF-8 字节
#   ID     33 → b'B'
#   ID   1777 → b'PE'
#   ID    374 → b' is'
#   ID   8056 → b' amazing'
#   ID     13 → b'.'

# ── 统计 token 数(不解码,只计数,更快)──────────────
num_tokens = len(enc.encode(text))
print(f"token 数:{num_tokens}")

# ── 特殊 token ─────────────────────────────────────────
# GPT 系列有一些特殊 token,如 <|endoftext|>
special_enc = tiktoken.get_encoding("cl100k_base")
eot_id = special_enc.encode("<|endoftext|>", allowed_special={"<|endoftext|>"})
print(f"<|endoftext|> 的 token ID:{eot_id}")  # [100257]

实用场景:计算 API 费用前预估 token 数

OpenAI API 按 token 计费,发送前先估算:

import tiktoken

def count_tokens_for_gpt4(messages: list[dict]) -> int:
    """
    估算 GPT-4 API 调用的 token 数。
    messages 格式:[{"role": "user", "content": "..."}, ...]
    """
    enc = tiktoken.encoding_for_model("gpt-4")
    total_tokens = 0

    for message in messages:
        # 每条消息有固定的格式开销(role + 分隔符)
        total_tokens += 4
        for value in message.values():
            total_tokens += len(enc.encode(str(value)))

    total_tokens += 2  # 回复的起始开销
    return total_tokens


messages = [
    {"role": "system", "content": "你是一个有帮助的助手。"},
    {"role": "user", "content": "请帮我写一首关于春天的诗。"},
]
estimated_tokens = count_tokens_for_gpt4(messages)
print(f"预估输入 token 数:{estimated_tokens}")
# 按 GPT-4 价格 $0.03/1K tokens 估算费用
cost_usd = estimated_tokens / 1000 * 0.03
print(f"预估费用:${cost_usd:.6f}")

六、其他主流模型的 Tokenizer

模型系列 Tokenizer 类型 词表大小
GPT-2/3/4 Byte-level BPE 50K / 100K / 200K tiktoken
LLaMA-1/2 SentencePiece BPE 32,000 sentencepiece
LLaMA-3 Byte-level BPE 128,256 tiktoken 兼容
Qwen2 Byte-level BPE 151,936 tiktoken 兼容
BERT WordPiece 30,522 HuggingFace tokenizers
T5 SentencePiece Unigram 32,100 sentencepiece

WordPiece(BERT 用)和 BPE 思路类似,区别在于合并标准:

  • BPE:合并频率最高的字符对
  • WordPiece:合并能最大化训练数据似然的字符对(更精确但更慢)

SentencePiece(LLaMA-1/2 用):

  • 直接在原始文本上操作,不需要预分词(空格也是普通字符)
  • 支持 BPE 和 Unigram 两种算法
  • 跨语言效果好

七、一个有趣的现象:token 边界影响模型能力

BPE 切分方式会直接影响模型的推理能力:

import tiktoken
enc = tiktoken.get_encoding("cl100k_base")

# "9.11" 和 "9.9" 的 token 切分
print(enc.encode("9.11"))   # 实际输出:[24, 13, 806]  → [b'9', b'.', b'11']
print(enc.encode("9.9"))    # 实际输出:[24, 13, 24]   → [b'9', b'.', b'9']

# GPT 早期版本认为 9.11 > 9.9,因为 "11" > "9"(字符串比较)
# 这是 token 粒度导致的数字理解问题
# 字符计数问题:strawberry 里有几个 r?
print(enc.encode("strawberry"))
# 实际输出:[496, 675, 15717]  → [b'str', b'aw', b'berry']
# 模型看到的是三个 token(str / aw / berry),不是字符,所以数字符时容易出错

这也是为什么现代模型(GPT-4o 用 o200k_base,词表 20 万)要扩大词表——更大的词表意味着更少的切分,更完整的语义单元,减少这类边界问题。


八、一句话总结

  • BPE:反复合并频率最高的相邻字符对,训练出一套合并规则,推理时按规则切分新词。解决了词级别 OOV 和字符级别序列过长的问题。
  • Byte-level BPE:在字节上做 BPE,初始词表只有 256 个元素,永远不会 OOV,GPT-2 之后的主流方案。
  • tiktoken:OpenAI 的高性能 BPE 实现,Rust 核心,GPT 系列标配,常用于计算 token 数和 API 费用估算。