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)|\scl100k 相对 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_base | 50257 | 1 | GPT-2、GPT-3(davinci) |
p50k_base | 50281 | 1 | Codex、text-davinci-002/003 |
p50k_edit | — | 4(含 FIM) | 早期 edit/insert 模型 |
cl100k_base | 100277 | 5 | GPT-3.5-turbo、GPT-4、text-embedding-3-* |
o200k_base | 200019 | 2 | GPT-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:
| 文本 | cl100k | o200k |
|---|---|---|
Hello world | 2 | 2 |
大语言模型的分词器 | 10 | 7 |
中文与多语言分词效率对比测试 | 14 | 10 |
こんにちは世界 | 4 | 2 |
差异来源是词粒度: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 概述 里编解码管线必须与模型绑定的原因。