文章摘要
当 LLM 应用响应缓慢时,问题往往不在 GPU 推理。本文拆解完整延迟预算,揭示 tokenization、同步检索和序列化如何成为 agentic 工作负载的隐藏瓶颈,并给出可落地的优化路径。
一个常见的性能陷阱
想象你正在构建一个 RAG(检索增强生成)应用。用户提问后,系统需要从知识库检索相关文档,组装 prompt,然后调用 LLM 生成回答。你发现整体响应时间是 800ms,于是开始优化 GPU 推理——调整 batch size、尝试量化、升级显卡。但即使推理速度提升了 20%,用户感知的延迟只减少了 65ms。
问题出在哪里?
答案是:你优化错了阶段。在典型的 LLM 流水线中,推理只占总延迟的一部分,而真正容易被忽视的是推理之前的预处理阶段——tokenization、文档检索、prompt 组装和序列化。这些阶段单独看都很小,但叠加起来往往成为优化的最大机会。
为什么会发生这种误判?因为推理阶段最容易测量:GPU 利用率、每秒 token 数、首 token 延迟,这些指标都有现成的监控工具。而预处理阶段分散在六七个不同的代码路径里,每个阶段看起来都只有几十毫秒,团队往往根本不为它们建立计时埋点。正如 2026 年一篇分析 LLM 流水线剖析的文章所指出的,这种「推理陷阱」是最常见的剖析失败模式:工程师测量容易测量的东西,而不是真正慢的东西。
本文将带你拆解 LLM 流水线的完整延迟预算,建立对预处理瓶颈的直观理解,并给出可落地的优化方法。读完之后,你将能够:
- 识别你的流水线中哪些预处理阶段是真正的瓶颈
- 用最小的工程投入获得最大的延迟改善
- 建立完整的 per-stage 可观测性,让隐藏瓶颈可见
💡 一句话理解
红色和橙色阶段合计只占总延迟的 19%(约 150ms),但优化它们的工程投入产出比往往远高于优化推理——因为不需要改动模型、量化或硬件,只需要改代码逻辑。
全局心智模型:LLM 流水线的延迟预算
在讨论具体优化之前,我们需要一个完整的延迟分解视图。一个生产级 RAG 系统的典型延迟分布如下:
- LLM 推理:650ms(81%)
- Overhead(上下文组装、编排、序列化):70ms(9%)
- 向量检索:45ms(6%)
- Query Embedding:35ms(4%)
乍一看,81% 的时间花在推理上,似乎应该优先优化推理。但换一个角度看:
- 推理提速 10%:节省 65ms
- 完全消除序列化开销:节省 70ms(而且往往花更少的工程时间)
- 将检索和 embedding 并行化:从关键路径中完全消除 80ms(只需一次 async 重构)
真正的问题不是「哪个阶段最大」,而是「给定每工程周的投入,哪些阶段最值得攻击」。这也是为什么性能剖析必须基于端到端的分布式追踪,而不是只盯着 GPU profiler 或单阶段计时器。
值得强调的是,这个 81% 的比例只是其中一种工作负载的画像。不同场景的分布差异极大:在纯流式聊天场景,推理占比可能超过 90%;而在文档密集的 RAG 场景,预处理和检索可能占到 30% 以上;在 agentic 场景,随着工具调用和上下文累积,tokenization 的比例会快速上升。因此,任何优化决策都应该基于你自己的延迟瀑布图,而不是别人的平均数。
| 优化手段 | 延迟节省 | 工程投入 | 投入产出比 |
|---|---|---|---|
并行化独立阶段 | 35-80ms | 低(改调用顺序) | ★★★★★ |
消除双重 Tokenization | 20-30ms | 中(改客户端调用) | ★★★★ |
批量 Embedding | 每文档 5-10× | 中(改数据加载) | ★★★★ |
使用 fastokens | TTFT 40% | 高(引入新依赖) | ★★★ |
推理量化 INT8 | 推理 10-20% | 高(需重新验证) | ★★ |
第一个隐藏瓶颈:文档预处理与摄入
大多数团队只在 LLM 调用处添加计时埋点,而忽略了上游的文档处理阶段。这个阶段包括 PDF/Word 解析、文本清洗、文档分块(chunking)、去重和过滤。
这些操作是 CPU 密集型的,而且通常串行执行。当系统每秒处理 50 个请求时,CPU 争用会形成队列,导致每个请求的预处理延迟从 10ms 膨胀到 100ms 以上。
为什么预处理是 CPU 密集型的? 解析 PDF 需要处理字体、布局和嵌入对象;分块需要按段落边界切分文本;去重需要计算哈希或相似度。这些工作都没有 GPU 参与,完全依赖 CPU 算力。在云环境中,如果 embedding 和推理都跑在 GPU 节点上,而预处理跑在共享 CPU 节点上,CPU 争用问题会更加严重——多个请求同时到达时,单核处理能力就成了瓶颈。
常见反模式:逐个文档处理
即使单个文档的处理只需 2ms,100 个文档串行处理也需要 200ms。而批量处理可以将网络开销摊销到 16-32 个文档的批次中,每文档开销降低 5-10 倍。
关键检查点:如果你的 ingestion pipeline 在 embedding 之前有串行的文档解析步骤,这就是第一个要优化的地方。另一个常见陷阱是:文档预处理发生在请求的关键路径上,而不是提前离线完成。如果文档集是相对静态的,预处理应该在 ingestion 时完成并缓存,而不是每次请求都重新解析。
# 反模式:逐个文档调用 embedding
for doc in documents:
embedding = model.encode(doc) # 每次都有网络开销
index.insert(doc, embedding)
# 100 个文档 × 2ms = 200ms# 优化:批量处理
embeddings = model.encode_batch(documents, batch_size=32)
index.insert_batch(documents, embeddings)
# 4 个批次 × 5ms = 20ms(10× 加速)第二个隐藏瓶颈:双重 Tokenization
这是生产流水线中最常见的反模式之一:同一段文本被 tokenized 两次。
团队为了避免超出模型的上下文长度限制,会在调用 LLM 之前先估算 prompt 的 token 数量。然后 LLM 客户端库内部又会再做一次 tokenization。在 prompt 由多个检索片段组装而成的场景下,这种双重 tokenization 会显著增加延迟——而且完全是浪费的计算。
双重 tokenization 为什么会出现? 它的根源是职责分离:长度检查逻辑通常写在应用层,而 tokenization 的实际执行在推理库内部。应用层为了「负责任地」管理上下文窗口,会自己调用 tokenizer 计数;但推理库并不知道你已经算过,它必须按自己的流程重新 tokenize。两个团队各自认为自己的做法是正确的,却共同制造了重复计算。
解决方案:将第一次 tokenization 的结果缓存,直接传递 token ids 给 LLM 客户端,避免重复计算。这个优化在长上下文场景下效果尤为明显——对于包含 10 个检索片段的 prompt,双重 tokenization 可能增加 20-30ms 的延迟,而复用结果可以将这部分开销降为零。
关键检查点:如果你在调用 LLM 之前手动做了 token counting,检查你的客户端库是否支持直接传入 token ids。vLLM、SGLang 等主流推理引擎都支持传入 input_ids;如果使用 OpenAI 兼容接口,也可以尝试传入数组形式的 messages 而不是拼接好的字符串。
# 第一次 tokenization:估算长度
token_count = tokenizer.encode(prompt)
if len(token_count) > max_context:
prompt = truncate(prompt)
# 第二次 tokenization:LLM 内部再做一次
response = llm.generate(prompt) # 内部又调用了一次 tokenizer# 优化:复用 tokenization 结果
tokens = tokenizer.encode(prompt)
if len(tokens) > max_context:
tokens = tokens[:max_context]
# 直接传递 token ids,避免重复 tokenization
response = llm.generate(input_ids=tokens)第三个隐藏瓶颈:同步检索阻塞生成
这是大多数 RAG 流水线中影响最大的单个优化点。
典型的串行流水线按顺序执行五个阶段:接收 query → embedding → 检索 → 组装 prompt → 调用 LLM。问题在于:embedding 和检索都是网络 I/O,而且互不依赖——它们可以从 query 到达的那一刻起并行执行。
如果 embedding 需要 35ms,检索需要 45ms,串行执行成本 80ms。并行执行成本 45ms。你没有改动模型、索引或 embedding 服务——只是改变了调用顺序。
这是结构性的改变,能带来每工程小时最高的延迟收益,而且在你有延迟瀑布图之前完全不可见。
为什么串行模式如此普遍?因为大多数代码是从同步脚本演化而来的:先写一个线性函数,把所有调用串起来,测试通过后直接上线。异步重构需要处理并发原语、错误传播和超时,看起来「更复杂」。但从收益角度看,这往往是最值得做的一次重构。
还有哪些阶段可以并行? 除了 embedding 和检索,以下独立阶段通常也值得并行调度:用户画像查询、权限检查、缓存命中查询、系统提示词组装。凡是「互不依赖且都是网络 I/O」的阶段,都可以从关键路径中移除——用它们的最大值替代总和。
需要注意的边界是:并行化本身也有成本。当阶段数很少(2-3 个)且每个阶段都很慢时,并行收益明显;但如果有 10 个毫秒级的阶段需要并行调度,调度开销和连接池争用可能吃掉收益。经验法则是:优先并行「最慢的几个大 I/O」,而不是把所有小操作都拆开。
import asyncio
async def rag_pipeline(query):
# 同时启动 embedding 和不依赖 embedding 的预处理
embedding_task = asyncio.create_task(embed_service.encode(query))
metadata_task = asyncio.create_task(fetch_metadata(query))
embedding = await embedding_task
metadata = await metadata_task
# 检索可以立即开始,不需要等 metadata
docs = await vector_db.search(embedding)
prompt = assemble_prompt(docs, metadata)
return await llm.generate(prompt)第四个隐藏瓶颈:阶段间的序列化开销
在 Agent 和多阶段流水线中,中间结果经常需要在阶段之间传递。常见的做法是将结果序列化为 JSON。
单个序列化操作很快(<1ms),但在一个有 5 个阶段的 Agent 流水线中,每次边界都序列化和反序列化,累积的固定开销可能达到 10-20ms。如果中间结果是大型数据结构——比如包含完整文档内容、工具输出和中间推理——序列化成本会更高。
序列化开销来自哪里? 序列化不只是把对象变成字符串:它需要遍历对象图、处理嵌套结构、转义特殊字符、分配字符串缓冲区。反序列化同样需要解析和重新分配对象。在现代解释型语言中,这些操作的 CPU 成本远高于直觉估计,而且对象越大、嵌套越深,成本越高。
解决方案:在同一进程内的阶段之间直接传递结构化对象,只在跨服务边界或需要持久化时才序列化。
除了去掉不必要的序列化,还可以考虑更轻量的替代方案:在进程内传递引用而不是拷贝;在需要日志或追踪时,用延迟求值(lazy evaluation)只在真正需要时才生成序列化结果;跨服务边界时优先选择二进制协议(如 protobuf、MessagePack)而不是 JSON。
# 反模式:频繁序列化
for stage in pipeline_stages:
result = stage.process(input)
input = json.dumps(result) # 每次都要序列化
# 优化:传递结构化对象
for stage in pipeline_stages:
result = stage.process(input) # 直接传递对象
input = result
# 只在跨进程或持久化时序列化
if need_to_log:
log_to_trace(result.to_dict())深入机制:为什么 Tokenization 在 Agentic 工作负载中成为瓶颈
随着 Agent 应用的兴起,tokenization 的瓶颈效应变得更加明显。
Tokenization 的基本原理:Tokenization 是将文本转换为模型可以处理的 token 序列的过程。最常见的算法是 BPE(Byte Pair Encoding,字节对编码),它通过统计文本中最常见的字节对,逐步合并成更大的 token。例如,"tokenization" 可能被分解为 ["token", "ization"] 两个 token。
为什么 Agentic 工作负载更敏感? 在传统问答场景中,prompt 通常只有几百个 token。但在 Agent 场景中,prompt 会快速膨胀:
- 工具调用结果:每次工具返回可能包含数千 token
- 检索文档:RAG 场景下可能检索 5-10 个文档片段
- 对话历史:多轮对话累积的上下文
- 中间推理:Agent 的思考链(Chain of Thought)
一个典型的 Agent 请求可能包含 10,000-50,000 个 token。在这种情况下,tokenization 的延迟从几毫秒膨胀到几十毫秒,成为不可忽视的瓶颈。更关键的是,tokenization 是串行阻塞的——模型无法开始处理任何 token,直到整个 prompt 完成 tokenization。这意味着 tokenization 的每一毫秒都直接叠加到用户感知的首 token 延迟上。
为什么 Rust 实现能快这么多? 传统 Python tokenizer 的瓶颈在于逐 token 的解释开销和频繁的对象分配。Rust 实现可以把整个 BPE 匹配过程编译为高效的机器码,配合 SIMD 向量化指令一次处理多个字节,同时通过更好的内存布局减少缓存未命中。这解释了为什么同样运行在 CPU 上,fastokens 能实现接近一个数量级的加速。
fastokens:用 Rust 重写 Tokenization
Crusoe 和 NVIDIA Dynamo 合作开发了 fastokens,一个用 Rust 实现的高性能 BPE tokenizer。它在保持与 HuggingFace Tokenizers 兼容的同时,实现了显著的性能提升:
- 平均加速 9.1 倍:在多种模型和 prompt 长度下测试
- TTFT(Time to First Token)改善 40%:在长上下文 Agent 工作负载中
- 零代码改动:可以通过 monkey-patch 直接替换 HuggingFace tokenizer
fastokens 的核心优化包括:
- 并行预处理:将文本分块后并行处理
- 缓存友好:优化内存布局,减少缓存未命中
- SIMD 指令:利用现代 CPU 的向量化指令加速字节对匹配
何时应该引入 fastokens? 判断标准很简单:如果长上下文(10K+ tokens)在你的工作负载中占比较高,且你的延迟瀑布图显示 tokenization 阶段超过 20ms,就值得评估。如果 prompt 都很短(几百 token),tokenization 只有 1-2ms,引入新依赖的收益有限。
需要注意的兼容性边界:fastokens 需要与你的模型 tokenizer 词汇表完全一致。BPE 算法的具体实现(比如特殊 token 处理、空格前缀约定)可能存在细微差异,替换后必须做 token 级别的对拍测试——对同一段文本分别用新旧 tokenizer 编码,确认输出完全一致,再上线。否则可能出现上下文长度估算偏差或生成质量波动。
使用方式有两种:直接使用 fastokens 的原生 API,或者通过 monkey-patch 无缝替换 HuggingFace 的实现。后者特别适合已有代码库的渐进式优化。
from fastokens._native import Tokenizer
tokenizer = Tokenizer.from_model("deepseek-ai/DeepSeek-V3.2")
tokens = tokenizer.encode("A very long prompt that is now lightning fast.")import fastokens
fastokens.patch_transformers()
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("deepseek-ai/DeepSeek-V3.2")
# 现在使用的是 fastokens 的高性能实现
tokens = tokenizer.encode("A very long prompt that is now lightning fast.")| 指标 | HuggingFace Tokenizers | fastokens | 提升 |
|---|---|---|---|
短 prompt(<1K tokens) | 2-5ms | 0.3-0.8ms | 6-9× |
长 prompt(10K+ tokens) | 30-80ms | 3-9ms | 9-11× |
Agentic workload(50K tokens) | 150-300ms | 15-30ms | 10× |
TTFT 改善(端到端) | 基准 | -40% | 显著 |
API 兼容性 | 标准 | 100% 兼容 | 零改动 |
可观测性:让隐藏瓶颈可见
上述所有优化的前提是:你能看到这些瓶颈。大多数团队只在 LLM 调用处添加监控,而忽略了预处理阶段。
使用 OpenTelemetry 或类似的分布式追踪工具,为每个阶段创建 span。通过追踪,你可以清楚地看到每个阶段的延迟分布,识别真正的瓶颈。
一个完整的延迟瀑布图应该包含所有阶段的时间戳,让你能够回答三个关键问题:
- 哪些阶段可以并行化?
- 哪些阶段的延迟异常高?
- 优化的优先级应该是什么?
最小可行实践:不需要一开始就上完整的可观测性平台。从一个简单的中间件开始——为每个阶段记录开始和结束时间,输出到一个统一的日志流或结构化事件中。先跑一周,你就能看到延迟的分布和异常。之后再逐步升级到分布式追踪,关联跨服务调用链。
如何解读追踪结果? 有三个常见信号值得关注:一是「占比小但方差大」的阶段——平均值只有几毫秒,但 P99 达到上百毫秒,说明存在偶发的网络抖动或 GC 停顿;二是「串行阶段的总和明显大于最大值」——说明有可以并行化的独立 I/O;三是「tokenization 时间与 prompt 长度不成线性」——说明 tokenizer 可能存在额外的分配开销。这些信号会直接指向本文前面介绍的优化手段。
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
async def rag_pipeline(query):
with tracer.start_as_current_span("preprocessing"):
docs = await preprocess(query)
with tracer.start_as_current_span("tokenization"):
tokens = tokenize(query, docs)
with tracer.start_as_current_span("retrieval"):
context = await retrieve(tokens)
with tracer.start_as_current_span("llm_inference"):
response = await llm.generate(context)
with tracer.start_as_current_span("serialization"):
result = serialize(response)
return result选型与决策框架
面对多个优化选项,如何决定优先级?以下是一个基于工程投入产出比的决策框架:
优先级建议:
- 并行化独立阶段:工程投入最小(改几行代码),收益最大(可消除 30-80ms)
- 消除双重 Tokenization:中等投入(需要改 LLM 客户端调用),收益明显(长上下文场景可节省 20-30ms)
- 批量处理 Embedding:中等投入(需要改数据加载逻辑),收益稳定(每文档开销降低 5-10 倍)
- 使用高性能 Tokenizer:较高投入(需要引入新依赖),收益在长上下文场景显著(TTFT 改善 40%)
何时应该优化推理? 当预处理优化已经做到极限,且推理延迟仍然不可接受时,才应该考虑模型量化、Speculative Decoding、模型蒸馏或硬件升级。
决策的心智模型:把每个优化选项想象成一个「成本-收益」组合。成本包括:改动的代码量、引入的依赖、需要重新验证的风险。收益包括:延迟节省、可复用性(是否惠及所有请求)、长期可维护性。并行化之所以排在首位,是因为它成本低、收益高、且几乎零风险——它只是改变了调用顺序,不改变任何语义。
组合使用的效果:这些优化不是互斥的。一个完整的优化路径可能是:先并行化 embedding 和检索(-35ms),再消除双重 tokenization(-25ms),然后引入批量 embedding(-10ms),最后在长上下文场景接入 fastokens(TTFT -40%)。每一步都建立在延迟瀑布图的新基线上,形成持续改进的循环。
边界与限制
预处理优化可以显著降低延迟,但有其上限:
Tokenization 的理论上限:Tokenization 的时间复杂度是 O(n),其中 n 是文本长度。理论上最优的实现应该做到线性扫描、缓存友好和向量化。fastokens 接近这个理论上限,但仍有优化空间:GPU 加速、预计算常见模板、增量更新多轮对话。
工程权衡:在实际工程中,需要在延迟 vs 质量、成本 vs 性能、复杂度 vs 收益之间做权衡。减少检索文档数量可以降低延迟,但可能影响生成质量;复杂的优化(如 speculative decoding)可能带来显著收益,但增加系统复杂度。
⚠️ 常见踩坑
不要为了优化预处理而牺牲生成质量。预处理优化的目标是消除浪费的计算,而不是减少有用的信息。如果减少检索文档数量导致幻觉率上升,这不是优化,而是退化。
实战检查清单
在优化你的 LLM 流水线之前,先完成以下检查:
是否建立了完整的分布式追踪,覆盖所有预处理阶段?
是否识别了可以并行化的独立阶段(如 embedding 和检索)?
是否存在双重 tokenization 的反模式?
Embedding 是否使用批量处理而非逐个调用?
阶段间传递是否避免了不必要的序列化?
是否监控了长上下文场景下的 tokenization 延迟?
是否考虑了使用 fastokens 等高性能 tokenizer?
💡 一句话理解
优化的第一步永远是建立可观测性。没有 per-stage 的延迟数据,你只是在猜测瓶颈在哪里。花一个小时接入 OpenTelemetry,比花一周盲目优化更值得。
参考资料
Profiling LLM Pipelines: The Bottlenecks That Aren't Inference — TianPan.co (2026-05-07) https://tianpan.co/blog/2026-05-07-profiling-llm-pipeline-bottlenecks-beyond-inference
How fastokens Cuts LLM Time-to-First-Token by Up to 40% — Crusoe AI https://www.crusoe.ai/resources/blog/reducing-ttft-by-cpumaxxing-tokenization
Agentic RL and the Tokenization Bottleneck — MLHive (2026-05) https://www.mlhive.com/2026/05/agentic-rl-tokenization-bottleneck-hugging-face
Bottlenecks in LLM Inference Optimization — DigitalOcean Community https://www.digitalocean.com/community/conceptual-articles/bottlenecks-llm-inference-optimization
Breaking Down a RAG Pipeline: Where Latency Really Comes From — Echelon Edge https://www.echelonedge.com/blogs/breaking-down-a-rag-pipeline-where-latency-really-comes-from/
MIST: A Co-Design Framework for Heterogeneous, Multi-Stage LLM Inference (arXiv:2504.09775) https://arxiv.org/html/2504.09775v1
🎯 相关面试题
巩固本篇知识点,备战 AI 岗位面试。
- 高级系统设计查看详解 →
当 LLM 上下文窗口达到 1M token 时,"Lost in the Middle" 效应对 RAG 系统设计有何影响?如何工程化缓解?
即使上下文窗口达到 1M token,"Lost in the Middle"(Liu et al. 2023)与 Context Rot(Chroma 2026)仍使模型对中间位置信息的利用率显著下降——标称容量不等于有效容量。RAG 系统不能把检索结果无脑堆叠进超长上下文,而须做检索重排(首尾优先)、分层上下文预算(工作记忆 vs 长尾知识)、分块聚合(map-reduce)与位置探针评测。本题考察候选人能否把位置偏置从论文概念转化为 RAG 工程约束。
- 中级场景高频查看详解 →
生产环境中如何系统性降低 LLM 幻觉?
多层组合:RAG 接地+引用、降温度、约束输出、提升检索质量、事实校验/自一致、拒答兜底、评测监控。
- 中级开放查看详解 →
上下文窗口扩展到 100 万 token 时,哪些现有业务场景会发生质变?
百万 token 上下文让整本书、整库代码、长合同一次性喂入成为可能,跨大量文档综合与超长 Agent 会话出现质变,并弱化对 chunk 式 RAG 的依赖;但成本延迟、“中段信息丢失”和精准可控需求决定了它替代不了 RAG,二者将长期共存。
- 初级概念查看详解 →
Embedding(向量)在实际应用里能用来做什么?
Embedding 把文本变向量算相似度,能做语义搜索、相似推荐、去重、分类、RAG 检索。
