Skip to content
Zegging's Tech Blog
Go back

从 UTF-8 到 Token:你的 Prompt 进入 LLM 前发生了什么

1. 一个被默认忽略的边界

人与 LLM 的交互界面是字符串进、字符串出,但 LLM 内部只认识一种东西:整数序列。从「人类可读的 prompt」到「LLM 可读的 token 序列」之间,存在一条精心设计的预处理流水线。理解这条流水线能解释很多日常困惑:

  • 为什么 "hello"" hello" 在 tokenizer 里常常是不同的 token?
  • 中文 prompt 真的比英文更耗 token 吗?多消耗多少?为什么?
  • 为什么模型从不报「无法识别的字符」错误?
  • 为什么 <|endoftext|> 这种字符串无法被无意注入到 prompt 里?
  • 为什么 Context Window 是 200k tokens,而不是 200k characters?

本文用一条链路把这些问题串起来:字符 → 码点 → 字节 → 字节 token → BPE 合并 → 最终 token ID,然后再展开讲讲和 tokenization 相关的一些有趣的信息。

2. 第一层:字符 → 码点(Unicode)

屏幕上看到的 H😀 都是字符 (character)。计算机不识字,只认识数字。Unicode 标准给世界上每个字符分配了一个独一无二的编号,叫码点 (code point)

字符码点 (十六进制)码点 (十进制)
HU+004872
iU+0069105
U+002032
U+4F6020320
U+597D22909
😀U+1F600128512
字符 "你"  →  查 Unicode 表  →  码点 20320

码点只是一个抽象的整数编号,它不规定字符在硬盘/网络上怎么存。

3. 第二层:码点 → 字节(UTF-8)

字节是计算机存储和传输的最小可寻址单元,1 字节 = 8 bit,取值范围 0~255。

把码点存成字节序列时,不能简单地「把十进制转二进制填进去」。因为不同字符的码点大小悬殊(H 是 72,😀 是 128512),直接用原始二进制硬塞,解码器读完一串字节后根本不知道字符边界在哪里——E4 BD A0 E5 A5 BD 是两个 3 字节中文字,还是六个 1 字节字符?无法分辨。

UTF-8 用变长编码 + 每字节前缀标识解决了这个问题:

码点范围字节数模板
0 ~ 12710xxxxxxx
128 ~ 20472110xxxxx 10xxxxxx
2048 ~ 6553531110xxxx 10xxxxxx 10xxxxxx
65536 ~ 1114111411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

每个字节开头的 011011101111010 就是自描述的标识位。解码器读第一个字节的前几位就知道这个字符占几个字节,读到 10 开头就知道它是「续字节」。代价是:标识位牺牲了一部分存储空间,换来了字符边界的确定性ASCII 完全兼容(前 128 个字符与 ASCII 一致,1 字节存储)。

3.1 具体计算: (码点 20320)

码点 0x4F60 = 二进制 0100 1111 0110 0000  (16 位)

20320 落在 2048~65535 → 用 3 字节模板:
  1110xxxx  10xxxxxx  10xxxxxx

码点 16 位拆成三组填入:
  高 4 位  0100    →  1110 0100  =  0xE4  =  228
  中 6 位  111101  →  10 111101  =  0xBD  =  189
  低 6 位  100000  →  10 100000  =  0xA0  =  160

结果: [228, 189, 160]

3.2 一个直接影响成本的事实

字符:     H        i      (空格)      你                好
码点:     72      105      32       20320            22909
UTF-8:  [0x48]  [0x69]  [0x20]   [0xE4,0xBD,0xA0]  [0xE5,0xA5,0xBD]
字节数:    1       1       1           3                3

至此,"Hi 你好" 在计算机里变成了 [72, 105, 32, 228, 189, 160, 229, 165, 189] —— 一串纯粹的、一维的字节序列。这是任何文本在内存中的真实形态,也是 BPE 工作的真正起点。

关于「中文比英文更耗 token」的一个常见误区。 经常能看到这样的说法:「英文字符 1 字节、中文 3 字节,所以中文 prompt 必然更耗 token」。这个推理把三件事混成了一件,需要拆开来看:

层级中文 vs 英文能直接比吗?关键点
UTF-8 字节可以ASCII 通常 1 字节,常见汉字通常 3 字节(少数扩展汉字 4 字节)——这是编码事实
token不能由字节数推出token 是某个具体模型的 tokenizer 切出来的单位,可以覆盖多字节、多字符甚至整个短语
信息量 / 同义不能用字符/字节衡量中文常常能用更少字符表达同一意思(人工智能 4 字 vs artificial intelligence 23 字符)

UTF-8 字节数只是存储编码的事实,并不是 token 数的定律。BPE 完全可以把一个汉字的 3 个字节合并成 1 个 token、把双字词合并成 1 个 token、甚至把常见短语合并成 1 个 token——是否真的合并,取决于该 tokenizer 的词表对中文的覆盖程度,与 UTF-8 无关。第 9.2 节会用 tiktoken 跑一组真实平行语料的对比,并讨论现有研究是怎么说的。

4. 第三层:BPE 要解决什么问题?

假设要训练一个语言模型,它只能接受整数(token ID)作为输入。这就需要一个编码器把任意人类文本变成一串整数。这个编码器必须同时满足三个看似冲突的需求:

  1. 完备性:任何文本都不能编码失败,不能因为没见过某个字符就报错、丢字或用 <UNK> 代替;
  2. 高效性:常见的词/子词应该用极少的 token 表达,避免一段普通英文被切成几百个字符 token;
  3. 可逆性:从 token 列表必须能无损还原原文。

朴素方案各有致命缺陷:

方案完备性效率致命问题
按词切(word-level)词表爆炸,新词全部变 <UNK>
按字符切(char-level)序列过长,浪费算力,丢失语义粒度
按字节切(byte-level)极低序列更长,比按字符还低效

Byte Pair Encoding (BPE) 是字节和词之间的折中方案,它的核心思想一句话概括:

从 256 个单字节 token 起步,在训练数据上反复合并最常见的相邻 token 对,逐步学到有意义的子词单元。

GPT-2 / GPT-3.5 / GPT-4 / Claude 等主流模型都用的是 byte-level BPE——直接在 UTF-8 字节流上做合并。这种设计带来了两个不可替代的好处:

  • 永远不会有 <UNK>:最坏情况下任何字节都能用基础的 256 个字节 token 表示。
  • 天然支持所有语言、emoji、乱码、二进制:BPE 不需要理解「这是中文」「这是日文」,它只在数字序列上做统计。

4.1 256 个原子的诞生

BPE 词表的前 256 个 token,每个对应一个字节值:

ranks = {}
for i in range(2**8):              # i = 0, 1, 2, ..., 255
    ranks[bytes([i])] = i          # {b'\x00': 0, b'\x01': 1, ..., b'\xFF': 255}
Token ID对应字节恰好等于哪个 ASCII 字符
00x00(空)
320x20空格
720x48大写 H
970x61小写 a
2280xE4不可读(仅 的第 1 字节)
2550xFF不可读

256 覆盖了全部可能的字节值。任何 UTF-8 文本都能被这 256 个原子 token 编码——这就是「BPE 从不需要 <UNK>」的根本原因。

4.2 BPE 训练:在字节序列上反复合并

训练时每次迭代做四件事:

(a) 统计训练语料中所有相邻 pair 的出现次数
(b) 取出现次数最多的那个 pair
(c) 把它作为新 token 加入词表,分配下一个可用 ID
(d) 在训练数据中把所有出现的这个 pair 全部替换为新 token

tiktoken._educational 在自身源码上训练为例,前若干次合并大致是:

轮次最常见 pair新 tokenrank本质
1b' ' + b' 'b' '257两个空格合并
4b'i' + b'n'b'in'260字母对合并
8b'c' + b'o'b'co'265字母对合并
12b'o' + b'k'b'ok'268字母对合并
13b'ok' + b'en'b'oken'269已合并 token 继续合并
14b't' + b'e'b'te'270字母对合并
21b'y' + b'te'b'yte'282已合并 token 继续合并

注意 269 号和 282 号——合并是递归叠加的:先 ok + enoken,再 t + oken 最终就可能变成 token

4.3 对中文会怎样?

"你好" → UTF-8: [228, 189, 160, 229, 165, 189]

如果训练语料中中文足够多:
  b'\xE4' + b'\xBD' + b'\xA0' → 合并成 b'\xE4\xBD\xA0' (= "你")
  → "你" 变成 1 个 token

如果训练语料中中文很少:
  b'\xE4' + b'\xBD' → 可能只合并成 2 字节 token
  → "你" 仍然要 2~3 个 token

BPE 不”认识”中文或英文,它只是在字节值序列上做统计合并。 训练语料里谁出现得多,谁就更容易被压缩成更短的 token 序列。所以以英文为主训练的模型对英文压缩率高、对中文/小语种压缩率低——并不是模型「歧视」中文,而是统计意义上中文在训练集里出现得不够多。

4.4 BPE 编码(推理时):用学到的规则压缩新文本

训练完成后,mergeable_ranks 就是一个 {bytes 序列: rank} 字典,rank 越小优先级越高。编码新文本时:

输入: "hello world"
  1. regex 切分    → ["hello", " world"]
  2. 转单字节序列  → [[104,101,108,108,111], [32,119,111,114,108,100]]
  3. 对每个词块调 bpe_encode:
     - 从单字节出发: [104, 101, 108, 108, 111]
     - 在所有相邻 pair 中找 rank 最小的合并
     - 反复合并直到没有可合并的 pair
     - → 合并成 [388]  (对应 b'hello')
     对 [32,119,111,114,108,100]:
     - → 合并成 [372]  (对应 b' world')
  4. 输出 token: [388, 372]

为什么是「rank 最小」优先? rank 越小 = 训练时越早学到的规则。按训练时的合并顺序来编码,才能保证结果与训练一致,也才能保证「无损可逆」。

5. 词表大小是怎么定的?一场 GPU 上的成本权衡

BPE 是「反复合并最常见的相邻 pair」,但合并多少次才停?为什么 GPT-2 选 50,257、cl100k_base 选 ~10 万、o200k_base 选 ~20 万,而不是 1 万或 100 万?

这不是语言学问题,而是一道 GPU 工程优化题。词表大小 VV 在硬件层面有两个对立的成本,需要找平衡点。

5.1 词表会扩张两个矩阵

假设:

  • VV:词表大小(如 50,000)
  • dd:模型隐藏维度(如 4,096)
  • BB:batch size
  • LL:每条样本的 token 长度

模型要把 token ID 映射成 dd 维向量,用的是输入 embedding 矩阵

ERV×dE \in \mathbb{R}^{V \times d}
V = 50,000,d = 4,096
Embedding 矩阵 = 50,000 × 4,096 = 204,800,000 个参数

BF16 / FP16 下,每参数 2 字节:
204.8M × 2 ≈ 410 MB

模型还要把最后的隐藏状态投影回整个词表,预测「下一个 token 是哪个」,对应输出投影矩阵

WoutRd×VW_{\text{out}} \in \mathbb{R}^{d \times V}

如果输入 embedding 和输出投影不共享权重,就又是一份约 410 MB。很多 Transformer 会做 weight tying 让两者共享同一套参数,省掉一份显存——但输出端的计算照样要做。

5.2 更大的开销在输出 logits

训练时,模型每个位置都要算出「词表里每个 token 的分数」,得到 logits 张量:

H×WoutlogitsRB×L×VH \times W_{\text{out}} \rightarrow \text{logits} \in \mathbb{R}^{B \times L \times V}
B = 8,L = 2,048,V = 50,000
logits 张量 = 8 × 2,048 × 50,000 = 819,200,000 个数

BF16 下:
819.2M × 2 ≈ 1.64 GB

这还只是 logits 本身,训练时还要算 softmax、交叉熵、反向梯度等中间张量。实际工程会用 fused kernel、分块计算、重算等手段降峰值显存,但词表越大,开销就越大的趋势是对的。

最后一层本质就是一个矩阵乘法:[B × L × d] × [d × V]。词表从 50k 翻到 100k,这一层的输出宽度需要 ×2。

5.3 反向收益:词表变大,序列可能变短?

如果只看 5.1 和 5.2,结论是「词表越小越好」。但有一个反向作用:

词表更大
  → tokenizer 能把更多常见片段合并成 1 个 token
  → 同样文本的 token 数变少
  → 序列长度 L 下降
  → Transformer 主体的计算下降

而 Transformer 主体对 LL 极其敏感:

  • Attention 的某些核心计算近似按 L2L^2 增长;
  • MLP、线性层按 LL 线性增长;
  • KV cache 按 LL 增长;
  • 自回归解码的步数也按 LL 增长。

所以词表大小是一场两类成本的对冲

词表变大后好处代价
token 更少序列更短、上下文容纳更多文本、解码步数更少
embedding 矩阵更大更容易覆盖罕见词、术语、代码、多语言参数 + 显存增加
输出 softmax 更大最后一层矩阵乘法和 logits 更重
多语言切分更好小语种不容易被切碎词表名额可能被高频语言挤占

5.4 长上下文模型为什么更愿意把词表做大

举个直观对比:同一篇中文文档被切成

方案 A:10,000 token
方案 B:7,000 token   (词表更大、最后 softmax 更贵)

方案 B 看似单步更贵,但它带来:

  • 更少的 KV cache(推理显存的大头);
  • 更少的 Transformer 层计算
  • 更低的 attention 二次方成本
  • 更大的实际可读文本容量(128k context 能装更多真实信息);
  • RAG 切块时更少的 token 浪费

这就是为什么从 GPT-3.5 的 cl100k_base(~10 万)到 GPT-4o 的 o200k_base(~20 万),词表整整翻了一倍的原因:长上下文 + 多语言 + 代码场景下,收益远大于最后一层 softmax 的额外开销

5.5 不同词表下 embedding 矩阵的体量对比

固定 d=4096d = 4096

词表大小Embedding 参数量单份 BF16 权重
32k约 1.31 亿约 262 MB
50k约 2.05 亿约 410 MB
100k约 4.10 亿约 819 MB
200k约 8.19 亿约 1.64 GB

如果输入输出不共享权重,再 ×2。训练时还会出现梯度、Adam 一阶/二阶动量、可能的 FP32 master weights……实际训练显存远高于权重文件本身

不过在超大模型里,Transformer 的 MLP 和 attention 通常占了绝大多数参数,词表矩阵是否「特别大」,取决于 VV 相对于模型维度和总参数量的比例。

5.6 还有一个细节:矩阵对齐

实际工程里,词表大小常被选成或 padded 到某些对齐值(64、128、256 的倍数附近)。原因不是语言学,而是 GPU 矩阵乘法硬件喜欢规则的尺寸:Tensor Core、tensor parallel 切分、内存布局、kernel 实现都在对齐尺寸上更高效。

比如模型在 8 张 GPU 上做 tensor parallel 时,希望 V/8V / 8 是整齐的大小,方便每张卡负责词表的一部分。所以一个团队可能不会选 50,003 这种”恰好”的数字,而是 50,176、50,688 或 51,200 这种容易对齐和分片的值。

GPT-2 的 50,257 就是个例子:50,000 个 BPE 合并 token + 256 个字节 token + 1 个 <|endoftext|>——表面上看像是”语言学决定的”,背后其实是「凑齐后再做对齐」的工程选择。

5.7 回到「中文为何可能更耗 token」

GPU/模型预算
  → 能接受多大的 V
  → 给中文分到多少有效词表项
  → 中文常见字、词、短语能否被合并
  → 实际中文 token 数

所以并不是网上流传的所谓近似数据:

汉字 3 字节 → 必然 3 倍 token → 中文必然更贵

中文是否被切得碎,取决于该模型是否愿意、也是否有足够词表容量,把常见中文字、词、短语和中英混排模式学进去。词表太小或中文训练数据占比太低时,中文容易吃亏;词表设计得好时,中文完全可能比英文更紧凑——cl100k_base 到 o200k_base 的变化就是最好的证据。

6. Regex 预切分:BPE 的「围墙」

如果直接把整篇文章的字节流喂给 BPE,会面临一个比较严重问题:

原文: "hello world"

如果不对 BPE 设边界:
  "hello" 的最后一个字节 + "world" 的第一个字节
  → b'o' + b' '  → 可能被合并成 b'o '
  → b' ' + b'w'  → 可能被合并成 b' w'

合并跨越了单词边界,产生 b'o ', b' w' 这种毫无语义的 token,
浪费词表空间,破坏跨上下文的泛化能力。

所以 GPT-2 引入了 regex 预切分:先用一个精心设计的正则把文本切成「伪词」(pre-tokens),然后在每个伪词内部独立地做 BPE 合并。BPE 永远不会跨越伪词边界。

6.1 GPT-2 的 regex pattern 拆解

gpt2_pattern = r"""'s|'t|'re|'ve|'m|'ll|'d| ?[\p{L}]+| ?[\p{N}]+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""

| 分隔六条子规则,优先级从左到右递减。以 "Don't look back 123 times!" 为例:

子规则匹配内容作用
's|'t|'re|'ve|'m|'ll|'d't英文缩写单独切开,'t 可在 don't/can't/won't 间共享
?[\p{L}]+Don, look, back, times字母词,前面的 ? 把前导空格吸进token 里面
?[\p{N}]+123数字串,同样把前导空格吸进去
?[^\s\p{L}\p{N}]+!标点串,前导空格也吸进去
\s+(?!\S)(行尾空白,此例无)行尾的纯空白单独切
\s+其余多余空白兜底

切分结果:

"Don"   "'t"   " look"   " back"   " 123"   " times"   "!"

6.2 前导空格 ? 是什么 ?

注意 ?[\p{L}]+ 中开头那个 ?(可选空格)。它带来两个核心好处:

"hello world"  →  ["hello", " world"]
                     ↑          ↑
                  无前空格   空格被吸进 "world" 这个 token 里
  1. 自然重建原文:解码时 token " world" = + world,拼回去就是 "hello world",不需要任何额外的空格处理逻辑;
  2. 区分词首与词中" world"(句中)和 "world"(句首/紧跟标点)在 BPE 眼中是不同的字节序列起点,会被分配成不同的 token ID。

这也是为什么 hellohello 在 tiktoken 中常常对应不同的 token。

6.3 完整预处理流程

输入: "Don't look back 123 times!"

    │  regex.findall(gpt2_pattern, text)

["Don", "'t", " look", " back", " 123", " times", "!"]

    │  每个词块分别 .encode("utf-8") → 单字节序列

[[68,111,110], [39,116], [32,108,111,111,107], [32,98,97,99,107],
 [32,49,50,51], [32,116,105,109,101,115], [33]]

    │  对每个词块内部分别执行 BPE 合并

[b'Don'], [b"'t"], [b' look'], [b' back'], [b' 123'], [b' times'], [b'!']

    │  查 mergeable_ranks → token ID

[4652, 1109, 1820, 2314, 4186, 1788, 0]

regex 切分定义了 BPE 的「围墙」——合并只发生在词块内部,永远不会跨越词块边界。这就是为什么 GPT 系列模型的所有 subword token 都不会跨单词。

7. 特殊词表:模型的带内控制信号

到目前为止,所有 token 都来自 UTF-8 字节经 BPE 合并的结果,它们都可以被「字符串化」。但 LLM 还需要一类永远不会自然出现在普通文本中的特殊 token,作为模型与上层框架之间的「带内控制信号」:

特殊 token用途
<|endoftext|>标记一段文档/对话结束,训练时切分样本边界
<|startoftext|>部分模型在每段开头插入
<|im_start|> / <|im_end|>ChatML 格式中的消息边界(OpenAI 聊天模型)
<|fim_prefix|> / <|fim_middle|> / <|fim_suffix|>Fill-in-the-Middle,代码补全场景
<|system|> / <|user|> / <|assistant|>各家自定义的角色 token

这些 token 不参与 BPE 合并,而是被显式追加在词表末尾,分配独立的 ID。tiktoken 在编码时会做严格隔离:

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

# 默认情况:用户输入中出现 <|endoftext|> 字符串会直接抛错
enc.encode("hello <|endoftext|> world")
# raises ValueError

# 显式声明信任:作为特殊 token 处理
enc.encode("hello <|endoftext|> world", allowed_special={"<|endoftext|>"})
# → [..., 100257, ...]

# 显式声明禁止:作为普通字面量逐字节切分(不会变成 100257)
enc.encode("hello <|endoftext|> world", disallowed_special=())

这个隔离机制的本质是防止 prompt injection。如果攻击者在用户输入里写一段 <|im_end|><|user|>...<|im_end|>,而上层粗心地直接拼进 prompt,模型就可能被骗。tiktoken 的默认行为是「碰到特殊 token 字符串就报错」,强制开发者显式声明是否信任输入来源。

这也解释了为什么特殊 token 在词表中是「保留区」——它们不能被自然文本无意命中。哪怕用户真的输入了 <|endoftext|> 这个字符串,只要按普通文本走,BPE 也只会把它切成 <|end、…… 这种零碎的字节 token,绝不会变成那个单独的特殊 ID。

8. 完整链路串联

把前面所有层叠起来:

"Hi 你好<|endoftext|>"  (人类看到的字符串)

    │  Unicode 标准

[72, 105, 32, 20320, 22909] + 特殊 token 标记  (码点 + 特殊标识)

    │  UTF-8 编码(仅对自然文本部分)

[72, 105, 32, 228, 189, 160, 229, 165, 189]  (字节序列)

    │  GPT-2 regex 预切分

["Hi", " 你好"]  (伪词)

    │  Byte-level BPE 合并(256 初始 token + 训练学到的合并规则)

[13347, 50230, ...]  (常规 token ID,取决于具体编码)

    │  追加特殊 token

[13347, 50230, ..., 100257]  (最终送入模型的整数序列)

每一层都是对上一层的纯函数转换

层级输入输出转换规则谁定义
Unicode字符码点查表Unicode 标准化组织
UTF-8码点字节序列位运算 + 变长编码RFC 3629
Pre-token字节序列伪词数组regex模型作者(如 GPT-2)
BPE伪词(字节)token ID 数组训练学到的合并规则训练语料统计
Special上层控制信号保留 token ID词表末尾保留 + 编码时隔离框架/模型上层协议

BPE 始终工作在字节层。 它对字符、码点、语言都一无所知,只是在 0~255 的数字序列上做统计。BPE 词表里看到的 b'in'b'oken' 等可读片段,只是因为 ASCII 范围的字节值恰好对应可打印字符——这是巧合,不是 BPE 在「理解语言」

9. tiktoken 实操:Token 计数与 Context Window 管理

OpenAI 开源的 tiktoken 是 GPT 系列模型实际使用的 BPE 实现(Rust 写的,比纯 Python 快上百倍)。日常工程中,它最有用的场景是在请求发出之前,准确知道这段 prompt 会消耗多少 token

9.1 基础用法

import tiktoken

# 方式 1:按模型名拿编码器(推荐,自动选对应版本)
enc = tiktoken.encoding_for_model("gpt-4o")         # → o200k_base
enc = tiktoken.encoding_for_model("gpt-4")          # → cl100k_base
enc = tiktoken.encoding_for_model("gpt-3.5-turbo")  # → cl100k_base

# 方式 2:按编码名拿(只关心编码方案时使用)
enc = tiktoken.get_encoding("cl100k_base")

# 编码
tokens = enc.encode("Hello, 你好世界!")
print(tokens)
# [9906, 11, 220, 57668, 53901, 3574, 30543, 6447]

# 看看每个 token 长啥样
for t in tokens:
    print(t, repr(enc.decode([t])))
# 9906  'Hello'
# 11    ','
# 220   ' '
# 57668 '你'
# 53901 '好'
# 3574  '世'
# 30543 '界'
# 6447  '!'

# 反编码
text = enc.decode(tokens)   # 'Hello, 你好世界!'

# 只关心数量
n = len(enc.encode(prompt))

观察上面的结果:

  • "Hello" 是 1 个 token(英文常见词被完整学进了词表);
  • "," 单独 1 个 token;
  • " " 单独 1 个 token(因为后面跟的是中文,前导空格没有被「吸进」中文 token);
  • "你""好""世""界" 各 1 个 token(cl100k_base 中常用中文字基本各自 1 token);
  • "!"(中文全角)1 个 token。

9.2 中文真的「更耗 token」吗?拿数据说话

第 3 节末尾已经指出「中文字符 3 字节」是 UTF-8 编码层面的事实,不能直接推出「中文 prompt 必然更耗 token」。但反过来,到底实际差距有多大?这个问题没法靠推理回答,只能靠实测。

错误的比较方式

最常见的两种错误对比:

  • 按字符数比"人工智能" 4 字 vs "artificial intelligence" 23 字符——拿这个比 token 数毫无意义,因为信息量不等价。
  • 按字节数比:4 × 3 = 12 字节 vs 23 字节——中文反而更少,又会得出相反的错误结论。

正确的对比应该使用同义平行文本:同一句话的自然中文表达与自然英文表达,分别用目标模型的 tokenizer 编码,比较 token 数。这正是学术界做的事——例如 FLORES 平行语料库就是把同一批句子翻译成 200 多种语言,用于跨语言公平比较。

一组 tiktoken 对照实验

用 10 组手工对齐的中英文句对,分别在 cl100k_base (GPT-3.5/4) 和 o200k_base (GPT-4o / o1) 下编码:

编码器同义 prompt ZH/EN token 比34 个常用汉字的平均 token 数词表大小
cl100k_base (GPT-3.5 / GPT-4)1.86×1.12 tok/字(30 个 1 tok,4 个 2 tok)~10 万
o200k_base (GPT-4o / o1)1.13×1.00 tok/字(34 个全部 1 tok)~20 万

几个具体对比例子(o200k_base):

"I love natural language processing."             → 6 tok
"我喜欢自然语言处理。"                              → 6 tok    (1.00×)

"Artificial intelligence is changing the world."  → 7 tok
"人工智能正在改变世界。"                            → 6 tok    (0.86×,中文反而更短)

"Machine learning models require large amounts of data and computation."  → 11 tok
"机器学习模型需要大量的数据和计算资源。"                                    → 10 tok  (0.91×)

学术界怎么说

上面的小样本实测和已发表的研究结论方向一致,但学术研究覆盖的语言范围远比”中英文”宽得多,结论也更细致:

  • Ahia et al., EMNLP 2023, Do All Languages Cost the Same?:在 FLORES 平行语料上比较了 ChatGPT/GPT-3.5 tokenizer 对不同语言的 token 消耗,发现最不利的语言编码同一内容时最多要用约 5× 的 token。差异来源是训练语料分布 + 文字系统属性的联合作用,而不是单由 UTF-8 字节长度决定。
  • Petrov et al., NeurIPS 2023, Language Model Tokenizers Introduce Unfairness Between Languages:研究了多个 tokenizer,发现同一句话翻译成不同语言后,token 数可能出现很大倍数差异;论文的重点是 tokenizer 的词表分配和语言覆盖不公平,而非”某种文字天生更贵”。
  • Limisiewicz et al., ACL Findings 2023:进一步表明,多语言 tokenizer 中某语言获得多少专属词表项、不同语言如何共享词表,直接影响下游效率与表现。

也就是说:“某语言更耗 token” 是一个模型与 tokenizer 特定的经验问题,不是 UTF-8 的必然推论。

正确的表述

原说法判定正确的版本
英文字符 1 字节、中文 3 字节基本正确这是 UTF-8 字节层面的事实(少数扩展汉字 4 字节)
所以同样信息量的中文 prompt 通常更耗 token过度推论对某些英文中心、中文覆盖不足的 tokenizer 经验上可能成立,但不能从字节数直接推出,也不是跨模型规律
BPE 也无法完全抹平这个差距不严谨BPE 完全可以把汉字的多字节、常用中文词乃至常见短语合并成单个 token;真正的限制是 tokenizer 词表对中文的覆盖,而非 UTF-8 的 3 字节性质

实践建议

不要猜,直接用目标模型的 tokenizer 数。OpenAI 官方提供了 Tokenizer 页面;本地用 tiktoken.encoding_for_model("gpt-4o").encode(text) 一行就能拿到准确数字。如果是 Claude/Gemini/开源模型,对应的 tokenizer 也都有 SDK。别用一个模型的 token 比例去外推另一个模型的成本。

9.3 Context Window 管理的常见模式

Context Window 是以 token 数而非字符数计的上限:GPT-4o 是 128k,Claude 3.5 是 200k,o1 系列是 200k。输入 + 输出都计入这个窗口。

模式一:请求前预算检查

MAX_INPUT = 120_000     # 给输出留 8k
MAX_OUTPUT = 8_000

def safe_call(prompt: str, model: str = "gpt-4o"):
    enc = tiktoken.encoding_for_model(model)
    n = len(enc.encode(prompt))
    if n > MAX_INPUT:
        raise ValueError(f"prompt too long: {n} > {MAX_INPUT}")
    return openai.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=MAX_OUTPUT,
    )

模式二:按 token 截断长文本(不能按字符截断,会把多字节字符切碎)

def truncate_by_token(text: str, max_tokens: int, model: str = "gpt-4o") -> str:
    enc = tiktoken.encoding_for_model(model)
    tokens = enc.encode(text)
    if len(tokens) <= max_tokens:
        return text
    return enc.decode(tokens[:max_tokens])

注意:enc.decode 即使切在多字节字符中间也能正确处理——tiktoken 内部用 errors="replace" 兜底,不会抛异常,但末尾可能出现替换符。如果对边界敏感,使用 decode_with_offsets 或自行在字符边界对齐。

模式三:滑动窗口的对话历史压缩

def trim_messages(messages: list[dict], max_tokens: int, model: str = "gpt-4o"):
    enc = tiktoken.encoding_for_model(model)
    def count(m): return len(enc.encode(m["content"])) + 4   # ChatML 包装开销

    total = sum(count(m) for m in messages)
    system = [m for m in messages if m["role"] == "system"]
    rest   = [m for m in messages if m["role"] != "system"]
    while total > max_tokens and len(rest) > 2:
        dropped = rest.pop(0)
        total -= count(dropped)
    return system + rest

模式四:精确的 ChatML 计费

OpenAI 聊天接口的 token 计数并不是简单地 len(enc.encode(content)),而是包括了 <|im_start|>role<|im_end|> 等控制 token。社区已有标准实现(参考 官方 cookbook):

def num_tokens_from_messages(messages, model="gpt-4o"):
    enc = tiktoken.encoding_for_model(model)
    tokens_per_message = 3   # <|start|>role\ncontent<|end|>
    tokens_per_name    = 1
    n = 0
    for m in messages:
        n += tokens_per_message
        for k, v in m.items():
            n += len(enc.encode(v))
            if k == "name":
                n += tokens_per_name
    n += 3                   # 每次回复以 <|start|>assistant<|end|> 起头
    return n

模式五:流式输出时的实时计数

stream = client.chat.completions.create(model="gpt-4o", messages=..., stream=True)
out_tokens = 0
for chunk in stream:
    delta = chunk.choices[0].delta.content or ""
    out_tokens += len(enc.encode(delta))
    if out_tokens > MAX_OUTPUT:
        break    # 主动切断

10. 推荐玩具:tiktoken._educational

tiktoken 仓库里藏着一个少有人提的模块 tiktoken/_educational.py。它是纯 Python 实现的 BPE 训练 + 编码 + 可视化,专门为教学而生——完全不追求性能,但能把本文讨论的每一个细节都看得见、摸得着。

from tiktoken._educational import SimpleBytePairEncoding, train_simple_encoding

# 1. 在 _educational.py 自身源码上训练一个 mini 编码器
enc = train_simple_encoding()
# 训练过程会逐步打印:
#   round 1: merging (b' ', b' ') -> b'  '  (rank=257)
#   round 4: merging (b'i', b'n') -> b'in'  (rank=260)
#   ...
#   round 13: merging (b'ok', b'en') -> b'oken'  (rank=269)

# 2. 用它编码任意文本,并打印每一步合并过程
enc.encode("hello world", visualise="colour")
# 会用颜色把每次合并的 pair 高亮出来

# 3. 也可以加载真实的 cl100k 编码器,但用慢速 Python 实现来观察
import tiktoken
real = tiktoken.get_encoding("cl100k_base")
slow = SimpleBytePairEncoding(
    pat_str=real._pat_str,
    mergeable_ranks=real._mergeable_ranks,
)
slow.encode("Don't look back!", visualise="colour")

跑一遍 train_simple_encoding() 即可观察到:

  • 第 1 轮就把 (空格, 空格) 合并了——因为源码里两空格缩进太常见;
  • 字母对会比标点对更早被合并;
  • 已合并的 token 会在后续轮次里继续作为「原子」参与更上层的合并(如 ok + enoken);
  • 整个过程没有「语言」概念,完全是字节频次驱动。

读懂这两百行代码远比读十篇博客更能说清 BPE 的工作机制——它就是这么朴素,又这么有效。

11. 总结

整条流水线本质上是把人类世界的「字符」这个高维抽象,逐层降维成 LLM 唯一能消化的整数序列。几个会反复用到的事实:

  1. BPE 不理解语言,只统计字节——所以中文、emoji、乱码、二进制都能编码,但效率取决于训练语料分布;
  2. regex 预切分是 BPE 的「围墙」——subword 永远不会跨单词;
  3. 前导空格被吸进 token——所以 "hello"" hello" 是不同 token,且解码无需额外处理空格;
  4. 特殊 token 是带内控制信号——使用前请决定信任策略,否则 tiktoken 会主动报错防止 prompt injection;
  5. Context Window 以 token 计——任何长文本处理、对话历史维护、流式输出,都应基于 tiktoken 做精确预算,而不是字符长度。

从一段「人话」到模型眼中的一串整数,中间没有任何神秘魔法,只有 Unicode 的查表、UTF-8 的位运算、regex 的语言学经验,和 BPE 在字节频次上的贪心合并。理解这条流水线,才真正理解 token 计费的来由、上下文窗口的边界,以及为什么有些 prompt 写法比另一些「便宜」得多。


Share this post on:

Previous Post
作为研发直接负责具体业务的感受:SEO 业务与产品的价值
Next Post
谢赛宁的启示与世界模型的布道