长上下文推理正在成为大语言模型落地中最容易被低估的性能瓶颈。当上下文长度从 8K 扩展到 128K,推理系统的算力占用和显存占用并不是线性增长,而是近似平方级放大。摩尔线程发布《MTT S5000 Prefill-as-a-Service 技术白皮书》,核心思路是把 Prefill 阶段从整条推理链路中独立出来,作为一种可调度的服务形态交付。这个方向直接指向一个长期存在但经常被忽视的问题:在 GPU 上处理大模型请求时,Prefill 和 Decode 的资源模型差异太大,混在一起必然会产生浪费。
这篇文章不逐页摘录白皮书原文,而是从推理服务架构入手,拆解 Prefill-as-a-Service 要解决什么问题、资源拆分为什么能降低成本、服务链路如何设计、部署验证有哪些坑,以及哪些指标能提前暴露风险。读完以后,即使不接触白皮书原文,也能依据本文的工程思路来评估自己的推理服务是否适合拆分。
1. 长上下文推理的成本瓶颈,首先来自 Prefill 与 Decode 的资源错配
1.1 Prefill 和 Decode 的时间占比完全不对称
大语言模型生成响应是一个自回归过程。用户输入的 prompt 会一次性进入模型,在隐藏层中逐层计算,得到每个位置的状态和缓存,这一步称为 Prefill。随后模型开始逐 token 生成结果,每个新 token 都要依赖前面已经生成的所有 token,这一步称为 Decode。
从时间上看,两者的关系并不对称。同样一段上下文,Prefill 需要处理所有输入 token 的注意力计算,是一次性的“并发扫描”;Decode 则是串行的,每生成一个 token 就需要一次完整的前向计算。可以这样类比:Prefill 像把整页纸一次性扫描进系统,Decode 像按顺序逐字读写。
在短上下文场景里,例如 1K 到 4K token,Prefill 的耗时常常被忽略。但在长上下文场景里,例如 64K 到 128K token 的文档问答、代码仓库分析、多轮长对话,Prefill 阶段会因为输入 token 数量太大而变得非常耗时。
可以做一个粗略估算:大模型前向计算一次所需的浮点运算大致与模型参数量和 token 数成正比。一个 70 亿参数的模型处理 128K 个 token,理论计算量已经达到 10 的 15 次方 FLOPs 量级,这还没有算注意力机制的额外开销和中间激活值成本。当这类请求同时到达时,单台 GPU 很容易在 Prefill 阶段形成排队。
用表格对比两种阶段:
| 对比维度 | Prefill 阶段 | Decode 阶段 |
|---|---|---|
| 输入 | 全部上文 token,例如 128K | 单个新生成的 token |
| 计算形态 | 大批量矩阵乘、并行注意力 | 小批量矩阵乘、串行注意力 |
| 主要瓶颈 | 算力(FLOPs) | 显存带宽、KV Cache 读取 |
| 耗时 | 随上下文长度近似线性增加 | 每 token 延迟固定,随生成数量累计 |
| 对延迟敏感度 | 首 token 延迟敏感 | 每 token 间隔敏感 |
这张表是整个 Prefill-as-a-Service 架构的理论前提:两种阶段对 GPU 资源的需求并不相同。
1.2 KV Cache 是长上下文场景的显存黑洞
除了计算,长上下文还会带来一个显存问题,即 KV Cache。在注意力计算过程中,模型需要把每个 token 的 Key 和 Value 缓存下来,供后续 token 生成时复用。如果不缓存,每生成一个 token 都需要重新计算前面所有 token 的键值,成本更高,因此主流推理引擎都选择缓存。
KV Cache 大小可以近似表示成:
KV Cache 大小 ≈ 2 × 隐藏层维度 × 层数 × 序列长度 × KV 头压缩因子 × 数据类型字节数以一个 7B 参数模型为例,假设隐藏层维度 4096、层数 32、采用分组查询注意力后 KV 头数为 8,FP16 存储,则每个 token 大约需要 128KB。当序列长度到 128K 时,单个请求的 KV Cache 就已经是 16GB 量级。这还只是一个请求,如果并发请求数上升,KV Cache 会迅速占满显存。
更关键的是,Decode 阶段每次生成新 token,都需要把这些 KV 数据从显存读一遍。128K 长度的请求在实际生成过程中,等于反复对几十 GB 的数据做串行读取,这种访问模式对显存带宽的消耗非常大。
这就是长上下文推理成本高企的真正原因。计算量增大,显存占用增大,最终落到 GPU 上是算力、显存容量、显存带宽三个维度同时承压。
1.3 混跑场景下,算力和显存带宽都得不到充分利用
传统推理服务通常把 Prefill 和 Decode 放在同一个 GPU 上执行。请求进入后,先在这个 GPU 上完成 Prefill,再在同一个 GPU 上完成所有 Decode token。这个设计实现简单,但资源利用上存在明显错配。
Prefill 阶段的计算量很大,通常会把 GPU 的计算单元打满,但在这个阶段,模型权重和 KV 数据被读取的次数相对集中,对显存带宽的占用并不稳定。Decode 阶段恰好相反,计算量小,真正的高开销来自每次迭代都要把权重和 KV Cache 读一遍。
如果两种请求混在同一批中,会出现两种典型问题:算力需求高的 Prefill 请求把 GPU 计算单元占住,导致 Decode 请求得不到及时调度;或者 Decode 请求长期占用显存带宽,让后续 Prefill 请求排队。无论哪种,GPU 都很难持续保持高利用率。
更麻烦的是,长上下文请求的 Decode 阶段可能持续很长时间。一个 128K 上下文的请求,生成 4096 个新 token,如果每个 token 需要几十毫秒,整个请求就要持续数分钟。这段时间里,它占用的 GPU 无法服务其他高优先级请求,资源被锁定在一个慢速阶段中。
这种错配说明,问题不只是 GPU 性能不够,而是资源模型不匹配。拆分服务,本质上是把两种资源模型分别交给不同的硬件承担。
2. 拆分 Prefill 的底层逻辑:计算密集型与访存密集型解耦
2.1 Prefill 阶段为什么是算力驱动
Prefill 阶段在计算特征上是典型的“计算密集型”。所有输入 token 组成高维矩阵,模型执行的是大矩阵乘法和多头注意力中的并行计算。
计算强度,也就是“每次内存访问配套多少次浮点运算”,在 Prefill 阶段通常很高。大量输入 token 会提高矩阵块的复用率,GPU 的矩阵计算单元可以长时间保持满负荷。如果只考虑 Prefill 工作负载,决定吞吐量的核心指标是 GPU 峰值算力,以及算力在持续运行时的稳定度。
也是因为这一点,Prefill 阶段出现大量并发请求时可以做连续批处理。多个请求共享输入阶段,把 token 合并成更大的 batch,矩阵乘的规模越大,单位 token 的计算成本越低。vLLM、SGLang 这类推理引擎中,Prefill 请求往往会被打包进同一个 batch,配合分页 KV Cache 来提高吞吐。
如果只把 Prefill 放在一个算力强的 GPU 上,它可以长时间处于高算力利用状态,单位时间能处理更多输入 token。这样,算力资源不必为后续的慢速 Decode 过程空等。
2.2 Decode 阶段为什么是带宽驱动
Decode 阶段每个 step 只生成一个 token,输入是从单个 token 对应的向量开始,计算量很小。但模型仍然需要访问全部权重,以及当前请求全部历史 token 的 KV Cache。
在长上下文场景中,KV Cache 规模远大于输入向量本身。因此每次生成一个 token 的时间,主要由“从显存读取权重和 KV Cache 的耗时”决定。更准确的说法是:Decode 阶段的算术强度很低,瓶颈在显存带宽,而不是峰值算力。
如果分开评价,Prefill 适合用“每秒钟处理多少输入 token”来衡量,Decode 则要看“每秒钟能完成多少次显存读取”。当一个 GPU 同时承接两类请求,这两类指标会互相干扰。
举一个极端情况:如果把 Prefill 和 Decode 放在同一张 GPU 上,而这颗 GPU 的算力很强、显存带宽相对有限,那么 Decode 请求在长上下文压力下会把带宽耗尽。这时,即使纯 Prefill 请求进入,也会感觉 GPU“变慢了”,因为访存已经成为整机的主要矛盾。
2.3 从“单服务处理完”到“服务化拆解”的转变
传统的单服务设计是这样一个请求链路:
请求进入 -> Prefill 计算 -> 迭代生成 token -> 返回完整结果整个生命周期在一个进程和一个 GPU 上下文里完成。拆分成 Prefill-as-a-Service 后,链路变成了:
请求进入 -> Prefill 服务计算并生成 KV Cache -> 将 Cache 传给 Decode 服务 -> Decode 服务逐 token 生成 -> 返回结果看起来只是多了一个“传 Cache”的环节,但可行性需要两个前提:
- Prefill 和 Decode 可以在不同 GPU 上运行。
- KV Cache 可以序列化,并且能从 Prefill 服务传递到 Decode 服务。
当一个请求完成 Prefill 后,输出不是最终文本,而是一份“状态缓冲区”,通常包含各层的 Key、Value 张量,以及采样所需的位置信息和随机数状态。Decode 服务拿到这份状态后,就可以从第 0 个新 token 开始生成,不需要重新读入原始 prompt。
这种设计把原本耦合在单个 GPU 上的“高算力需求”和“高带宽需求”拆成两段。Prefill 服务可以按算力需求扩容,Decode 服务可以按显存带宽或 KV Cache 容量扩容,两个队列各自独立,不会因为一种请求阻塞而拖累另一种请求。
这就是“Prefill-as-a-Service”字面意思的来源:Prefill 不再只是一个内部步骤,而是被抽象成一种可以独立部署、独立调度、独立计费的服务。
3. Prefill-as-a-Service 的服务链路由与关键设计
3.1 一次请求流转的完整链路
一个可工作的 Prefill-as-a-Service 至少需要三个部分:
- 入口网关:接收请求,判断走 Prefill 服务还是直接从已有 Cache 续写。
- Prefill Worker:执行 Prefill 计算,生成 KV Cache 和中间状态。
- Decode Worker:读取 KV Cache,执行逐 token 生成。
入口网关的调度逻辑可以简化成这样:
def route_request(prompt, session_state): if session_state is None: # 第一次处理,走 Prefill kv_cache = prefill_service.execute(prompt) session_id = cache_store.register(kv_cache) return decode_service.generate(session_id) else: # 已有缓存,直接从 Decode 续写 return decode_service.generate(session_state.session_id)这段伪代码展示了一个关键点:网关并不关心 KV Cache 的物理形态,只关心“当前请求有没有可复用的 Prefill 结果”。在有状态场景中,例如多轮对话、流