长上下文推理优化:Prefix Cache、调度与 MTP

在 100K Context 已经装得下之后,继续拆解 Prefix Cache、Chunked Prefill、Continuous Batching 与 MTP 分别优化哪一段延迟和吞吐。

📚 双 RTX 5060 Ti 部署 Qwen3.8 系列 · 6/6

💡 导读

100K Context 能装下,不代表 100K Context 真正用起来就快。对长代码 Agent 来说,真正值得优先优化的是重复 Prefill、长 Prompt 对 Decode 的阻塞,以及多请求之间的 GPU 利用率;MTP 则属于更靠后的 Decode 优化。

前一篇最终把双 RTX 5060 Ti 的长上下文配置落在了:

2 × RTX 5060 Ti 16GB
Qwen3.8-27B NVFP4
TP2
FP8 KV
CUDA Graph ON
100K Context
Maximum Concurrency ≈ 1.07x

到这里,显存问题基本解决了。

但“能够容纳 100K Context”和“100K Context 真正用起来足够快”,其实是两个问题。

对于代码 Agent,一次请求经常包含:

  • System Prompt
  • Tool Definitions
  • 项目规则
  • Repository Context
  • 历史对话
  • Tool Result
  • 当前用户输入

其中大量内容在连续几轮请求里几乎不会变化。

如果每一轮都重新 Prefill 几十 K、甚至接近 100K Token,那么即使 Decode 已经有 30 多 token/s,整体体验仍然可能很慢。

所以长 Context 解决容量以后,我真正开始关心的是另外三个问题:

重复内容能不能不重新计算?

超长 Prefill 会不会阻塞正在 Decode 的请求?

多个请求能不能更高效地共享 GPU?

它们分别对应:

  • Prefix Cache
  • Chunked Prefill
  • Continuous Batching

至于 MTP,它解决的是更后面的 Decode 串行问题。

这几个功能经常一起出现在“LLM 推理加速”列表里,但实际优化的是完全不同的阶段。

对于长代码 Agent,我现在的优先级非常明确:

Prefix Cache
Chunked Prefill / Scheduler
Continuous Batching
最后才评估 MTP

1. Prefill 和 Decode 是两种完全不同的负载

一次 LLM 请求大致可以拆成:

Prompt
Prefill
第一个输出 Token
Decode
Decode
...

Prefill 一次处理大量输入 Token。

如果 Prompt 是 100K,那么模型首先要把这 100K Token 处理一遍,建立 Attention KV、Linear Attention State 等运行状态。

这个阶段矩阵规模较大,通常更偏 Compute-heavy,也更容易把 Tensor Core 利用起来。

Decode 则不同。

每一步只新增一颗 Token,但它仍然要经过整个模型。

单流 Decode 更容易受到:

  • Weight Traffic
  • 显存带宽
  • 小 GEMM
  • Kernel Launch
  • TP Collective
  • Scheduler

影响。

所以:

Prefill 快,不代表 Decode 快;Decode 很快,也不能解决一个 100K Prompt 首次加载很慢的问题。

后面的优化,第一步就是先分清楚自己到底在优化哪一段。


2. Prefix Cache:先把重复 Prefill 消掉

假设第一次请求是:

内容 Token
System Prompt 5K
Tool Definitions 5K
Repository Context 80K
History 5K
User Prompt 5K
总计 100K

第一次没有办法,这 100K 基本都要经历 Prefill。

但 Agent 完成一次 Tool Call 后,第二轮请求很可能只是末尾发生变化:

System Prompt       5K   ← 不变
Tool Definitions    5K   ← 不变
Repository Context 80K   ← 大部分不变
History             8K
Tool Result          3K

如果没有 Prefix Cache,第二轮还是要从头处理整段 Prompt。

第三轮又重新来一次。

Prefix Cache 的思路是:

已经计算过、并且 Token Prefix 完全相同的部分,直接复用对应的 Cache Block / 状态。

例如:

100K Prompt

其中 90K Prefix 已命中
真正重新 Prefill
≈ 10K

这带来的收益不是:

“把 100K Prefill 优化 20%。”

而更接近:

90K Token 这次根本不重新计算。

对于长 Context Agent,这通常比继续挤一点 Kernel 性能更有价值。

它改善的是 TTFT,不是 Decode TPS

假设当前单流 Decode 是:

≈ 33.5 token/s

打开 Prefix Cache 以后,即使命中了 90K Prefix,也不会因此让后面的 Decode 自动变成 60 token/s。

已经进入 Decode 阶段以后,每生成一颗 Token,该走的模型计算仍然要走。

Prefix Cache 节省的是进入 Decode 之前的重复 Prefill。

所以它最明显影响的是:

TTFT——Time To First Token。

测试 Prefix Cache 时,我更关心:

  • TTFT
  • Cached Tokens
  • 实际重新 Prefill 的 Token 数
  • Prefix Cache Hit Rate

而不是只看 Decode token/s。


代码 Agent 天生适合 Prefix Cache,Prompt Layout 也会直接影响命中率

普通聊天历史会持续变化。

代码 Agent 则经常有一大段非常稳定的 Context:

  • System Prompt
  • Tool Schema
  • 项目规则
  • AGENTS.md / CLAUDE.md 一类规则文件
  • Repository 文件
  • 大量已经加载的代码
  • 长时间不变的环境说明

真正经常变化的通常只是末尾:

  • 当前 User Prompt
  • Tool Result
  • 新生成的 Assistant 内容

这意味着代码 Agent 天然具有很高的 Prefix 重复率。

假设总 Prompt 是 100K,其中 80K~90K 能稳定命中,那么真正需要重新 Prefill 的可能只有最后一小段。

但 Prefix Cache 不是语义缓存。

两段 Prompt “意思一样”,不代表能够命中。它要求前面的 Token Prefix 真正一致。

例如:

当前时间:10:01
这里是 80K 项目代码……

下一轮变成:

当前时间:10:02
这里是同样的 80K 项目代码……

虽然后面的 Repository Context 完全没变,但 Prefix 在很靠前的位置已经不同。

因此我更倾向于让 Agent Prompt 按稳定程度排列:

最稳定
System Prompt
Tool Definitions
固定规则
Repository Context
历史内容
Tool Result
当前 User Input
最动态

而不是把当前时间、随机 ID、动态状态放在巨大固定 Context 的前面。

对于 100K 代码上下文:

Prompt Layout 已经不只是 Prompt Engineering,也属于 Serving Optimization。

一个小动态字段放错位置,可能直接让后面几十 K Token 的缓存价值消失。

不过对 Qwen3.8-27B 还要多加一个 Hybrid Model 的前提。

它不是纯 Full Attention,而是:

48 × Gated DeltaNet + 16 × Full Attention

因此 vLLM 做 Prefix Cache 时,不只是管理传统 Full-Attention KV Block,还要处理 Linear Attention 的 recurrent state / state checkpoint。

这意味着 Hybrid Model 的缓存命中粒度、State 保留位置和 Block 对齐,会比纯 Transformer 更复杂。

所以上面:

100K Prompt / 90K Prefix Hit

应该理解成一个便于建立直觉的例子,而不是“只要前 90K Token 相同,就一定能以任意粒度完整命中”。

真实部署里,我会直接看当前 vLLM 版本实际报告的:

  • Cached Tokens
  • Prefix Cache Hit Rate
  • TTFT

来判断 Hybrid Prefix Cache 到底复用了多少计算。


Prefix Cache 不是无限容量,多租户还要考虑隔离

Prefix Cache 最终仍然占用实际 Cache Pool。

假设当前同时存在:

Session A:80K Prefix
Session B:90K Prefix
Session C:70K Prefix
Session D:100K Prefix
...

消费级 GPU 不可能永久保留所有 Prefix。

当 Cache Pool 需要空间给新的请求时,旧 Block 最终会被淘汰和复用。

所以 Prefix Cache 的真实收益会受到:

  • Cache Capacity
  • 活跃 Session 数量
  • Prefix 大小
  • 请求是否集中在同一个项目
  • 请求局部性

影响。

这也是为什么它特别适合我的这类 workload:

长时间围绕同一个 Repository 连续工作。

如果变成几百个用户,每个人都有完全不同的 100K Prefix,命中率自然会下降。

另外,如果是公网多租户 API,还要考虑 Cache 隔离和 Timing Side Channel。

vLLM 提供 cache_salt 一类机制,让不同租户可以控制 Prefix Cache 的共享边界。

个人 Coding Agent 不一定需要重点考虑这一点,但正式多租户服务不能完全忽略。


3. Chunked Prefill:Cache Miss 时别让长 Prompt 霸占 GPU

Prefix Cache 很强,但它解决不了第一次请求。

第一次打开一个大型 Repository:

100K New Prompt

没有旧 Prefix 可以复用,完整 Prefill 还是必须发生。

问题随之变成:

一个 100K Prefill 会不会长时间霸占 GPU?

假设:

Request A
已经进入 Decode,正在持续输出

Request B
突然进来一个 100K Prompt

如果 B 一次性把整个 Prefill 做完,再让 A 继续 Decode,那么 A 的 Inter-Token Latency 很可能突然升高。

用户看到的现象就是:本来流式输出很顺,突然卡了一段时间。

Chunked Prefill 会把一个很大的 Prefill 拆成多个 Chunk:

100K Prefill
Chunk 1
Chunk 2
Chunk 3
Chunk 4
...

Scheduler 就有机会在这些 Chunk 之间继续安排其他 Decode 工作。

更接近:

Decode A
+
Prefill B 的一部分


Decode A
+
Prefill B 的下一部分

而不是:

先让 B 完整 Prefill 100K
A 一直等
B 算完以后 A 再继续

vLLM V1 的调度思路本身就会优先照顾正在 Decode 的 Sequence,再把剩余 Token Budget 分给 Prefill;Prefill 太大时就拆 Chunk。

这对 Serving 很有价值,因为 Prefill 和 Decode 的资源特征并不完全相同:

Prefill
更偏大矩阵 / Compute-heavy

Decode
更偏 Weight Traffic / Memory-bound

把两种 workload 更合理地交错调度,有机会同时改善用户延迟和 GPU 利用率。


max_num_batched_tokens:本质上是 TTFT、ITL 和吞吐的交换

Chunked Prefill / Scheduler 有一个很重要的 Token Budget 概念:

max_num_batched_tokens

可以粗略理解成:

一次 Scheduler Iteration 最多允许处理多少 Token。

如果 Budget 较小,大型 Prefill 会被切成更多 Chunk。

优点是:

  • Decode 更容易持续插入
  • ITL 往往更友好
  • 长 Prefill 不容易长时间阻塞其他请求

代价则是:

  • Prefill 被切得更碎
  • 调度次数更多
  • 单个大 Prompt 完成 Prefill 可能更慢

反过来,Budget 更大时,一轮可以吃下更多 Prefill Token,通常更有利于大 Prompt 吞吐和 TTFT,但也可能让其他 Decode 等得更久。

所以它不是“越大越好”,也不是“越小越低延迟就一定最好”。

它本质上是在三项指标之间做取舍:

TTFT
vs
ITL
vs
Aggregate Throughput

个人单用户 Agent 和高并发 API,最优值很可能不一样。

这类参数最终还是应该用真实 workload Benchmark,而不是只看一个离线吞吐数字。


4. Continuous Batching:把多个请求持续填进 GPU

假设同时有三个请求:

  • A:生成 100 Token
  • B:生成 500 Token
  • C:生成 50 Token

如果它们被绑定成一个传统静态 Batch,C 很快结束以后,它的位置可能空着;A 随后也结束,最后只剩 B 一直跑。

Batch 利用率会不断降低。

Continuous Batching 的思路是:

A B C

C 完成
D 加入

A B D

A 完成
E 加入

E B D

新的 Request 可以持续进入正在运行的 Batch,完成的 Request 及时退出。

它主要改善的是:

多请求 Aggregate Throughput 和 GPU 利用率。

所以和 Prefix Cache 一样,不能拿错指标。

打开 Continuous Batching 后,一条请求原来是 33.5 token/s,并不意味着它一定会突然变成 50、60 token/s。

真正应该看的,是多条请求同时存在时:

整张 GPU 每秒总共能够完成多少有效 Token,以及请求吞吐能不能持续保持。


Prefix Cache、Chunked Prefill 和 Continuous Batching 是一起工作的

真实 Agent Serving 很可能同时出现三种请求。

Request A

  • 已有 90K Prefix Cache
  • 只新增 5K

Request B

  • 第一次进入系统
  • 需要完整 Prefill 100K

Request C

  • 已经完成 Prefill
  • 正在 Decode

这时候三种机制刚好各自解决一个问题。

A:Prefix Cache

90K 不再重复计算。

B:Chunked Prefill

100K 不一次霸占整个调度周期。

C:Continuous Batching / Scheduler

继续参与 Decode,不需要等其他请求全部处理完。

所以我更喜欢把它们记成三句话:

Prefix Cache:重复的别再算。

Chunked Prefill:太长的别一次算完。

Continuous Batching:不同请求尽量一起算。

这三项放到一起,才真正体现出 Serving Runtime 和“单条请求模型推理程序”的区别。


5. MTP 用到 Speculative Decoding 里,才真正开始减少 Decode 串行性

前面的优化主要集中在:

Prefill
+
Scheduler
+
多请求利用率

MTP 则是另一条路线。

传统自回归 Decode:

Token N
Forward
Token N+1
Forward
Token N+2

一次 Target Forward 通常只让最终输出前进一颗 Token。

MTP 和 Speculative Decoding 其实不是同一个概念。

MTP 提供的是“预测后续多颗 Token”的能力;在推理阶段把 MTP Head 当成 Speculative Proposer,再由 Target Model 验证这些候选,才形成真正的 speculative decoding 路径:

MTP Proposal
Target Verification
接受若干 Token

如果一次能够稳定接受多颗 Token,就可能减少平均每颗最终 Token 所需要的串行 Decode Step。

所以它真正优化的是:

Decode 串行深度和 ITL。

但它不是一个:

ON = 固定 +30% TPS

的开关。

实际收益更接近:

节省的 Target Decode Step
-
Proposal 开销
-
Verification 开销
=
最终收益

最关键的运行指标包括:

  • Acceptance Rate
  • Mean Accepted Tokens / Step

而且 Serving 还要考虑:

  • Tool Calling
  • Structured Output
  • Sampling 配置
  • 长 Context
  • Kernel / CUDA Graph 兼容性
  • 当前模型与框架版本的稳定性

所以我没有把 MTP 放进这套部署的默认基础配置。

不是因为它理论上“用质量换速度”——标准 Speculative Decoding 本来就会让 Target Model 做验证。

而是因为它属于:

模型、Cache、TP、Kernel、CUDA Graph 和 Scheduler 都已经工作以后,再单独 A/B Benchmark 的高级优化。

对于我的长代码 Agent workload,前面几个问题的优先级明显更高。

尤其如果一次请求是:

  • Prompt:100K
  • Output:2K

MTP-based Speculative Decoding 能优化的是后面的 2K Decode。

如果下一轮有 90K 相同 Prefix,Prefix Cache 则可以直接让 90K 重复 Prefill 不再计算。

所以我的判断一直是:

先解决“不必要的计算”,再解决“剩下的计算怎么更快”。


6. 做到这一层以后,Benchmark 不能再只看 token/s

只记录:

token/s

已经明显不够了。

至少应该把性能拆成四类。

TTFT

Time To First Token

主要受:

  • Prefill
  • Prefix Cache
  • Scheduler

影响。

ITL

Inter-Token Latency

主要反映流式输出是否顺滑,会受到:

  • Decode
  • TP
  • Kernel
  • CUDA Graph
  • Scheduler
  • MTP

影响。

Aggregate Throughput

Total Output Tokens / Second

更适合观察:

  • Continuous Batching
  • Scheduler
  • 并发
  • Batch 利用率

Prefix Cache Hit Rate / Cached Tokens

对长 Context Agent 来说,这甚至可能成为最值得长期记录的指标之一。

如果每轮都能稳定命中 80%、90% 甚至更高,那么系统真实成本和“每次完整 Prefill 100K”已经完全不是一回事。


7. 双 5060 Ti 的最终优化顺序

回到整个系列一直使用的这套部署:

2 × RTX 5060 Ti 16GB
Qwen3.8-27B NVFP4
TP2
FP8 KV
CUDA Graph ON
100K Context

前几篇已经分别解决了:

NVFP4
→ 让权重完整 GPU Resident

TP2
→ 聚合双卡计算与显存带宽

PCIe / P2P 分析
→ 判断 TP 的通信成本是否值得

Kernel / CUDA Graph
→ 降低模型执行开销

FP8 KV
→ 把 100K Context 放进显存

到了最后一层,我真正优先做的是:

Prefix Cache
→ 减少长代码重复 Prefill

Chunked Prefill
→ Cache Miss 时避免长 Prompt 阻塞 Decode

Continuous Batching
→ 多个 Agent / Session 共享 GPU 时提高总吞吐

MTP 则保留成可选项:

只有实际 A/B Benchmark 证明 Acceptance、稳定性和功能兼容性都足够好,才值得加入默认配置。

它不是这套部署架构成立的前提。


8. 结论

大模型推理不存在一个统一的“加速开关”。

不同优化对应不同阶段:

优化 主要解决的问题
Prefix Cache 避免重复 Prefill
Chunked Prefill 长 Prefill 不要一次阻塞整个调度
Continuous Batching 多请求持续填充 GPU
MTP-based Speculative Decoding 减少 Decode 串行 Step

对于长 Context 代码 Agent,我最终更认可这样的顺序:

先减少不必要的计算
再改善 GPU 调度
再提高多请求利用率
最后再优化自回归 Decode

也就是:

Prefix Cache
>
Chunked Prefill / Scheduler
>
Continuous Batching
>
MTP

这个系列从最开始的问题:

“两张 5060 Ti 能不能跑 27B?”

一路走到了:

模型精度
显存容量
多卡并行
跨卡通信
Kernel / CUDA Graph
长 Context Cache
Prefix / Scheduler / Decode 优化

到最后,我觉得真正值得记住的已经不是某一个启动参数,而是一种部署思路:

在可接受的模型质量下,让有限的显存、显存带宽和计算能力尽可能花在真正需要重新计算的 Token 上。

这也是我这一周折腾双 RTX 5060 Ti + Qwen3.8-27B 以后,最终得到的最有价值的结论。


系列导航: ← 上一篇:200K Context 到底需要多少显存?从 KV Cache 算到 Qwen3.8 的真实部署

使用 Hugo 构建
主题 StackJimmy 设计