Python计算LLM Token:tiktoken与零依赖启发式对比

用抽象接口统一tiktoken精确计数与零依赖启发式估算,auto模式优先精确并优雅降级。
文章指出任何LLM功能都绕不开token计数问题,而字符数和单词数都不可靠。作者提出两种方案:一是用tiktoken进行精确BPE计数,适合计费、硬上下文限制和精确裁剪,但依赖编译包和词表下载;二是用纯Python正则启发式估算,零依赖、确定性输出,适合离线分析和相对比较,但明确不能用于计费或硬限制。真正的技巧是定义抽象Tokenizer接口,让其余代码不关心具体实现,通过惰性导入和工厂函数实现auto模式——优先精确、缺失依赖时静默降级到启发式,同时保留显式的heuristic和tiktoken模式以区分测试与生产需求。
任何LLM功能最终都要回答一个问题:这段文本有多少token?上下文窗口按token计费,成本按token计算,截断也按token进行。字符数和单词数看似接近,实则既不可靠也不精确。
本文介绍两种计数方法,并展示如何通过统一接口优雅地隐藏两者的选择。

方法一:使用tiktoken精确计数
tiktoken是OpenAI多个模型使用的字节对编码(BPE)分词器。它给出的是模型实际看到的token数。如果涉及计费、硬性上下文限制或精确裁剪提示词,必须使用精确计数。
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode("Counting tokens is deceptively subtle.")
print(len(tokens)) # 精确token数
有两个关键点:第一,编码方式很重要。cl100k_base对应一个模型系列,其他模型使用不同词表,需选择目标模型实际使用的编码。第二,encode在处理包含特殊token的文本时会报错,除非明确告知不特殊处理。生产环境中我始终传递disallowed_special=(),确保任意用户文本不会引发异常。
def count(text: str) -> int:
if not text:
return 0
return len(enc.encode(text, disallowed_special=()))
精确的代价是依赖:tiktoken是编译包,会下载词表文件,在轻量库、无网络CI环境或受限环境中并不总是合适。
BPE(Byte Pair Encoding,字节对编码) 是现代LLM分词的主流算法。其核心思想是从单个字节或字符出发,反复统计语料中出现频率最高的相邻符号对,将其合并为新符号,如此迭代直到词表达到预设大小。最终得到的词表中,常见词(如 the、is)往往占据一个token,生僻词则被拆成多个子词片段。
关键点在于:BPE是在字节层面操作的,因此任意Unicode文本(包括emoji、中文、代码符号)都能被无损编码,不会出现无法表示的字符。这也解释了为什么同一个词在不同模型中token数可能不同——每个模型训练时使用的语料和词表大小不同,合并规则自然不同。cl100k_base 是OpenAI为GPT-3.5/GPT-4系列训练的词表,约10万条合并规则,因此不同模型必须匹配各自的编码名称,不能混用。
方法二:零依赖启发式估算
有时不需要精确数字。当我在分析提示词中哪部分占用最多上下文,或观察对话中用量增长趋势时,相对大小比精确整数更重要。此时使用纯Python启发式方法,零依赖、无需网络。
其原理是:真实BPE分词器大致将几个字符打包为一个token,并将长单词拆分为多个片段。因此将文本拆分为单词、数字和符号段,对较长段收取更多token。
import re
文中提到的 `[^\W\d_]+` 使用了Python正则的否定字符类简写,理解其含义有助于判断启发式方法为何这样设计:
- `\W` 表示非单词字符(即非字母、非数字、非下划线)
- `\d` 表示数字
- `_` 是下划线
- 因此 `[^\W\d_]` 等价于“是单词字符、但不是数字、也不是下划线”,即**Unicode字母**
配合 `re.UNICODE` 标志,`\w` 会匹配所有语言的字母(中文、阿拉伯文、西里尔字母等),而不仅仅是ASCII。这正是启发式方法能粗略处理多语言文本的原因。`\d+` 捕获连续数字,`[^\s\w]` 捕获单个非空格符号(如标点、括号)。三段交替匹配,把文本切分为“字母块、数字块、符号单元”,再按每4字符≈1 token估算,这个4字符比例大致对应英文BPE的平均压缩率,但中文、代码等场景偏差会明显放大。
# 单词(含Unicode字母)、数字或任意单个非空格符号
_TOKEN_RE = re.compile(r"[^\W\d_]+|\d+|[^\s\w]", re.UNICODE)
_CHARS_PER_PIECE = 4
def heuristic_count(text: str) -> int:
if not text:
return 0
total = 0
for match in _TOKEN_RE.finditer(text):
piece = match.group(0)
n = len(piece)
if n <= _CHARS_PER_PIECE:
total += 1
else:
# 长片段拆分为子token,向上取整
total += (n + _CHARS_PER_PIECE - 1) // _CHARS_PER_PIECE
# 空白折叠到相邻token中,不单独计数
return total
这段代码在任何Python环境都能运行,确定性输出使测试和CI无需重型安装即可稳定运行。
诚实提醒:这完全是近似方法。它不还原任何真实BPE词表,结果与模型实际计数会有差异,在代码、非英文文本或符号密集输入上差异可能显著。绝不要用启发式方法向客户计费或执行硬性上下文限制。它适用于相对比较和离线分析,涉及后果的场景必须使用tiktoken。
统一接口:不再二选一
真正的技巧不是选择哪个计数器,而是让其余代码不关心使用了哪个。定义一个抽象接口,两个实现都满足:
from abc import ABC, abstractmethod
class Tokenizer(ABC):
name: str = "base"
@abstractmethod
def count(self, text: str) -> int:
raise NotImplementedError
def count_all(self, texts: list[str]) -> list[int]:
return [self.count(t) for t in texts]
两个计数器作为子类。启发式版本始终可用。tiktoken版本在__init__内部惰性导入,只有实际在缺少依赖时构建才会失败:
class TiktokenTokenizer(Tokenizer):
def __init__(self, encoding: str = "cl100k_base") -> None:
try:
import tiktoken
except ImportError as exc:
raise ImportError(
"tiktoken is not installed; install it to get exact counts"
) from exc
self._enc = tiktoken.get_encoding(encoding)
self.name = f"tiktoken:{encoding}"
def count(self, text: str) -> int:
if not text:
return 0
return len(self._enc.encode(text, disallowed_special=()))
注意导入位于构造函数内部,而非模块顶部。这是实现优雅降级的关键:导入模块本身不需要依赖,ImportError只在调用者请求无法满足的功能时才出现。
工厂函数将两者结合。auto优先精确,静默降级到启发式:
def _try_tiktoken(encoding: str = "cl100k_base"):
try:
return TiktokenTokenizer(encoding)
except ImportError:
return None
def get_tokenizer(name: str = "auto") -> Tokenizer:
name = (name or "auto").lower()
if name == "heuristic":
return HeuristicTokenizer()
if name == "tiktoken":
tok = _try_tiktoken()
if tok is None:
raise ImportError("tiktoken requested but not installed")
return tok
if name == "auto":
return _try_tiktoken() or HeuristicTokenizer()
raise ValueError(f"unknown tokenizer: {name!r}")
设计选择是刻意的。auto给出当前可用的最佳答案且不崩溃;heuristic保证无依赖路径,测试应固定使用它,确保不依赖可选安装;tiktoken明确且响亮——如果请求精确但无法满足,直接报错而非静默近似。最后这个区别很重要,因为静默将精确替换为近似,正是导致意外超额账单的bug。
惰性导入(lazy import) 是一种将 import 语句从模块顶层移到函数或方法内部的模式。Python在模块首次被导入时执行顶层代码,如果顶层写了 import tiktoken,那么任何 import 本模块 的操作都会立刻尝试加载 tiktoken,缺少依赖时直接崩溃。
把导入放进 __init__ 后,模块本身可以在没有 tiktoken 的环境中正常加载,只有在真正构造 TiktokenTokenizer 实例时才触发依赖检查。这就是文中所说的“优雅降级”得以成立的前提。需要权衡的是:每次构造实例都会执行导入查找(Python有模块缓存,开销很小),并且导入错误只能在运行时暴露而非启动时。对于可选依赖来说这个取舍是值得的,但对于核心依赖,仍建议放在顶层以便尽早失败。
收益
应用程序其余部分不再接触tiktoken或正则。它只调用tokenizer.count(text)。切换计数器只需在边缘修改一行,测试离线运行启发式版本,生产环境在有依赖处获得精确计数。保留name字段使报告能说明数字来源,因为"1,240 tokens (heuristic)"和"1,240 tokens (tiktoken)"对读者含义不同。
整个模式就是:抽象count、惰性可选依赖、以及优先精确并优雅降级的auto模式。如果想看它如何接入真实工具分析提示词中token的分布,这直接来自我的项目ctxlens,完整分词器包在github.com/AgentPostmortem/ctxlens。借用接口,保留提醒,自信计数。
相关推荐

DPO计算图随机剪枝:训练加速1.4倍、峰值内存更低
DPO训练太慢太吃显存?这篇论文提出DPO计算图随机剪枝框架,从位置、序列、分支三个维度独立采样,分支剪枝实现1.426倍步时加速、训练时间减少约30%,并深入分析内存与时间收益的归因。

JEPA在纵向电子病历表示学习中为何不敌MLM?
新研究在MIMIC-IV上对比JEPA与MLM用于纵向电子病历表示学习,发现三种JEPA变体虽训练稳定无坍缩,下游性能却全面落后。目标大小而非异质性推高预测误差,缩小目标也无法补救,核心挑战在于定义临床有意义的潜目标。

Agentic模型编排真有效?FairAMO揭示集成收益的边界
FairAMO研究在GIFT-Eval基准上受控评估Agentic模型编排:冻结候选后,最佳编排器相对最强单一TSFM的MASE改进不足6%,增益取决于候选互补性而非数量,历史证据快速饱和。