这篇文章写给做过线上推理服务的人:你可能已经把模型跑起来了,也知道“开缓存”“开 batch”“上 vLLM/SGlang”这些词;但真到线上你会发现:
- 吞吐提升了,P99 延迟炸了;
- KV cache 开了,显存不够了;
- batch 大了,排队时间把 decode 省下来的全吃回去;
- 模型换成更大/更小,系统瓶颈位置完全变了。
我想把这些“工程里真的会遇到的 tradeoff”讲清楚,并给一套可以直接复制的压测+指标口径。
0. 先把问题说清楚:你在优化的到底是什么?
我见过很多团队一上来就说“要提升 QPS”,然后开始堆机器、调 batch、换框架。最后上线一周:
- 平均延迟看起来还行
- P95/P99偶发尖刺
- 用户抱怨“有时候特别慢”
- 成本居高不下
原因是:LLM 推理服务通常要同时满足三个目标:
- 吞吐(Throughput):单位时间能处理多少 token / request
- 延迟(Latency):尤其是 TTFT(Time To First Token)和 P99
- 成本(Cost):显存/显卡利用率、单位 token 成本
它们是一个三角形:动一个角,另两个角经常会变形。
本文会用统一的指标口径来讲:
- TTFT:从请求到第一个 token 输出
- TPOT(Time Per Output Token):每个输出 token 的平均耗时(不含排队)
- Queue Wait:排队等待调度的时间(batching 的副作用通常在这里)
- Tokens/s:吞吐(每秒输出 token 数)
1. 推理的两个阶段:Prefill vs Decode(别混着优化)
LLM 推理可以粗暴拆成:
- Prefill(又叫 prompt 处理):把输入 prompt 喂进去,构建 KV cache
- Decode(逐 token 生成):每一步用 KV cache 继续生成下一个 token
在工程上这非常关键:
- Prefill 更像“大矩阵乘法”,吞吐通常高,延迟与 prompt 长度强相关
- Decode 更像“小步迭代”,每一步计算量小,但要做很多步,且更容易被调度/通信/内存访问拖慢
一个简单但很有效的做法:把压测拆成两类:
- 固定输出长度,扫输入长度(prefill 压测)
- 固定输入长度,扫输出长度(decode 压测)
你会很快看到瓶颈到底在哪。
2. KV Cache:它不是“开了就快”,而是“开了就占内存”
KV cache 的本质:把 attention 里历史 token 的 Key/Value 存下来,避免每一步重算。
2.1 工程上最常见的坑:显存被 KV 吃光
很多人看到“KV cache 加速”,就默认开到最大。然后出现两类问题:
- OOM:并发一上来就爆
- 吞吐下降:为了不 OOM,把 batch/并发压低,整体吞吐反而下降
你需要一个“显存预算”的概念:
显存 = 模型权重 + 激活/临时 buffer + KV cache
而 KV cache 与以下因素线性相关:
- 并发请求数(或同时 decode 的序列数)
- 上下文长度(prompt + 已生成 token)
- 层数、头数、head dim
- dtype(fp16/bf16/int8 量化方案)
2.2 一个可以直接用的 KV cache 估算脚本(真实代码)
下面这段 Python 代码可以粗估 KV cache 占用(偏保守),你可以拿去给容量评审用。
# kv_estimate.pyfromdataclassesimportdataclass@dataclassclassModelCfg:num_layers:intnum_kv_heads:inthead_dim:intdtype_bytes:int# fp16/bf16 = 2defestimate_kv_bytes(cfg:ModelCfg,batch:int,seq_len:int)->int:# 每层 KV:K 和 V 各一份# shape ~ (batch, num_kv_heads, seq_len, head_dim)per_layer=2*batch*cfg.num_kv_heads*seq_len*cfg.head_dim*cfg.dtype_bytesreturnper_layer*cfg.num_layersdefhuman(n:int)->str:forunitin['B','KB','MB','GB','TB']:ifn<1024:returnf"{n:.2f}{unit}"n/=1024returnf"{n:.2f}PB"if__name__=='__main__':# 以一个常见 7B-13B 量级的配置举例(请按你的模型改)cfg=ModelCfg(num_layers=32,num_kv_heads=32,head_dim=128,dtype_bytes=2)forbatchin[1,4,8,16]:forseqin[512,2048,8192]:kv=estimate_kv_bytes(cfg,batch=batch,seq_len=seq)print(f"batch={batch:>2}seq={seq:>5}-> KV ~{human(kv)}")你会发现:上下文长度从 2k 到 8k 是 4 倍,并发从 4 到 16 也是 4 倍,叠加就是 16 倍。
这就是为什么“我只是把 max_tokens 调大一点”可能会让线上直接炸。
3. Batching:吞吐的灵丹妙药,也是 P99 的头号杀手
batching 的逻辑很简单:把多个请求合并,让 GPU 一次算更多。
但线上最常见的问题是:
- 你 batch 越大,排队时间越长
- 你为了吞吐把 queue 拉长,TTFT 变差,用户感觉“卡”
3.1 Continuous Batching(连续批处理)为什么是关键
传统 batching 是“凑齐一批再算”,会导致等待。
Continuous batching(vLLM、SGLang 等框架里常见)是:
- GPU 一直在跑
- 新请求可以插进来
- 旧请求生成完就退出
它的价值是:在不牺牲太多吞吐的情况下,显著改善 TTFT 和 P99。
3.2 一个最小可用的“队列 + 批处理”模拟(真实代码)
这段代码不是为了精准模拟 GPU,而是让你在白板上解释清楚:为什么 batch 会拖慢 P99。
# batch_queue_sim.pyimportrandomdefsimulate(arrival_rate,service_ms,batch_size,duration_s=10):"""Rough simulation: Poisson arrival + batch service."""now=0.0end=duration_s*1000queue=[]latencies=[]whilenow<end:inter_arrival=random.expovariate(arrival_rate/1000.0)# msnow+=inter_arrival queue.append(now)whilelen(queue)>=batch_size:batch=[queue.pop(0)for_inrange(batch_size)]start=max(now,batch[0])finish=start+service_msfortinbatch:latencies.append(finish-t)now=finishifnotlatencies:returnNonelatencies.sort()p50=latencies[int(0.50*len(latencies))]p95=latencies[int(0.95*len(latencies))]p99=latencies[int(0.99*len(latencies))]returnp50,p95,p99,len(latencies)if__name__=='__main__':random.seed(42)forbsin[1,2,4,8,16]:r=simulate(arrival_rate=50,service_ms=40,batch_size=bs)print(f"batch={bs:>2}->{r}")你会看到一个趋势:batch 越大,吞吐上去了,但尾延迟会被排队拉长。
工程上真正要做的是:
- 给 batching 一个最大等待时间(max wait / batching window)
- 给交互式请求更高优先级(例如 chat vs batch job)
4. 你应该如何压测:别只看 QPS,至少看这 6 个指标
一个可落地的压测方式是:用一个脚本同时输出
- request/s
- tokens/s
- TTFT
- TPOT
- P95/P99
- GPU 利用率(sm%、mem%、显存占用)
4.1 一个可直接跑的压测客户端(Python + httpx)
假设你的服务是一个 OpenAI-compatible 的/v1/chat/completions,支持stream=true。
# loadgen.pyimportasyncioimporttimeimportjsonimportstatisticsimporthttpx API_URL="http://127.0.0.1:8000/v1/chat/completions"MODEL="your-model"PROMPT="""你是一个严谨的工程师。请用 3 点总结 continuous batching 的优缺点,并给出一个线上调参建议。"""defnow_ms():returntime.time()*1000asyncdefone(client:httpx.AsyncClient,max_tokens=256):t0=now_ms()ttft=Noneout_tokens=0payload={"model":MODEL,"stream":True,"max_tokens":max_tokens,"messages":[{"role":"user","content":PROMPT}],}asyncwithclient.stream("POST",API_URL,json=payload,timeout=120)asr:r.raise_for_status()asyncforlineinr.aiter_lines():ifnotline:continueifline.startswith("data: "):data=line[len("data: "):]ifdata=="[DONE]":breakobj=json.loads(data)delta=obj["choices"][0]["delta"].get("content")ifdeltaisnotNone:ifttftisNone:ttft=now_ms()-t0# rough token estimate by chars; replace with tokenizer in prodout_tokens+=max(1,len(delta)//4)t1=now_ms()total=t1-t0returnttftortotal,total,out_tokensasyncdefmain(concurrency=10,seconds=30):ttfts,totals,toks=[],[],[]asyncwithhttpx.AsyncClient()asclient:start=time.time()asyncdefworker():whiletime.time()-start<seconds:ttft,total,out=awaitone(client)ttfts.append(ttft)totals.append(total)toks.append(out)awaitasyncio.gather(*[worker()for_inrange(concurrency)])defp(xs,q):xs=sorted(xs)returnxs[int(q*len(xs))]print(f"requests={len(totals)}")print(f"avg_total_ms={statistics.mean(totals):.1f}p95={p(totals,0.95):.1f}p99={p(totals,0.99):.1f}")print(f"avg_ttft_ms ={statistics.mean(ttfts):.1f}p95={p(ttfts,0.95):.1f}p99={p(ttfts,0.99):.1f}")print(f"tokens_total={sum(toks)}tokens/s={sum(toks)/seconds:.1f}")if__name__=='__main__':asyncio.run(main(concurrency=20,seconds=30))这份脚本的价值在于:它会把 TTFT 单独拉出来,让你看到 batching/排队的真实代价。
5. vLLM / SGLang / TensorRT-LLM:工程选型时我会看什么
这里不做“文档复述”,我只说上线会遇到的点:
5.1 你的瓶颈是算力还是调度?
- 如果 GPU 算力吃满(SM 利用率高),但 tokens/s 仍不够:考虑量化、算子融合、TensorRT-LLM
- 如果 SM 利用率不高,但延迟大:多半是调度/queue/IO/CPU 端瓶颈,先把 batching 和服务架构理顺
5.2 KV 管理策略
- PagedAttention 这类方案能缓解碎片化,但不是免费午餐:会引入额外管理开销
- 对长上下文,prefix caching / prompt cache(复用系统 prompt / 业务模板)往往比“无脑扩显存”更划算
5.3 多租户/多模型
一个现实问题:线上不是只有一个模型。
- 多模型共享 GPU:调度更复杂,容易互相干扰
- 多模型分 GPU:资源更浪费,但稳定
我更倾向的策略是:
- 交互式主模型独占一组 GPU(保证 P99)
- 批处理/离线模型用另一组 GPU(吞吐优先)
- 需要弹性时,再做跨池迁移
6. 线上调优清单(我真正会按这个顺序做)
按优先级:
- 先把指标口径打通:TTFT、TPOT、queue wait、tokens/s
- 拆 prefill/decode:分别压测,不要用一个平均值糊弄
- 给 batching 加上上限:batching window + 最大并发
- 做显存预算:权重/kv/buffer,明确最大上下文与最大并发
- 把请求分类:交互式 vs 批处理,走不同队列/不同 GPU 池
- 再考虑框架/量化升级:否则你可能在错误的瓶颈上花 2 周
7. 结语:优化推理不是“换个框架”,是把系统当系统看
推理加速的本质是:
- 你在做一个有排队、有调度、有资源竞争的在线系统
- LLM 只是其中最贵、最显眼的那个组件
当你把 TTFT/TPOT/queue wait 拆开看,把 KV cache 当成显存预算的一部分,把 batching 当成排队系统的一部分,很多“玄学”就会变成可解释、可调参、可复现。
如果你愿意进一步做工程化:
- 把压测脚本接入 CI,做回归
- 把线上参数变更纳入变更流程
- 给 P99 配置 SLO + 自动扩缩容
你会发现:推理性能这件事,不再靠“某个同学经验很强”,而是靠体系。