Tiktoken 是 OpenAI 开源的字节级 BPE 编码器:只做推理侧的 encode/decode,不做训练,靠 Rust 核心把整词哈希快查与局部 BPE 拼成单线程 10 MB/s 量级的吞吐。读懂它的词表文件格式、正则预切分模式与 cl100k_base → o200k_base 的演进,就能准确预估 API 计费的 token 数。

定位:推理侧的编码器,不是训练器

Tiktoken 与 SentencePiece 的分工完全不同:后者训练词表并导出模型文件,前者只加载 OpenAI 已发布的 merge ranks 做编码。包里没有训练器,tiktoken._educational 只是教学用的玩具实现。

它是彻底的 BBPE——词表底层 256 个单字节 token 保证任意字节串可编码,永不产生 UNK,细节见 BBPE 字节级分词。但它不沿用 GPT-2 那套 byte-to-unicode 重映射:.tiktoken 词表文件直接以 base64 存原始字节。

IQ== 0      # b'!'  → rank 0
Ig== 1      # b'"'  → rank 1

每行 base64(token_bytes) rank,rank 就是 BPE 算法原理 里的 merge 优先级序号。文件从 openaipublic.blob.core.windows.net 拉取,缓存到 TIKTOKEN_CACHE_DIR(未设置则落在临时目录的 data-gym-cache/,key 是 URL 的 SHA-1)。离线环境预置该目录即可,无需联网。

Rust 核心的三层加速

Python 侧只是壳,CoreBPE 在 Rust 里实现,三个关键设计叠出性能:

  • 整词哈希快查:正则切出的 piece 先在 HashMap<Vec<u8>, Rank> 里整体查一次,命中直接返回单个 token。真实文本绝大多数 piece 走这条路,完全不进 BPE 循环。
  • 局部 BPE:只有未命中的 piece 才做 merge 合并,且搜索空间被预切分限制在一个词内部,与词表大小无关。
  • rayon 批量并行:encode_ordinary_batch / encode_batch 按文档切分到线程池,GIL 在 Rust 侧释放。

单线程实测约 2.5 MB 文本 0.24 s(约 93 万 token),4 文档批量 0.29 s 对比串行 0.82 s,接近线性加速。官方基准称比同类开源实现快 3–6 倍。

预切分用 fancy-regex,模式串里大量使用占有量词(\p{L}++)并把 \s+ 拆成 \s++$|\s*[\r\n]|\s+(?!\S)|\s 这类等价改写,目的是消除回溯——避免对抗性输入触发指数级匹配。

正则预切分模式的演进

预切分决定 BPE 的边界,是三代词表差异最大的地方。

# r50k_base / p50k_base(GPT-2、GPT-3、Codex)
'(?:[sdmt]|ll|ve|re)| ?\p{L}++| ?\p{N}++| ?[^\s\p{L}\p{N}]++|\s++$|\s+(?!\S)|\s
 
# cl100k_base(GPT-3.5 / GPT-4)
'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s

cl100k 相对 r50k 的三处硬改动:

改动效果
缩写匹配加 (?i:)'S、'LL 等大写缩写不再被切碎
\p{N}+ → \p{N}{1,3}数字最多 3 位一组,杜绝长数字串生成罕见 token
\s*[\r\n] 单独成支换行与缩进空白独立成 token,代码场景收益明显

数字改成定长分组是对 数字与代码的 Tokenization 里算术能力问题的直接回应:1234567890 在 cl100k 与 o200k 下都切成 123 / 456 / 789 / 0。

o200k_base 的模式更长,核心变化是按 Unicode 大小写类别拆成两条主分支:

[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?|...

两条分支分别处理「前导大写 + 小写主体」和「全大写」,并把缩写后缀并入同一 piece;\p{M} 纳入组合记号,对天城文、阿拉伯语等带变音符号的文字友好;[\r\n/]* 额外吸收斜杠,利好 URL 与路径。

词表代际与模型映射

编码n_vocab特殊 token代表模型
r50k_base502571GPT-2、GPT-3(davinci)
p50k_base502811Codex、text-davinci-002/003
p50k_edit—4(含 FIM)早期 edit/insert 模型
cl100k_base1002775GPT-3.5-turbo、GPT-4、text-embedding-3-*
o200k_base2000192GPT-4o、GPT-4.1、o 系列推理模型
o200k_harmony—多个gpt-oss 开放权重模型

cl100k_base 的 5 个特殊 token 含完整 FIM 三件套(<|fim_prefix|>、<|fim_middle|>、<|fim_suffix|>)与 <|endofprompt|>;o200k_base 反而收敛到 2 个,对话结构改由 chat 模板层的保留 token 承载,见 特殊 Token 与 Chat Template。

不要在代码里硬编码编码名,用模型名反查:

import tiktoken
 
enc = tiktoken.encoding_for_model("gpt-4o")   # → o200k_base
print(enc.name, enc.n_vocab)

新模型名未收录时 encoding_for_model 会抛 KeyError,此时升级 tiktoken 而非手写猜测的映射。

词表翻倍换来的多语言收益

o200k 把词表从 10 万扩到 20 万,增量几乎全给了非英语,实测压缩率差异集中在 CJK:

文本cl100ko200k
Hello world22
大语言模型的分词器107
中文与多语言分词效率对比测试1410
こんにちは世界42

差异来源是词粒度:cl100k 下「大语言模型」切成 5 个单字 token,o200k 切成 大 / 语言 / 模型 3 个。英文基本无变化——10 万词表对英语早已饱和。代价是 embedding 与输出层参数翻倍,以及训练语料清洗不足带来的污染 token,详见 中文与多语言分词。

特殊 token 的安全默认

encode() 默认拒绝把用户文本里的 <|endoftext|> 字面量当成特殊 token,直接抛 ValueError。这是防 prompt injection 的默认姿态:

enc = tiktoken.get_encoding("o200k_base")
text = "hello <|endoftext|> world"
 
enc.encode_ordinary(text)                      # 9 tokens,字面量按普通字节编码(推荐)
enc.encode(text, allowed_special="all")        # [24912, 220, 199999, 2375],识别为特殊 token
enc.encode(text)                               # ValueError

处理不可信输入一律走 encode_ordinary()。

与 API 计费的关系

计费按 token 计数,input 与 output 分别定价,tiktoken 算的就是计费口径的数字——但有三条边界:

  • chat 请求有模板开销。每条 message 除内容外附带角色标记等固定 token,回复还有引导开销。cl100k 时代的经验值是每条 message +3、带 name 再 +1、整体 +3;新模型的确切开销官方未公布,务必以响应里的 usage 字段为准,tiktoken 只用于事前预估。
  • 多模态输入不走 tiktoken。图像按分辨率折算 token,音频按时长计价,都有独立公式。
  • prompt caching 只改单价不改计数。命中缓存的 input token 仍按同样数量统计,折扣体现在费率上。

预估函数模板:

import tiktoken
 
def estimate_tokens(messages, model="gpt-4o"):
    """事前预估 chat 请求的 input token 数;精确值取响应的 usage。"""
    enc = tiktoken.encoding_for_model(model)
    per_message = 3          # 角色标记等固定开销的经验值
    total = 3                # 回复引导开销
    for m in messages:
        total += per_message
        for key, value in m.items():
            total += len(enc.encode_ordinary(str(value)))
            if key == "name":
                total += 1
    return total
 
msgs = [
    {"role": "system", "content": "你是简洁的技术助手。"},
    {"role": "user", "content": "解释 BBPE 与 WordPiece 的区别。"},
]
print(estimate_tokens(msgs))

常用操作

import tiktoken
 
enc = tiktoken.get_encoding("o200k_base")
 
ids = enc.encode_ordinary("大语言模型的分词器")
print(ids)                                    # token id 序列
print([enc.decode([i]) for i in ids])         # 逐 token 还原,观察切分边界
 
# 批量编码:num_threads 交给 rayon 线程池,远快于 Python 循环
docs = ["第一段文本", "第二段文本", "第三段文本"]
batch = enc.encode_ordinary_batch(docs, num_threads=4)
 
# 单 token 解码可能是不完整 UTF-8 字节,用 decode_single_token_bytes 避免乱码
print(enc.decode_single_token_bytes(ids[0]))  # b'\xe5\xa4\xa7'
 
# 截断到预算内(注意:按 token 截断可能切断多字节字符的中间)
budget = 5
print(enc.decode(ids[:budget]))

使用边界

  • decode 单个 token 不保证是合法字符。CJK 与 emoji 常跨多个 token,逐 token decode() 会得到替换符 �,流式输出必须缓冲不完整字节。
  • 词表不可自行扩展到模型里。tiktoken.Encoding 支持传自定义 mergeable_ranks 造新编码,但服务端模型只认自己那套,本地加 token 不改变计费与行为。
  • 不同代际词表的 token 数不可互换。跨模型迁移 prompt 时长度预算要重算,这也是 Tokenizer 概述 里编解码管线必须与模型绑定的原因。