[控场AI]
· 10 分钟阅读· 5,088 字

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

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

用抽象接口统一tiktoken精确计数与零依赖启发式估算,auto模式优先精确并优雅降级。

文章指出任何LLM功能都绕不开token计数问题,而字符数和单词数都不可靠。作者提出两种方案:一是用tiktoken进行精确BPE计数,适合计费、硬上下文限制和精确裁剪,但依赖编译包和词表下载;二是用纯Python正则启发式估算,零依赖、确定性输出,适合离线分析和相对比较,但明确不能用于计费或硬限制。真正的技巧是定义抽象Tokenizer接口,让其余代码不关心具体实现,通过惰性导入和工厂函数实现auto模式——优先精确、缺失依赖时静默降级到启发式,同时保留显式的heuristic和tiktoken模式以区分测试与生产需求。

任何LLM功能最终都要回答一个问题:这段文本有多少token?上下文窗口按token计费,成本按token计算,截断也按token进行。字符数和单词数看似接近,实则既不可靠也不精确。

本文介绍两种计数方法,并展示如何通过统一接口优雅地隐藏两者的选择。

tiktoken与启发式方法对比

方法一:使用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。借用接口,保留提醒,自信计数。

#tiktoken#LLM token计数#Python#BPE分词#依赖管理#上下文窗口#启发式估算
分享:

相关推荐