
1. 从一次线上事故说起为什么推理工程值得单独拎出来讲去年冬天我参与维护的一个对话服务在凌晨两点突然大面积超时。模型本身没挂GPU利用率也正常但P99延迟从800毫秒一路飙到12秒。排查了三个小时才发现问题出在推理服务的批处理调度器上——一个上游业务方把请求的max_tokens从512悄悄改成了4096导致动态批处理里混进了超长序列整个batch被这一个请求拖垮。这件事让我彻底意识到一个被很多人忽略的事实训练大模型是科学推理大模型是工程。训练阶段你可以容忍一次跑三天、失败了重来但推理阶段每一个毫秒、每一分显存、每一次调度决策都直接对应着真金白银和用户体验。标题里的The engineering behind LLM inference说的就是这件事——不是讲Transformer的注意力公式怎么推导而是讲当你把一个几十GB的模型真正推到生产环境、面对真实流量时那些决定成败的工程细节。这篇文章适合三类人一是正在做LLM应用落地、被延迟和成本折磨的后端工程师二是想搞清楚推理服务内部到底发生了什么、不想只停留在调API层面的算法同学三是准备自建推理集群、需要做技术选型和容量规划的技术负责人。我会从推理的两个阶段讲起把KV Cache、批处理调度、量化、显存管理、并行策略这些核心工程点拆开揉碎配上我实际踩过的坑和可复现的参数计算。读完之后你应该能自己算出一个模型在你的硬件上大概能跑多快、能扛多少并发以及当延迟不达标时该往哪个方向调。需要先说明的是下面涉及的具体数字都基于常见开源模型和主流推理框架的典型表现不同硬件、不同框架版本会有差异但计算逻辑和排查思路是通用的。2. 推理的两个阶段prefill和decode的工程性格完全不同2.1 为什么同一个模型首token慢而后续token快很多人第一次做推理压测时会困惑为什么第一个token要等好几百毫秒甚至几秒而后面每个token只要几十毫秒这不是框架的bug而是因为LLM推理天然分成两个计算特征截然不同的阶段。Prefill阶段也叫预填充处理的是你输入的整段prompt。假设你输入了1000个token模型需要把这1000个token一次性送进网络计算出每一层的Key和Value并缓存起来。这个阶段是计算密集型的——所有token可以并行计算GPU的算力能被充分打满矩阵乘法的维度很大效率很高。但因为要处理的token数量多绝对耗时并不短。Decode阶段也叫解码是逐个生成输出token的过程。每生成一个新token模型都要基于之前所有的token包括prompt和已生成的输出做一次前向计算。这个阶段是内存带宽密集型的——每次只处理一个token矩阵乘法的规模很小GPU的算力根本用不满瓶颈在于把模型权重和KV Cache从显存里读出来的速度。用一个生活化的类比Prefill像是你一次性把一整本书读完并做了笔记脑子转得飞快但确实要花时间Decode像是你根据笔记一个字一个字地往外写每写一个字都要回头翻一遍笔记写得快不快取决于你翻笔记的速度内存带宽而不是你脑子转得多快算力。这个区别直接决定了优化方向。Prefill慢你要优化的是算力利用率和并行度Decode慢你要优化的是显存带宽和KV Cache的读取效率。搞混了这两个方向调优就是白费力气。2.2 算一笔账你的模型到底能跑多快在动手优化之前先学会估算理论上限这样你才知道自己离天花板还有多远。Decode阶段有一个非常实用的估算公式单次decode的理论耗时 ≈ 模型参数量 × 每参数字节数 / 显存带宽举个例子一个70亿参数7B的模型用FP16精度每参数2字节部署在一张显存带宽为2000 GB/s的卡上权重总量 7 × 10^9 × 2 字节 14 GB理论单token耗时 ≈ 14 GB / 2000 GB/s 7 毫秒理论最大吞吐 ≈ 1 / 0.007 ≈ 143 token/s单请求注意这是单请求、batch size为1的情况。如果做批处理多个请求共享同一份权重读取吞吐可以大幅提升但单请求延迟会略微增加。这就是为什么推理服务的吞吐和延迟永远是一对需要权衡的矛盾。再看Prefill。Prefill的耗时和输入长度近似成平方关系因为注意力机制是O(n²)所以输入长度翻倍Prefill耗时可能变成四倍。这就是为什么长上下文场景下首token延迟会爆炸式增长。实际中很多框架会用FlashAttention这类IO感知的注意力实现来缓解但平方复杂度这个本质改不了。我建议你在做容量规划时先用这个公式算出理论值再拿实际压测结果对比。如果实际值只有理论值的30%以下说明你的推理服务有严重的工程问题值得深挖如果已经到70%以上那基本接近硬件极限想再快只能换硬件或换更激进的量化方案。3. KV Cache推理服务里最值钱也最容易被浪费的显存3.1 KV Cache到底占多少显存怎么算KV Cache是自回归生成的核心数据结构。每生成一个token模型每一层都要缓存这个token对应的Key和Value向量供后续token计算注意力时使用。它的显存占用可以用这个公式估算KV Cache显存 2 × 层数 × 注意力头数 × 头维度 × 序列长度 × batch size × 精度字节数以一个典型的7B模型为例32层、32个注意力头、头维度128、FP16精度每token每层的KV 2 × 32 × 128 × 2 字节 16 KB每token所有层 16 KB × 32 512 KB一个2048长度的序列 512 KB × 2048 ≈ 1 GB如果batch size是16就是16 GB看到问题了吗模型权重才14 GBKV Cache在中等并发下就能吃掉16 GB。这就是为什么很多推理服务在并发上来之后突然OOM——不是模型放不下是KV Cache把显存挤爆了。3.2 PagedAttention解决了什么又带来了什么新问题vLLM之所以能成为主流推理框架核心贡献就是PagedAttention。它借鉴了操作系统的虚拟内存分页思想把KV Cache切成固定大小的block比如16个token一块不再要求每个序列占用连续的显存空间。这个设计解决了两个老大难问题。第一是内存碎片传统方案给每个请求预分配最大长度的连续显存实际用不满就浪费了PagedAttention按需分配block浪费率从60%以上降到4%以下。第二是共享前缀多个请求如果有相同的system prompt这些前缀的KV block可以物理共享不用每个请求都存一份。但PagedAttention不是银弹。它引入了block管理的开销在极短序列、超高并发的场景下调度器的CPU开销可能反而成为瓶颈。我实测过一个场景请求平均长度只有30个token、QPS上千这时候block分配和回收的锁竞争非常明显把block_size从16调到32反而提升了吞吐。所以参数没有绝对最优要看你的流量特征。还有一个容易被忽略的点KV Cache的精度。很多人只量化模型权重忘了KV Cache也可以量化。把KV Cache从FP16降到INT8显存直接减半能多扛一倍的并发而质量损失在大多数任务上几乎感知不到。这个优化性价比极高但需要框架支持vLLM、TensorRT-LLM都有相关选项。3.3 前缀缓存一个被低估的吞吐利器如果你的应用有固定的system prompt或者多轮对话里历史消息会被反复送进来前缀缓存prefix caching能带来惊人的收益。原理很简单相同的前缀只做一次Prefill后续请求直接复用缓存的KV block跳过Prefill阶段。我做过一个多轮客服场景的测试system prompt有800个token平均对话5轮。开启前缀缓存后Prefill的计算量下降了约70%首token延迟从1.2秒降到350毫秒吞吐提升了近3倍。这个优化的前提是你的框架支持并且请求之间确实有可复用的前缀。注意前缀缓存对显存的要求更高因为要长期保留这些共享block。如果你的显存本来就紧张开启前要算清楚账别为了省算力把显存撑爆了。4. 批处理调度吞吐和延迟之间的那根钢丝4.1 静态批处理为什么在生产环境基本不能用最早的推理服务用的是静态批处理攒够N个请求或者等够T毫秒打包成一个batch一起送进GPU等这个batch全部生成完再返回。这个方案在离线批量任务里没问题但在线服务里是灾难。原因在于生成长度不可预测。同一个batch里有的请求生成10个token就结束了有的要生成500个。静态批处理必须等最长的那个生成完短请求的GPU时间被白白浪费而且用户要一直等到batch结束才能拿到结果。GPU利用率看起来很高实际有效吞吐很低。4.2 连续批处理vLLM们的核心武器连续批处理continuous batching也叫in-flight batching的思路是不等整个batch结束任何一个序列生成完就立刻把它踢出batch腾出的位置马上塞进新请求。这样GPU几乎永远在满负荷工作新请求也不用等前面的batch跑完。这个机制对吞吐的提升是数量级的。我在一个7B模型上做过对比同样的硬件和流量静态批处理吞吐约400 token/s连续批处理能到1800 token/s提升4倍多。代价是调度逻辑复杂了很多框架需要精细管理每个序列的状态、KV block的分配回收、以及batch的重新组装。连续批处理也有它的脾气。当请求长度差异极大时比如有的10个token、有的4000个token长请求会长期占用batch位置短请求不断进出导致长请求的延迟被反复打断。这时候可以考虑chunked prefill——把长请求的Prefill切成小块和Decode请求混在一起调度避免一个超长Prefill阻塞所有Decode。这个特性在较新的vLLM版本里已经支持对混合负载场景效果明显。4.3 调度参数怎么调几个我踩过的坑调度相关的参数看着不起眼调错了性能差一倍。分享几个实际经验max_num_seqs最大并发序列数这个值不是越大越好。设太大KV Cache不够用会触发抢占preemption反而导致请求被反复换入换出延迟飙升。我的经验是从小往大试观察显存占用和抢占次数找到那个显存用到85%左右、抢占接近0的甜点值。max_num_batched_tokens单批最大token数控制一个batch里Prefill和Decode加起来能处理多少token。设太小Prefill被切得太碎算力利用率低设太大单个batch耗时过长Decode请求的延迟抖动明显。一般建议设成max_num_seqs的2到4倍。抢占策略当显存不够时框架要决定牺牲谁。默认的recompute策略是把被抢占序列的KV Cache丢掉等它重新被调度时再重算一遍Prefill代价是额外的计算。swap策略是把KV Cache换到CPU内存代价是PCIe带宽。在显存紧张但CPU内存充裕的场景swap往往更划算反之用recompute。这些参数没有万能配置必须结合你的流量特征压测。我一般会准备一套压测脚本模拟真实流量的长度分布然后网格搜索这几个参数记录吞吐、P99延迟、抢占次数三个指标选综合最优的组合。5. 量化用精度换显存和速度但别换过头5.1 权重量化的几个档位和实际收益量化是推理工程里最直接的降本手段。核心思路是用更少的比特表示模型权重减少显存占用和内存带宽压力。常见的档位精度每参数字节7B模型权重相对FP16质量适用场景FP16214 GB基准质量要求极高INT817 GB几乎无损通用推荐INT4 (GPTQ/AWQ)0.53.5 GB轻微损失显存受限INT4 (GGUF Q4)~0.55~4 GB轻微损失本地/边缘部署从FP16到INT8显存减半、Decode速度理论上翻倍因为瓶颈是内存带宽质量损失在绝大多数任务上测不出来。从INT8到INT4显存再减半但质量开始有可感知的下降尤其在数学推理、代码生成这类对精度敏感的任务上。我的建议是优先用INT8除非显存实在不够再上INT4。INT4的量化方法里AWQ和GPTQ是目前比较成熟的AWQ对激活值敏感的通道做保护通常比GPTQ略好一点但差距不大。GGUF格式主要用于本地部署场景它的Q4_K_M这类混合量化在质量和体积之间平衡得不错。5.2 量化不是免费的午餐那些质量下降的隐蔽场景量化带来的质量损失往往不是均匀分布的它在某些场景下会突然放大。我遇到过几个典型的翻车场景长链推理让模型做多步数学推导INT4量化后中间步骤容易出错一步错步步错最终答案错误率明显上升。而单步问答几乎看不出差异。结构化输出要求模型输出严格的JSON量化后偶尔会漏掉引号或括号导致解析失败。这类任务对token级别的精确性要求高量化误差容易被放大。多语言低资源语言的表示在量化后更容易退化因为量化误差对训练不充分的语言影响更大。所以量化之后一定要做任务级别的评测不能只看困惑度perplexity。困惑度涨一点点在你的具体任务上可能就是可用和不可用的区别。我一般会准备一个几百条的真实业务测试集量化前后各跑一遍对比准确率和格式合规率。5.3 KV Cache量化被忽视的显存大户前面提过KV Cache的显存占用这里再强调一下它的量化价值。模型权重量化省的是固定的那部分显存而KV Cache量化省的是随并发和长度增长的那部分在高并发场景下收益更大。把KV Cache从FP16降到INT8显存减半意味着同样硬件能扛的并发翻倍。质量方面因为KV Cache存的是中间激活量化误差对最终输出的影响通常比权重量化更小。主流框架里vLLM通过kv_cache_dtypefp8这类选项支持TensorRT-LLM也有对应配置。注意KV Cache量化对某些模型架构的兼容性有差异开启后务必做回归测试确认没有出现输出重复、乱码等异常。6. 并行策略与显存管理单卡放不下之后怎么办6.1 张量并行和流水线并行的取舍当模型大到单卡放不下就得上多卡。两条主流路线张量并行TP把每一层的权重矩阵按维度切分到多张卡上每张卡算一部分然后通过集合通信all-reduce汇总结果。优点是单请求延迟低因为所有卡并行算同一层缺点是通信量大对卡间带宽要求高一般要求NVLink这类高速互联。TP适合延迟敏感的场景通常TP不超过8再大通信开销就压不住了。流水线并行PP把模型的不同层分到不同卡上数据像流水线一样依次流过。优点是通信量小对带宽要求低缺点是会有流水线气泡bubble单请求延迟高因为要等前面所有阶段算完。PP适合吞吐优先、对延迟不敏感的场景。实际部署里经常是TP和PP混合。比如一个70B模型8卡部署可以用TP4、PP2。具体怎么切要看你的卡间互联拓扑和流量特征。我的经验是能TP就TPTP搞不定再上PP因为PP的调度复杂度和延迟代价都更高。6.2 显存都去哪了一份实用的显存账单排查OOM时你需要清楚显存被谁吃了。一份典型的推理服务显存账单模型权重参数量 × 精度字节数固定开销KV Cache随并发和序列长度动态增长通常是最大变量激活值Prefill阶段临时占用和batch内token数相关框架开销CUDA context、通信buffer、临时workspace通常1到3 GB碎片显存分配器产生的碎片PagedAttention能大幅缓解但无法完全消除排查时我一般用nvidia-smi看总量用框架自带的显存分析工具vLLM有--gpu-memory-utilization参数控制预分配比例看细分。如果KV Cache占比超过60%说明并发压力大考虑量化KV Cache或加卡如果激活值峰值很高说明Prefill batch太大调小max_num_batched_tokens。6.3 一个真实的容量规划案例假设你要部署一个13B模型FP16精度目标支持100并发、平均输入500 token、平均输出200 token、P99首token延迟低于2秒。硬件是单张80GB显存的卡。先算显存权重26 GBKV Cache每token每层 2 × 40层 × 40头 × 128维 × 2字节 1.6 MB每token所有层1.6 MB一个700长度500输入200输出的序列约1.1 GB100并发就是110 GB——单卡根本放不下。所以必须量化。权重降到INT8是13 GBKV Cache降到INT8是55 GB加起来68 GB加上框架开销勉强能塞进80 GB但余量很小P99延迟可能因为抢占而超标。更稳妥的方案是INT8权重 INT4 KV Cache或者干脆上两张卡做TP2。这个计算过程就是容量规划的核心先算显存能不能放下再算带宽能不能满足延迟最后压测验证。三步缺一不可光靠拍脑袋选配置上线必翻车。7. 那些文档里不会写的排查经验7.1 延迟突然劣化先看这三个地方线上推理服务延迟劣化按这个顺序排查效率最高第一看请求长度分布有没有变化。前面说过Prefill是平方复杂度如果上游突然开始传长prompt延迟会非线性上升。我遇到的那次事故就是max_tokens被改大导致的。查一下最近的请求长度P50、P99有没有跳变。第二看KV Cache占用和抢占次数。如果抢占次数突然上升说明显存不够了要么是并发涨了要么是序列变长了。这时候要么限流要么加卡要么调小max_num_seqs。第三看batch的实际组成。用框架的metrics看每个batch里Prefill和Decode的比例。如果Prefill占比过高说明长请求在阻塞短请求考虑开chunked prefill。这三步能覆盖80%的延迟问题。剩下的20%可能是硬件故障、网络抖动、上游超时重试风暴等需要结合监控大盘综合判断。7.2 吞吐上不去别只盯着GPU很多人以为吞吐瓶颈一定在GPU其实CPU侧的调度开销经常是隐形杀手。特别是高QPS、短序列的场景tokenizer、请求排队、block管理、结果组装这些CPU操作可能比GPU计算还耗时。判断方法很简单用py-spy或框架自带的profiler看CPU时间花在哪。如果发现调度器或tokenizer占用大量CPU可以考虑把tokenizer换成更快的实现比如基于Rust的tokenizers库、增加调度线程数、或者用多进程分担CPU负载。还有一个常见坑是Python GIL。如果你的推理服务用Python写且调度逻辑是纯Python的GIL会成为并发瓶颈。vLLM这类框架把核心调度用C/CUDA实现就是为了绕开这个限制。自研服务的话要么用异步IO要么把热点逻辑下沉到C扩展。7.3 关于框架选型的一点个人看法vLLM、TensorRT-LLM、SGLang、LMDeploy这几个主流框架我都用过。简单说下适用场景vLLM生态最活跃PagedAttention和连续批处理成熟上手快适合大多数通用场景。缺点是极致性能调优空间不如TensorRT-LLM。TensorRT-LLMNVIDIA官方性能天花板最高但编译流程复杂模型支持需要转换适合对性能极致追求且有工程团队维护的场景。SGLangRadixAttention对前缀共享做了深度优化多轮对话和few-shot场景优势明显调度器设计也很优雅。LMDeploy国产框架对国产硬件和中文场景支持好部署轻量。选型没有标准答案关键看你的团队技术栈、硬件环境和流量特征。我的建议是先用vLLM快速跑通遇到性能瓶颈再针对性换框架或做深度优化。别一上来就追求极致性能把业务跑起来、把监控建起来比什么都重要。8. 写在最后的一点体会做LLM推理工程这两年我最大的感受是这个领域的知识更新太快但底层原理变化很慢。框架版本一个月一更新参数名字换来换去但Prefill和Decode的计算特征、KV Cache的显存公式、批处理的调度逻辑这些核心东西从Transformer诞生到现在没怎么变过。所以与其追着每个框架的新特性跑不如把基础算清楚。能自己估算显存、能自己算理论延迟、能看懂框架的调度日志这三点做到了换任何框架你都能快速上手。我见过太多人遇到问题就到处问参数怎么调其实只要理解了背后的原理参数该往哪个方向调是显而易见的。最后分享一个习惯我会给每个部署的推理服务建一个性能基线文档记录硬件配置、模型版本、框架版本、关键参数、以及标准压测下的吞吐和延迟数据。每次升级框架或改参数都跑一遍同样的压测对比。这样一旦性能劣化能立刻定位是哪个变更引入的。这个习惯帮我省了无数次深夜排查的时间推荐你也试试。