1. 什么是分词器? #
- 分词器(Tokenizer) 是把自然语言文本转成模型能处理的 Token 整数编号 的工具。
- 大模型不直接读汉字或英文,而是读整数序列;分词器负责「文本 → Token 编号 → 文本」的双向转换。
- 项目里最常用的场景:调用大模型 API 之前统计 Token 数,避免超出上下文长度、控制费用。
- 不同模型厂商使用不同的分词算法和词表,不能混用。
- 本教程介绍两个常用分词库:tiktoken(OpenAI 系列)和 deepseek_tokenizer(DeepSeek 系列)。
2. 前置知识 #
- 使用分词器前,先弄清下面三个概念,后面两个库的用法会更容易理解。
2.1 什么是 Token? #
- Token 是模型处理文本的最小单位,可以是一个字、一个词,或常见词片段。
- 代码里体现为整数列表,例如
[24912, 2375]。 - 字符数 ≠ Token 数:中文通常比同长度的英文消耗更多 Token。
- 经验参考:英文约 1 Token ≈ 4 字符;中文约 1 汉字 ≈ 1~2 Token(因模型而异)。
2.2 Token 与 API 计费 #
- 调用大模型 API 时,输入和输出都按 Token 计费。
- 每个模型有上下文长度上限(如 128K),超出会被拒绝或截断。
- 发送请求前用分词器本地估算 Token,是最常见的用法。
- 实际计费以 API 返回的
usage字段为准;本地分词器用于发送前预估。
2.3 用哪个分词器? #
- 关键原则:分词器必须和 API 调用的模型一一对应,选错会导致计数偏差。
- 下面是对照表,按你对接的平台选择即可。
| API 平台 | 分词库 | 典型模型 |
|---|---|---|
| OpenAI | tiktoken | gpt-4o、gpt-3.5-turbo |
| DeepSeek | deepseek_tokenizer | deepseek-v4-pro、deepseek-v4-flash |
- 对接 OpenAI → 看 §3;对接 DeepSeek → 看 §4。
3. tiktoken(OpenAI 系列) #
- tiktoken 是 OpenAI 开源的 Python 分词库(当前版本 0.12.x)。
- 适用于 GPT-4o、GPT-3.5-turbo 等 OpenAI 系列模型。
- 核心用法:
encoding_for_model获取编码器 →encode编码 →len()统计数量。 - 不支持 DeepSeek 模型;对接 DeepSeek 请跳到 §4。
3.1 安装 #
# 说明:安装 tiktoken 分词库
py -m pip install tiktoken- 下载慢时可加镜像:
py -m pip install tiktoken -i https://pypi.tuna.tsinghua.edu.cn/simple
3.2 验证安装 #
- 能打印版本号并完成一次 encode,即表示安装成功。
# -*- coding: utf-8 -*-
# 说明:导入 tiktoken 库
import tiktoken
# 说明:打印 tiktoken 版本号
print("tiktoken 版本:", tiktoken.__version__)
# 说明:按模型名获取编码器(gpt-4o 使用 o200k_base)
enc = tiktoken.encoding_for_model("gpt-4o")
# 说明:将文本编码为 Token 列表
tokens = enc.encode("hello world")
# 说明:打印 Token 数量
print("Token 数量:", len(tokens))
# 说明:打印验证通过提示
print("验证通过")3.3 基本用法 #
- 推荐始终用
encoding_for_model("模型名"),自动匹配正确编码。 decode用于把 Token 列表还原为文本,调试时偶尔用到。
# -*- coding: utf-8 -*-
# 说明:导入 tiktoken
import tiktoken
# 说明:按目标模型名获取编码器(与 API 调用时使用同一模型名)
enc = tiktoken.encoding_for_model("gpt-4o")
# 说明:待统计的文本
text = "Python 是一门简洁易学的编程语言。"
# 说明:编码为 Token 列表
tokens = enc.encode(text)
# 说明:打印 Token 数量(项目中最常用的写法)
print("Token 数量:", len(tokens))
# 说明:解码还原原文(验证 encode 是否正确)
print("解码结果:", enc.decode(tokens))3.4 模型与编码对照 #
- 不同 OpenAI 模型使用不同编码;不确定时直接用
encoding_for_model即可。
| 编码名称 | 典型模型 |
|---|---|
o200k_base |
GPT-4o、GPT-4o-mini、GPT-4.1、o1、o3-mini |
cl100k_base |
GPT-4、GPT-3.5-turbo、text-embedding-3-small |
3.5 统计单段文本 #
- 把计数逻辑封装成函数,传入文本和模型名即可复用。
# -*- coding: utf-8 -*-
# 说明:导入 tiktoken
import tiktoken
def count_tokens(text: str, model: str = "gpt-4o") -> int:
"""统计一段文本在 OpenAI 模型下的 Token 数量。"""
# 说明:按模型名获取编码器
enc = tiktoken.encoding_for_model(model)
# 说明:编码后返回 Token 个数
return len(enc.encode(text))
# 说明:测试 Prompt 文本
prompt = "请用三句话介绍 Python 的特点。"
# 说明:统计 Token 数并打印
print("Token 数量:", count_tokens(prompt))3.6 统计 Chat API 消息列表 #
- 调用 Chat API 时传入 messages(含 system、user、assistant 等 role)。
- 除各条
content外,还有消息格式本身的固定开销。 - 下面函数给出近似值,足够用于发送前的长度检查。
# -*- coding: utf-8 -*-
# 说明:导入 tiktoken
import tiktoken
def count_message_tokens(messages: list, model: str = "gpt-4o") -> int:
"""估算 OpenAI Chat API messages 列表的总 Token 数(近似值)。"""
# 说明:按模型获取编码器
enc = tiktoken.encoding_for_model(model)
# 说明:每条消息的固定格式开销
tokens_per_message = 3
# 说明:累计 Token 总数
total = 0
# 说明:遍历每条消息
for msg in messages:
# 说明:加上本条消息的固定开销
total += tokens_per_message
# 说明:遍历 role、content 等字段并累加 Token
for key, value in msg.items():
total += len(enc.encode(value))
# 说明:加上模型回复的起始开销
total += 3
return total
# 说明:构造典型的对话消息
messages = [
{"role": "system", "content": "你是一名 Python 编程助手,回答简洁清晰。"},
{"role": "user", "content": "列表和元组有什么区别?"},
]
# 说明:估算并打印总 Token 数
print("messages 约", count_message_tokens(messages), "tokens")4. deepseek_tokenizer(DeepSeek 系列) #
- deepseek_tokenizer(当前版本 0.3.0)是 DeepSeek V4 系列官方分词库,无第三方运行时依赖。
- 适用于
deepseek-v4-pro、deepseek-v4-flash等 V4 系列模型。 - 对接 DeepSeek API 时不要用 tiktoken,两者词表不同,计数会偏差。
- 官方文档:Token 用量计算。
4.1 安装 #
# 说明:安装 DeepSeek 官方分词库
py -m pip install deepseek-tokenizer- 下载慢时可加镜像:
py -m pip install deepseek-tokenizer -i https://pypi.tuna.tsinghua.edu.cn/simple
4.2 基本用法 #
- 导入全局对象
ds_token,调用encode/decode即可,用法与 tiktoken 类似。 len(ds_token.encode(text))统计的是纯文本内容的 Token 数。- Chat API 的
usage.prompt_tokens还会加上 role、消息格式等开销,通常比纯文本多几个 Token。
# -*- coding: utf-8 -*-
# 说明:从 deepseek_tokenizer 导入官方分词器
from deepseek_tokenizer import ds_token
# 说明:待统计的文本
text = "你是谁?"
# 说明:编码为 Token 列表
tokens = ds_token.encode(text)
# 说明:打印 Token 列表
print("Token 列表:", tokens)
# 说明:打印纯文本 Token 数量
print("内容 Token 数:", len(tokens))
# 说明:解码还原原文
print("解码结果:", ds_token.decode(tokens))- 上面
"你是谁?"通常编码为 2 个 Token。 - 但通过 Chat API 发送时,
usage.prompt_tokens可能是 6 左右。 - 多出的部分来自消息格式(
role: user、分隔符等),以 API 返回的usage为准。
4.3 封装计数函数 #
- 项目中对接 DeepSeek 时,建议单独封装计数函数,与 OpenAI 的 tiktoken 函数分开。
# -*- coding: utf-8 -*-
# 说明:导入 DeepSeek 官方分词器
from deepseek_tokenizer import ds_token
def count_deepseek_tokens(text: str) -> int:
"""统计 DeepSeek 模型下一段纯文本的 Token 数量。"""
# 说明:编码后返回 Token 个数
return len(ds_token.encode(text))
# 说明:测试 Prompt 文本
prompt = "请用三句话介绍 Python 的特点。"
# 说明:统计并打印 Token 数
print("DeepSeek 内容 Token 数:", count_deepseek_tokens(prompt))4.4 两种分词器对比 #
- 同一段文本,tiktoken 和 deepseek_tokenizer 的结果不同,不能混用。
- 下面示例展示
"你是谁?"在两种分词器下的差异。
# -*- coding: utf-8 -*-
# 说明:导入 tiktoken
import tiktoken
# 说明:导入 DeepSeek 官方分词器
from deepseek_tokenizer import ds_token
# 说明:对比用的短文本
text = "你是谁?"
# 说明:用 tiktoken(OpenAI 编码,不能用于 DeepSeek 计费)
tiktoken_count = len(tiktoken.get_encoding("cl100k_base").encode(text))
# 说明:用 DeepSeek 官方分词器
deepseek_count = len(ds_token.encode(text))
# 说明:打印对比结果
print(f"文本:{text!r}")
print(f"tiktoken (cl100k_base):{tiktoken_count} tokens ← OpenAI 用")
print(f"deepseek_tokenizer: {deepseek_count} tokens ← DeepSeek 用")
print("Chat API prompt_tokens 还会再加消息格式开销,以 usage 为准")5. BPE 分词原理 #
- BPE(Byte Pair Encoding,字节对编码) 是 tiktoken 和 deepseek_tokenizer 底层使用的核心算法。
- 理解 BPE 有助于明白:为什么同一个字在不同模型里 Token 数不同、为什么分词器不能混用。
- BPE 的思路:从单个字符出发,反复合并语料中出现最频繁的相邻片段,形成越来越长的 Token。
5.1 BPE 的两个阶段 #
- 训练阶段(离线):用大量语料统计高频字符对,逐轮合并,生成词表和合并规则;模型发布前已完成,用户无需自己训练。
- 编码阶段(在线):输入新文本时,按合并规则把字符逐步合并成 Token,再查表转为整数 ID。
- 训练决定「词表里有哪些 Token」;编码决定「一段具体文本如何被切分」。
- 官方分词库(tiktoken、deepseek_tokenizer)本质是预训练好的 BPE 词表 + 高效编码实现。
5.2 训练与编码时序 #
- 下面时序图展示
SimpleBPE从训练到编码的完整流程。 - 先循环合并高频字符对,再用学到的规则处理新文本。
sequenceDiagram
participant 用户
participant BPE as SimpleBPE
participant 规则 as 词表与合并规则
Note over 用户,规则: 阶段一:训练(离线,只需一次)
用户->>BPE: train(语料, num_merges)
loop 重复 num_merges 次
BPE->>BPE: 统计所有相邻字符对出现次数
BPE->>BPE: 找出频率最高的字符对(如 h + e)
BPE->>规则: 合并为新 Token,写入 merges
end
BPE-->>用户: 训练完成
Note over 用户,规则: 阶段二:编码(每次输入文本时)
用户->>BPE: encode("hello world")
loop 按 merges 顺序逐轮合并
BPE->>BPE: 将匹配的相邻字符对替换为新 Token
end
BPE->>规则: 查词表,转为 Token ID
BPE-->>用户: 返回 Token ID 列表
5.3 编码单词的合并过程 #
- 训练完成后,编码
"hello"时会按学习顺序逐轮应用合并规则。 - 例如:
h+e→he→he+l→hel→hel+l→hell→hell+o→hello,最终得到 1 个 Token。 - 下面时序图展示编码单个单词时的逐步合并。
sequenceDiagram
participant 用户
participant BPE as SimpleBPE
participant 序列 as token 序列
用户->>BPE: encode("hello")
BPE->>序列: 初始 ['h','e','l','l','o']
BPE->>序列: 合并 h+e -> ['he','l','l','o']
BPE->>序列: 合并 he+l -> ['hel','l','o']
BPE->>序列: 合并 hel+l -> ['hell','o']
BPE->>序列: 合并 hell+o -> ['hello']
BPE->>BPE: 查词表 hello -> ID 11
BPE-->>用户: 返回 [11, ...]
5.4 BPE 实现 #
# -*- coding: utf-8 -*-
# 说明:从 collections 导入 defaultdict,用于统计字符对频率
from collections import defaultdict
# 定义SimpleBPE类
class SimpleBPE:
# 构造函数
def __init__(self):
# 初始化词表,为字典类型(字符到id的映射)
self.vocab = {}
# 初始化合并规则,存储相邻字符对到新token的映射
self.merges = {}
# 初始化反向映射,token_id到token文本
self.id_to_token = {}
# 定义特殊结束标记
self.end_token = "<|endoftext|>"
# 训练BPE分词器的方法,参数为文本和合并次数
def train(self, text, num_merges=100):
"""
训练BPE分词器:学习合并频率最高的相邻字符对
"""
print(f"\n【训练开始】语料: {text!r},计划合并 {num_merges} 轮")
# 从文本中提取所有唯一字符并排序(排除空格),作为初始字符集
chars = sorted(c for c in set(text) if c != " ")
# 初始化词表,将所有字符映射为id,然后加上特殊结束标记
self.vocab = {i: char for i, char in enumerate(chars)}
# 为特殊结束标记分配一个id
self.vocab[len(self.vocab)] = self.end_token
# 下一个可用的token id
next_id = len(self.vocab)
print(f"初始词表 (共 {len(self.vocab)} 项):")
for token_id, token_str in self.vocab.items():
print(f" ID {token_id:>2} -> {token_str!r}")
# 将每个单词拆成字符,作为 BPE 训练的初始 token 序列
word_tokens = [self._tokenize_word(word) for word in text.split()]
# 进行多次合并迭代
merge_round = 0
for _ in range(num_merges):
# 统计所有词的相邻字符对出现频率
pair_counts = defaultdict(int)
# 遍历所有token序列
for token_list in word_tokens:
# 遍历当前token序列中的所有相邻对
for i in range(len(token_list) - 1):
# 取相邻两个token组成pair
pair = (token_list[i], token_list[i + 1])
# 该pair对应频次加1
pair_counts[pair] += 1
# 如果没有可统计的pair,提前结束
if not pair_counts:
print(f"【合并轮次 {merge_round + 1}】无可合并的字符对,训练提前结束")
break
merge_round += 1
# 找出出现频率最高的pair
best_pair = max(pair_counts, key=pair_counts.get)
best_count = pair_counts[best_pair]
# 生成一个新token,为best_pair两个字符串拼接
new_token = best_pair[0] + best_pair[1]
# 新token分配新id,并写入vocab
self.vocab[next_id] = new_token
# 新合并规则写入merges
self.merges[best_pair] = next_id
print(
f"【合并轮次 {merge_round}】"
f" {best_pair[0]!r}+{best_pair[1]!r} -> {new_token!r}"
f" (ID: {next_id}, 频次: {best_count})"
)
# 用新token替换word_tokens中所有best_pair出现的位置
new_word_tokens = []
for token_list in word_tokens:
# 新的token序列
new_list = []
# 遍历token_list中的元素
i = 0
while i < len(token_list):
# 如果当前位置和下一个正好是best_pair
if i < len(token_list) - 1 and (token_list[i], token_list[i + 1]) == best_pair:
# 合并到新token
new_list.append(new_token)
# 跳过合并的两个位置
i += 2
else:
# 否则,正常加入该token
new_list.append(token_list[i])
i += 1
new_word_tokens.append(new_list)
# 更新word_tokens为本轮后的结果
word_tokens = new_word_tokens
# 新的id自增
next_id += 1
# 训练结束后,构建反向映射:token文本->token_id
self.id_to_token = {v: k for k, v in self.vocab.items()}
print(f"\n【训练结束】实际合并 {len(self.merges)} 轮,最终 token 序列: {word_tokens}")
print(f"最终词表 (共 {len(self.vocab)} 项):")
for token_id in sorted(self.vocab.keys()):
print(f" ID {token_id:>2} -> {self.vocab[token_id]!r}")
# 编码函数,将文本转为token id序列
def encode(self, text):
"""
编码:将文本转换为token IDs
"""
print(f"\n【编码】文本: {text!r}")
# 将文本按照空格分割为单词
words = text.split()
# 结果token id列表
result = []
# 遍历所有单词
for word in words:
# 把单词拆为字符序列
token_list = list(word)
# 应用所有合并规则直到不能再合并
changed = True
while changed:
# 先假定本轮没有变化
changed = False
i = 0
# 新的token序列
new_list = []
# 遍历当前token_list
while i < len(token_list):
# 如果当前和下一个字符组成的pair在合并规则中
if i < len(token_list) - 1:
pair = (token_list[i], token_list[i + 1])
if pair in self.merges:
merged_token = self.vocab[self.merges[pair]]
# 用合并生成的新token替换
new_list.append(merged_token)
# 跳过两个字符
i += 2
# 本次有合并,改为True
changed = True
# 跳到下一轮
continue
# 否则,正常加入当前字符
new_list.append(token_list[i])
i += 1
# 更新token_list为本轮合并后的结果
token_list = new_list
# 把最终token序列转为id
for token in token_list:
# 遍历词表,找到token对应的id
for token_id, token_str in self.vocab.items():
if token_str == token:
result.append(token_id)
break
# 加入结束标记id
end_id = self.id_to_token.get(self.end_token, -1)
result.append(end_id)
print(f"Token IDs: {result}")
# 返回token id列表
return result
# 解码函数,将token id列表还原为字符串
def decode(self, token_ids):
"""
解码:将token IDs还原为文本
"""
print(f"\n【解码】输入 IDs: {token_ids}")
# 用于存放解码后的token字符串
tokens = []
# 遍历输入的token id
for token_id in token_ids:
# 保证id在词表中
if token_id in self.vocab:
# 获取对应token文本
token_str = self.vocab[token_id]
# 跳过特殊结束标记
if token_str != self.end_token:
tokens.append(token_str)
# 拼接返回最终字符串
decoded = "".join(tokens)
print(f"解码结果: {decoded!r}")
return decoded
# 把单词拆分成字符数组的辅助方法
def _tokenize_word(self, word):
"""将单词拆分为字符"""
# 直接按字符转为列表
return list(word)
# ===================== 使用示例 =====================
# 定义demo方法进行功能演示
def main():
bpe = SimpleBPE()
corpus = "hello world hello hello world"
bpe.train(corpus, num_merges=50)
text = "hello world"
token_ids = bpe.encode(text)
tokens = [
bpe.vocab[token_id]
for token_id in token_ids
if token_id in bpe.vocab and bpe.vocab[token_id] != "<|endoftext|>"
]
decoded = bpe.decode(token_ids)
print("\n【汇总结果】")
print(f"文本: {text!r}")
print(f"Token IDs: {token_ids}")
print(f"Token 字符串: {tokens}")
print(f"解码后: {decoded!r}")
print(f"\n全部 {len(bpe.merges)} 条合并规则:")
for (left, right), token_id in bpe.merges.items():
print(f" {left!r} + {right!r} -> {bpe.vocab[token_id]!r} (ID: {token_id})")
# 说明:判断是否以主程序方式运行
if __name__ == "__main__":
# 说明:调用演示函数
main()
6. 常见问题 #
- 遇到 Token 统计问题时,按下面条目排查即可。
- 大多数问题都与「分词器选错」或「字符数当 Token 数」有关。
6.1 编码结果和示例不一致 #
- 原因:不同模型使用不同分词器,Token 编号自然不同。
- 解决:OpenAI 用 tiktoken(§3);DeepSeek 用 deepseek_tokenizer(§4);不要混用。
6.2 中文为什么更耗 Token? #
- 中文在词表中常无整词匹配,会被拆成更小片段。
- 同样内容,中文 Token 数通常多于英文。
- 写 Prompt 和设
max_tokens时,中文要留更多余量。
6.3 本地计数与 API usage 有差异 #
- 本地分词器只统计纯文本内容的 Token。
- Chat API 的
prompt_tokens还包含 role、消息格式等额外 Token。 - 例如
"你是谁?"用deepseek_tokenizer计为 2,但 API 返回prompt_tokens=6是正常的。 - 实际费用以 API 响应
usage为准。
6.4 模型名找不到 #
encoding_for_model("deepseek-v4-pro")会抛出KeyError,因为 tiktoken 不支持 DeepSeek。- 对接 DeepSeek 请改用
from deepseek_tokenizer import ds_token(§4)。