这篇文章想聊聊 LLM 推理链路里一个特别容易被低估的角色:Tokenization。我是在一次给检索增强问答服务做压测时被它“绊倒”的。当时并发量一上去,CPU 曲线出现一段奇怪的尖峰,显存和 GPU 利用率都还好,但整机负载却先被打满。一开始我怀疑是 embedding 模型的问题,用 py-spy 抓了几次栈,才看到大量线程陷在正则匹配和 BPE merge 的逻辑里。
也就是说,慢的不是模型本身,而是把文本切成 token 这一步。
这件事之后,我把手头几个项目的分词路径都重新 review 了一遍,也补了一批 profiling 和实验。这篇就把“LLM Tokenization 为什么慢”这个问题拆开讲清楚:它慢在哪个环节、底层哪些数据结构决定它快不起来、实测时怎么定位,以及最后怎么优化。文章偏工程向,适合正在做推理服务、RAG 检索或本地小模型部署的读者,想避坑的也能找到参考。
1. 慢在链路里的哪个环节:一张耗时段位图
1.1 推理链路里的三个耗时区
一次标准的 LLM 请求,按时间顺序可以拆成三段:输入预处理(包括 tokenization、padding、attention mask 计算)、模型前向计算(也就是真正的 transformer 层运算)、输出后处理(logits 处理、采样、流式输出)。大多数人压测时只盯第二段,但这三段的耗时特性完全不同:
- 模型前向计算是 GPU-bound,显存带宽和算力决定了下限,这部分大家都很熟。
- 输出后处理是轻量 CPU 操作,采样一个 token 通常百微秒级,除非用了特别重的采样器,否则不是瓶颈。
- 输入预处理是 CPU-bound,而且常常是单线程的,tokenization 是其中绝对的大头。
如果你的服务架构是“先分词,再调模型”,那分词时间会直接加进首 token 延迟(TTFT)。模型本身算得快的时候,这个前置延迟会被放得很大。举个例子,一个 7B 模型在 A10 上跑 2000 token 的 prompt,前向计算可能就几百毫秒,但一个没优化的 Python tokenizer 对同样长度的文本做切分,也可能吃掉几十毫秒,这时候它已经占了不能被忽略的比例。
1.2 预填充和解码阶段,分词耗时的分布完全不同
搞清楚“分词慢”具体发生在哪个阶段很重要,因为优化手段不一样。
LLM 推理分两个阶段:**预填充(prefill)**把整段 prompt 一次性算完,**解码(decode)**则是一个 token 一个 token 地生成。Tokenization 只发生在预填充阶段——也就是对用户输入的提示词做一次性切分。解码阶段生成 token id 之后直接查 embedding,根本不会走分词器。所以:
- 如果你的服务是短 query 高频请求,分词绝对耗时不高,但它在请求关键路径上,可能占 TTFT 的很大比例。
- 如果你的服务是长文档问答,prompt 动辄几千上万字符,分词延迟线性增长,这时候它就不是“毫无存在感”的小环节了。
- 并发场景下问题更突出。分词是 CPU 密集操作,多路请求同时进来时,CPU 核数不够就会形成排队,后端表现为“模型没跑,但延迟已经上去了”。
这个特性决定了后面所有优化策略的方向:要么减少分词本身的耗时,要么把分词从关键路径上挪开。
2. 根源拆解:BPE 合并与正则回溯为什么天生吃 CPU
2.1 分词的底层三步
主流 LLM 用的分词器,比如 GPT 系列、Llama 系列、BERT 的 WordPiece,在编码新文本时基本都走三步:
- 文本规范化与预切分:把原始字符串做 Unicode 归一化,然后用一个正则表达式把文本切成一个个“单词片段”。这一步的核心是正则引擎的匹配。
- 字节/字符序列化:对每个片段按 UTF-8 编码成字节序列。中文、emoji 这些多字节字符,一个字符会膨胀成 3-4 个 byte,待处理的元素数量直接翻倍。
- BPE/WordPiece 合并:拿着训练好的词表,反复把相邻字节/字符的最高频组合并成更大的 token,直到不能合或者 token 数满足上限。
很多人以为慢在第三步,但实测你会发现第一步正则匹配常常比 BPE 合并还吃 CPU。原因接下来说。
2.2 BPE 合并的串行流程与复杂度
BPE 合并是一个天然串行的过程。
训练好的 tokenizer 里有一个 merge 规则表,比如("h", "e") -> "he",它记录了每一步应该合并哪些相邻 pair。编码时,算法先初始化所有字节/字符对,然后按照训练时的顺序,不断找到当前“还能合并且 rank 最高”的 pair 进行合并,合并完再更新相邻关系,继续找下一个。
从这个流程能看出两个耗时点:
- 每合并一次都要在局部范围内重新评估相邻 pair 的优先级,完全并行的难度很大。
- 合并轮数取决于文本长度。一个 5000 字节的文本,可能要执行上千次合并操作,每一步都涉及字典查找、数组更新、边界判断。
原始的朴素 BPE 实现在这一步通常用“每轮都扫一遍词表找最优 pair”的办法,复杂度可以到 O(n×V) 甚至更糟,n 是字节长度,V 是词表大小。好在工业实现不会这么傻。OpenAI 的 tiktoken 用了一个很关键的手段:把训练好的合并规则转成一张 rank 表,用整数 ID 存相邻 pair,编码时直接查表决定合不合并,把大部分工作压成了内存中连续的数组操作,同时核心合并循环用 C 实现,避开 Python 解释器的开销。
这也是为什么纯 Python 手写的 BPE 和 tiktoken、tokenizers 这种底层用 C/Rust 的实现,性能能差出一个数量级的原因。同样的文本,前者可能跑几十毫秒,后者可以压缩到几毫秒。
2.3 正则 alternation 和特殊分支是隐藏的“回溯陷阱”
预切分用的正则表达式,看起来只是一个 pattern,实际非常复杂。
GPT 系列 tokenizer 的预切分 pattern 是一长串 alternation,包含缩写词、数字、标点、字母簇、空白等多类分支。正则引擎拿到文本后,要在这个庞大的选择分支里找出最合适的匹配。正常情况下引擎会按顺序尝试分支,很快命中某个规则;但遇到一些边缘情况,比如“连续标点夹着空白再夹着一串数字”,引擎可能在前一个分支匹配失败后触发回退,重新走另一个分支,这个回退过程就带来了额外开销。
最坏情况下,某些畸形输入会让正则引擎进入类似“灾难性回溯”的状态,虽然现代引擎针对这种 alternation 做了不少优化,但并不是所有实现都能抗住。我自己实测过:同一份 tokenizer 配置,处理普通英文句子和处理一长串连续数字、混合空白标点的文本,单条延迟能差 3-5 倍。
特殊 token 分支也在拖后腿。像 “<|endoftext|>”“```”(代码块标记)这些 special token,分词器在每段文本里都要单独做一次查找和分支判断。平时输入里没几个 special token,但正则引擎并不会因为内容里没有就跳过这个分支,它总是要试一遍。
注意:正则预切分和 BPE 合并是叠加关系,不是二选一。正则先把长文本切成片段,BPE 再在每个片段上合并。所以正则切出来的片段越多,BPE 要初始化的相邻 pair 也越多,整个耗时是乘法关系。
2.4 另一个容易忽略的因素:UTF-8 多字节膨胀
英文文本里一个字符等于一个字节,中文、日文、emoji 则要 3-4 个字节表示。BPE 的输入单位是字节而不是字符,所以同样 5000 个中文字符,要处理的输入元素是 15000 个字节,合并轮数和 pair 统计量都按比例上涨。
这个因素解释了为什么纯英文任务和中文任务在 tokenizer 上的耗时表现差异巨大。如果你做的是中文场景,分词开销天然比英文场景高 3 倍左右,这不是代码质量问题,是编码设计决定的。
3. 实测拆解:给 tokenizer 做一次完整 profile
3.1 一个最小可复现的测量脚本
在动手优化之前,先量化。下面这个脚本用 transformers 的 AutoTokenizer 测单条文本的编码耗时,同时也测 tokenizers 库原生 API 的耗时,两个做一个对照:
import time from transformers import AutoTokenizer from tokenizers import Tokenizer text = "什么是 LLM Tokenization?它慢不慢,这篇文章来回答。" # 方式一:transformers 包装层 tok = AutoTokenizer.from_pretrained("your-model-path") t0 = time.perf_counter() for _ in range(200): ids = tok.encode(text) t1 = time.perf_counter() print(f"transformers encode avg: {(t1 - t0) / 200 * 1000:.3f} ms") # 方式二:直接用 tokenizers 库 tok_fast = Tokenizer.from_file("tokenizer.json") t0 = time.perf_counter() for _ in range(200): enc = tok_fast.encode(text) t1 = time.perf_counter() print(f"tokenizers encode avg: {(t1 - t0) / 200 * 1000:.3f} ms")如果你对哪一步慢感兴趣,可以再拆细一点,单独测预切分和 BPE:
import re, time from tokenizers import pre_tokenizers # 只看正则预切分耗时 pre = pre_tokenizers.Sequence([ pre_tokenizers.Whitespace(), pre_tokenizers.Punctuation(), ]) t0 = time.perf_counter() for _ in range(200): pre.pre_tokenize_str(text) t1 = time.perf_counter() print(f"pretokenize avg: {(t1 - t0) / 200 * 1000:.3f} ms")这种拆法能帮你快速判断:你的场景里到底是正则匹配慢,还是 BPE 合并慢。
3.2 不同输入特征下的耗时差异
我给同一份 tokenizer 喂了几类不同特征的文本,分别测了平均编码耗时。数量级不一定适用你的机器,但规律是一致的:
| 输入特征 | 字符长度 | 相对耗时 | 说明 |
|---|---|---|---|
| 普通英文句子 | 200 | 1x | 基准 |
| 中文句子 | 200 | 2.5x-4x | 多字节膨胀 |
| 长数字串+标点混合 | 200 | 3x-5x | 正则分支回退 |
| 代码缩进+特殊token | 300 | 2x | 空白规则与 special token 处理 |
| 长文本段落 | 5000 | 20x-30x | 线性增长 |
长文本段落那条不是吓人,而是直接说明:如果你做 RAG,每次把整篇文档塞进 prompt,分词耗时和文档长度几乎线性相关。单条慢还可以接受,并发一上来就是灾难。
3.3 批量并发场景下的时间放大
单独测单条文本只是第一步,更重要的是测并发。
分词是 CPU-bound,而且不少实现有锁或有不可重入的部分。你用 200 个并发请求同时打同一个 tokenizer 实例,实际耗时会比单条测试的 200 倍涨得还多,因为线程切换、锁竞争、内存分配都出来了。我在服务里抓过 py-spy,看到的情况是:十几个 worker 线程全部卡在tokenizers的 Rust 调用上,但 Rust 侧又不能完全并行,因为每个请求的文本长度不同,短文本做完的要等长文本的。
所以在做容量评估时,不要只按“单次分词 xx 毫秒”来估算。正确做法是把分词放到整个请求链路的火焰图里看,看它占的 CPU 时间和墙钟时间分别是多少,才能在服务端做针对性的线程池调整。
4. 词表、特判、并发:三个容易被忽略的隐藏杀手
4.1 大词表带来的隐藏成本
词表大小对分词耗时有影响,但影响方式不像很多人想的那么直接。
如果是纯数组扫描式的实现,32K 词表和 128K 词表的遍历成本肯定不一样,但现代实现都用字典或哈希表,单次查找是 O(1)。大词表真正拖慢的地方有两个:
- 词表加载和预热时间。128K 词表光加载 JSON 就要花不少时间,服务启动时如果每次都重新解析,冷启动延迟会明显变长。
- 正则 pattern 的 alternation 变长。词表越大,预切分正则里的分支通常也越多。虽然很多 tokenizer 的 pattern 是固定写的,但一些训练框架会根据词表生成分支,这部分一涨,正则匹配的候选路径更多,回退也更频繁。
另外,大词表模型的 embedding 矩阵变大,虽然这不属于 tokenizer 的耗时,但它会让 GPU 显存占用更高,间接影响整体吞吐,排查性能时容易把这两件事搞混。
4.2 特殊 token 处理与规范化不是免费的
文本进 tokenizer 之前,通常还会做 Unicode 规范化。NFKC 或 NFC 这种操作会把全角字符转半角、把兼容字符做组合,本身就需要遍历字符串并做字符映射。这个开销在短文本上不明显,长文本上就很可观。
再加一层 special token 拼接。很多服务的 prompt 模板长这样:
<|system|>你是助手<|end|> <|user|>{query}<|end|> <|assistant|>模板里的<|system|>、<|end|>每次都要和真实文本一起传进 tokenizer。tokenizer 内部会在预切分时额外识别这些特殊片段,有时候还要把它们从正常 BPE 流程里摘出来,单独编码。这意味着你的 prompt 模板越复杂、special token 越多,固定的分词开销就越高。
4.3 不同实现路径的额外开销
同一种 tokenizer 配置,走不同调用路径,性能能差好几倍。
我做过一个对照,对比几种常见方式:
| 调用方式 | 相对耗时 | 备注 |
|---|---|---|
transformers.AutoTokenizer.encode | 最慢 | Python 包装层 + 特判多 |
tokenizers 库Tokenizer.encode | 快 | Rust 实现 |
tokenizers 库encode_batch | 更快 | 批内并行 |
| llama.cpp 的 LLM tokenizer | 较快 | C++ 实现,含依赖加载 |
| 离线预分词 + 缓存 | 最快 | 不走在线路径 |
transformers慢不慢在分词算法本身,而在于 Python 层的包装、配置加载、各种兼容性特判。如果你在性能敏感的服务里直接AutoTokenizer.from_pretrained()然后同步调用,那就是把大量时间花在纯 Python 代码上。
4.4 服务端并发模型导致分词排队
最后一个隐藏杀手在服务端架构。很多 FastAPI 服务是这样写的:
@app.post("/generate") async def generate(request: Request): prompt = request.json()["prompt"] tokens = tokenizer.encode(prompt) # 同步阻塞调用 result = model.generate(tokens) return resulttokenizer.encode是同步阻塞的,放在 async 函数里会直接把事件循环卡住。高并发下所有请求都会在分词这一步排队,后面的模型推理再快也没用,因为请求压根走不到那一步。
正确做法要么把分词放到线程池里,要么干脆用独立进程做预处理,后面第 5 节会展开。
5. 优化方案怎么落:从缓存、批处理到换实现
5.1 prompt 缓存与 LRU 是投入产出比最高的一招
如果你的服务存在大量重复 prompt 前缀,比如固定的 system prompt、固定的 RAG 检索模板,那给分词器加缓存几乎是性价比最高的优化。
一个简单的 LRU 就能解决:
from functools import lru_cache @lru_cache(maxsize=4096) def tokenize_with_cache(text: str): return tokenizer.encode(text).ids注意几个坑:
- 缓存 key 用原始文本,不要用 repr 或 json 序列化后的结果,那会浪费内存。
- 文本内部有细微变化就会导致缓存失效。比如带时间戳的 prompt、每次都不一样的随机占位符,这类文本不适合做整段缓存。
- 缓存的是 token id 列表,不是单个 int。Python 里存一个大列表本身有内存开销,maxsize 要结合平均 token 数来调。
更进一步,如果 prompt 是“固定前缀+可变后缀”,可以对前缀做缓存,只对后缀做分词,再把两部分 id 拼接起来。这个优化对 RAG 场景特别明显,因为检索出来的文档前缀往往很长且频繁复用。
5.2 用 encode_batch 代替循环式单条编码
如果一次请求里有多个文本要同时分词,比如 RAG 检索后要把标题、正文、摘要都放进上下文,那不要用循环调用encode,直接用encode_batch:
texts = [title, content, summary] encodings = tokenizer.encode_batch(texts)encode_batch在 Rust 侧做了并行调度,多段短文本可以同时在多个线程上处理,比 Python 层 for 循环快很多。要注意的是,批内文本长度差异不要太大,否则长文本会让短文本的线程干等,并行收益会打折扣。
如果你的服务是单条请求多个文本段,可以在预处理时把这几段合并成一个大字符串一起编码,让 BPE 在更大输入上跑,也能减少多次初始化开销。但合并文本要小心 token 边界问题——不同段落拼接处的 token 可能和单独编码时不一致,如果你的下游逻辑依赖逐段的 token 数量,就不能这么干。
5.3 离线分词:把耗时从关键路径上挪走
这是我自己最推荐的做法。
对于 RAG 这类“文档可以预先处理”的场景,文档侧的分词完全没有必要放在在线请求里。你可以在离线任务里把文档切分成 chunk,每个 chunk 提前编码好,把 token ids 直接落库。在线请求只对 query 做一次轻量分词,再拿 token ids 去向量库或索引里检索。
这样做的好处不仅是快,还能保证“同一份文档每次请求的分词结果一致”,不会因为词典版本更新或特殊 token 配置改动导致存储的 id 和在线渲染的 id 对不上。
对于在线侧,如果 query 很短,分词耗时基本可以忽略;如果 query 也很长,比如用户直接贴了几千字的文章进来,那可以把格式清洗、normalization 这部分也移到异步任务里,不要让主请求链路等待。
5.4 换实现:从 transformers 到 tokenizers 或原生 C++
如果你的服务已经确定用某个开源模型,且不想做离线缓存,那至少要确认你调用的 tokenizer 是走 Rust 还是纯 Python 路径。
- 能用
tokenizers库直接加载tokenizer.json就尽量别走AutoTokenizer。 - 用 llama.cpp 这类 C++ 推理框架时,分词器是原生 C++ 实现,直接调用 LLM 的接口做 tokenize,能省掉 Python 和 C 之间的数据序列化开销。
- 如果自己写服务,可以对 tokenizer 做进程级预加载,避免每个 worker 启动时都重新解析一遍词表 JSON。
另外可以关注一下 tiktoken 这类专门为高速分词设计的实现。它的合并逻辑用 rank 表实现,核心循环是 C 代码,编码速度比 Python 版 BPE 快一个量级。换实现这个步骤,通常能让分词延迟降到原来的 1/3 甚至更低。
5.5 并发模型的调整
前面提到同步分词会卡住事件循环,对应的修法是把它放进线程池:
from concurrent.futures import ThreadPoolExecutor tokenizer_executor = ThreadPoolExecutor(max_workers=4) async def generate(request): prompt = request.json()["prompt"] tokens = await asyncio.get_running_loop().run_in_executor( tokenizer_executor, tokenizer.encode, prompt ) result = model.generate(tokens) return result注意线程池大小不要盲目开大。分词器本身有锁和内部状态,线程太多反而会因为锁竞争变慢。我是从 4 个 worker 开始调的,短文本场景 4-8 个足够,长文本场景建议直接用进程池隔离,避免一个长文本分词占满线程池导致其他请求饿死。
6. 踩坑笔记与我的取舍建议
6.1 几个反复出现的坑
第一,缓存和模型不匹配。我踩过最亏的一次是换了模型版本但忘了清分词缓存,旧模型的 token ids 直接喂给新模型,生成结果全乱。如果要在服务里引入缓存,一定把模型名称或 tokenizer 版本号拼进缓存 key,否则宁可不开缓存。
第二,只优化了单条路径,没管峰值。单条分词从 5ms 优化到 2ms 听起来不错,但高并发下真正决定上限的是 CPU 总耗时,长尾请求反而会因为线程排队拖垮整体延迟。优化完一定要重新压测并发场景,不能只看单条 benchmark。
第三,中文和英文的耗时差异。如果你拿英文基准测试的结果去估算中文服务的容量,通常会被严重低估。中文场景记得把多字节膨胀系数算进去,最好直接用中文语料做压测。
第四,词表加载时间。用大词表模型时,服务冷启动时间可能比想象中长。词表加载和模型权重加载是两回事,但经常一起被归到“启动慢”里。要单独 profile 才能定位。
6.2 我在实际项目里的取舍
综合下来,我会按这个优先级做:
- 第一优先:确认 tokenizer 走的是 Rust/C++ 路径,不是 Python 慢速路径。
- 第二优先:给固定前缀和常见文档段落做缓存。
- 第三优先:把离线侧文档全量预分词,在线只做轻量 query 分词。
- 第四优先:调好服务端并发模型,分词放线程池或独立进程。
- 最后才是考虑换更小的词表或自定义 tokenizer,因为那会影响模型效果,不是纯粹的工程问题。
我见过不少团队把精力花在优化注意力机制和量化上,反而忽略了 tokenizer 这个每次请求都会碰到的小环节。其实分词慢这个问题,只要肯花半天时间做 profiling,再按上面几个方向调整,通常能把整条链路的首 token 延迟降下来一个明显的档次,属于投入小、见效快的优化项。说到底,一个推理服务的性能不是只由 GPU 决定的,CPU 侧的每一个隐性环节,最终都会在延迟曲线上显形。