为一个 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 显存。
核心 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,结束的请求立刻让位、排队的请求立刻补进来。
# 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 立刻回收
核心 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 翻了几倍,吞吐随之暴涨。
核心 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:更强的模型质量更高但更慢更贵;这三者构成不可能三角,工程手段是在不牺牲质量的前提下,沿着「成本-延迟」边界往外推。
原理:你不可能同时拿到「最强模型 + 最低延迟 + 最低成本」。但有几招能把帕累托前沿往外推:
# 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 个
# 草稿越准,接受率越高、加速越大;草稿太弱则白算 → 草稿模型选型是关键
高频面试题:① 为什么 decode 阶段单请求 GPU 利用率极低,连续批处理如何救?② 估算 70B 模型 8k 上下文下单卡能并发多少请求。③ prefix-aware routing 怎么在「缓存命中率」和「负载均衡」间取舍?④ speculative decoding 为什么是无损加速,什么时候反而变慢?⑤ TTFT 与 TPOT 各受什么瓶颈支配,分别怎么优化?
显存除法:权重 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× 后说「显存不再是瓶颈」——瓶颈被搬走了。注意这是数量级估算,实际受最大长度预留、抢占策略影响。
典型是 prefill 插入打断 decode。新请求到来要做 prefill(compute-bound,吃满算力几十~几百 ms),这一步会抢占正在 decode 的 batch,导致已有请求的 TPOT 突然飙高——用户看到流式输出卡顿一下。
解法:① Chunked prefill——把长 prompt 的 prefill 切成小块,与 decode 步交错,每步只插一点 prefill;② prefill/decode 解耦(DistServe)——干脆放不同 GPU 池,互不干扰;③ 调度优先级——给 decode 更高优先级保 TPOT。这题考的是「吞吐优化(连续批处理)和尾延迟(TPOT 毛刺)是两个目标,优化一个会伤另一个」。
表面看:少省一半前缀 prefill。但实际放大效应更狠。成本:未命中的请求要重新 prefill,prefill 是 compute-bound 吃满算力——多出的 prefill 抢占了本可用于 decode 的算力,使整个集群有效吞吐下降,等于要加机器,成本非线性上升。
TTFT:命中时 TTFT 主要是网络+少量计算(百 ms 内);未命中要把 2k+ token 的前缀完整 prefill,TTFT 可能翻几倍。且 prefill 排队会互相挤压,p99 进一步恶化。
常见诱因:有人在 system prompt 里加了动态内容(时间戳/会话 ID),让前缀哈希失配——一行代码就能把命中率从 80% 砸到 10%。所以「前缀稳定性」是要被监控和守护的工程契约。
单请求看是加速,集群看可能降吞吐,这是反直觉点。投机解码每步多跑一次草稿模型 + 大模型并行验证 k 个 token,额外消耗算力。
所以生产里常按负载动态开关投机解码:低峰开(省延迟),高峰关(保吞吐)。
KV Cache:它不是「可选加速」而是计算的必需中间态——丢了就得重算,没有「读旧值」的一致性问题,只有「放不下要驱逐/抢占」的容量问题。失效=请求结束即回收。最像 CPU 寄存器/工作内存,不像数据缓存。
Prefix cache:更接近传统缓存——可复用、可命中可不命中、命中省算力。但它的「key」是逐 token 的前缀序列,且只要前缀完全相同结果必然相同(确定性 prefill),不存在 stale 问题——这点比 Redis 缓存简单,没有「先写 DB 还是先删 cache」的难题。它的难题在路由亲和(缓存绑在具体 GPU 上)和容量驱逐。
Redis 数据缓存:源数据会变,核心难题是失效与一致性(第 2 期讲的 thundering herd、双删、最终一致窗口)。
本质区别:LLM 的两种 cache 因「相同输入→确定输出」而免疫一致性问题,难题搬到了「显存容量」和「有状态路由」;传统缓存的难题在「源数据会变」。同样叫 cache,约束维度完全不同。