27届大模型面试准备(二十五):推理服务化与投机解码------vLLM、PagedAttention、Medusa 与连续批处理
上一篇《位置编码与长度外推》解决了"模型怎么读懂 128K 上下文"的问题,解决了"能装下"之后,下一个绕不开的工程问题是"怎么把模型高效的往外吐"。这是本系列第二十五篇。一个 70B 模型在单卡上跑不起来、在八卡上吞吐上不去、在百路并发下延迟爆炸,这些都是推理服务化的范畴。2025 年之后,几乎所有大模型团队的面试都会问到 vLLM、PagedAttention、连续批处理(continuous batching),以及越来越火的投机解码(speculative decoding)。本系列 A15 讲的是训练侧的高效推理(蒸馏、量化训练),A19 讲的是权重量化,这一篇是它们的"最后一公里"------把训好的、量化过的模型变成一个能扛住真实流量的服务。本文按"吞吐与延迟的权衡 → KV Cache 与显存碎片 → PagedAttention → 连续批处理 vs 静态批处理 → 调度架构 → 投机解码原理与变体 → 服务化工程 → 评测指标"展开,结尾给面试速答和高频追问清单。
一、推理服务化的核心矛盾:吞吐 vs 延迟
1.1 为什么训练集群跑得好好的,推理却很难
训练 (Training) 推理 (Inference)
目标 压低 loss,吞吐优先 既要高吞吐,又要低延迟
批大小 大 batch,几千 token/步 单请求 1~N token/步
显存占用 activations + 参数 + 优化器状态 参数 + KV Cache(随序列增长)
计算模式 高度规整的矩阵乘 自回归:decode 阶段每步只算 1 个 token
关键瓶颈 算力 (FLOPS) 显存带宽 (memory-bandwidth bound)
自回归生成的 decode 阶段有个残酷的事实:每生成一个 token,都要把全部参数从显存读一遍,但只做一次矩阵向量乘(matvec)。计算强度极低,瓶颈不在算力而在显存带宽。这决定了推理优化的两条主线:省显存(让 KV Cache 装得下更多并发) 和 省带宽/增加并行度(让每次 decode 做更多有用功)。
1.2 两个必须分清的指标
| 指标 | 含义 | 受什么影响 | 典型优化手段 |
|---|---|---|---|
| 吞吐 (Throughput) | 单位时间生成的 token 数,常换算为 requests/s 或 tokens/s | 批大小、显存利用率、调度效率 | 连续批处理、PagedAttention、多卡并行 |
| 延迟 (Latency) | 单请求从发起到结束的耗时 | 首 token 延迟 TTFT、逐 token 间隔 ITL、队列等待 | 投机解码、KV Cache 复用、Prefill/Decode 分离 |
| TTFT | Time To First Token,用户最敏感的"开场白"速度 | Prefill 长度、并发排队、批内优先级 | Chunked Prefill、请求优先级 |
| ITL | Inter-Token Latency,逐 token 间隔,决定"打字机"是否流畅 | decode 批大小、投机命中率 | 投机解码、受限批大小 |
面试常考的一个坑:吞吐高不等于体验好。把批大小开到极致能拉满吞吐,但每个请求的 ITL 会变差,用户看到的是"卡顿"。生产系统往往要在两者间找一个 SLA 下的工作点。
二、KV Cache 与显存碎片
2.1 自回归为什么必须缓存 KV
Transformer 每层的注意力要拿当前 token 的 query 去和前面所有 token 的 key/value 算分数。朴素做法每次都重算历史 K/V,复杂度 O(n^2)。实际做法是把每一步算出的 K、V 缓存下来:
第 t 步 decode:
新 token x_t -> Q_t, K_t, V_t
KV Cache 追加 (K_t, V_t)
Attention(Q_t, [K_1..K_t], [V_1..V_t])
显存占用 ≈ 2 * num_layers * seq_len * num_kv_heads * head_dim * batch * dtype_bytes
例:7B 模型, seq=4096, batch=64, FP16
≈ 2 * 32 * 4096 * 32 * 128 * 64 * 2 ≈ 68 GB <- 光 KV 就吃掉一张卡
这就是为什么"省 KV Cache 显存"是推理服务化的第一战场。KV Cache 越大,能并发的请求越多,吞吐越高。
2.2 传统显存管理的碎片问题
早期实现给每个请求预先分配一段连续、且等于 max_seq_len 的 KV 块。问题有两个:
请求 A 实际只用了 200 token,却占了 4096 的连续空间 -> 内部碎片
请求 B 想增长,但旁边没有连续空闲块 -> 外部碎片
结果:GPU 显存看着还有 30% 空闲,新请求却因"凑不出连续块"被拒
这正是操作系统当年面对的问题,vLLM 的解法是把"分页"思想搬过来:PagedAttention。
三、PagedAttention:把显存当虚拟内存管
3.1 核心思想
传统:每个序列一块连续 KV 显存 (预分配 max_len)
PagedAttention:KV 被切成固定大小的 block (如 16 token/block)
序列的 KV 由一组逻辑 block 组成,物理上可离散分布
用一张 block table 做"逻辑块 -> 物理块"映射
逻辑序列: [block0 | block1 | block2 | block3]
| | | |
物理显存: [P7] [P2] [P9] [P4] (离散、可复用)
block_table = {0:P7, 1:P2, 2:P9, 3:P4}
好处:
-
消除外部碎片 :物理块按需分配,序列增长时找一个空闲块挂上即可。
-
消除内部碎片 :只分配实际用到的 block,不再预占 max_len。
-
支持 Copy-on-Write 共享:并行采样(同一个 prompt 出多个回答)、beam search 的共享前缀,KV 块可以引用计数共享,显存直接省下数倍。
3.2 一个 block table 的示意代码
python
# 概念示意:PagedAttention 的块表与注意力寻址
class PagedKVCache:
def __init__(self, num_blocks, block_size=16, kv_dim=4096):
self.block_size = block_size
self.free_blocks = list(range(num_blocks)) # 空闲物理块池
self.block_table = {} # seq_id -> [物理块id...]
self.kv = torch.empty(num_blocks, block_size, kv_dim)
def append_token(self, seq_id, token_kv):
blocks = self.block_table.setdefault(seq_id, [])
if len(blocks) == 0 or self._is_last_full(seq_id):
pid = self.free_blocks.pop() # 按需分配新物理块
blocks.append(pid)
pid = blocks[-1]
slot = (len(self._tokens(seq_id)) - 1) % self.block_size
self.kv[pid, slot] = token_kv
def _is_last_full(self, seq_id):
n = len(self._tokens(seq_id))
return n % self.block_size == 0
面试常追问:PagedAttention 和操作系统分页的区别? 答:粒度是 token block 而非 byte page;KV 是只读追加(append-only),没有随机写回;支持引用计数式的 CoW 共享,这是 OS 分页没有的"序列语义"。
四、连续批处理:让 GPU 永远不空转
4.1 静态批处理为什么会浪费算力
静态批处理 (Static Batching):
凑齐 N 个请求 -> 一起跑 -> 等最慢的那个生成完 -> 整个 batch 一起返回
问题:请求 A 在第 5 步就 EOS 了,但仍占着 batch 名额,
后面 20 步 GPU 都在为等 B/C/D 空转(或算 padding)
时间轴:
Req A: ████EOS.......................... (浪费 20 步 slot)
Req B: ████████████████████████████████
Req C: ███████████████████EOS...........
Req D: ████████████████████████████████
4.2 连续批处理(Continuous Batching / In-flight Batching)
核心:每个 decode 步(iteration)都重新决定"本步哪些序列参与计算"。结束的立即腾出位置,新的立即插入:
连续批处理:
step1: [A B C D]
step2: [A B C D E] <- E 在 step1 刚到达,立即加入
step3: [A B C D E F] <- C 已 EOS 退出,F 补位
step4: [A B D E F G] <- 每步动态重组 batch
效果:GPU 利用率从 ~30% 提到 ~90%+,吞吐可翻 2~4 倍
连续批处理必须和 PagedAttention 配合:因为退出/加入会改变每个序列的 KV 布局,只有"块表 + 动态物理分配"才能支撑这种随时重组。这也是为什么 vLLM 是把这两者一起设计的------光有连续批处理而 KV 还按连续预分配,碎片问题会立刻反噬。
五、推理服务的调度架构
5.1 单实例内的调度队列
┌─────────────────────────────┐
客户端请求 ──> 等待队列 │ Scheduler │
(prompt, params) │ - 优先级 / FCFS │
│ - chunked prefill 切分 │
│ - 连续批处理重组 │
└──────────┬──────────────────┘
│ 每步选出 running + 新加入
┌──────────▼──────────────────┐
│ GPU Worker │
│ Prefill 阶段 (并行算 prompt)│
│ Decode 阶段 (逐 token) │
│ PagedAttention KV 管理 │
└──────────┬──────────────────┘
│ 生成的 token
┌──────────▼──────────────────┐
│ 采样 (temp/top_p/beam) │
│ -> 流式返回 / 整体返回 │
└─────────────────────────────┘
5.2 Prefill 与 Decode 的算力特征不同
Prefill:一次性算整个 prompt,batch 内 token 多 -> 计算密集 (compute-bound)
Decode :每步只算 1 个新 token -> 访存密集 (memory-bound)
混在一起批处理的副作用:
Prefill 的大矩阵乘会"霸占" GPU,导致同批 decode 的 ITL 抖动。
解法 A:Chunked Prefill ------ 把长 prompt 切成 chunk,和 decode 交错,平滑延迟。
解法 B:Prefill/Decode 分离 ------ 两套实例,prefill 算完把 KV 传给 decode 实例
(如 DistServe / 腾讯Angel、阿里星河等架构)。
六、投机解码:用"草稿模型"换"大模型的逐 token 延迟"
6.1 为什么要投机
Decode 阶段每步只产 1 个 token,且受带宽限制。如果我们能让每步一次前向就验证多个候选 token,等效地把 decode 变成小批量 matmul,带宽利用率就上去了。这就是投机解码(Speculative Decoding)。
标准 decode (串行,每步 1 token):
t1 -> t2 -> t3 -> t4 -> t5 (5 次大模型前向,每次 1 token)
投机 decode (草稿并行,验证式接受):
草稿模型一次出 5 个候选: [c1 c2 c3 c4 c5]
大模型一次前向同时验证这 5 个 -> 接受若干 (如 c1 c2 c3)
等价于 1 次大模型前向"白赚"了 3 个 token
6.2 数学保证:无损
投机解码的关键是输出分布与大模型自回归完全一致(无损加速)。机理是拒绝采样(rejection sampling):对草稿给出的每个候选 token x,以概率
p(x) - q(x) p = 大模型分布, q = 草稿模型分布
accept = min(1, ─────────)
q(x)
若拒绝,则从修正分布 (p - q 截断归一化) 重采样一个 token。
由于修正分布的设计,最终序列的边缘分布严格等于 p。也就是说加速不改变生成质量,这是它区别于普通"小模型直接替大模型答"的根本。
6.3 草稿模型怎么来
| 草稿来源 | 做法 | 优缺点 |
|---|---|---|
| 独立小模型 | 用 7B 给 70B 当草稿 | 简单;但要额外显存,且 q 与 p 偏差大时命中率低 |
| 同架构浅层 | 取大模型的前几层当草稿头 | 复用权重、显存友好、分布接近,命中率更高 |
| 自蒸馏草稿 | 训练一个专门匹配大模型的草稿 | 命中率最优,但要训练成本 |
| 无草稿 (Medusa / EAGLE) | 见下文 | 不用串行小模型,靠"多头并行预测" |
七、Medusa 与 EAGLE:不要串行草稿
7.1 Medusa:多头并行预测
标准投机是"草稿模型串行吐一串",瓶颈仍在草稿的串行。Medusa 给大模型接几个独立的预测头(head),每个 head 并行预测"未来第 k 个 token":
大模型最后一层表示 h
├─ Head1 预测 t+1
├─ Head2 预测 t+2
├─ Head3 预测 t+3
└─ Head4 预测 t+4 (并行,一次前向出 4 个位置候选)
再对每个位置做树状候选 + 大模型一次验证 (tree attention)
接受后整段回写,类似投机但草稿是"宽"的不是"深"的
Medusa 的训练只训这几个轻量 head,冻结主干,成本很低。
7.2 EAGLE:基于上一层"嵌入"预测
EAGLE(Extrapolation Algorithm for Greater Language-model Efficiency)观察到:直接预测 token 分布不如预测下一层的隐藏状态来得准。它用一层小网络,输入"上一层的隐藏状态 + 刚采样的 token 嵌入",预测下一层隐藏状态,再接大模型剩下的层:
标准投机:草稿模型独立产出 token 分布
EAGLE :在 embedding 空间里做"下一步状态"预测,对齐更紧
-> 接受率更高,加速比通常优于 Medusa
权衡:依赖对大模型内部表示的访问,实现耦合度更高
7.3 几种方案对比
| 方案 | 草稿生成方式 | 是否无损 | 额外显存 | 典型加速 |
|---|---|---|---|---|
| 标准投机解码 | 小模型串行草稿 | 是 | 中(小模型权重) | 2~3x |
| Medusa | 多头并行预测头 | 是 | 低(仅 head) | 2~3x |
| EAGLE / EAGLE-2 | 隐含状态外推 | 是 | 低 | 3~4x |
| 普通小模型直答 | 小模型直接答 | 否(质量降) | - | 高但失真 |
八、服务化工程:从单机到集群
8.1 多卡并行推理的三种切分
TP (Tensor Parallel):单层权重切到多卡,all-reduce 拼结果。适合单请求内加速。
PP (Pipeline Parallel):模型按层切到多卡,micro-batch 流水。适合超长模型。
DP (Data Parallel):多份模型副本,不同请求走不同副本。配合连续批处理提吞吐。
真实部署常见 TP=8 的单机 8 卡跑一个 70B,或 TP=4 x PP=2 的组合。
注意:TP 通信密集,必须同机 NVLink;跨机只能 PP/DP。
8.2 一个最小化的本地服务封装
python
# 用 vLLM 起一个 OpenAI 兼容服务(面试能默写启动参数很有用)
from vllm import LLM, SamplingParams
llm = LLM(
model="Qwen2.5-72B-Instruct",
tensor_parallel_size=8, # TP=8
gpu_memory_utilization=0.9, # 显存占用上限
max_model_len=32768, # 支持长上下文
enable_prefix_caching=True, # 相同前缀的 prompt 复用 KV
speculative_config={ # 投机解码配置
"method": "medusa",
"model": "Qwen2.5-72B-Instruct-Medusa",
"num_speculative_tokens": 4,
},
)
params = SamplingParams(temperature=0.7, top_p=0.9, max_tokens=1024)
outputs = llm.generate(prompts, params)
enable_prefix_caching 值得单独记:很多请求共享 system prompt 或 few-shot 示例,前缀 KV 命中后,新请求跳过这部分 Prefill,TTFT 直接下降一个量级。
九、评测指标与上线前 checklist
9.1 吞吐/延迟的衡量
| 场景 | 关注指标 | 说明 |
|---|---|---|
| 在线对话 | TTFT + ITL + P99 延迟 | 用户体验敏感 |
| 离线批量 | total tokens/s, QPS | 成本敏感 |
| 压测 | 随并发上升的吞吐拐点 | 找显存/调度的瓶颈 |
9.2 上线前常被问的工程问题
- OOM 怎么排查 :先降
gpu_memory_utilization,再开 PagedAttention(默认开),看是否 KV 峰值过高;必要时限max_model_len或限并发。 - 长尾延迟高:多为 prefill 与 decode 争抢,开 chunked prefill 或 PD 分离。
- 投机反而变慢:草稿模型接受率低于某个阈值(如 <0.5)时,草稿开销盖过收益,应关掉或换草稿策略。
- 成本怎么算:按 token 计价时,量化(A19)+ 投机(本篇)+ 前缀缓存三者叠加,单位 token 成本可降数倍。
十、KV Cache 显存预算:把容量规划算成一道算术题
面试里最能区分"看过博客"和"真上过线"的问题,就是让你现场估一下:给定模型和并发,需要几张卡。这个不需要背,能推导出来就行。
10.1 KV Cache 的精确公式
每个 token、每一层,都要存一份 K 和一份 V:
单 token KV 显存 = 2 (K和V)
× num_layers (层数)
× num_kv_heads (KV 头数,注意不是 num_attention_heads)
× head_dim (每头维度)
× dtype_bytes (fp16=2, fp8=1, int8=1)
总 KV 显存 = 单 token KV × 平均序列长度 × 并发请求数
关键陷阱:必须用 num_kv_heads 而不是 num_attention_heads。现代模型普遍用 GQA(Grouped Query Attention)或 MQA,KV 头数远小于 Q 头数。这是 KV Cache 优化最大的一笔红利,很多人算错就是错在这里。
10.2 拿 Llama-3-70B 实际算一遍
Llama-3-70B 配置:
num_layers = 80
num_kv_heads = 8 (GQA,注意 num_attention_heads=64,是 8 倍)
head_dim = 128
dtype = fp16 (2 bytes)
单 token KV = 2 × 80 × 8 × 128 × 2 bytes
= 327,680 bytes
≈ 320 KB / token
单请求 8K 上下文 = 320KB × 8192 ≈ 2.5 GB
并发 32 路 8K = 2.5GB × 32 ≈ 80 GB <-- 光 KV 就吃掉一整张 H100
再加权重:70B × fp16 = 140 GB
合计 220 GB -> 至少 4 张 80G 卡(还要留 activation 与碎片余量)
对比一下如果没有 GQA(即 MQA 之前的 MHA,num_kv_heads=64):单 token KV 会变成 2.5MB,并发 32 路 8K 需要 640GB KV------直接不可行。这就是为什么 GQA 是长上下文时代的必选项。
10.3 从预算反推最大并发
生产上更常见的是反过来问:4 张 H100(320GB),跑 70B,最多扛多少并发?
总显存 320 GB
- 权重 140 GB (fp16)
- 框架/激活 ~20 GB (经验值,含 CUDA context、临时 buffer)
= KV 可用 160 GB
平均上下文按 4K 估:单请求 KV = 320KB × 4096 ≈ 1.31 GB
最大并发 = 160 / 1.31 ≈ 122 路
若把权重换成 AWQ int4:权重降到 ~35 GB
KV 可用 = 320 - 35 - 20 = 265 GB -> 并发 ≈ 202 路
这就直接回答了"量化的收益到底在哪":int4 量化省下的权重显存,转化成了更大的 KV 池,也就是更高的并发和吞吐------收益不只是单次推理变快,更是服务容量翻倍。这是 A19 量化那篇的工程落点。
10.4 KV Cache 量化:再省一半
当并发是瓶颈时,可以把 KV Cache 本身也量化到 fp8 或 int8:
| 方案 | KV 显存 | 质量影响 | 备注 |
|---|---|---|---|
| fp16 KV | 基准 | 无 | 默认 |
| fp8 KV | 50% | 极小(长文本略有累积误差) | H100 原生支持,推荐 |
| int8 KV(带 scale) | 50% | 小,需 per-token 量化 | 老卡可用 |
| int4 KV | 25% | 明显,长序列退化 | 慎用 |
经验规则:K 比 V 更敏感。K 参与 softmax,量化误差会被指数放大;V 是加权求和,误差平均化。所以常见做法是 K 用 fp8、V 用 int4 的非对称配置。
十一、前缀缓存与 RadixAttention:把重复的 prefill 省掉
11.1 为什么前缀缓存值钱
真实流量里有大量重复前缀:同一个几千 token 的 system prompt、同一份 few-shot 示例、多轮对话里不变的历史、RAG 里同一批被反复命中的文档。这些内容每次请求都重新做一遍 prefill,是纯粹的浪费。
典型客服场景:
system prompt + 工具定义 3000 token <-- 每个请求都一样
对话历史 1500 token <-- 同一会话内递增但前缀不变
本轮用户输入 50 token <-- 唯一真正新的部分
无前缀缓存:每轮 prefill 4550 token
有前缀缓存:每轮 prefill 只需 50 token
prefill 计算量降低约 98%,TTFT 从数百毫秒降到几十毫秒
11.2 RadixAttention:用前缀树管理 KV
vLLM 的 automatic prefix caching 和 SGLang 的 RadixAttention 思路一致:把所有活跃序列的 KV 块组织成一棵基数树(radix tree),公共前缀天然共享同一批物理块。
请求A: [system][few-shot][问题1]
请求B: [system][few-shot][问题2]
请求C: [system][工具定义][问题3]
基数树:
root
|
[system] <-- A/B/C 共享,物理块只存一份
/ \
[few-shot] [工具定义] <-- A/B 共享左枝
/ \ |
[问题1] [问题2] [问题3] <-- 各自独占
命中逻辑:新请求进来,沿树做最长前缀匹配,
匹配到的部分直接复用物理块(引用计数+1),只 prefill 剩余部分。
淘汰策略:LRU + 引用计数为 0 才可回收,热前缀常驻。
11.3 与 PagedAttention CoW 的区别(高频追问)
这两个概念很容易混淆,面试被追问时要分清:
| 维度 | PagedAttention 的 CoW 共享 | 前缀缓存 / RadixAttention |
|---|---|---|
| 作用范围 | 同一请求派生出的多个序列(如 beam search、parallel sampling 的 n>1) | 跨请求、跨会话 |
| 生命周期 | 请求结束即释放 | 请求结束后仍保留,等待后续命中 |
| 触发方式 | 框架内部自动派生 | 前缀匹配命中 |
| 省的是什么 | 显存(同样内容不重复存) | 显存 + prefill 计算(TTFT) |
一句话概括:CoW 是"同一次请求内部的分叉共享",前缀缓存是"跨请求的历史复用"。两者都建立在 PagedAttention 的块级抽象之上,但解决的是不同问题。
11.4 工程注意事项
- 前缀顺序要稳定:把不变的内容(system prompt、工具定义)放最前面,把变化的(用户输入、时间戳)放最后。如果每次都在开头插一个当前时间,前缀缓存直接全部失效。
- 多租户下要隔离:不同租户的 KV 块共享会造成信息泄露(可通过时序侧信道推断他人 prompt)。生产上要按租户分区,这与 B21 多租户隔离那篇呼应。
- 命中率是核心指标 :要上报
prefix_cache_hit_rate,低于 30% 说明前缀设计有问题。
十二、投机解码的接受率:从数学到调参
12.1 加速比的定量模型
设草稿模型一次投机 k 个 token,接受率(每个位置被接受的概率)为 α,草稿模型与目标模型的单步耗时比为 c = T_draft / T_target。
期望接受长度(含最后大模型必定补的 1 个 token):
E[接受数] = (1 - α^(k+1)) / (1 - α)
一轮投机的成本 = k × c × T_target (草稿串行跑 k 步)
+ 1 × T_target (大模型一次并行验证)
加速比 S = E[接受数] / (k × c + 1)
代入实际数字体会一下:
| α(接受率) | k=4, c=0.1 时的加速比 | 结论 |
|---|---|---|
| 0.9 | (1-0.9^5)/(1-0.9) / 1.4 = 4.10/1.4 ≈ 2.93x | 草稿很准,接近理论上限 |
| 0.8 | 3.36/1.4 ≈ 2.40x | 典型良好水平 |
| 0.6 | 2.18/1.4 ≈ 1.56x | 仍有收益 |
| 0.4 | 1.50/1.4 ≈ 1.07x | 几乎白干 |
| 0.3 | 1.30/1.4 ≈ 0.93x | 负收益,反而变慢 |
这张表就是"投机反而变慢"的定量解释:当 α 低于约 0.35 时,草稿开销盖过收益。生产上必须监控实时接受率,低于阈值自动降级为普通解码。
12.2 k 怎么选
k 不是越大越好。α^k 随 k 指数衰减,后面的位置几乎必然被拒绝,但草稿的串行成本是线性增长的。
最优 k 的直觉:
α 高(0.85+)-> k 可以开到 5~8,吃满长接受
α 中(0.6~0.8)-> k = 3~5 最稳
α 低(<0.5) -> k = 1~2 或直接关闭
现代框架(vLLM 的 dynamic speculative decoding)会在线估计 α,
动态调整 k,甚至按请求粒度决定是否启用投机。
12.3 什么因素决定接受率
- 草稿与目标的分布相似度:同家族、同 tokenizer、同训练数据的小模型(如 Llama-3-8B 草稿 + Llama-3-70B 目标)α 最高。跨家族基本不可用。
- 任务类型:代码、结构化输出(JSON)、有大量模板重复的文本,α 很高(0.85+),因为下一个 token 高度可预测;开放创作、诗歌类 α 低。
- 采样温度:temperature 越高,目标分布越平,拒绝概率越大,α 下降。贪心解码(T=0)下 α 最高。这解释了为什么"投机在 T=0 时收益最大"。
- 批大小 :注意一个反直觉的点------大 batch 下投机的收益会下降 。因为 decode 阶段在大 batch 时已经从访存密集转向算力密集,GPU 本来就不空闲了,投机带来的"一次多算几个 token"不再是免费午餐。所以投机解码主要服务于低并发、低延迟场景(如单用户对话),而不是高吞吐离线批处理。
12.4 拒绝采样的实现要点
python
# 概念示意:投机解码的验证与拒绝采样(保证输出分布与目标模型完全一致)
import torch
def speculative_verify(draft_tokens, q_probs, p_probs):
"""
draft_tokens: 草稿模型产出的 k 个候选 token
q_probs: 草稿模型在各位置给这些 token 的概率
p_probs: 目标模型一次前向后,在各位置给这些 token 的概率
返回: 接受的 token 列表
"""
accepted = []
for i, tok in enumerate(draft_tokens):
p, q = p_probs[i][tok], q_probs[i][tok]
# 核心:以 min(1, p/q) 的概率接受
if torch.rand(1).item() < min(1.0, (p / q).item()):
accepted.append(tok)
else:
# 拒绝后必须从残差分布 norm(max(0, p - q)) 重采样一个 token
# 这一步是"无损"的数学保证所在,不能简单丢弃后重来
residual = torch.clamp(p_probs[i] - q_probs[i], min=0)
residual = residual / residual.sum()
accepted.append(torch.multinomial(residual, 1).item())
break # 一旦拒绝,后面的草稿全部作废(因为上下文已偏离)
else:
# k 个全部接受,则大模型的"免费"下一个 token 也可直接采用
accepted.append(torch.multinomial(p_probs[-1], 1).item())
return accepted
两个必须记住的细节:一是拒绝后要从残差分布 max(0, p-q) 归一化后重采样,不是直接用 p 重采样 ,这是无损性的数学关键;二是一旦某个位置被拒绝,其后所有草稿 token 立即作废,因为它们是基于被拒绝的那个 token 继续生成的,上下文已经不成立了。
十三、落地决策路径与常见误区
13.1 一套推理服务该怎么从零搭起
面试里经常有一道开放题:给你一个模型和一批预算,你会怎么把服务搭起来。回答这类问题最忌讳的是上来就报框架名字,正确的路径是先明确约束,再倒推技术选型。
第一步是确定业务形态。在线对话类业务对首 token 延迟极其敏感,用户在屏幕前等着,超过一秒就会觉得卡;离线批处理类业务完全不在乎单条延迟,只关心单位成本和总吞吐。这两类的技术选型几乎是相反的:前者要限制批大小、开投机解码、必要时牺牲吞吐保延迟;后者要把批大小拉满、关掉投机、用最激进的量化换更大的并发。如果一个系统里两类流量混在一起,正确做法是物理隔离成两个集群,而不是试图用一套参数同时满足,因为调度器无法在同一个批次里同时优化两个相互冲突的目标。
第二步是估算显存与卡数,也就是第十节那套算术。这一步的产出是一个明确的结论:需要几张什么卡、能扛多少并发、上下文上限设多少。这里最容易犯的错误是只算权重不算 KV Cache,结果上线后一压测就 OOM。经验上,KV Cache 在长上下文高并发场景下占用的显存经常超过权重本身,把它当作事后再考虑的细节,是新手和老手最明显的分水岭。
第三步是选择优化手段的施加顺序。这个顺序不是随意的,而是由收益确定性和实施风险共同决定的。最先做的应该是那些几乎无损、纯赚的项:开启 PagedAttention 和连续批处理,这是现代框架的默认能力,收益巨大且没有质量代价;接着是前缀缓存,只要把 prompt 结构设计对,就能白拿 prefill 的节省。之后才轮到有质量风险的项:权重量化会带来轻微精度损失,需要在业务评测集上验证;KV Cache 量化的风险更高一些,长序列场景要重点测。最后才是投机解码,因为它的收益高度依赖场景和接受率,属于"锦上添花但需要持续调参"的项,过早引入会增加系统复杂度却不一定见效。
第四步是建立可观测与压测基线。没有基线的优化都是自我感动。至少要能持续看到首 token 延迟的分位数、逐 token 间隔、批内平均序列数、KV Cache 利用率、前缀缓存命中率、投机接受率这几个指标。特别是最后两个,它们直接决定了对应优化是否还在产生收益------前缀命中率跌下来通常意味着有人在 prompt 开头加了变化内容,投机接受率跌下来通常意味着流量结构变了,两者都需要及时发现并调整。
13.2 四个高频误区
第一个误区是认为吞吐和延迟可以同时最优。这是所有推理服务问题的根源性矛盾。把批大小调大,GPU 利用率上去了,总吞吐上去了,但批内每个请求的逐 token 间隔必然变长,用户看到的打字机效果就会变慢。反过来限制批大小保延迟,卡就会有大量空转。工程上没有免费午餐,只有在给定服务质量约束下寻找最优工作点。面试时如果能主动说出"我需要先知道这个业务的延迟 SLA 是什么",基本就答对了一半。
第二个误区是把投机解码当成万能加速开关 。前面的定量分析已经说明,接受率低于某个阈值时投机是负收益。更微妙的是,投机的收益在高并发下会自然衰减,因为大批量解码时 GPU 已经不再是访存瓶颈,"顺手多算几个 token"不再免费。所以投机解码本质上是一个用空闲算力换延迟的手段,只有在算力有富余的低并发场景才成立。很多团队在压测时开着投机测出好数据,上线后并发一高收益就消失了,原因就在这里。
第三个误区是忽视 prompt 结构对性能的影响。工程师习惯把注意力放在框架参数和硬件上,但前缀缓存的命中率完全由业务侧的 prompt 拼装方式决定。一个在 system prompt 里插入当前时间戳的改动,可以让整个集群的前缀命中率从八成掉到零,而这个改动在代码评审里看起来人畜无害。所以推理性能不只是基础设施团队的事,prompt 的组织规范应该作为团队约定沉淀下来:不变内容在前、可变内容在后、绝不在前缀里放随机值。
第四个误区是孤立看待各项优化。量化、投机、前缀缓存、连续批处理之间存在复杂的相互作用。量化省下的显存会转化为更大的 KV 池,从而支持更高并发,而更高并发又会削弱投机解码的收益;前缀缓存提高了 KV 复用率,间接也提升了有效并发。这些耦合意味着不能把每项优化的收益简单相乘来估算总收益,必须在真实流量上端到端压测。面试时能说出"这几项优化之间不是独立的,需要联合调优",会比逐个罗列技术名词显得深入得多。
十四、面试速答 + 高频追问清单
面试速答(60 秒版)
推理服务化围绕两件事:省 KV Cache 显存 和提高 GPU 利用率。PagedAttention 把 KV 切块、用 block table 做逻辑-物理映射,消除内外碎片并支持共享;连续批处理在每个 decode 步动态重组 batch,让结束的请求立刻腾位、新请求立刻补位,把利用率从 ~30% 拉到 90%+,且必须配合 PagedAttention。Decode 阶段是访存密集而非算力密集。投机解码用草稿模型一次产出多个候选、大模型一次前向验证并接受(拒绝采样保证无损),把串行 decode 变成小批量并行;Medusa 用多头并行预测、EAGLE 在隐含状态空间外推,都不需要串行小模型且加速更高。Prefill/Decode 分离(chunked prefill 或 PD 分离)解决两者算力特征冲突导致的延迟抖动。
高频追问清单
- PagedAttention 的 block table 在 GPU 上怎么存?注意力计算时怎么 gather 物理块?
- 连续批处理下,不同序列长度混在同一 batch,padding 怎么处理?flash attention 在这里的作用是什么?
- 投机解码的接受率由什么决定?为什么草稿模型越像大模型,加速越明显?
- 拒绝采样公式里
p(x) - q(x) < 0的情况(草稿概率高于大模型)怎么处理? - EAGLE 比 Medusa 快的根本原因?它为什么必须访问大模型内部表示?
- 前缀缓存(prefix caching)和 PagedAttention 的 CoW 共享是同一回事吗?区别在哪?
- Prefill/Decode 分离架构里,KV Cache 怎么在两套实例间传输?跨机带宽会是瓶颈吗?
- tensor parallel 和 pipeline parallel 各自的通信模式与适用场景?为什么 TP 必须同机?
- 怎么给一个 70B 模型定 SLA:TTFT < 500ms、ITL < 50ms、并发 100,显存和卡数怎么估?
- 量化(AWQ/GPTQ)和投机解码能叠加吗?两者的加速是否独立、会不会相互抵消?
下一篇预告:本篇让模型"吐得快",但还有个更扎心的问题------它吐出来的到底是真还是编的。下一篇《幻觉成因与缓解》从校准、不确定性估计到弃权机制,讲怎么让模型的"不确定"变得可测量、可利用。