KV Cache 复用与批处理的真实收益边界
KV Cache 前缀复用、连续批处理、量化,几乎已经成了大模型推理降本路上的三件必备手段。但这三项优化的收益曲线都不单调:命中率、batch 大小、量化位宽各自都有一个“划算区间”,越出区间之后成本会掉头往上走。下面用一套固定口径的压测,把这三个区间分别划出来,并给出可直接用来判断“该不该开”的公式。
一、统一评定标准,否则三条曲线不可比
降本讨论最容易出问题的地方是评定标准漂移:有人比较 QPS,有人比较单请求延迟,有人看 GPU 利用率。这些指标之间可以互相牺牲,因此必须先把“成本”固定成单一可比的数字。
本文采用 ¥/1M 达标输出 token:只有满足 SLO 的请求才计入分子,延迟不达标的吞吐不算数。
单位成本 = 实例小时单价 × 1M / (达标输出吞吐 tok/s × 3600)
有效成本 = 单位成本 / (1 - 重试率)
压测台固定以下六个变量,任何一组只改一个:
| 变量 | 取值 |
|---|---|
| 硬件 | 单机 8 卡同型号,禁跨机,固定功耗档 |
| 模型 | 7B / 13B dense,max_len 32k |
| 输入长度 | 短 128~512 / 中 2k~4k / 长 16k~32k 三档 |
| 输出长度 | 固定 256,另跑一档 1024 |
| 并发 | 1 / 4 / 16 / 48 / 96 / 192 阶梯加压 |
| SLO | TTFT P95 ≤ 1.5s(长输入 3s),TPOT P95 ≤ 80ms |
采集窗口为预热 3 分钟后取 10 分钟稳态,只保留 P95 而不是均值,因为均值会把尾部长请求的影响掩盖掉。压测骨架用固定到达率、流式读取首 token 计 TTFT:
import asyncio, time
SLO_TTFT, SLO_TPOT = 1500, 80
async def one(session, url, req):
t0 = time.perf_counter()
async with session.post(url, json=req) as r:
ttft, n = None, 0
async for line in r.content: # 流式 chunk
if not line.strip():
continue
if ttft is None:
ttft = (time.perf_counter() - t0) * 1000
n += 1
total = (time.perf_counter() - t0) * 1000
tpot = (total - (ttft or total)) / max(n - 1, 1)
return ttft or total, tpot, n
def report(rows, wall_s, card_hour_cny=60.0):
ok = [r for r in rows if r[0] <= SLO_TTFT and r[1] <= SLO_TPOT]
tps = sum(r[2] for r in ok) / wall_s # 只算达标请求
return {"pass_rate": len(ok) / len(rows),
"eff_tps": round(tps, 1),
"cny_per_1m": round(card_hour_cny * 1e6 / (tps * 3600), 2)}
下文出现的数字均来自这套台架,更换硬件或模型后绝对值会变,看形状不看绝对值。
二、前缀缓存:命中率的统计口径决定结论,收益窗口在 30%~80%
前缀缓存只在 block 边界上复用,常见的 block 是 16 个 token,因此前缀末尾不足一个 block 的部分永远要重算。这决定了它有两个必须先讲清的统计口径问题:
- 统计口径按请求还是按 token。用“命中请求数 / 总请求数”在长 prompt 场景会严重高估收益,必须用 token 级:
命中率 = 复用 prefill token / 总 prefill token。同一个系统两种算法能差 3 倍。 - 收益要乘以 prefill 占比。命中省掉的只是 prefill,如果输出很长、decode 主导,prefill 归零也救不了端到端吞吐。
hit_rate = hits_total / max(queries_total, 1) # token 级
prefill_share = prefill_ms / (prefill_ms + decode_ms)
speedup ≈ 1 / (1 - hit_rate * prefill_share)
按这个式子,收益可以分成三段:
- < 25%:基本打平。缓存块在未命中时仍然占着显存直到被驱逐,块管理本身也有开销,净收益在噪声范围内。
- 30%~80%:近似线性。实测 prefill 时间下降与 token 级命中率基本 1:1,端到端吞吐提升 ≈ 命中率 × prefill 占比。
- > 85%:边际递减。剩下的 prefill 是必算的 suffix,且缓存块逼近 budget 后驱逐频繁,会出现 cache thrash,命中率抖动。
| 流量形态 | 前缀特征 | token 命中率 | prefill 占比 | 端到端吞吐 | 结论 |
|---|---|---|---|---|---|
| Agent / 客服多轮 | 1~3 种,约 3k token 的统一 system + 工具描述 | 85%~95% | 55% | +70%~90% | 必开 |
| 代码补全 | 每文件前缀不同,共享段短 | 15%~25% | 70% | +5%~10%,但并发上限降一档 | 谨慎,需与 max-num-seqs 联调 |
| 开放域长文档问答 | 每请求文档不同 | < 5% | 80% | 负收益 | 关 |
判定规则:前缀长度 ≥ 512 token、前缀种类数在几十个量级、实例并发 ≥ 8,三条同时满足才开。多租户场景如果每家一份 system prompt,命中率会被请求数量稀释,先按租户拆池再谈。
三、连续批处理:最优点在“TPOT 刚好达标”处,不在最大 batch
decode 阶段是 memory-bound:每生成 1 个 token 都要把权重和全部 KV 读一遍。batch 变大时权重读取被摊薄,吞吐近似线性上升;但每个 token 的计算量也在涨,TPOT 同步劣化。两条线交叉的地方才是经济的批大小。
| 并发 | 达标输出吞吐 tok/s | TTFT P95 | TPOT P95 | ¥/1M 达标 token | 判定 |
|---|---|---|---|---|---|
| 4 | 180 | 0.35s | 32ms | 92.6 | 资源闲置 |
| 16 | 1650 | 0.6s | 41ms | 10.1 | 偏保守 |
| 48 | 3400 | 0.9s | 62ms | 4.9 | 甜点 |
| 96 | 4100 | 1.6s ✗ | 95ms ✗ | 名义 4.1,达标后 6.4 | 越过 SLO |
| 192 | 4300 | 3.4s ✗ | 150ms ✗ | — | 排队主导 |
关键读法在第 4 行:名义吞吐还在涨,但 SLO 达标率跌破阈值,计入口径后成本反而比并发 48 更贵。这就是“批量越大越省钱”这句话的反例。
具体调法是反推而不是试错:把 TPOT P95 打到 SLO 的 80%(即 64ms)对应的 max-num-seqs 作为在线池起点。
vllm serve $MODEL \
--max-num-seqs 64 \
--max-num-batched-tokens 8192 \
--enable-chunked-prefill \
--enable-prefix-caching
两个坑要提前躲:
- 长短混跑会有队头阻塞。chunked prefill 把长 prefill 切片插进 decode 之间,TTFT 变平滑,代价是 TPOT 抖动上升。给 chunk 上限设个 2k 左右的硬顶,否则长请求会持续挤占 decode 时间片。
- 在线池和离线池必须分开。离线批处理没有 SLO 约束,可以把 batch 拉到显存极限,吞吐比在线最优点再高 30%~50%,但 P99 是秒级。两类流量混在一个实例里,两边的甜点都会被对方毁掉。
四、量化:省的是显存并换取并发,但别拿困惑度验收
量化有两条路径,收益来源完全不同:
- 权重量化(W8A8 / W4A16):权重显存下降,decode 阶段的权重读取带宽下降,直接改善 TPOT;同时腾出的显存可以多放 KV block,抬高并发上限。
- KV Cache 量化(FP8 / INT8):只影响 KV 显存,本质上是在换最大并发和最大上下文长度,对单请求 TPOT 改善有限,但误差在长上下文里会累积。
| 方案 | 权重显存 | KV 显存 | 相对吞吐 | 结构化输出合规率 | 16k 召回 | 结论 |
|---|---|---|---|---|---|---|
| BF16 基线 | 1.0x | 1.0x | 1.00 | 99.2% | 96% | 基线 |
| W8A8 | 0.5x | 1.0x | 1.55x | 99.1% | 96% | 默认选它 |
| W8A8 + FP8 KV | 0.5x | 0.5x | 1.80x | 98.7% | 93% | 高并发短上下文可用 |
| W4A16 (AWQ) | 0.28x | 1.0x | 2.10x | 97.4% | 90% | 先做任务级回归 |
| W4 + INT8 KV | 0.28x | 0.5x | 2.40x | 95.1% | 84% | 仅离线 / 低风险链路 |
真正致命的误区是用困惑度当验收指标。困惑度只涨 2% 看起来无害,但工具调用的 JSON 解析失败率可能从 0.8% 涨到 4.9%——这类损失在困惑度上看不出来。
也别把损失夸大。单次失败重试只增加约 5% 期望成本,但线上链路通常是“重试 → 仍失败 → 降级到更大模型或人工兜底”,单请求成本是 3~5 倍,5% 的失败率折算下来是 +10%~20% 的期望成本,同时以 P95 变差的形式还给用户。吞吐 2.1 倍的收益经此折损,实际落在 1.6 倍附近——仍然划算,但绝没有账面那么漂亮。
验收只有一条路:用同一批真实请求做 shadow 双跑,比对三项业务指标——结构化输出解析成功率、长上下文召回、拒答率。任一项劣化超过 1 个百分点就退回上一档。
五、三条曲线叠加:哪些流量形态真的省钱
单项最优不等于组合最优。前缀缓存吃显存、批处理吃延迟预算、量化吃精度余量,三者共用同一个资源池。
| 流量形态 | 前缀缓存 | 连续批处理 | 量化 | 主导成本项 |
|---|---|---|---|---|
| Agent / 客服(高 QPS、长共享前缀) | 强开 | 开到 SLO 甜点 | W8A8 + FP8 KV | prefill 与 decode 均衡 |
| 内部工具(< 1 QPS、多租户前缀分散) | 关 | 小 batch,不等批 | 仅 W8A8 | 显存闲置,优化空间在缩容 |
| 离线批量(抽取、数据合成) | 开,同模板批量提交 | 拉到显存极限 | W4 可试 | 纯吞吐 |
| 长上下文 RAG(每请求文档不同) | 关,或只缓存指令段 | chunked prefill | 忌 KV 量化 | prefill |
三种“开了反而更贵”的典型情形:
- 低 QPS + 高前缀基数:缓存块长期占着显存不被复用,KV block 池缩小,并发上限掉一档;而低 QPS 本来并发就用不满,等于纯粹白占,极端配置下还会因 block 分配失败触发重算让延迟更差。
- 并发不足时把 batch 上限开大:调度器为了凑批引入等待,TTFT 涨了吞吐没涨,纯粹是负优化。
- 精度敏感任务上激进量化:质量损失不会凭空消失,它会以重试和降级的形式回到账单,同时以 P95 的形式回到用户。
统一的判定式:
净收益 = 单位请求成本下降额
- Δ失败率 × 单次失败额外成本
- 运维复杂度折算
仅当 净收益 > 0 且 SLO 达标率未下降 时才上线
落地建议
- 先建评定标准再谈优化:固定硬件、模型、长度分布与 SLO,把
¥/1M 达标输出 token作为唯一对比指标;不达标的吞吐不进分子,否则所有结论都会被“平均延迟”带偏。 - 前缀缓存按 token 级命中率做开关决策:单实例连续观测 24 小时,命中率低于 25% 直接关;高于 60% 且 prefill 占比超过 40% 才值得为它单独切池。不要按“命中请求数”上报这个数。
- 批处理参数用反推不用试错:先测 TPOT 曲线,取 P95 打到 SLO 80% 对应的并发作为 max-num-seqs 起点;在线池与离线池分开部署,混跑会让两边的甜点同时消失。
- 量化以业务指标验收:结构化输出解析成功率、长上下文召回、拒答率三项,任一劣化超过 1 个百分点就退回上一档;困惑度只能作为初筛,不能作为上线依据。
- 把重试成本计入账单:每周用一批真实请求做 shadow 双跑(新配置 vs 当前基线),用
成本 / (1 - 重试率)复核,避免优化只优化了名义成本。
- 点赞
- 收藏
- 关注作者
评论(0)