这篇文章写给做过线上推理服务的人你可能已经把模型跑起来了也知道“开缓存”“开 batch”“上 vLLM/SGlang”这些词但真到线上你会发现吞吐提升了P99 延迟炸了KV cache 开了显存不够了batch 大了排队时间把 decode 省下来的全吃回去模型换成更大/更小系统瓶颈位置完全变了。我想把这些“工程里真的会遇到的 tradeoff”讲清楚并给一套可以直接复制的压测指标口径。0. 先把问题说清楚你在优化的到底是什么我见过很多团队一上来就说“要提升 QPS”然后开始堆机器、调 batch、换框架。最后上线一周平均延迟看起来还行P95/P99偶发尖刺用户抱怨“有时候特别慢”成本居高不下原因是LLM 推理服务通常要同时满足三个目标吞吐Throughput单位时间能处理多少 token / request延迟Latency尤其是 TTFTTime To First Token和 P99成本Cost显存/显卡利用率、单位 token 成本它们是一个三角形动一个角另两个角经常会变形。本文会用统一的指标口径来讲TTFT从请求到第一个 token 输出TPOTTime Per Output Token每个输出 token 的平均耗时不含排队Queue Wait排队等待调度的时间batching 的副作用通常在这里Tokens/s吞吐每秒输出 token 数1. 推理的两个阶段Prefill vs Decode别混着优化LLM 推理可以粗暴拆成Prefill又叫 prompt 处理把输入 prompt 喂进去构建 KV cacheDecode逐 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 dimdtypefp16/bf16/int8 量化方案2.2 一个可以直接用的 KV cache 估算脚本真实代码下面这段 Python 代码可以粗估 KV cache 占用偏保守你可以拿去给容量评审用。# kv_estimate.pyfromdataclassesimportdataclassdataclassclassModelCfg:num_layers:intnum_kv_heads:inthead_dim:intdtype_bytes:int# fp16/bf16 2defestimate_kv_bytes(cfg:ModelCfg,batch:int,seq_len:int)-int:# 每层 KVK 和 V 各一份# shape ~ (batch, num_kv_heads, seq_len, head_dim)per_layer2*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]:ifn1024:returnf{n:.2f}{unit}n/1024returnf{n:.2f}PBif__name____main__:# 以一个常见 7B-13B 量级的配置举例请按你的模型改cfgModelCfg(num_layers32,num_kv_heads32,head_dim128,dtype_bytes2)forbatchin[1,4,8,16]:forseqin[512,2048,8192]:kvestimate_kv_bytes(cfg,batchbatch,seq_lenseq)print(fbatch{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 batchingvLLM、SGLang 等框架里常见是GPU 一直在跑新请求可以插进来旧请求生成完就退出它的价值是在不牺牲太多吞吐的情况下显著改善 TTFT 和 P99。3.2 一个最小可用的“队列 批处理”模拟真实代码这段代码不是为了精准模拟 GPU而是让你在白板上解释清楚为什么 batch 会拖慢 P99。# batch_queue_sim.pyimportrandomdefsimulate(arrival_rate,service_ms,batch_size,duration_s10):Rough simulation: Poisson arrival batch service.now0.0endduration_s*1000queue[]latencies[]whilenowend:inter_arrivalrandom.expovariate(arrival_rate/1000.0)# msnowinter_arrival queue.append(now)whilelen(queue)batch_size:batch[queue.pop(0)for_inrange(batch_size)]startmax(now,batch[0])finishstartservice_msfortinbatch:latencies.append(finish-t)nowfinishifnotlatencies:returnNonelatencies.sort()p50latencies[int(0.50*len(latencies))]p95latencies[int(0.95*len(latencies))]p99latencies[int(0.99*len(latencies))]returnp50,p95,p99,len(latencies)if__name____main__:random.seed(42)forbsin[1,2,4,8,16]:rsimulate(arrival_rate50,service_ms40,batch_sizebs)print(fbatch{bs:2}-{r})你会看到一个趋势batch 越大吞吐上去了但尾延迟会被排队拉长。工程上真正要做的是给 batching 一个最大等待时间max wait / batching window给交互式请求更高优先级例如 chat vs batch job4. 你应该如何压测别只看 QPS至少看这 6 个指标一个可落地的压测方式是用一个脚本同时输出request/stokens/sTTFTTPOTP95/P99GPU 利用率sm%、mem%、显存占用4.1 一个可直接跑的压测客户端Python httpx假设你的服务是一个 OpenAI-compatible 的/v1/chat/completions支持streamtrue。# loadgen.pyimportasyncioimporttimeimportjsonimportstatisticsimporthttpx API_URLhttp://127.0.0.1:8000/v1/chat/completionsMODELyour-modelPROMPT你是一个严谨的工程师。请用 3 点总结 continuous batching 的优缺点并给出一个线上调参建议。defnow_ms():returntime.time()*1000asyncdefone(client:httpx.AsyncClient,max_tokens256):t0now_ms()ttftNoneout_tokens0payload{model:MODEL,stream:True,max_tokens:max_tokens,messages:[{role:user,content:PROMPT}],}asyncwithclient.stream(POST,API_URL,jsonpayload,timeout120)asr:r.raise_for_status()asyncforlineinr.aiter_lines():ifnotline:continueifline.startswith(data: ):dataline[len(data: ):]ifdata[DONE]:breakobjjson.loads(data)deltaobj[choices][0][delta].get(content)ifdeltaisnotNone:ifttftisNone:ttftnow_ms()-t0# rough token estimate by chars; replace with tokenizer in prodout_tokensmax(1,len(delta)//4)t1now_ms()totalt1-t0returnttftortotal,total,out_tokensasyncdefmain(concurrency10,seconds30):ttfts,totals,toks[],[],[]asyncwithhttpx.AsyncClient()asclient:starttime.time()asyncdefworker():whiletime.time()-startseconds:ttft,total,outawaitone(client)ttfts.append(ttft)totals.append(total)toks.append(out)awaitasyncio.gather(*[worker()for_inrange(concurrency)])defp(xs,q):xssorted(xs)returnxs[int(q*len(xs))]print(frequests{len(totals)})print(favg_total_ms{statistics.mean(totals):.1f}p95{p(totals,0.95):.1f}p99{p(totals,0.99):.1f})print(favg_ttft_ms {statistics.mean(ttfts):.1f}p95{p(ttfts,0.95):.1f}p99{p(ttfts,0.99):.1f})print(ftokens_total{sum(toks)}tokens/s{sum(toks)/seconds:.1f})if__name____main__:asyncio.run(main(concurrency20,seconds30))这份脚本的价值在于它会把 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 自动扩缩容你会发现推理性能这件事不再靠“某个同学经验很强”而是靠体系。