训练完成的那一刻,成本才刚开始发生。推理是把训练投资变现的环节,也是工程优化的主战场:同等能力的单 Token 价格在两三年里下降了两个数量级,而支撑这个数字的不是模型变小,而是服务侧工程的一整套组合拳------调度、显存管理、量化、投机解码。把这些技术拆开看,它们全部服从同一条第一性原理:解码阶段瓶颈在显存带宽而非算力,一切优化都在省字节、摊字节,或用空闲算力换字节。本文先证明这条原理,再看每项技术如何演绎它。
一、两种完全不同的算子:Prefill 与 Decode
自回归推理的显性特征是分两个阶段,且两阶段的硬件画像截然相反:
- Prefill(预填充) :把整个 prompt 一次性并行算出 KV Cache。大量 Token 同时过模型,矩阵乘满血发挥,算力受限(compute-bound)。决定首 Token 延迟(TTFT)。
- Decode(解码):逐 Token 生成,每步前向只有一个 Token。决定后续每个 Token 的间隔(TPOT)和整体吞吐。
Decode 的受限于什么,可以精确算出来。生成一个 Token,每参数做一次乘加(约 2 FLOPs),同时每参数必须从显存读一次(权重无法驻留 SRAM):
python
def decode_is_memory_bound():
N = 70e9 # 70B 参数
flops = 2 * N # 每 Token 计算量
bytes = 2 * N # bf16 权重读入量
intensity = flops / bytes # ≈ 1 FLOP / byte
# H100: 990 TFLOPS bf16 密集算力 vs 3.35 TB/s HBM 带宽
machine_balance = 990e12 / 3.35e12 # ≈ 296 FLOP / byte
print(f"算术强度/机器平衡点 = {intensity/machine_balance:.3%}")
# ≈ 0.3% ------ decode 时 99.7% 的算力在陪跑等数据
decode_is_memory_bound()
0.3%。这就是解码的本质:GPU 的计算单元在算力上极度过剩,Token 生成的速度被钉死在"每秒能把权重读几遍"上------70B bf16 模型在 3.35 TB/s 带宽上理论极限约每卡 24 Token/s,与实测单请求吞吐同一量级。预填充则相反,算力利用接近满血。两阶段共享同一个"批处理池"还是分开处理,是后面调度层所有设计的分歧点。
二、调度层:Continuous Batching
传统静态批处理(凑够一个 batch 一起进出)的浪费是结构性的:序列长度不同,短序列生成完了只能空等同批长序列,GPU 大量时间在跑 padding。Orca(OSDI 2022)提出的迭代级调度(即 continuous batching)改写了游戏规则:调度的粒度从"请求级"降到"单次前向迭代级":
python
class ContinuousBatcher:
def __init__(self, max_batch):
self.running, self.queue = [], [] # 在跑/排队中的请求
self.max_batch = max_batch
def step(self, engine):
# 1. 每次迭代先收割: 完成的请求立即腾出坑位
self.running = [r for r in self.running if not r.finished()]
# 2. 再补位: 新请求随时插入(新 prompt 走 prefill)
while len(self.running) < self.max_batch and self.queue:
self.running.append(self.queue.pop(0))
# 3. 单步前向: 所有在跑序列同解一步, 长度不齐由 kernel 内处理
engine.forward(self.running) # step 级而非 request 级
每个迭代周期都是"收割---补位---前向"的循环,请求到达即插入、完成即离场,批的构成随时重组。配合 vLLM 的 PagedAttention(碎片问题此前拆注意力性能时已讲过,不再展开),吞吐相对静态批普遍翻倍以上。
单批内部还有第二个矛盾:prefill 是算力任务,decode 是带宽任务,混在一个批次里跑,长 prompt 的 prefill 会让同批所有 decode 请求的 TPOT 突然恶化。Chunked prefill (Sarathi 系)的解法是把长 prompt 切成固定大小的块,分散到多次迭代里消化,让每次迭代的负载均匀化;生产系统则进一步走向预填充/解码分离 (DistServe、Mooncake 路线):两类算子放进不同的硬件池,各自按自己的瓶颈优化,KV Cache 通过池间搬运传递。这条分离路线的代价是架构复杂度,收益是 TTFT 和 TPOT 两个 SLO 可以独立规划------评测口径也随之升级为 goodput(满足 SLO 前提下的有效吞吐),而不是裸吞吐。
三、显存层:KV Cache 的容量经济学
服务系统的并发上限不是"想开多大 batch 就多大",而是一道显存除法:
python
def max_concurrency(vram_gb=80, weights_gb=140/8, kv_per_req_gb=2.0):
# weights_gb: 70B 模型 8 卡张量并行, 每卡 17.5GB
# kv_per_req_gb: 4K 上下文 GQA 架构单请求 KV (第2篇算过 ≈2GB)
return int((vram_gb - weights_gb) / kv_per_req_gb)
print(max_concurrency()) # 31 个并发请求/卡 ------ 这就是服务的天花板
三个变量对应三类扩容手段:权重显存靠量化(下一节)、单请求 KV 靠 GQA 与 KV 量化、剩余空间靠碎片治理。碎片之外更值钱的是复用 :企业场景里 system prompt 千人一面,重复计算是纯浪费。Prefix caching(vLLM 的 automatic prefix caching、SGLang 的 RadixAttention)把 KV 块按内容哈希索引成树,新请求的 prompt 前缀命中即直接挂载现成 KV,首 Token 延迟从秒级砍到毫秒级。对多轮对话、RAG 系统模板、Few-shot 场景,这是免费午餐里最大的一份。
KV 本身也可以量化:int8 近乎无损,int4 需要技巧------K 矩阵(RoPE 之前)有显著的离群通道,对量化最敏感,KIVI 类方案对 K 按通道、对 V 按 Token 分别处理来压回误差。单请求 KV 减半,直接意味着上式分母减半、并发翻倍。
四、量化:把字节打薄
权重是解码带宽的大头,量化是最直接的省字节手段。主流三档:
| 方案 | 形式 | 权重显存/带宽 | 质量代价 | 适用 |
|---|---|---|---|---|
| bf16 基线 | --- | 2B/参数 | --- | --- |
| W8A8 | 权重激活均 int8 | 1B/参数 | 近无损 | 高吞吐在线服务 |
| W4A16 | 权重 int4,激活 bf16 | 0.5B/参数 | 小幅退化 | 端侧/单卡低并发 |
W4A16 只量化权重、激活保持高精度,是因为激活分布里存在离群值(大幅超出正态范围的通道),直接 int8 会砸精度。W8A8 的配套技术 SmoothQuant 的思路就是解决它:用缩放因子把量化难度从激活迁移到分布更规整的权重侧。
权重 int4 不是简单取整,GPTQ 与 AWQ 两个代表方案都在利用校准数据补偿误差:
python
# GPTQ 核心: 逐列量化, 误差沿着 Hessian 信息转移到未量化列
# 目标函数: min ||(W - Q)X||², H = 2XX^T
# 每量化一列 w_j, 量化误差 Δ 按海森逆矩阵的比例加到剩余列上
# ------ 知错能改: 后面的列可以抵消前面犯的错
# AWQ 另辟蹊径: 激活幅度前 ~1% 的通道是"关键通道"
# 对它们做 per-channel 预缩放保护, 其余通道放心低精度
实测规律值得记住两点。其一,误差随规模衰减:同样的 W4 方案,7B 模型掉分明显,70B 模型常常近乎无感------大模型的冗余性天然吸收量化噪声。其二,量化的收益结构不均匀:它省的是带宽,因此在低 batch(带宽受限区)加速显著;batch 打大后系统转入算力受限区,权重读入被大量请求摊薄,W4A16 相对 bf16 的优势快速收窄。选量化方案前先看自己的负载落在哪个区,而不是无脑上最低比特。
五、投机解码:用空闲算力换带宽
第一节算过账:解码时 99% 的算力闲置。投机解码(Leviathan et al., 2023)把这些算力变成了燃料------用小模型起草,大模型一次性验证:
draft: 小模型(1B)连写 k 个 Token: d1 d2 d3 d4
verify: 大模型(70B)一次前向, 并行算出 d1..d4 各自位置的正确分布
accept: 逐位比对, 前缀一致的整段收下; 拒绝处修正, 之后的草稿作废
验证 kkk 个草稿 Token 只需一次大模型前向------权重读一遍,同时干 kkk 份活。带宽单价没变,Token 产量翻了,数学上它还精确保持目标模型的输出分布(不是近似!),靠的是拒绝采样修正:
python
import numpy as np
def speculative_verify(p_t, p_d, draft, rng):
"""p_t: (k+1, V) 目标模型分布; p_d: (k, V) 草稿分布; draft: k 个草稿 token"""
out = []
for i, t in enumerate(draft):
if rng.random() < min(1.0, p_t[i, t] / p_d[i, t]):
out.append(t) # 接受
else:
residual = np.clip(p_t[i] - p_d[i], 0, None) # 修正分布
residual /= residual.sum()
out.append(rng.choice(len(residual), p=residual))
return out, False # 拒绝即停, 后续草稿作废
bonus = rng.choice(len(p_t[-1]), p=p_t[-1]) # 全接受: 白赚下一个 token
return out + [bonus], True
收益的上界由接受率和草稿长度共同决定。设每 Token 接受率 α\alphaα、草稿长 kkk,每步期望产出:
python
def expected_tokens(alpha, k):
return (1 - alpha**(k+1)) / (1 - alpha)
def speedup(alpha, k, draft_cost=0.1):
tau = expected_tokens(alpha, k)
step_time = k * draft_cost + 1.0 # k 次小模型 + 1 次大模型验证
return tau / step_time
print(f"α=0.8, k=4: 期望 {expected_tokens(0.8, 4):.2f} token/步, "
f"加速 {speedup(0.8, 4):.1f}x") # 3.36 token/步, 2.4x
几何级数自然衰减------接受率 0.8、草稿 4 个 Token 时,每步期望产出 3.36 个,扣除草稿成本净加速 2.4 倍。加速的全部来源是 α\alphaα(两个分布的契合度),于是草稿器演化成一条独立赛道:从外挂小模型,到 Medusa 的多头并行预测,再到 EAGLE 的特征层自回归(在隐藏层而非 Token 层起草,接受率显著更高,当前效率最高的一系)。另一个极端是零成本草稿:n-gram 匹配直接从上文复制片段(prompt lookup decoding),对编辑、摘要、RAG 引用这类"答案就在上下文里"的任务,几乎是免费的加速。
同样要写进账本的还有衰减条件:投机解码赚的是"带宽受限"的钱。服务端 batch 打大后,GPU 逐渐转入算力受限区,验证 kkk 个 Token 的算力开销从免费变成显性成本------大流量在线服务里投机解码收益缩水,低并发本地部署里它大杀四方,同一枚硬币的两面。
六、收束
| 技术 | 作用机理 | 省下/摊薄什么 | 代价 |
|---|---|---|---|
| Continuous batching | 迭代级调度补位 | GPU 空转时间 | 调度复杂度 |
| Chunked / 分离部署 | 拆开异质负载 | TTFT 与 TPOT 互扰 | 架构复杂度 |
| Prefix caching | KV 复用 | 重复 prefill 算力 | 命中率依赖场景 |
| W4A16 / W8A8 量化 | 权重字节打薄 | 带宽与显存 | 精度、区域依赖收益 |
| KV int8/int4 | 缓存打薄 | 显存 → 并发 | K 离群值需处理 |
| 投机解码 | 并行验证草稿 | 带宽(用闲置算力换) | 草稿质量、batch 效区衰减 |
这些技术没有一个在改模型的数学------输出分布逐位不变是其中好几项的卖点。全部收益来自让硬件的物理事实(带宽稀缺、算力过剩、显存有限)与负载的统计事实(长度不齐、前缀重复、答案局部可预测)对上频率。推理工程的下一程是把这些手段与 MoE 的稀疏激活、长上下文的层级缓存组合起来,但分母始终是同一个:每秒能从显存里搬出多少个有效的参数字节数。