Day 32 Hard LLM Serving Continuous Batching KV Cache Cost/Latency/Quality

LLM 服务架构 — 把 GPU 喂饱,把延迟压住LLM Serving: Continuous Batching, PagedAttention, Prompt Caching, Cost/Latency/Quality

问题场景 + 需求约束

为一个 1000 万 DAU 的对话产品自建推理平台:模型是 70B dense,部署在 8×H100 节点(张量并行)。用户人均每天 15 轮,平均 input 3k token、output 400 token,峰值约 5k 请求/秒。这不是「调个 API」——一张 H100 每小时 $2–4,GPU 是绝对的成本大头,把利用率从 30% 提到 80% 就是把账单砍掉一半

SLO 要拆成两个,因为 LLM 是逐 token 流式返回的:TTFT(time-to-first-token,首 token 延迟)p99 < 700ms 决定「卡不卡」;TPOT(time-per-output-token,每 token 间隔)p99 < 40ms(≈25 tok/s,快过人阅读)决定「流得顺不顺」。这两个指标背后是两种完全不同的计算特性,整套架构都是在围绕它们做权衡。

高层架构

graph LR
    C["Client
SSE 流式接收"] GW["API Gateway
鉴权 · 限流 · 计量"] R["Router
前缀感知 LB"] PC[("Prompt Cache
前缀 KV 复用")] subgraph Engine["推理引擎 (vLLM/TRT-LLM)"] PF["Prefill 池
compute-bound"] DC["Decode 池
memory-bound"] KV[("KV Cache
PagedAttention")] end C -->|① prompt| GW --> R R -.前缀命中?.-> PC R -->|② 路由| PF PF -->|KV transfer| DC PF <--> KV DC <--> KV DC -.->|③ token 流| GW -.SSE.-> C classDef client fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef edge fill:#0e2030,stroke:#5eead4,color:#e8eef5 classDef cache fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef gpu fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class C client class GW,R edge class PC,KV cache class PF,DC gpu

请求先查前缀缓存,再进引擎;引擎内部 prefill 与 decode 分工不同,token 逐个经 SSE 回流

Gateway 做鉴权、限流(按 token 而非按请求计)、用量计量。Router 是 LLM 特有的:它要做「前缀感知」——把 system prompt 相同的请求尽量路由到同一个 GPU,复用已缓存的 KV。引擎内部把推理拆成 prefill(一次性处理整段 prompt)和 decode(逐 token 自回归生成),两者抢同一块 KV Cache 显存。

关键技术点

1. 推理两阶段 + Continuous Batching:吞吐的命门

核心 trade-off:静态批处理让快请求等慢请求,GPU 大量空转;连续批处理在每个 token 步动态换人,把利用率打满。

原理:LLM 推理分两阶段。Prefill 把整段 prompt 并行过一遍、算出所有 token 的 KV,是 compute-bound(算力受限,能吃满 GPU);Decode 每次只生成 1 个 token,但要把整个模型权重从显存搬进计算单元,是 memory-bandwidth-bound(带宽受限,算力大量闲置)。decode 阶段单请求的 GPU 利用率可能只有几个百分点——唯一的救赎是把很多请求的 decode 攒成一个大 batch 一起算,权重只搬一次,分摊给整个 batch。

但传统「静态批处理」有致命缺陷:一个 batch 里有的请求生成 10 token 就结束,有的要生成 2000 token,先结束的位置只能空等到全 batch 完成。Continuous batching(连续批处理 / iteration-level scheduling)把调度粒度从「整个请求」降到「单次 token 迭代」:每生成一个 token 就重新组 batch,结束的请求立刻让位、排队的请求立刻补进来。

Trade-off:
# Continuous batching 调度循环(pseudo)
running = []                       # 正在 decode 的请求
while True:
    # 1) 有空位就从队列补新请求做 prefill
    while can_admit(running) and queue:
        req = queue.pop()
        req.kv = prefill(req.prompt)   # 算完整段 KV
        running.append(req)
    # 2) 把所有 running 请求的「下一个 token」攒成一个 batch 一起算
    logits = decode_step(running)      # 权重只搬一次,分摊给整个 batch
    for req in running:
        tok = sample(logits[req])
        stream_out(req, tok)           # 立刻 SSE 推给客户端
        if tok == EOS or req.len >= req.max:
            release_kv(req); running.remove(req)   # 让位,KV 立刻回收
现实案例:

2. KV Cache 是第一性瓶颈:PagedAttention 与压缩

核心 trade-off:KV Cache 占多少显存,直接决定能并发多少请求、batch 能多大——它而非算力,才是大多数 serving 系统的真正天花板。

原理:自回归生成每一步都要回看之前所有 token 的 K/V,为避免重算就把它们缓存起来。每 token 的 KV 显存 ≈ 2 × 层数 × KV头数 × head_dim × 字节数。以 70B GQA 模型为例约 0.3 MB/token,8k 上下文一条请求就吃 ~2.5 GB。70B 权重 fp16 本身就 140GB(需多卡),剩下的显存被 KV Cache 瓜分——所以「能并发几个请求」本质是道显存除法题。更糟的是朴素实现给每个请求预留「最大长度」的连续显存,碎片化 + 过度预留浪费 60–80%。

PagedAttention 借操作系统虚拟内存的思路:把 KV Cache 切成固定大小的 block(页),逻辑连续、物理可不连续,用页表映射。按需分配、几乎零碎片,还能让相同前缀的请求共享同一批物理页(copy-on-write)。这把可用 batch size 翻了几倍,吞吐随之暴涨。

压缩 KV 的几条路(各有牺牲):
现实案例:

3. Prompt Caching + 前缀感知路由:把重复的 prefill 省掉

核心 trade-off:多数请求共享长前缀(system prompt、few-shot、RAG 文档)——缓存这段前缀的 KV,能把昂贵的 prefill 算力和 TTFT 一起省掉,代价是路由必须「亲和」。

原理:一个 agent 应用的 system prompt 可能 2k token,每个请求都重新 prefill 是纯浪费。Prefix caching 把前缀算出的 KV 留在显存(或下沉到 CPU/SSD),后续请求若前缀逐 token 相同,直接复用、跳过这段 prefill。但缓存在具体某块 GPU 的显存里——这就要求 前缀感知路由(prefix-aware routing):把前缀相同的请求路由到持有该缓存的节点,否则缓存形同虚设。这是 LLM 负载均衡区别于传统无状态 LB 的根本点——它是有状态的、亲和性的

# 前缀感知路由(pseudo):让相同前缀落到同一节点
def route(req):
    prefix_hash = hash(req.system_prompt + req.shared_context)
    node = consistent_hash_ring.get(prefix_hash)   # 前缀亲和
    if node.kv_load > HIGH_WATERMARK:              # 但要防热点
        node = least_loaded(replicas_of(prefix_hash))
    return node
# 工程要点:缓存命中省 prefill,但把 cache 写进 prompt 的「稳定前缀末尾」
# 才有用——前缀里任何一个 token(含时间戳)变了,hash 不匹配即全部失效
Trade-off:纯轮询 LB 简单但前缀缓存命中率近 0;纯前缀亲和命中率高但易形成热点(热门 system prompt 把单节点打爆)。生产里是「前缀亲和 + 负载阈值兜底」的混合,命中率与均衡之间动态平衡。
现实案例:

4. Cost / Latency / Quality 三角:架构师的总权衡

核心 trade-off:更强的模型质量更高但更慢更贵;这三者构成不可能三角,工程手段是在不牺牲质量的前提下,沿着「成本-延迟」边界往外推。

原理:你不可能同时拿到「最强模型 + 最低延迟 + 最低成本」。但有几招能把帕累托前沿往外推:

# Speculative decoding(pseudo):草稿猜、大模型验
def speculative_step(ctx):
    draft = small_model.generate(ctx, k=4)        # 便宜地猜 4 个 token
    logits = big_model.verify(ctx, draft)         # 一次前向并行验证 4 个
    accepted = longest_matching_prefix(draft, logits)  # 接受匹配前缀
    return accepted + [sample(logits[len(accepted)])]  # 至少前进 1 个
# 草稿越准,接受率越高、加速越大;草稿太弱则白算 → 草稿模型选型是关键
现实案例:

扩展与优化(增长后怎么办)

常见陷阱 + 面试问题

1. 只看吞吐不看延迟分布。 把 batch 调大吞吐很漂亮,但 TPOT 被拖慢、p99 TTFT 爆炸。面试要能说清「吞吐 vs 单请求延迟」是同一资源的两面,得按 SLO 定 batch 上限。
2. 以为 LLM 负载均衡 = 无状态轮询。 KV Cache 和 prefix cache 让节点变得有状态、有亲和性。轮询会让前缀缓存命中率归零。这是最常被追问的点。
3. 混淆 compute-bound 与 memory-bound。 prefill 缺算力、decode 缺带宽。优化手段完全不同(decode 靠加大 batch,prefill 靠切片/并行),答错暴露不懂底层。
4. 把 prompt 前缀写得不稳定。 在 system prompt 里塞当前时间戳、随机 ID,导致前缀哈希永不命中、缓存白建。缓存要放在「逐 token 稳定」的前缀末尾。
5. 用平均值定容量。 input/output 长度是长尾分布,几个超长上下文请求能吃光 KV 显存触发抢占(preemption)甚至 OOM。要按分布和 admission control 设计。

高频面试题:① 为什么 decode 阶段单请求 GPU 利用率极低,连续批处理如何救?② 估算 70B 模型 8k 上下文下单卡能并发多少请求。③ prefix-aware routing 怎么在「缓存命中率」和「负载均衡」间取舍?④ speculative decoding 为什么是无损加速,什么时候反而变慢?⑤ TTFT 与 TPOT 各受什么瓶颈支配,分别怎么优化?

深入资源

深入思考(点击展开答案)

1. 给定 8×H100(640GB 显存)、70B fp16 模型,估算理论并发请求数。瓶颈是算力还是显存?

显存除法:权重 70B×2B ≈ 140GB(张量并行分摊到 8 卡,但仍占总量 140GB)。激活、框架开销留 ~100GB,则 KV Cache 可用约 640−140−100 ≈ 400GB。70B GQA 下约 0.3MB/token,平均上下文(3k input + 渐增 output ≈ 4k)每请求 ~1.2GB → 约 300 个并发请求

瓶颈是显存不是算力:decode 是 memory-bandwidth-bound,算力大量闲置;真正卡住「能并发几个」的是 KV Cache 显存。所以省显存(MQA、KV 量化、PagedAttention)比堆算力更能提并发。这也解释了为何 Character.AI 把 KV 缩 20× 后说「显存不再是瓶颈」——瓶颈被搬走了。注意这是数量级估算,实际受最大长度预留、抢占策略影响。

2. 连续批处理把 GPU 喂满了,为什么用户仍抱怨「打字打到一半卡一下」?

典型是 prefill 插入打断 decode。新请求到来要做 prefill(compute-bound,吃满算力几十~几百 ms),这一步会抢占正在 decode 的 batch,导致已有请求的 TPOT 突然飙高——用户看到流式输出卡顿一下。

解法:① Chunked prefill——把长 prompt 的 prefill 切成小块,与 decode 步交错,每步只插一点 prefill;② prefill/decode 解耦(DistServe)——干脆放不同 GPU 池,互不干扰;③ 调度优先级——给 decode 更高优先级保 TPOT。这题考的是「吞吐优化(连续批处理)和尾延迟(TPOT 毛刺)是两个目标,优化一个会伤另一个」。

3. prompt caching 命中率从 80% 掉到 40%,成本和 TTFT 会怎样变化?为什么不是线性的?

表面看:少省一半前缀 prefill。但实际放大效应更狠。成本:未命中的请求要重新 prefill,prefill 是 compute-bound 吃满算力——多出的 prefill 抢占了本可用于 decode 的算力,使整个集群有效吞吐下降,等于要加机器,成本非线性上升。

TTFT:命中时 TTFT 主要是网络+少量计算(百 ms 内);未命中要把 2k+ token 的前缀完整 prefill,TTFT 可能翻几倍。且 prefill 排队会互相挤压,p99 进一步恶化。

常见诱因:有人在 system prompt 里加了动态内容(时间戳/会话 ID),让前缀哈希失配——一行代码就能把命中率从 80% 砸到 10%。所以「前缀稳定性」是要被监控和守护的工程契约。

4. speculative decoding 号称无损 2–3× 加速,什么情况下反而拖慢、甚至降低整体吞吐?

单请求看是加速,集群看可能降吞吐,这是反直觉点。投机解码每步多跑一次草稿模型 + 大模型并行验证 k 个 token,额外消耗算力

  • 草稿不准(接受率低):草稿模型与大模型分布差异大时,多数猜测被拒,白白浪费验证算力,净效果变慢。
  • 大 batch 高负载场景:低负载时 GPU 算力闲置,投机用闲置算力换延迟很划算;但高负载、batch 已经很大时,decode 本就 compute 接近饱和,投机的额外算力没地方挤,反而降低总吞吐——它把闲置算力换延迟,没闲置算力时就是负收益

所以生产里常按负载动态开关投机解码:低峰开(省延迟),高峰关(保吞吐)。

5. 把第 2 期「缓存」的思维套到 LLM serving:KV Cache、prefix cache、传统 Redis 缓存,三者在「失效」「一致性」上有何本质不同?

KV Cache:它不是「可选加速」而是计算的必需中间态——丢了就得重算,没有「读旧值」的一致性问题,只有「放不下要驱逐/抢占」的容量问题。失效=请求结束即回收。最像 CPU 寄存器/工作内存,不像数据缓存。

Prefix cache:更接近传统缓存——可复用、可命中可不命中、命中省算力。但它的「key」是逐 token 的前缀序列,且只要前缀完全相同结果必然相同(确定性 prefill),不存在 stale 问题——这点比 Redis 缓存简单,没有「先写 DB 还是先删 cache」的难题。它的难题在路由亲和(缓存绑在具体 GPU 上)和容量驱逐

Redis 数据缓存:源数据会变,核心难题是失效与一致性(第 2 期讲的 thundering herd、双删、最终一致窗口)。

本质区别:LLM 的两种 cache 因「相同输入→确定输出」而免疫一致性问题,难题搬到了「显存容量」和「有状态路由」;传统缓存的难题在「源数据会变」。同样叫 cache,约束维度完全不同。