LLM 推理服务引擎深度拆解:从 PagedAttention 到 PD 分离,把 GPU 榨干的七层优化栈
训练一个模型是一次性开销,推理是每天都在烧的钱。2026 年,绝大多数团队已经不再纠结「要不要自己训模型」,而是被另一个更现实的问题按在地上摩擦:同样一张卡、同样一个模型,为什么隔壁团队的 QPS 是我的三倍?
答案通常不在模型里,而在推理服务栈里。
这篇文章不讲「怎么用 vLLM 起一个服务」——那种教程满大街都是。我要拆的是底下那七层东西:分页显存、连续批处理、前缀缓存、Chunked Prefill、PD 分离、KV Cache 分层、投机解码。每一层解决什么问题、代价是什么、什么时候该开、什么时候开了反而更慢。文章里所有的架构和机制都能在开源实现里找到对应代码,所有的数值我会明确标注是「公式推导」「社区公开量级」还是「需要你自己压测」,不编数据。
一、先把账算清楚:推理到底贵在哪
1.1 两个阶段,两种完全不同的机器
自回归 LLM 的一次请求被切成两段,这是理解一切优化的起点:
Prefill(预填充):把整段 prompt 一次性喂进去,并行计算所有 token 的注意力,产出第一个输出 token,同时把这些 token 的 Key/Value 写进 KV Cache。这一步是计算密集型——GPU 的 Tensor Core 被打满,显存带宽相对闲着。
Decode(解码):从第二个 token 开始,每次只算一个新 token。计算量小得可怜(一次 GEMV 而不是 GEMM),但每一步都要把整个 KV Cache 从显存里读一遍。这一步是显存带宽密集型——带宽被打满,Tensor Core 大部分时间在发呆。
用 Roofline 的语言说:prefill 的算术强度(arithmetic intensity,每读一字节数据做多少次浮点运算)很高,落在 compute-bound 区;decode 的算术强度接近 1,死死钉在 memory-bound 区。
这是两台物理特性完全相反的机器,我们却长期把它们塞进同一个进程、同一张卡、同一个 batch 里。后面所有的架构演进,本质上都是在处理这个矛盾。
举个厨房的比方(这个比方社区里用得很多,因为确实贴切):prefill 是切菜,decode 是炒菜。让一个厨师同时干,切菜时锅闲着,炒菜时刀闲着。
1.2 四个必须刻在脑子里的指标
大部分性能讨论之所以变成扯皮,是因为双方说的根本不是同一个指标。
| 指标 | 含义 | 主要受什么影响 |
|---|---|---|
| TTFT (Time To First Token) | 从请求进来到吐出第一个字 | prefill 计算量、排队时间、前缀缓存命中率 |
| TPOT / ITL | 输出 token 之间的间隔 | decode 阶段的显存带宽、batch 内其他请求的干扰 |
| Throughput | 单位时间总吞吐(token/s 或 req/s) | batch size、显存能装下多少并发 |
| Goodput | 满足 SLO 前提下的有效吞吐 | 以上全部 |
第四个指标是关键。很多引擎的 benchmark 只报 throughput,把 batch 堆到 512,吞吐数字非常漂亮,但 P99 的 TTFT 已经飙到 8 秒——对一个聊天产品来说,这些吞吐一个都不算数。
定一条规矩:任何吞吐数字,不带 SLO 约束就是耍流氓。 你的压测报告标题应该是「TTFT P95 < 1s、TPOT P95 < 50ms 前提下的最大吞吐」,而不是「最大吞吐」。
1.3 显存墙:先学会算 KV Cache
服务能扛多少并发,几乎完全由「显存里还能塞下多少 KV Cache」决定。这个数必须会手算:
KV Cache 字节数
= 2 (K和V)
× layers # 层数
× kv_heads # 注意力 KV 头数(GQA 下远小于 Q 头数)
× head_dim # 每个头的维度
× seq_len # 序列长度
× dtype_bytes # FP16=2, FP8=1
× batch_size
写成脚本,方便你对着自己的模型算:
def kv_cache_bytes(layers, kv_heads, head_dim, seq_len,
dtype_bytes=2, batch=1):
return 2 * layers * kv_heads * head_dim * seq_len * dtype_bytes * batch
def fmt_gb(b):
return f"{b / 1024**3:.2f} GB"
# 示例:一个 32 层、GQA 8 个 KV 头、head_dim=128 的中型模型
cfg = dict(layers=32, kv_heads=8, head_dim=128)
for seq in (2048, 8192, 32768, 131072):
per_req = kv_cache_bytes(seq_len=seq, **cfg)
print(f"seq={seq:>7} 单请求 KV={fmt_gb(per_req):>9} "
f"64 并发={fmt_gb(per_req*64):>9}")
跑出来你会发现一个残酷的事实:序列长度是线性放大器,而并发数是另一个线性放大器,两个一乘,80GB 的卡在长上下文场景下瞬间就满了。
再算一笔账,理解为什么 GQA(Grouped Query Attention)在 2023 年之后成了标配:如果 kv_heads 从 64 降到 8,KV Cache 直接砍到 1/8,等价于并发能力提升 8 倍。架构层面的优化永远比工程层面的优化收益大,这是第一课。
1.4 decode 阶段的带宽账
再算一个更反直觉的账。decode 每生成一个 token,需要:
- 读一遍全部模型权重(W 字节)
- 读一遍当前 batch 所有请求的 KV Cache(K 字节)
假设一张卡的显存带宽是 B(字节/秒),那么理论上每步 decode 的时间下界是 (W + K) / B。
关键在于:读权重的开销是整个 batch 摊薄的。batch=1 时,你花了读 W 的代价只算了 1 个 token;batch=64 时,同样读一遍 W 却算了 64 个 token。
这就是为什么「增大 batch」对 decode 吞吐的提升近乎免费——直到 KV Cache 把显存吃光,或者 K 的读取开销超过 W 为止。
def decode_step_time(weight_gb, kv_gb_per_req, batch, bw_gb_s):
"""粗略的 decode 单步耗时下界(纯带宽模型,忽略计算与开销)"""
bytes_read = weight_gb + kv_gb_per_req * batch
return bytes_read / bw_gb_s # 秒
W = 14.0 # 7B 模型 FP16 权重约 14GB
KV = 0.5 # 单请求 KV 假设 0.5GB
BW = 2000.0 # 假设 2TB/s 显存带宽
for b in (1, 4, 16, 64, 128):
t = decode_step_time(W, KV, b, BW)
print(f"batch={b:>4} 单步 {t*1000:.2f} ms "
f"总吞吐 {b/t:.0f} tok/s 单请求 {1/t:.0f} tok/s")
这段代码会告诉你一件重要的事:单请求的输出速度会随 batch 增大而下降,但总吞吐大幅上升。这就是吞吐和延迟的根本 tradeoff,没有任何工程技巧能绕开它,只能在其中找平衡点。
二、第一层:PagedAttention——把操作系统的分页搬进显存
2.1 连续分配的三种浪费
在 PagedAttention 出现之前,主流做法是给每个请求预分配一块连续的 KV Cache 显存,大小按 max_seq_len 算。这会产生三种浪费:
- 内部碎片:请求声明最大 4096 token,实际只用了 300 个,剩下 3796 个 token 的显存位置被占着不放。这是最大的一块浪费。
- 外部碎片:不同请求申请不同大小的连续块,反复分配释放之后,显存里全是「不够大的空洞」。
- 无法共享:两个请求哪怕有完全相同的 system prompt,KV 也得各存一份。
社区早期的测量普遍认为,这种方案的显存有效利用率经常只有 20%~40%。也就是说,你买的 80GB 卡,可能只有 20GB 真的在干活。
2.2 分页思想
PagedAttention 的想法非常「操作系统」:逻辑连续,物理不连续。
- 把 KV Cache 切成固定大小的 block(比如每块 16 个 token)
- 每个请求维护一张 block table,记录「我的第 i 段逻辑 token 存在哪个物理 block 里」
- 注意力算子在读 KV 时,通过 block table 做一次间接寻址
这跟操作系统的虚拟内存 → 页表 → 物理页帧,是一模一样的结构。收益立竿见影:
- 内部碎片被限制在最后一个 block 以内(最多浪费 block_size - 1 个 token 的空间)
- 外部碎片彻底消失(所有块等大,任意块可复用)
- 块级共享成为可能——这为后面的前缀缓存打下了地基
2.3 手写一个 Block Manager
理解一个机制最快的方式是把它写出来。下面是一个能跑的最小实现:
from dataclasses import dataclass, field
from typing import Dict, List, Optional
BLOCK_SIZE = 16
@dataclass
class PhysicalBlock:
block_id: int
ref_count: int = 0 # 引用计数,支持共享与写时复制
token_ids: List[int] = field(default_factory=list)
class BlockManager:
def __init__(self, num_blocks: int):
self.blocks: Dict[int, PhysicalBlock] = {
i: PhysicalBlock(i) for i in range(num_blocks)
}
self.free_ids: List[int] = list(range(num_blocks))
# ---------- 基础分配 ----------
def allocate(self) -> Optional[int]:
if not self.free_ids:
return None # 显存耗尽,触发抢占
bid = self.free_ids.pop()
self.blocks[bid].ref_count = 1
self.blocks[bid].token_ids = []
return bid
def incref(self, bid: int):
self.blocks[bid].ref_count += 1
def free(self, bid: int):
blk = self.blocks[bid]
blk.ref_count -= 1
if blk.ref_count == 0:
blk.token_ids = []
self.free_ids.append(bid)
# ---------- 写时复制 ----------
def cow(self, bid: int) -> int:
"""共享块要被写入时,复制一份私有块出来"""
blk = self.blocks[bid]
if blk.ref_count == 1:
return bid # 独占,直接原地写
new_bid = self.allocate()
if new_bid is None:
raise MemoryError("no free block for CoW")
self.blocks[new_bid].token_ids = list(blk.token_ids)
self.free(bid)
return new_bid
@property
def usage(self) -> float:
used = len(self.blocks) - len(self.free_ids)
return used / len(self.blocks)
class Sequence:
"""一个请求的逻辑视图"""
def __init__(self, mgr: BlockManager, seq_id: int):
self.mgr, self.seq_id = mgr, seq_id
self.block_table: List[int] = []
self.length = 0
def append_token(self, token_id: int):
# 最后一个块满了(或者还没有块),申请新块
if self.length % BLOCK_SIZE == 0:
bid = self.mgr.allocate()
if bid is None:
raise MemoryError(f"seq {self.seq_id} 无可用块")
self.block_table.append(bid)
else:
# 可能是共享块,写入前做 CoW
last = self.block_table[-1]
self.block_table[-1] = self.mgr.cow(last)
self.mgr.blocks[self.block_table[-1]].token_ids.append(token_id)
self.length += 1
def fork(self, new_id: int) -> "Sequence":
"""并行采样 / beam search 的分叉:共享全部已有块"""
child = Sequence(self.mgr, new_id)
child.block_table = list(self.block_table)
child.length = self.length
for bid in child.block_table:
self.mgr.incref(bid)
return child
def release(self):
for bid in self.block_table:
self.mgr.free(bid)
self.block_table = []
跑一下感受 fork 的威力:
mgr = BlockManager(num_blocks=64)
s = Sequence(mgr, 0)
for t in range(100): # 100 token 的 prompt
s.append_token(t)
print("prompt 占用块数:", len(s.block_table), "利用率:", f"{mgr.usage:.1%}")
# 并行采样 4 条候选:全部共享 prompt 的块,零额外拷贝
kids = [s.fork(i + 1) for i in range(4)]
print("fork 4 条后利用率:", f"{mgr.usage:.1%}") # 几乎没变
# 各自往下走 1 步,只有最后一块触发 CoW
for k in kids:
k.append_token(999)
print("各写 1 token 后利用率:", f"{mgr.usage:.1%}")
fork 之后利用率几乎不变,这就是块级共享的价值。在 n=8 的并行采样场景下,传统方案要复制 8 份 prompt 的 KV,分页方案一份都不用复制。
2.4 Block Size 怎么选
这是个实际调参项,别照抄默认值:
- 块大一点(32、64):block table 短,寻址开销低;单块能覆盖更长的公共前缀,前缀匹配效率高。缺点是内部碎片变大,短请求浪费明显。
- 块小一点(8、16):显存利用率高,前缀匹配粒度细。缺点是索引开销上升,注意力 kernel 里的间接寻址次数变多。
经验路线:短对话为主 → 偏小;长文档 / RAG / 固定长 system prompt → 偏大。但一定要压测验证,不同的注意力 kernel 实现对 block size 的敏感度差别很大。
三、第二层:Continuous Batching——消灭队头阻塞
3.1 静态批处理的病
传统的 request-level batching:攒够 N 个请求组成一个 batch,一起前向,全部跑完再放下一批。
问题在于 LLM 的输出长度是高度不均匀的。一个 batch 里可能有一个请求要输出 2000 token,其他 31 个只要 20 token。结果就是:31 个请求早就算完了,却因为 batch 没结束,它们占的显存不释放、GPU 的计算槽位空转,新请求进不来。
这是典型的队头阻塞(Head-of-Line Blocking),而且是 LLM 场景里最严重的一种,因为输出长度的方差实在太大。
3.2 迭代级调度
Continuous Batching(也叫 iteration-level scheduling / in-flight batching)把调度粒度从「请求」下沉到「一次前向迭代」:
- 每完成一步 decode,调度器就重新审视一次 batch
- 已经结束的请求立刻退出,显存立刻回收
- 等待队列里的新请求立刻补位进来
GPU 因此几乎不会因为「等最慢的那个」而空转。
3.3 一个能跑的迷你调度器
from collections import deque
from dataclasses import dataclass
@dataclass
class Req:
rid: int
prompt_len: int
max_new: int
generated: int = 0
done: bool = False
@property
def cur_len(self):
return self.prompt_len + self.generated
def blocks_needed(self, block_size=16):
return (self.cur_len + block_size - 1) // block_size
class ContinuousScheduler:
def __init__(self, total_blocks, max_batch=64, block_size=16):
self.total_blocks = total_blocks
self.free_blocks = total_blocks
self.max_batch = max_batch
self.block_size = block_size
self.waiting = deque()
self.running = []
self.finished = []
self.step_count = 0
self.preempt_count = 0
def add(self, req: Req):
self.waiting.append(req)
def _try_admit(self):
"""把等待队列里放得下的请求塞进 running"""
while self.waiting and len(self.running) < self.max_batch:
r = self.waiting[0]
need = r.blocks_needed(self.block_size)
if need > self.free_blocks:
break # 显存不够,后面的也别试了
self.waiting.popleft()
self.free_blocks -= need
self.running.append(r)
def _preempt(self):
"""显存告急:把最新进来的请求踢回等待队列(LIFO 抢占)"""
if not self.running:
raise MemoryError("单请求都放不下,需要更大的显存或更短的上下文")
victim = self.running.pop()
self.free_blocks += victim.blocks_needed(self.block_size)
victim.generated = 0 # 简化:重算模式,KV 直接丢弃
self.waiting.appendleft(victim)
self.preempt_count += 1
def step(self):
self._try_admit()
self.step_count += 1
still = []
for r in self.running:
before = r.blocks_needed(self.block_size)
r.generated += 1
after = r.blocks_needed(self.block_size)
if after > before: # 跨过块边界,要新块
if self.free_blocks <= 0:
r.generated -= 1
still.append(r)
self.running = still + self.running[len(still):]
self._preempt()
continue
self.free_blocks -= (after - before)
if r.generated >= r.max_new:
r.done = True
self.free_blocks += after
self.finished.append(r)
else:
still.append(r)
self.running = still
def run(self, max_steps=100000):
while (self.waiting or self.running) and self.step_count < max_steps:
self.step()
return self.step_count
对比实验,直观看差距:
import random
random.seed(42)
# 构造长度方差很大的请求:这是真实业务的常态
reqs = [Req(i, prompt_len=random.randint(50, 800),
max_new=random.choice([16, 32, 64, 512, 1024]))
for i in range(200)]
# —— 连续批处理 ——
sched = ContinuousScheduler(total_blocks=4096, max_batch=32)
for r in reqs:
sched.add(Req(r.rid, r.prompt_len, r.max_new))
steps_cont = sched.run()
# —— 静态批处理:每批 32 个,按批内最长的走 ——
steps_static = 0
for i in range(0, len(reqs), 32):
batch = reqs[i:i+32]
steps_static += max(r.max_new for r in batch)
print(f"连续批处理: {steps_cont} 步 (抢占 {sched.preempt_count} 次)")
print(f"静态批处理: {steps_static} 步")
print(f"迭代数减少: {(1 - steps_cont/steps_static):.1%}")
在输出长度方差大的场景下,减少的迭代数通常相当可观。方差越大,连续批处理的优势越明显——这也解释了为什么它对「混合流量」(既有短问答又有长文生成)的服务收益最大。
3.4 抢占策略:Recompute vs Swap
显存不够时踢掉一个请求,被踢掉的那位的 KV Cache 怎么办?两条路:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| Recompute | 直接丢弃,恢复时重新 prefill | 实现简单,不占额外资源 | 浪费算力,长 prompt 代价高 |
| Swap | 换出到 CPU 内存,恢复时换回 | 不浪费算力 | 占 PCIe 带宽,可能比重算还慢 |
判断标准其实是个简单的比较:
def preempt_choice(seq_len, kv_bytes_per_token,
prefill_tok_per_s, pcie_gb_s):
recompute_s = seq_len / prefill_tok_per_s
swap_bytes = seq_len * kv_bytes_per_token
swap_s = 2 * swap_bytes / (pcie_gb_s * 1024**3) # 换出+换入
return ("recompute" if recompute_s < swap_s else "swap",
recompute_s * 1000, swap_s * 1000)
# 短序列往往重算更划算,长序列且 PCIe 快(比如 Gen5)时 swap 才有优势
print(preempt_choice(512, 80*1024, 20000, 32))
print(preempt_choice(32768, 80*1024, 20000, 32))
顺带一句:如果你的服务频繁触发抢占,说明并发设定或显存预算本身就有问题。抢占是保命机制,不是常态运行模式。看到抢占计数持续上升,该做的是调低 max_num_seqs 或者加卡,而不是继续调抢占策略。
四、第三层:前缀缓存——白捡的性能
4.1 为什么这是收益最高的一层
现代 LLM 应用的 prompt 结构高度重复:
[ 长 system prompt:角色设定 + 工具定义 + 输出规范,1000~4000 token ]
[ few-shot 示例:500~2000 token ]
[ RAG 检索片段:500~5000 token ]
[ 多轮历史:随对话增长 ]
[ 用户本轮输入:几十 token ] ← 只有这一小段是新的
Agent 场景更夸张:一次工具调用循环里,每一轮请求都包含前面所有轮的完整历史,前缀重合度经常超过 95%。
如果每次都完整重算 prefill,你就是在反复为同样的 token 付钱。前缀缓存把这部分算力直接省掉,是整个优化栈里投入产出比最高的一项——因为它几乎不需要牺牲任何东西。
4.2 两种实现思路
思路一:哈希链(vLLM 路线)
以 block 为单位算哈希,每个块的哈希包含三部分:
- 父块的哈希值(保证前缀路径唯一)
- 本块内的完整 token 序列(降低碰撞风险)
- 额外哈希(多模态场景下区分图片等,因为占位符 token id 相同但内容不同)
这样一来,「查找最长公共前缀」就退化成一次哈希表查找的循环。
思路二:基数树 / RadixAttention(SGLang 路线)
用 Radix Tree(压缩前缀树)组织所有缓存的 token 序列,节点存 KV block 指针。匹配时从根往下走,天然找到最长公共前缀;淘汰时按 LRU 剪叶子节点。
两者的工程权衡:
| 维度 | 哈希链 | 基数树 |
|---|---|---|
| 查找复杂度 | O(前缀块数) 次哈希 | O(前缀长度) 次树遍历 |
| 实现复杂度 | 简单 | 较高(需要处理节点分裂/合并) |
| 部分匹配 | 块粒度 | token 粒度(可更细) |
| 淘汰策略 | 全局 LRU 好做 | 树形 LRU,需要注意父节点被子节点钉住 |
对绝大多数业务,两种实现的实际命中率差别不大,真正决定命中率的是你怎么组织 prompt,而不是引擎用了哪种数据结构。
4.3 哈希链的最小实现
import hashlib
from typing import Optional, Tuple, List, Dict
BLOCK = 16
def block_hash(parent_hash: Optional[str],
tokens: Tuple[int, ...],
extra: str = "") -> str:
h = hashlib.sha256()
h.update((parent_hash or "ROOT").encode())
h.update(",".join(map(str, tokens)).encode())
h.update(extra.encode())
return h.hexdigest()[:32]
class PrefixCache:
def __init__(self, capacity_blocks: int):
self.table: Dict[str, int] = {} # hash -> physical block id
self.lru: List[str] = [] # 简化的 LRU 队列
self.capacity = capacity_blocks
self.hits = 0
self.misses = 0
def _touch(self, h: str):
if h in self.lru:
self.lru.remove(h)
self.lru.append(h)
def lookup(self, token_ids: List[int]) -> Tuple[int, List[str]]:
"""返回 (命中的 token 数, 沿途的块哈希列表)"""
parent, hit_tokens, chain = None, 0, []
for i in range(0, len(token_ids) - BLOCK + 1, BLOCK):
chunk = tuple(token_ids[i:i+BLOCK])
h = block_hash(parent, chunk)
chain.append(h)
if h in self.table:
hit_tokens += BLOCK
self._touch(h)
parent = h
else:
break # 前缀断了,后面不可能命中
if hit_tokens:
self.hits += 1
else:
self.misses += 1
return hit_tokens, chain
def insert(self, chain: List[str], block_ids: List[int]):
for h, bid in zip(chain, block_ids):
if h not in self.table:
if len(self.table) >= self.capacity:
victim = self.lru.pop(0) # 淘汰最久未用
self.table.pop(victim, None)
self.table[h] = bid
self._touch(h)
@property
def hit_rate(self):
tot = self.hits + self.misses
return self.hits / tot if tot else 0.0
模拟一个 Agent 多轮场景:
cache = PrefixCache(capacity_blocks=4096)
SYS = list(range(1000)) # 1000 token 的固定 system prompt
history = list(SYS)
next_block_id = 0
for turn in range(1, 8):
user = list(range(90000 + turn*100, 90000 + turn*100 + 60))
prompt = history + user
hit, chain = cache.lookup(prompt)
need = len(chain)
bids = list(range(next_block_id, next_block_id + need))
next_block_id += need
cache.insert(chain, bids)
saved = hit / len(prompt) if prompt else 0
print(f"第{turn}轮 prompt={len(prompt):>5} token "
f"命中={hit:>5} 省掉 prefill {saved:.1%}")
history = prompt + list(range(80000+turn*50, 80000+turn*50+120)) # 模型输出
你会看到从第 2 轮开始命中率就冲到 90% 以上,并且随着对话变长越来越高。这就是「白捡」的含义。
4.4 提升命中率的六条实操规则
引擎开了前缀缓存不代表你就能吃到收益,prompt 的写法才是决定性的:
- 固定内容全部前置,变化内容全部后置。 时间戳、随机 ID、用户名这些东西如果出现在 system prompt 开头,命中率直接归零——因为第一个 block 的哈希就不一样了。
- system prompt 跨租户标准化。 每个租户一份定制 system prompt 意味着每个租户一套独立缓存,缓存容量被切碎。能抽成变量的就抽成变量放到后面。
- few-shot 示例保持字节级一致。 包括空格、换行、标点。任何一个字符的差异都会让整条哈希链断掉。
- RAG 场景:模板和指令前置,检索片段后置。 检索结果每次都不一样,放前面等于毁掉整条链。
- 不要在 prompt 里塞当前时间。 这是最常见的命中率杀手。真需要时间,作为工具调用的返回值,或者放在最后一个 block。
- 上线前实测命中率。 大多数引擎都会暴露
prefix_cache_hit_rate之类的指标,接进监控。低于 50% 就该回头看 prompt 结构了。
五、第四层:Chunked Prefill 与 PD 分离
5.1 prefill 对 decode 的暴力打断
回到第一节的矛盾:prefill 和 decode 特性相反,但连续批处理把它们混在同一个 batch 里。
问题来了:一个 8000 token 的 prefill 请求进来,这一步前向的耗时可能是普通 decode 步的几十倍。在这几十倍的时间里,batch 里所有正在 decode 的请求全部卡住。用户看到的现象就是:字一个个往外蹦,突然卡住一秒,然后又继续蹦。
TPOT 的 P99 就是这么被毁掉的。
5.2 方案 A:Chunked Prefill
思路很朴素:把长 prefill 切块,每一步只做一小块,剩下的名额留给 decode。
调度器维护一个 token 预算 max_num_batched_tokens(比如 2048):
- 先把所有 running 的 decode 请求塞进去(每个占 1 个 token 的名额)
- 剩下的预算用来做 prefill 的一个 chunk
- 下一步继续,直到 prefill 完成
def chunked_schedule(decode_reqs, prefill_queue,
token_budget=2048, chunk_min=256):
"""
返回本步的调度计划。
decode_reqs: 正在解码的请求列表
prefill_queue: [(rid, remaining_prefill_tokens), ...]
"""
plan = {"decode": [], "prefill": []}
budget = token_budget
# 1. decode 优先——保护 TPOT,这是延迟的生命线
for r in decode_reqs:
if budget <= 0:
break
plan["decode"].append(r)
budget -= 1
# 2. 剩余预算切给 prefill
i = 0
while budget >= chunk_min and i < len(prefill_queue):
rid, remaining = prefill_queue[i]
take = min(remaining, budget)
plan["prefill"].append((rid, take))
budget -= take
prefill_queue[i] = (rid, remaining - take)
if prefill_queue[i][1] == 0:
prefill_queue.pop(i)
else:
i += 1
return plan
# 演示:64 个请求在 decode,同时来了两个长 prompt
decodes = list(range(64))
prefills = [(1001, 8000), (1002, 3000)]
for step in range(6):
p = chunked_schedule(decodes, prefills, token_budget=2048)
print(f"step {step}: decode={len(p['decode'])} 个, prefill={p['prefill']}")
if not prefills:
break
参数调优心法:
max_num_batched_tokens调小:TPOT 更平稳,TTFT 变长(长 prompt 要切更多步)max_num_batched_tokens调大:TTFT 更短,TPOT 抖动加剧- 聊天类交互:偏小(1024~2048),保延迟平稳
- 批量任务 / 离线推理:偏大甚至关掉 chunk,保吞吐
chunk_min 也不能太小,否则 prefill 被切得太碎,GPU 利用率反而下降——每次前向都有固定开销,chunk 太小的话开销占比就上去了。
5.3 方案 B:PD 分离
Chunked Prefill 是在一台机器内部做时间片分配,本质上还是妥协。彻底的解法是空间上分开:prefill 用一组 GPU,decode 用另一组 GPU。
架构长这样:
┌──────────┐
Client ───────▶│ Proxy │ 路由 + 编排
└────┬─────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌───────────┐ KV Cache 传输 ┌───────────┐
│ Prefill │ ───────────────▶ │ Decode │
│ 实例 (P) │ NIXL/NCCL/RDMA │ 实例 (D) │
└───────────┘ └───────────┘
算力密集 带宽密集
可用高算力卡 可用大显存/高带宽卡
TP 切分为主 可更大 batch
请求流程:
- Proxy 收到请求,转发给 P 实例
- P 实例做完整 prefill,产出首 token 和完整 KV Cache
- KV Cache 通过 Connector 传给 D 实例
- D 实例接手,持续 decode 直到结束
- 首 token 可以从 P 直接返回(TTFT 不受传输影响),后续 token 从 D 流式返回
启动方式(以 vLLM 的 NIXL Connector 为例,这是社区文档里的典型配置):
# Prefill 实例
CUDA_VISIBLE_DEVICES=0 \
VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve <model> \
--port 8100 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both"}'
# Decode 实例
CUDA_VISIBLE_DEVICES=1 \
VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve <model> \
--port 8200 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both"}'
# 再起一个 proxy 把两者串起来(社区提供了示例脚本)
5.4 PD 分离的真实代价
不要被架构图的优雅骗了。PD 分离引入了三个新问题:
1. KV Cache 传输量不小
用前面的公式算:一个 8K token 的请求,32 层 / 8 KV 头 / head_dim 128 / FP16,单请求 KV 大约 1GB 量级。这 1GB 要在两台机器之间搬。
def pd_transfer_time_ms(seq_len, layers, kv_heads, head_dim,
dtype_bytes, link_gb_s):
b = 2 * layers * kv_heads * head_dim * seq_len * dtype_bytes
return b / (link_gb_s * 1024**3) * 1000
for link, name in [(400/8, "400Gb IB"), (200/8, "200Gb IB"),
(25/8, "25Gb 以太网"), (64, "NVLink 级")]:
t = pd_transfer_time_ms(8192, 32, 8, 128, 2, link)
print(f"{name:>12}: {t:7.1f} ms")
跑一下你就明白:没有 RDMA / InfiniBand / NVLink,PD 分离基本没有意义。用普通万兆以太网做 PD 分离,传输时间可能比省下来的时间还长。
2. 运维复杂度翻倍
两套实例、一个 proxy、一套 KV 传输通道,任何一环挂了都是故障。P:D 的配比还需要根据流量特征动态调整——prompt 长、输出短的流量需要更多 P;prompt 短、输出长的流量需要更多 D。配比错了,一边打满一边闲置。
3. 小规模部署纯亏
只有 2~4 张卡的时候,PD 分离带来的资源碎片化损失往往大于收益。
5.5 决策清单
def should_use_pd(gpu_count, interconnect_gb_s,
p99_ttft_slo_ms, p99_tpot_slo_ms,
avg_prompt_len, avg_output_len):
reasons = []
score = 0
if gpu_count >= 8:
score += 2; reasons.append("卡数充足,分离后仍能各自形成规模")
else:
reasons.append("卡数偏少(<8),分离会造成资源碎片")
if interconnect_gb_s >= 25:
score += 2; reasons.append("高速互联(RDMA/IB 级)满足 KV 传输")
else:
score -= 3; reasons.append("互联带宽不足,KV 传输将成为新瓶颈")
if p99_tpot_slo_ms <= 50:
score += 2; reasons.append("TPOT SLO 严格,prefill 干扰不可接受")
ratio = avg_prompt_len / max(avg_output_len, 1)
if ratio > 10:
score += 1; reasons.append(f"prompt/output={ratio:.1f},prefill 压力大")
elif ratio < 1:
score += 1; reasons.append("输出远长于输入,decode 侧可独立扩容")
verdict = "建议上 PD 分离" if score >= 4 else \
"先用 Chunked Prefill,暂不需要 PD 分离"
return verdict, score, reasons
v, s, rs = should_use_pd(16, 50, 1000, 40, 4000, 300)
print(v, f"(score={s})")
for r in rs:
print(" -", r)
一句话总结:先把 chunked prefill 和前缀缓存调好,实在满足不了 SLO 再上 PD 分离。 90% 的团队停在前者就够了。
六、第五层:KV Cache 分层与跨实例复用
6.1 显存不是唯一的存储层
一旦 KV Cache 可以跨请求复用(前缀缓存),下一个自然的问题是:显存装不下的那些缓存,能不能放到更便宜的地方?
于是有了分层存储:
HBM(显存) ~TB/s 几十 GB 热缓存,当前活跃请求
↕
DRAM(主机内存)~几十 GB/s 几百 GB 温缓存,近期会话
↕
NVMe SSD ~GB/s 几 TB 冷缓存,历史会话
↕
远程 KV 存储 取决于网络 ~无限 跨实例共享池
这条路线上,社区里 LMCache、Mooncake 这类项目做的就是这件事:把 KV Cache 做成一个独立的、可跨实例共享的存储层,而不是绑死在某个推理进程的显存里。
跨实例共享的价值在多副本部署下特别大:用户第一轮请求打到实例 A,第二轮被负载均衡打到实例 B。如果 KV 只在 A 的显存里,B 就得完整重算。有了共享 KV 池,B 可以直接拉。
6.2 关键判断:拉取还是重算
这是分层缓存里唯一需要认真算的账。从慢速存储拉一份 KV 回来,不一定比重新算一遍快。
def fetch_vs_recompute(seq_len, layers, kv_heads, head_dim, dtype_bytes,
link_gb_s, prefill_tok_per_s):
kv_bytes = 2 * layers * kv_heads * head_dim * seq_len * dtype_bytes
fetch_ms = kv_bytes / (link_gb_s * 1024**3) * 1000
recompute_ms = seq_len / prefill_tok_per_s * 1000
return fetch_ms, recompute_ms, ("fetch" if fetch_ms < recompute_ms
else "recompute")
def breakeven_prefill_speed(layers, kv_heads, head_dim,
dtype_bytes, link_gb_s):
"""临界 prefill 速度:超过这个速度,重算就比拉取划算"""
bytes_per_token = 2 * layers * kv_heads * head_dim * dtype_bytes
return link_gb_s * 1024**3 / bytes_per_token # token/s
cfg = dict(layers=32, kv_heads=8, head_dim=128, dtype_bytes=2)
for link, name in [(50, "RDMA 400G"), (12, "NVMe SSD"), (3, "25G 以太网")]:
be = breakeven_prefill_speed(link_gb_s=link, **cfg)
print(f"{name:>12}: 临界 prefill 速度 {be:,.0f} tok/s")
for L in (1024, 8192):
f, r, w = fetch_vs_recompute(L, link_gb_s=link,
prefill_tok_per_s=20000, **cfg)
print(f" seq={L:>5}: fetch {f:6.1f}ms vs recompute "
f"{r:6.1f}ms → {w}")
这段代码揭示了一个重要结论:临界点只跟「链路带宽」和「每 token 的 KV 字节数」有关,跟序列长度无关(因为两边都是线性的)。所以这是一个可以离线算好、写死在配置里的判断。
推论:
- GQA 让每 token 的 KV 字节数变小 → 传输更划算 → 分层缓存更有价值
- KV Cache 量化(FP8)把字节数再砍一半 → 同样让传输更划算
- 而如果你的模型是 MHA 且没量化,每 token KV 很大,很多时候不如直接重算
6.3 分层缓存的运维坑
- 一致性:模型换版本了、量化配置变了、tokenizer 变了,旧 KV 全部作废。缓存 key 里必须包含模型指纹,否则会输出乱码,而且极难排查。
- 淘汰风暴:一批长会话同时过期,可能瞬间打满回源带宽。
- 隐私:KV Cache 是用户输入的稠密表示。跨租户共享缓存池必须在 key 里隔离租户,否则理论上存在通过缓存命中时间侧信道推测他人 prompt 内容的风险。这条务必写进设计评审的检查项。
七、第六层:投机解码——用算力换带宽
7.1 原理
decode 是带宽瓶颈,Tensor Core 在发呆。投机解码的思路就是:既然算力是免费的,那就用算力去换带宽。
流程:
- 用一个便宜的「草稿模型」(或者更轻的机制)一口气猜出 k 个 token
- 把这 k+1 个位置一次性送进大模型做验证(一次前向,而不是 k 次)
- 从头开始比对:草稿和大模型一致的部分全部接受,第一个不一致的位置用大模型的结果替换,后面丢弃
- 重复
关键点:验证是一次前向。大模型的权重只读了一遍,却可能推进了好几个 token——这正是省带宽的地方。而且经过正确的接受-拒绝采样设计,输出分布与原模型完全一致,不是近似。
7.2 加速比的数学
设草稿长度为 k,每个位置被接受的概率为 α(独立近似),那么一轮期望接受的 token 数:
E[accepted] = (1 - α^(k+1)) / (1 - α)
一轮的成本 = 草稿模型跑 k 步 + 大模型验证 1 步。
def spec_speedup(alpha, k, draft_cost_ratio=0.1, verify_overhead=1.15):
"""
alpha: 单 token 接受率
k: 草稿长度
draft_cost_ratio: 草稿模型单步成本 / 大模型单步成本
verify_overhead: 验证 k+1 个 token 相比单 token 的开销倍数
"""
if alpha >= 1.0:
exp_tokens = k + 1
else:
exp_tokens = (1 - alpha ** (k + 1)) / (1 - alpha)
cost = k * draft_cost_ratio + verify_overhead
return exp_tokens / cost
print(f"{'α':>6} " + " ".join(f"k={k:<6}" for k in (1,2,3,4,5,7,10)))
for a in (0.5, 0.6, 0.7, 0.8, 0.9):
row = " ".join(f"{spec_speedup(a,k):<8.2f}" for k in (1,2,3,4,5,7,10))
print(f"{a:>6} {row}")
跑完这张表你会得到三个结论:
- 接受率是命门。α=0.5 时怎么调 k 都提升有限;α=0.9 时才有明显收益。
- k 有最优值,不是越大越好。k 太大,草稿成本线性上升,但期望接受数趋于饱和(等比数列收敛)。
- 最优 k 随 α 增大而增大。所以理想的实现应该是自适应调整 k,而不是写死。
7.3 三条技术路线
| 路线 | 做法 | 接受率 | 额外成本 |
|---|---|---|---|
| 独立草稿模型 | 用同系列小模型(如 0.5B 配 70B) | 中等 | 额外显存 + 额外部署 |
| N-gram / Prompt Lookup | 从 prompt 或已生成文本里直接抄重复片段 | 场景依赖极强 | 几乎为零 |
| 自投机(EAGLE / MTP 类) | 复用主模型的隐藏状态,加一个轻量头预测后续 token | 较高 | 少量参数 + 训练 |
N-gram 这条路特别值得单独说,因为它成本几乎为零,而在特定场景下效果惊人:
def ngram_draft(context_ids, k=4, n=3, max_lookback=2048):
"""从上下文中查找最近的 n-gram 匹配,抄后面 k 个 token 作为草稿"""
ctx = context_ids[-max_lookback:]
if len(ctx) < n:
return []
pattern = tuple(ctx[-n:])
# 从后往前找最近一次出现(越近的重复越可能再次成立)
for i in range(len(ctx) - n - 1, -1, -1):
if tuple(ctx[i:i+n]) == pattern:
return ctx[i+n : i+n+k]
return []
# 代码补全场景:重复模式极多
code_tokens = [10,11,12, 20,21, 10,11,12, 30,31, 10,11,12]
print("草稿:", ngram_draft(code_tokens, k=3, n=3)) # 大概率抄中 30,31...
适用场景清单:
- 代码生成 / 代码编辑:重复的变量名、import、样板代码极多,命中率高
- 文档摘要 / 翻译:大段引用原文,命中率高
- JSON / 结构化输出:字段名固定重复,命中率高
- 自由创作 / 开放对话:几乎不命中,别开
7.4 什么时候投机解码会让你更慢
这是最容易踩的坑,很多人只看到 benchmark 里的「2.5x 加速」就无脑开。
投机解码的前提是 GPU 有空闲算力。 在低并发(batch=1~4)时,这个前提成立,加速明显。但在高并发时:
- 大 batch 本身已经把 Tensor Core 喂饱了
- 验证 k+1 个 token 意味着实际计算量变成 (k+1) 倍
- 被拒绝的草稿 token 是纯浪费的算力
- 结果:吞吐不升反降
def spec_worth_it(batch_size, alpha, k, gpu_util_without_spec):
"""粗略判断:算力余量是否足够支撑投机"""
waste = 1 - (1 - alpha**(k+1)) / ((1-alpha) * (k+1)) # 浪费比例
extra_compute = (k + 1) * gpu_util_without_spec
if extra_compute > 1.0:
return f"不建议:算力已饱和(预计需要 {extra_compute:.1f}x 算力)"
return f"可以开:预计浪费算力 {waste:.1%},仍有余量"
print("低并发:", spec_worth_it(2, 0.8, 4, 0.15))
print("高并发:", spec_worth_it(64, 0.8, 4, 0.60))
实操建议:把投机解码做成按负载动态开关的策略——低峰期开(延迟优先),高峰期关(吞吐优先)。很多生产系统就是这么干的。
八、第七层:量化与并行策略
8.1 三种量化,用途完全不同
| 类型 | 省什么 | 典型精度损失 | 备注 |
|---|---|---|---|
| 权重量化 (INT4/AWQ/GPTQ) | 显存 + 权重读取带宽 | 小 | decode 收益最直接,因为 decode 就是在反复读权重 |
| KV Cache 量化 (FP8/INT8) | KV 显存 | 通常很小 | 直接提升并发数,长上下文场景收益巨大 |
| 激活量化 (FP8 全链路) | 计算时间 | 需要仔细校准 | prefill 收益大,需硬件支持 |
优先级建议:
- 先上 KV Cache 量化。收益直接(并发翻倍),风险最低,因为 KV 对量化误差的敏感度通常低于权重。
- 再上权重量化。decode 带宽瓶颈直接缓解。注意 4-bit 在小模型上的损失比大模型明显。
- 最后考虑激活量化。收益取决于硬件是否有原生 FP8 支持。
def concurrency_gain(kv_dtype_from=2, kv_dtype_to=1,
weight_gb=14, total_vram_gb=80,
kv_gb_per_req_fp16=0.5):
avail = total_vram_gb - weight_gb - 4 # 留 4GB 给激活和碎片
before = int(avail / kv_gb_per_req_fp16)
after = int(avail / (kv_gb_per_req_fp16 * kv_dtype_to / kv_dtype_from))
return before, after
b, a = concurrency_gain()
print(f"KV FP16→FP8: 并发上限 {b} → {a} (+{(a/b-1):.0%})")
8.2 并行策略选择
| 策略 | 切什么 | 通信量 | 适用 |
|---|---|---|---|
| TP(张量并行) | 层内矩阵按维度切 | 每层 AllReduce,很重 | 单机多卡(有 NVLink),降低单卡显存和延迟 |
| PP(流水线并行) | 按层切 | 层间点对点,较轻 | 跨机,模型放不下时 |
| DP(数据并行) | 整模型多副本 | 无(只有负载均衡) | 吞吐扩展,模型能放下时首选 |
| EP(专家并行) | MoE 的专家分布到不同卡 | All-to-All,很重 | MoE 模型必需 |
几条硬经验:
- TP 不要跨机。TP 的 AllReduce 极其频繁,一旦跨过 NVLink 边界走网络,性能会断崖式下跌。
- 模型能塞进单卡就用 DP。DP 几乎没有通信开销,扩展性最好。为了「显得高级」上 TP 是自残。
- MoE 的 EP 通信是 All-to-All,对网络拓扑极其敏感。这也是为什么 MoE 模型的推理部署门槛远高于稠密模型——2026 年一堆团队卡在这里。
- 组合使用时:机内 TP,机间 PP 或 DP,这是最常见的稳妥配置。
九、实战:一次系统化的调优该怎么做
方法论比数字重要。下面这套流程你可以直接照搬。
9.1 先建立基线,别急着调参
"""
最小可用的压测骨架 —— 关键是分位数,不是均值。
真实使用时把 fake_request 换成对你服务的 HTTP 调用。
"""
import asyncio, time, random, statistics as st
async def fake_request(prompt_len, output_len):
# 用简化模型模拟:TTFT 与 prompt 长度相关,TPOT 固定 + 抖动
ttft = 0.05 + prompt_len * 2e-5 + random.random() * 0.02
await asyncio.sleep(ttft)
t0 = time.perf_counter()
itls = []
for _ in range(output_len):
d = 0.012 + random.random() * 0.006
await asyncio.sleep(d)
itls.append(d)
return ttft, itls, time.perf_counter() - t0
async def bench(concurrency, n_requests, prompt_dist, output_dist):
sem = asyncio.Semaphore(concurrency)
ttfts, all_itls = [], []
async def one():
async with sem:
p = random.choice(prompt_dist)
o = random.choice(output_dist)
ttft, itls, _ = await fake_request(p, o)
ttfts.append(ttft * 1000)
all_itls.extend(x * 1000 for x in itls)
t0 = time.perf_counter()
await asyncio.gather(*[one() for _ in range(n_requests)])
wall = time.perf_counter() - t0
def pct(xs, q):
xs = sorted(xs)
return xs[min(int(len(xs) * q), len(xs) - 1)]
return {
"concurrency": concurrency,
"TTFT_p50": round(pct(ttfts, .50), 1),
"TTFT_p95": round(pct(ttfts, .95), 1),
"TTFT_p99": round(pct(ttfts, .99), 1),
"TPOT_p50": round(pct(all_itls, .50), 2),
"TPOT_p95": round(pct(all_itls, .95), 2),
"out_tok_s": round(len(all_itls) / wall, 1),
"req_s": round(n_requests / wall, 2),
}
async def sweep():
prompts = [200, 800, 2000, 6000]
outputs = [32, 128, 512]
rows = []
for c in (1, 4, 16, 64, 128):
rows.append(await bench(c, 200, prompts, outputs))
hdr = list(rows[0].keys())
print(" | ".join(f"{h:>10}" for h in hdr))
for r in rows:
print(" | ".join(f"{str(r[k]):>10}" for k in hdr))
# asyncio.run(sweep())
9.2 找到 SLO 约束下的最优点
有了扫描结果,用一个函数把「满足 SLO 的最大吞吐」直接筛出来:
def pick_best(rows, ttft_p95_slo=1000, tpot_p95_slo=50):
ok = [r for r in rows
if r["TTFT_p95"] <= ttft_p95_slo and r["TPOT_p95"] <= tpot_p95_slo]
if not ok:
return None, "所有配置都违反 SLO:需要加卡、缩短上下文或放宽 SLO"
best = max(ok, key=lambda r: r["out_tok_s"])
return best, f"最优并发 {best['concurrency']},吞吐 {best['out_tok_s']} tok/s"
9.3 调优顺序(按投入产出比排序)
这个顺序很重要,别一上来就折腾最复杂的:
- 确认前缀缓存开了,并测命中率。命中率 < 50%?先改 prompt 结构,这是零成本收益。
- KV Cache 量化到 FP8。并发直接翻倍,风险极低。
- 调
max_num_seqs/gpu_memory_utilization。把显存吃到 90% 左右,同时监控抢占次数——抢占频繁说明吃过头了。 - 调 chunked prefill 的 token 预算。按你的 TTFT/TPOT 权重来。
- 权重量化。评估精度损失是否可接受,一定要跑业务侧的评测集,不要只看 perplexity。
- 低峰期开投机解码。做成动态开关。
- 最后才考虑 PD 分离和分层 KV。这两个是架构级改动,运维成本高。
9.4 监控必须有的指标
# 引擎侧
prefix_cache_hit_rate # < 50% 报警,prompt 结构有问题
gpu_kv_cache_usage # 持续 > 95% 说明快撑不住了
num_preemptions_total # 持续增长说明并发设置过高
num_requests_waiting # 排队深度,直接反映容量缺口
# 请求侧(一定要分位数,均值会骗人)
ttft_seconds{quantile}
tpot_seconds{quantile}
e2e_latency_seconds{quantile}
# 业务侧
tokens_in / tokens_out # 成本核算的基础
slo_violation_rate # 最终的北极星指标
十、七个常见误区
误区 1:盯着平均延迟做优化。
用户感知的是 P95/P99。平均值好看而 P99 崩掉的系统,用户体验是灾难级的。
误区 2:batch 越大越好。
吞吐上去了,单请求速度下来了。聊天场景下用户会明显感觉「变慢了」。永远在 SLO 约束下找最优。
误区 3:无脑开投机解码。
高并发下它会偷走你的算力。做成动态开关。
误区 4:以为开了前缀缓存就自动有收益。
prompt 里塞了时间戳的话,命中率是 0。引擎能力 ≠ 实际收益。
误区 5:小规模上 PD 分离。
4 张卡搞 PD 分离,多半是负优化。先把 chunked prefill 调好。
误区 6:量化只看 perplexity。
perplexity 掉 0.1 看着没事,但你的业务可能是结构化输出,JSON 格式错误率翻了三倍。一定要跑业务评测集。
误区 7:换引擎当银弹。
A 引擎跑不动就换 B 引擎,往往换完发现还是慢。因为瓶颈在 prompt 结构、在显存配置、在 SLO 定义,不在引擎。先做归因,再做选型。
十一、总结:一张决策地图
把整篇文章压缩成一张表:
| 你的症状 | 该看哪一层 | 具体动作 |
|---|---|---|
| 显存不够,并发上不去 | 第一层 + 第七层 | 检查分页是否生效;KV Cache 量化到 FP8;确认模型用的是 GQA |
| GPU 利用率忽高忽低 | 第二层 | 确认开了连续批处理,不是静态 batching |
| TTFT 高,但 prompt 重复度大 | 第三层 | 开前缀缓存;重构 prompt(固定内容前置) |
| 输出「一卡一卡」的 | 第四层 | 开 chunked prefill,调小 token 预算 |
| TTFT 和 TPOT 无法同时满足 | 第四层 | 评估 PD 分离(前提:有 RDMA 且卡数 ≥ 8) |
| 多副本部署,缓存命中率低 | 第五层 | 引入跨实例共享 KV 池 |
| 低并发下单请求太慢 | 第六层 | 投机解码,代码/结构化场景优先试 N-gram |
| 模型放不下单卡 | 第七层 | 机内 TP,机间 PP;能放下就用 DP |
往前看
三个正在发生的趋势,值得盯着:
第一,KV Cache 正在变成独立的基础设施。 从「引擎内部的一个数据结构」变成「可以跨进程、跨机器、跨副本共享的存储服务」。这个转变的影响是深远的——它意味着推理引擎会变得更「无状态」,弹性伸缩会容易得多。
第二,调度器越来越像操作系统内核。 分页、抢占、优先级、多级缓存、CoW,这些概念一个不落地被搬进了推理引擎。懂操作系统的人在这个领域有巨大优势——因为这些问题在 40 年前就被研究透了,只是换了个场景重新出现。
第三,架构层的优化仍然碾压工程层。 GQA 一下砍掉 8 倍 KV,MLA 进一步压缩,线性注意力从根上把 KV 的增长从 O(n) 变成 O(1)。工程优化能给你 2-3 倍,架构变更能给你 10 倍。所以在你花三个月调参之前,先看看有没有架构更友好的模型可选。
最后说句实在话:这七层里,大部分团队真正需要做的只有前三层。 把前缀缓存的命中率从 20% 提到 90%,比折腾任何花哨的架构都实在。先把便宜的收益吃干净,再谈复杂度。
工程的本质从来不是用最先进的方案,而是用最合适的方案解决当下的瓶颈。