“你好,世界”有 5 个字符,用 cl100k_base 编码会得到 6 个 Token,换成 o200k_base,又只需要 3 个。
文字没有变,变化的是分词规则。大语言模型处理文本前,需要先通过分词器把文字转换成 Token;同一段文字,按照不同规则切分,得到的数量也可能不同。
我们平时看到的上下文长度、输入输出用量,都以 Token 计量。所以,计算模型用量之前,需要先弄清楚文字是怎样变成 Token 的,以及它和字符、字节有什么关系。
Token 与分词器
分词器(tokenizer)负责切分文本,并把切分结果编码成模型可以处理的编号。切出来的单位叫 Token,对应的整数编号叫 Token ID。
比如,cl100k_base 会把 hello world 编码成下面这样:
原文:hello world
片段:[hello] [ world]
编号:[15339, 1917]这里有两个 Token,分别对应编号 15339 和 1917。模型接收到这些编号后,再通过嵌入层把它们转换成向量,参与后续计算。编号来自分词器的词表,换一套词表,相同数字不一定表示相同内容。
第二个 Token 是 world,前面的空格也包含在里面。分词器并不是看到空格就拆开,也不会保证一个单词对应一个 Token。一个词可能拆成几部分,也可能与空格、标点组合;多个字符可以合成一个 Token,一个字符也可能拆成多个 Token。
字符、字节与 Token
拿到一段文本后,我们可以统计它有多长,但要先确定统计的是什么。
在 Python 中,len(text) 统计 Unicode 码位,len(text.encode("utf-8")) 统计编码后的字节数。“你好,世界”包含 5 个码位,这几个字符在 UTF-8 中各占 3 字节,合计 15 字节。
对于这句话,一个字符刚好对应一个码位,但部分组合字符和 emoji 会由多个码位组成,人眼看到的一个字符不一定能直接按 len() 的结果计数。Python Unicode 文档介绍了字符和字节的表示方式。
再把分词结果放在一起比较。下面使用 tiktoken==0.11.0 计算:
| 文本 | Unicode 码位 | UTF-8 字节 | cl100k_base | o200k_base |
|---|---|---|---|---|
hello world | 11 | 11 | 2 | 2 |
你好,世界 | 5 | 15 | 6 | 3 |
print('你好,世界') | 14 | 24 | 9 | 6 |
🙂 | 1 | 4 | 2 | 1 |
hello world 有 11 个码位,只需要 2 个 Token;“你好,世界”只有 5 个码位,在 cl100k_base 中却需要 6 个。内容从英文换成中文或代码,字符数和 Token 数之间的比例就会变化。
即使内容相同,换一套分词规则也会得到不同结果。tiktoken 使用字节级 BPE(Byte Pair Encoding):训练分词器时,从基础片段开始,反复合并语料中高频相邻片段,逐渐形成词表和合并规则。之后编码新文本,用的就是已经学到的规则,不需要为每次输入重新训练。
不同的训练数据、词表、预切分规则和合并规则,都会影响一段文字最终怎样切分。BPE 也不能简单理解成“每次找一个最长的单词”。BPE 的训练与编码过程介绍了规则怎样形成、又怎样应用到新文本上。
所以,“一个汉字算一个 Token”“四个字符算一个 Token”可以用于特定文本的粗估,但没有一个比例能通用于所有文本和分词器。
分词实验
用 tiktoken 可以直接查看一段文字被切成了什么,不需要调用模型 API。下面的脚本会比较两套 tokenizer,并打印“你好,世界”的 Token ID 和每个 Token 对应的字节。
本例使用 Python 3.10 及以上,接口用法见 tiktoken 版本说明。在独立目录中创建虚拟环境并安装依赖,安装和首次下载词表需要网络:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install "tiktoken==0.11.0"将代码保存为 token_demo.py,运行 python token_demo.py:
import tiktoken
samples = ["hello world", "你好,世界", "print('你好,世界')", "🙂"]
encodings = {
name: tiktoken.get_encoding(name)
for name in ("cl100k_base", "o200k_base")
}
print("文本 | 码位 | 字节 | cl100k | o200k")
for text in samples:
counts = [len(enc.encode(text)) for enc in encodings.values()]
print(
f"{text!r} | {len(text)} | {len(text.encode('utf-8'))}"
f" | {counts[0]} | {counts[1]}"
)
encoding = encodings["cl100k_base"]
token_ids = encoding.encode("你好,世界")
pieces = [encoding.decode_single_token_bytes(i) for i in token_ids]
print("Token ID:", token_ids)
print("Token 字节:", [piece.hex(" ") for piece in pieces])
print("解码:", encoding.decode(token_ids))
print("分开编码:", len(encoding.encode("hel")) + len(encoding.encode("lo")))
print("整体编码:", len(encoding.encode("hello")))输出:
文本 | 码位 | 字节 | cl100k | o200k
'hello world' | 11 | 11 | 2 | 2
'你好,世界' | 5 | 15 | 6 | 3
"print('你好,世界')" | 14 | 24 | 9 | 6
'🙂' | 1 | 4 | 2 | 1
Token ID: [57668, 53901, 3922, 3574, 244, 98220]
Token 字节: ['e4 bd a0', 'e5 a5 bd', 'ef bc 8c', 'e4 b8', '96', 'e7 95 8c']
解码: 你好,世界
分开编码: 2
整体编码: 1encode() 返回一个 Token ID 列表,列表有多长,这段文本就有多少个 Token。decode() 则把整串编号还原成文本。
脚本只用到了分词器,词表下载后会在本地缓存。如果加载时出现下载错误,检查网络和缓存即可,不需要另外部署推理模型。
字节边界与文本边界
在上面的输出中,“世”的 UTF-8 编码是 e4 b8 96,但它被拆成了 e4 b8 和 96,分别放进两个 Token:
[你] [好] [,] [世的前两个字节] [世的最后一个字节] [界]这就是“你好,世界”有 5 个字符,却算出 6 个 Token 的原因。换成 o200k_base,它会切成 [你好] [,] [世界],于是只需要 3 个。
一个 Token 包含的字节不一定刚好构成完整字符。这里用 decode_single_token_bytes() 查看字节,就是为了保留切分时的样子。如果把不完整的字节单独解码成字符串,可能看到替换字符;把完整 Token 序列交给 decode(),才能还原原文。相关行为见 tiktoken 解码接口。
切分结果还与文本怎样拼接有关。脚本最后把 hel 和 lo 分别编码,合计 2 个 Token;先拼成 hello 再编码,就只需要 1 个。
因此,分块读取文件、接收流式片段或拼接提示词时,各块 Token 数相加不一定等于完整文本的 Token 数。需要统计最终输入时,应把文本按实际发送的方式组织好,再进行计数。
计数与调用用量
上面的脚本算出了几段文本的长度。实际调用模型时,输入里往往还有系统指令、历史消息、检索资料、工具定义和工具结果,以及模型要求的消息格式。这些内容一起进入上下文,也会占用输入 Token。
所以,聊天框里只输入了一句话,不代表这次调用只需要处理这一句话。程序把哪些内容一起传过去,就要把哪些内容算进去。HTTP 请求 JSON 则是接口传输格式,直接统计它的字符或字节,也得不到模型处理的 Token 数。
在应用中,这几种计数各有用途:
- 长度约束:按产品约定统计字符、码位或字节,例如表单限制和文件大小限制。
- 上下文预算:使用目标模型的 tokenizer 和消息格式估算输入,为生成留出空间。
- 调用计量:记录服务返回的 usage,作为实际输入、输出和缓存等分类的依据。
对于同一段文本,同一套 tokenizer 会给出确定的计数。要让本地结果接近实际调用,还需要使用目标模型的规则,并统计完整输入。
服务返回的 usage 用来记录这次调用处理了多少输入、生成了多少输出,以及缓存等分类用量。如果没有取得这个字段,应保留缺失状态;按字数估出来的结果仍然只是估算,不能填成实际用量。
上下文的组织见 一次模型调用的输入究竟包含什么;得到用量后,还需要按照不同类别的价格计算费用,见 用 Python Decimal 计算 Token 费用:单位、缓存与金额精度。