编程 LLM 推理服务引擎深度拆解:从 PagedAttention 到 PD 分离,把 GPU 榨干的七层优化栈

2026-07-30 21:49:19 +0800 CST views 8

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 算。这会产生三种浪费:

  1. 内部碎片:请求声明最大 4096 token,实际只用了 300 个,剩下 3796 个 token 的显存位置被占着不放。这是最大的一块浪费。
  2. 外部碎片:不同请求申请不同大小的连续块,反复分配释放之后,显存里全是「不够大的空洞」。
  3. 无法共享:两个请求哪怕有完全相同的 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 的写法才是决定性的:

  1. 固定内容全部前置,变化内容全部后置。 时间戳、随机 ID、用户名这些东西如果出现在 system prompt 开头,命中率直接归零——因为第一个 block 的哈希就不一样了。
  2. system prompt 跨租户标准化。 每个租户一份定制 system prompt 意味着每个租户一套独立缓存,缓存容量被切碎。能抽成变量的就抽成变量放到后面。
  3. few-shot 示例保持字节级一致。 包括空格、换行、标点。任何一个字符的差异都会让整条哈希链断掉。
  4. RAG 场景:模板和指令前置,检索片段后置。 检索结果每次都不一样,放前面等于毁掉整条链。
  5. 不要在 prompt 里塞当前时间。 这是最常见的命中率杀手。真需要时间,作为工具调用的返回值,或者放在最后一个 block。
  6. 上线前实测命中率。 大多数引擎都会暴露 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

请求流程:

  1. Proxy 收到请求,转发给 P 实例
  2. P 实例做完整 prefill,产出首 token 和完整 KV Cache
  3. KV Cache 通过 Connector 传给 D 实例
  4. D 实例接手,持续 decode 直到结束
  5. 首 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 在发呆。投机解码的思路就是:既然算力是免费的,那就用算力去换带宽。

流程:

  1. 用一个便宜的「草稿模型」(或者更轻的机制)一口气猜出 k 个 token
  2. 把这 k+1 个位置一次性送进大模型做验证(一次前向,而不是 k 次)
  3. 从头开始比对:草稿和大模型一致的部分全部接受,第一个不一致的位置用大模型的结果替换,后面丢弃
  4. 重复

关键点:验证是一次前向。大模型的权重只读了一遍,却可能推进了好几个 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}")

跑完这张表你会得到三个结论:

  1. 接受率是命门。α=0.5 时怎么调 k 都提升有限;α=0.9 时才有明显收益。
  2. k 有最优值,不是越大越好。k 太大,草稿成本线性上升,但期望接受数趋于饱和(等比数列收敛)。
  3. 最优 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 收益大,需硬件支持

优先级建议:

  1. 先上 KV Cache 量化。收益直接(并发翻倍),风险最低,因为 KV 对量化误差的敏感度通常低于权重。
  2. 再上权重量化。decode 带宽瓶颈直接缓解。注意 4-bit 在小模型上的损失比大模型明显。
  3. 最后考虑激活量化。收益取决于硬件是否有原生 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 调优顺序(按投入产出比排序)

这个顺序很重要,别一上来就折腾最复杂的:

  1. 确认前缀缓存开了,并测命中率。命中率 < 50%?先改 prompt 结构,这是零成本收益。
  2. KV Cache 量化到 FP8。并发直接翻倍,风险极低。
  3. max_num_seqs / gpu_memory_utilization。把显存吃到 90% 左右,同时监控抢占次数——抢占频繁说明吃过头了。
  4. 调 chunked prefill 的 token 预算。按你的 TTFT/TPOT 权重来。
  5. 权重量化。评估精度损失是否可接受,一定要跑业务侧的评测集,不要只看 perplexity。
  6. 低峰期开投机解码。做成动态开关。
  7. 最后才考虑 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%,比折腾任何花哨的架构都实在。先把便宜的收益吃干净,再谈复杂度。

工程的本质从来不是用最先进的方案,而是用最合适的方案解决当下的瓶颈。

推荐文章

GROMACS:一个美轮美奂的C++库
2024-11-18 19:43:29 +0800 CST
Vue 中如何处理父子组件通信?
2024-11-17 04:35:13 +0800 CST
MySQL 1364 错误解决办法
2024-11-19 05:07:59 +0800 CST
如何将TypeScript与Vue3结合使用
2024-11-19 01:47:20 +0800 CST
程序员茄子在线接单