💡

文章摘要

当 LLM 应用响应缓慢时,问题往往不在 GPU 推理。本文拆解完整延迟预算,揭示 tokenization、同步检索和序列化如何成为 agentic 工作负载的隐藏瓶颈,并给出可落地的优化路径。

一个常见的性能陷阱

想象你正在构建一个 RAG检索增强生成)应用。用户提问后,系统需要从知识库检索相关文档,组装 prompt,然后调用 LLM 生成回答。你发现整体响应时间是 800ms,于是开始优化 GPU 推理——调整 batch size、尝试量化、升级显卡。但即使推理速度提升了 20%,用户感知的延迟只减少了 65ms。

问题出在哪里?

答案是:你优化错了阶段。在典型的 LLM 流水线中,推理只占总延迟的一部分,而真正容易被忽视的是推理之前的预处理阶段——tokenization、文档检索、prompt 组装和序列化。这些阶段单独看都很小,但叠加起来往往成为优化的最大机会。

为什么会发生这种误判?因为推理阶段最容易测量:GPU 利用率、每秒 token 数、首 token 延迟,这些指标都有现成的监控工具。而预处理阶段分散在六七个不同的代码路径里,每个阶段看起来都只有几十毫秒,团队往往根本不为它们建立计时埋点。正如 2026 年一篇分析 LLM 流水线剖析的文章所指出的,这种「推理陷阱」是最常见的剖析失败模式:工程师测量容易测量的东西,而不是真正慢的东西。

本文将带你拆解 LLM 流水线的完整延迟预算,建立对预处理瓶颈的直观理解,并给出可落地的优化方法。读完之后,你将能够:

  1. 识别你的流水线中哪些预处理阶段是真正的瓶颈
  2. 用最小的工程投入获得最大的延迟改善
  3. 建立完整的 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 时完成并缓存,而不是每次请求都重新解析。

python
# 反模式:逐个文档调用 embedding
for doc in documents:
    embedding = model.encode(doc)  # 每次都有网络开销
    index.insert(doc, embedding)
# 100 个文档 × 2ms = 200ms
python
# 优化:批量处理
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。vLLMSGLang 等主流推理引擎都支持传入 input_ids;如果使用 OpenAI 兼容接口,也可以尝试传入数组形式的 messages 而不是拼接好的字符串。

python
# 第一次 tokenization:估算长度
token_count = tokenizer.encode(prompt)
if len(token_count) > max_context:
    prompt = truncate(prompt)

# 第二次 tokenization:LLM 内部再做一次
response = llm.generate(prompt)  # 内部又调用了一次 tokenizer
python
# 优化:复用 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」,而不是把所有小操作都拆开。

图表加载中…
python
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。

python
# 反模式:频繁序列化
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 序列的过程。最常见的算法是 BPEByte 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 的核心优化包括:

  1. 并行预处理:将文本分块后并行处理
  2. 缓存友好:优化内存布局,减少缓存未命中
  3. SIMD 指令:利用现代 CPU 的向量化指令加速字节对匹配

何时应该引入 fastokens? 判断标准很简单:如果长上下文(10K+ tokens)在你的工作负载中占比较高,且你的延迟瀑布图显示 tokenization 阶段超过 20ms,就值得评估。如果 prompt 都很短(几百 token),tokenization 只有 1-2ms,引入新依赖的收益有限。

需要注意的兼容性边界:fastokens 需要与你的模型 tokenizer 词汇表完全一致。BPE 算法的具体实现(比如特殊 token 处理、空格前缀约定)可能存在细微差异,替换后必须做 token 级别的对拍测试——对同一段文本分别用新旧 tokenizer 编码,确认输出完全一致,再上线。否则可能出现上下文长度估算偏差或生成质量波动。

使用方式有两种:直接使用 fastokens 的原生 API,或者通过 monkey-patch 无缝替换 HuggingFace 的实现。后者特别适合已有代码库的渐进式优化。

python
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.")
python
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 Tokenizersfastokens提升

短 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。通过追踪,你可以清楚地看到每个阶段的延迟分布,识别真正的瓶颈。

一个完整的延迟瀑布图应该包含所有阶段的时间戳,让你能够回答三个关键问题:

  1. 哪些阶段可以并行化?
  2. 哪些阶段的延迟异常高?
  3. 优化的优先级应该是什么?

最小可行实践:不需要一开始就上完整的可观测性平台。从一个简单的中间件开始——为每个阶段记录开始和结束时间,输出到一个统一的日志流或结构化事件中。先跑一周,你就能看到延迟的分布和异常。之后再逐步升级到分布式追踪,关联跨服务调用链。

如何解读追踪结果? 有三个常见信号值得关注:一是「占比小但方差大」的阶段——平均值只有几毫秒,但 P99 达到上百毫秒,说明存在偶发的网络抖动或 GC 停顿;二是「串行阶段的总和明显大于最大值」——说明有可以并行化的独立 I/O;三是「tokenization 时间与 prompt 长度不成线性」——说明 tokenizer 可能存在额外的分配开销。这些信号会直接指向本文前面介绍的优化手段。

图表加载中…
python
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

选型与决策框架

面对多个优化选项,如何决定优先级?以下是一个基于工程投入产出比的决策框架:

优先级建议

  1. 并行化独立阶段:工程投入最小(改几行代码),收益最大(可消除 30-80ms)
  2. 消除双重 Tokenization:中等投入(需要改 LLM 客户端调用),收益明显(长上下文场景可节省 20-30ms)
  3. 批量处理 Embedding:中等投入(需要改数据加载逻辑),收益稳定(每文档开销降低 5-10 倍)
  4. 使用高性能 Tokenizer:较高投入(需要引入新依赖),收益在长上下文场景显著(TTFT 改善 40%)

何时应该优化推理? 当预处理优化已经做到极限,且推理延迟仍然不可接受时,才应该考虑模型量化、Speculative Decoding、模型蒸馏或硬件升级。

决策的心智模型:把每个优化选项想象成一个「成本-收益」组合。成本包括:改动的代码量、引入的依赖、需要重新验证的风险。收益包括:延迟节省、可复用性(是否惠及所有请求)、长期可维护性。并行化之所以排在首位,是因为它成本低、收益高、且几乎零风险——它只是改变了调用顺序,不改变任何语义。

组合使用的效果:这些优化不是互斥的。一个完整的优化路径可能是:先并行化 embedding 和检索(-35ms),再消除双重 tokenization(-25ms),然后引入批量 embedding(-10ms),最后在长上下文场景接入 fastokens(TTFT -40%)。每一步都建立在延迟瀑布图的新基线上,形成持续改进的循环。

图表加载中…

边界与限制

预处理优化可以显著降低延迟,但有其上限:

  • 推理仍然是主要延迟:即使预处理优化到极限,LLM 推理仍占总延迟的 70-80%
  • 硬件限制:GPU 推理速度受限于模型大小和硬件性能
  • 质量权衡:过度压缩 prompt 或减少检索文档可能影响生成质量

Tokenization 的理论上限Tokenization 的时间复杂度是 O(n),其中 n 是文本长度。理论上最优的实现应该做到线性扫描、缓存友好和向量化。fastokens 接近这个理论上限,但仍有优化空间:GPU 加速、预计算常见模板、增量更新多轮对话。

工程权衡:在实际工程中,需要在延迟 vs 质量、成本 vs 性能、复杂度 vs 收益之间做权衡。减少检索文档数量可以降低延迟,但可能影响生成质量;复杂的优化(如 speculative decoding)可能带来显著收益,但增加系统复杂度。

⚠️ 常见踩坑

不要为了优化预处理而牺牲生成质量。预处理优化的目标是消除浪费的计算,而不是减少有用的信息。如果减少检索文档数量导致幻觉率上升,这不是优化,而是退化。

实战检查清单

在优化你的 LLM 流水线之前,先完成以下检查:

  • 是否建立了完整的分布式追踪,覆盖所有预处理阶段?

  • 是否识别了可以并行化的独立阶段(如 embedding 和检索)?

  • 是否存在双重 tokenization 的反模式?

  • Embedding 是否使用批量处理而非逐个调用?

  • 阶段间传递是否避免了不必要的序列化?

  • 是否监控了长上下文场景下的 tokenization 延迟

  • 是否考虑了使用 fastokens 等高性能 tokenizer?

💡 一句话理解

优化的第一步永远是建立可观测性。没有 per-stage 的延迟数据,你只是在猜测瓶颈在哪里。花一个小时接入 OpenTelemetry,比花一周盲目优化更值得。

参考资料

🎯 相关面试题

巩固本篇知识点,备战 AI 岗位面试。