Hugging Face 的 tokenizers v1 候选版在 Token ID 不变的前提下,把编码提速 3 到 30 倍。但分语言数据里中文只有 6.77 倍、排倒数第二,词缓存对中文还略拖后腿。三处关键改动怎么起作用,按 Qwen2 切分规则实测中英文 pre-token 差在哪,都在这里。
9 月 21 日,Hugging Face 的 Arthur Zucker、Simon Brandeis、Luc Georges 和 Lysandre 在官方博客发了一篇《tokenizers v1: encode, decode and scaling, measured》,公布了 tokenizers 库第一个大版本的候选版(release candidate)和一整套基准数据。标题里的数字很抢眼:单线程编码比 v0.23 快 3 到 30 倍,8 线程下仍能保持 76% 的线性扩展。
但原文嵌在交互图表里的分语言数据,才是中文团队最该看的部分:14 种语言里,中文的提速只有 6.77 倍,排倒数第二;英文是 22.44 倍。v1 新加的词缓存,在中文语料上的收益是 0.98——也就是开了反而略慢。
下面先把 v1 到底改了什么讲清楚,再回答那个问题:中文为什么吃不到大头,以及你的场景能拿到多少。
分词长期不被当成瓶颈。相比模型本身的计算,把文本切成整数序列是很轻的活。原文的判断是,这个平衡正在变:在海量数据集上训练、同时服务大量并发请求、反复处理长输入,这些场景会把压力压到分词器上,严重时会让模型"等数据"。他们的目标写得很直白:GPU 不应该闲着等 CPU 分完词。
原文还引用了 Crusoe 和 NVIDIA 团队的一篇文章《Reducing TTFT by CPUMaxxing Tokenization》:每个 prompt 都得先分词,推理才能吐出第一个 token。在长 Agent 上下文、复用模型前缀缓存的请求里,这部分开销会直接体现在首 token 延迟(TTFT)上。换句话说,当推理侧的前缀缓存把大段计算省掉之后,剩下没法省的分词就显眼了。
先说没改的:v1 产出的 token ID 与 v0.23 完全一致。API、词表、合并优先级(merge ranks)都保留,v0.23 能加载的分词器 v1 都能加载。库也没有为了跑分只专精 BPE,WordPiece 和 Unigram 照样支持。对使用者来说,这意味着升级不需要重新训练或重新对齐任何东西。
tokenizers 把"文本变整数"分成四个阶段:
大部分优化发生在第三阶段。原文测的 10 个模型家族里有 8 个用 BPE(字节对编码):从 pre-token 的字节出发,反复把优先级最高的相邻两块合并,直到没有可合并的对。优先级在训练分词器时学出来,随分词器一起发布,所以同样的文本永远得到同样的 ID。关键的一点是:合并永远不会跨越 pre-token 的边界。后面讲中文时会用到这条。
原文列了六项改动:
| 改动 | 做了什么 |
|---|---|
| 拆分 crate | 原来一个 crate 拆成工作区:tk-encode 是必需的运行时,tk-serialize、tk-convert、tk-train 按需链接 |
| 零分配模型 | 合并用的工作集放在调用方持有的 scratch buffer 里,循环不碰内存分配器 |
| bitcannon | 把切分正则改写成位流上的布尔运算,用 SIMD 指令找切分点,不再走正则引擎 |
| 重写合并循环 | 待合并的片段组成一个预分配缓冲区里的侵入式双向链表,一次合并只改两个下标,不搬数据 |
| 词缓存 | 线程本地的备忘表,pre-token 字节 → 最终 ID,重复出现的词只合并一次 |
| 原生并行 | 一个共享的分词器可被多线程同时调用,每个线程从自己的子池取 scratch buffer 和词缓存,不再排队抢一把锁 |
其中三项值得展开。
BPE 模型用一个正则表达式做预切分。这个正则是模型的固定参数,随分词器发布、运行时从不改变——那就没必要每次编码都让一个通用正则引擎去解释它,完全可以针对具体模型用的那条模式,手写一个等价的切分函数。
手写之后就能用上 CPU 的 SIMD 指令(单指令多数据),一条指令处理一批字节,很适合 UTF-8 文本。bitcannon 把输入字节看成若干条并行的比特流,切分边界由整个寄存器上的布尔运算直接算出来,而不是一个字符一个字符往前扫——一次寄存器操作判定 64 个字节。同样的思路也驱动着文本处理的 Parabix 和 JSON 解析的 simdjson。
限制在于必须认得这条模式。候选版里 bitcannon 覆盖了 GPT-2、cl100k、o200k、Tekken 和 DeepSeek 这几种切分语法,原文说少数几种语法就覆盖了大多数字节级 BPE 模型;不在其中的分词器仍走正则,拿不到这部分加速。原文明确说,各模型提速差距那么大,原因就在这里。
免费获取企业 AI 成熟度诊断报告,发现转型机会
真实文本里重复的词很多。既然同一个 pre-token 永远产出同样的 ID,那处理一次就可以记下来。v1 给每个线程配了一张缓存表,键是 pre-token 的字节,值是 token ID,之后再遇到就跳过合并。
原文的附带条件同样写得很清楚:缓存只在 pre-token 大量重复时有效;重复少的输入要为查表付出成本,却拿不到多少命中。记住这句话,它是理解中文数据的钥匙。
BPE 合并循环的旧实现,每次调用都分配新内存,每个 pre-token 都新建一个优先队列。v1 改为复用调用方持有的 scratch buffer,符号存在扁平数组里、相邻关系用数组下标串起来,合并时的更新更便宜;还把一批 pre-token 放进一次模型调用里处理。
一个小技巧:每个候选对被打包成一个 64 位整数,合并优先级放在高位。比较两个候选就是比较两个整数;"这里不能合并"用最大值表示,于是找下一个合并点不需要分支判断。
所有数字都来自 Apple M4 Max,测的是 Rust crate 的候选版。
单线程:v1 的聚合吞吐是 139.6 MB/s,v0.23 是 8.8 MB/s,约 15.9 倍。按模型拆开,最高的是 gpt2(29.83 倍),最低的是 t5-base(3.33 倍),这就是"3 到 30 倍"的两端。几个国产模型的分词器都在中上段:GLM-5.2 是 24.19 倍,DeepSeek-V4 是 14.63 倍,Qwen2 是 13.0 倍,MiniMax 是 12.42 倍。按算法看,BPE 模型聚合 18.02 倍,WordPiece(BERT)6.73 倍,Unigram(T5)3.33 倍——后两者的提速小得多,原文说那是下一步的重点。
多线程:从 1 线程到 8 线程,v1 的吞吐从 130 MB/s 涨到 833 MB/s,是线性扩展的 76%;v0.23 从 8 MB/s 涨到 44 MB/s,扩展效率 77%。两者效率差不多,但基数差了一个数量级。对比项里有个值得注意的现象:gigatoken 单线程是 137.2 MB/s,和 v1 几乎打平,但在"多线程共享一个分词器"的模式下,8 线程反而只有 101 MB/s。原文的解释是 gigatoken 更适合"每个线程一个独立实例"的用法,v1 则在原生多线程下表现最好。选型时要先想清楚你的服务是哪种并发模型。
延迟:对一篇 512 字节的英文文档单次编码,8 个模型家族的 p99 延迟中位数从 110.02 微秒降到 7.04 微秒。逐模型看,p99 快了 9 到 28 倍,DeepSeek-V4 的 p99 从 145.9 微秒降到 5.29 微秒。
解码:原文称,在测解码的 6 个模型家族上,v1 的解码吞吐是 v0.23 的 5.4 到 8.8 倍。不过解码不是 v1 的强项——聚合数据里 tokie 的解码吞吐是 444.2 MB/s,高于 v1 的 373.1 MB/s。
内存和体积:用 gpt-oss 分词器、单 worker 处理约 1 MB 英文文本,v1 加载后堆内存 16.9 MB,v0.23 是 47.8 MB。拆分 crate 之后,只含编码功能的最小构建(去符号、gzip 压缩)是 305,989 字节,拆分前是 665,263 字节。

按语言统计的编码提速倍数(v1 / v0.23,10 个模型家族的聚合)。图片来源:Hugging Face / tokenizers v1 基准页
分语言看,英文 22.44 倍一骑绝尘,其次是阿姆哈拉语、印地语、希伯来语等,都在 10 倍以上;中文 6.77 倍,日文 7.11 倍,泰文 6.08 倍,排在最后几位。
原文对多语言的说法只有一段:很多非拉丁文字在 UTF-8 里要占更多字节,分词实现还可能叠加额外的切分工作;性能优化通常围着英文转,v1 把非拉丁语言也列为性能目标。但各语言差异具体从哪来,原文没有拆解。
词缓存那张图给出了一条线索:

开启词缓存 / 关闭词缓存的吞吐比,Apple M4 Max 实测,8 个模型家族取中位数。图片来源:Hugging Face / tokenizers v1 基准页
缓存收益最大的是"共享提示前缀"(2.03 倍,100 个真实 Agent 请求,每个 10 KiB,共享 8 KiB 前缀),其次是密集特殊 token(1.69 倍)、Agent 编程语料(1.38 倍)、源代码(1.30 倍)。中文是 0.98,比不开还略慢。
回到前面那条规则:合并不跨 pre-token 边界,缓存的单位也是 pre-token。那中文的 pre-token 长什么样?拿 Qwen2 的分词器(tokenizers 0.23.1)实际切一下就能看到。它的预切分规则会把一串连续汉字当成一个整体,于是一段中文技术新闻切出来是这样的:
['、亚马逊云科技以及由', ' CNCF', ' 支持的', ' Cure', '5', '3', ' 第三方审计', '——',
'帮助该项目加速了开发进程', ',引入了安全审计', ',并整合了后量子密码学', '、FIPS']
而英文是这样的:
[' inputs', ' can', ' put', ' enough', ' pressure', ' on', ' the', ' tokenizer', ' that', ...]
英文一个 pre-token 基本就是一个词,中文一个 pre-token 却常常是一整个短句。短句几乎不会原样重复出现,缓存自然很难命中。
为了量化这一点,我们各取约 44 KB 的中文和英文技术文章(中文来自 InfoQ 中文、量子位和阮一峰周刊,英文来自 Hugging Face、PyTorch、Google Research 等博客),用同一个 Qwen2 预切分器切完后统计:
| 指标 | 中文 | 英文 |
|---|---|---|
| pre-token 个数 | 3,550 | 8,489 |
| 平均每个 pre-token 字节数 | 12.4 | 5.2 |
| 平均每个 pre-token 的 token 数 | 3.02 | 1.06 |
| 按个数计,已出现过的 pre-token 占比 | 46.8% | 71.2% |
| 按字节计,已出现过的 pre-token 占比 | 10.7% | 59.1% |
最后一行最能说明问题。它是"缓存无限大、从头开始"时理论上能命中的字节比例:英文接近六成,中文只有一成出头。中文里重复出现的 pre-token 大多是标点、数字和夹在中间的英文词这类短片段,真正耗合并工作量的长汉字串几乎都是一次性的。
这个小实验的边界要说清楚:样本只有两份几十 KB 的语料,只看了 Qwen2 一种切分规则,不同模型的中文预切分方式并不一样。它能说明的是"缓存对中文先天难命中",但不能单独解释中文整体提速为什么最低——Hugging Face 自己的数据里,英文网页的缓存收益也只有 0.93,英文的高重复率并没有在那组测试里转化成缓存收益,而英文的整体提速仍然最高。整体差距里其他因素各占多少,原文没有给出分阶段的数据,我们也不做猜测。
想在自己的语料上看一眼,几行 Python 就够:
from tokenizers import Tokenizer
tok = Tokenizer.from_pretrained("Qwen/Qwen2-7B")
text = open("your_corpus.txt", encoding="utf-8").read()
pieces = [text[a:b] for _, (a, b) in tok.pre_tokenizer.pre_tokenize_str(text)]
seen, hit_bytes, total_bytes = set(), 0, 0
for p in pieces:
n = len(p.encode())
total_bytes += n
if p in seen:
hit_bytes += n
seen.add(p)
print(f"pre-token {len(pieces)} 个,按字节重复占比 {hit_bytes / total_bytes:.1%}")
这个比例越高,词缓存对你越有用;如果你的中文语料也在一成左右,就别指望缓存,收益主要得靠切分和合并循环那两处改动。
原文花了一整节讲测试方法,这部分比具体数字更耐用。他们给自己定的规则包括:所有引擎跑同一个计时循环,不给任何引擎开快速通道;加载词表单独计时,不算进编码;输出 ID 用 FNV-1a 哈希与基线逐一核对;只在所有引擎都跑通并验证过的格子上取中位数;每次重复都开新进程;工作线程绑定到 8 个不同的物理核,不用超线程的兄弟线程。
最值得记住的一条:反复编码同一篇文档,可能比编码一串不同的文档快得多。前者测的是"整篇文档都已在缓存里"的性能,后者测的是面对新输入、但之前见过的 pre-token 仍留在缓存里的性能。两种都常被叫做"热启动"(warm),测的却是完全不同的负载。原文的主要结果用的是不同文档组成的流,而且整个语料大到缓存装不下。他们的结论是:分词器基准必须写明用的是哪种负载,因为这个选择可能主导结果。
这对自己做选型测试的团队是个直接提醒。拿一篇长文档循环编码一万次得出的数字,放到线上面对的是千人千面的请求时,很可能对不上。
先确认你在不在受益范围里。 原文所有数字都是对 Rust crate 测的。Python 绑定包的是同一份代码,但每次调用有额外开销,这些测量都没有包含。原文给出的安装方式也只有 Rust 这一条:
cargo add tokenizers --pre
训练功能默认开启,会带进一个 C++ 依赖;只需要编码的话可以关掉默认特性:
cargo add tokenizers --pre --no-default-features --features http
调用方式不变,批量编码用 encode_batch,多核扩展测的就是这个调用。更精简的 Python 绑定(减少锁和包装类型、支持 free-threaded CPython)列在 1.0.0 的待办里。所以如果你的数据管线是 Python 写的,现在能做的是评估和准备,而不是指望换个版本号就拿到 15 倍。
国产模型的分词器整体受益不小。 GLM-5.2、DeepSeek-V4、Qwen2、MiniMax 分别是 24.19、14.63、13.0、12.42 倍。注意这是 22 个语料的聚合,不是纯中文的成绩;用它们处理纯中文时的倍数,原文没有单独给出。参照中文语料在 10 个模型上聚合的 6.77 倍,别直接拿 13 倍、24 倍去估算自己的中文管线。
Agent 类负载是词缓存的主场。 共享长前缀(系统提示词、工具定义、仓库上下文)的请求,缓存收益达到 2.03 倍。如果你在做 Agent 服务、前缀又比较固定,这部分收益来自前缀本身在请求间的重复。原文给了复现这项结果的命令:
tokbench measure prefix-sharing \
--engine pipeline \
--engine hf-tokenizers \
--compare-to pipeline-no-cache \
--corpus agentic_swe
用自己的数据复测。 原文所有基准都来自开源的 tokbench 仓库,并给出了在自己硬件上重跑的命令。M4 Max 上的结果不等于你的 x86 服务器;英文语料上的结果更不等于你的中文语料。
候选版已经包含的:拆分 crate、bitcannon、词缓存、更快的查找与合并结构、可复用的模型内存、批量模型调用、更快的解码、role_to_token 支持,以及 Node.js 绑定。
1.0.0 之前还要做:训练校验也改用 tk-encode,保证训练和推理不会产出不同的分词结果;offsets 和 mask 改为按需计算,不拖累只要 token ID 的路径;重做归一化器;更简单的 Python 绑定;面向 ExecuTorch 和 llama.cpp 的纯推理 C/C++ 绑定,之后可能还有 JVM、Swift、Go。原文还说,1.0.0 之前会把更多模型家族迁到新的合并循环上。
1.0.0 之后的探索方向是 tok-devices:在 GPU 上做编码和批量解码,文本和 token ID 全程留在设备上。解码器把词表上传一次,并行算出输出位置,再在 GPU 上收集对应的字节。原文把它定位为面向大批量的可选组件,还要进一步做原型和测量。
原文还有一句姿态上的表态:重构之前,tokenizers 离它应有的性能差得很远,所以给它贡献代码可能显得不值;这次重构就是想表明,它值得贡献。他们也点名感谢了 gigatoken、tiktoken、kitoken、tokie、fastokens、wordchipper、ai-tokenizer 这些项目,说文中不少思路是别的项目先证明了值得一试。从成绩单看,v1 在单线程上和 gigatoken 基本打平、在解码上还落后 tokie——这是一个追上生态、而不是甩开生态的版本。对使用者来说,这反而是好事:token ID 不变、API 不变,升级的代价很低,等 Python 绑定跟上,大多数人不用改一行代码就能拿到这份提速。
关注公众号

扫码关注,获取最新 AI 资讯
3 步完成企业诊断,获取专属转型建议
已有 200+ 企业完成诊断