尧图精选

大模型推理性能优化实战:从延迟根因到动态批处理与KV Cache调优

🕒 发布时间:2026/10/1 6:17:30 📁 来源:尧图网络
如果你负责过一个已经上线的大模型推理服务你大概率经历过这种周末群里告警一条接一条线上服务的P95延迟从平时的300毫秒一路涨到2.6秒用户已经在评论区骂娘了。你第一反应是扩GPU实例结果扩完发现吞吐没涨多少账单倒是先翻了一截。这种事情我过去两年遇到过不下五次最后想明白一个事儿AI系统性能工程到了推理阶段拼的早就不是谁家卡多而是谁更懂怎么把一张卡用透。这篇是系列的第三篇。前两篇我们搭好了性能工程的整体分析方法论也集中解决过训练侧的单卡利用率问题。今天重心整个放到推理侧这是绝大多数线上AI服务真正的主战场。文章会涉及线上延迟波动的根因定位、动态批处理和KV Cache的资源权衡、调度策略的选择、压测基线的建设最后是我在生产环境里踩过的几个不算冷门但特别容易忽略的坑。不管你是做推理服务开发、平台运维还是AI应用的后端这里应该都有可以直接抄走的经验。1. 为什么推理性能问题比训练性能问题更考验工程能力1.1 训练管预算推理管口碑很多团队对性能优化的第一反应都是盯训练GPU利用率是不是拉满了分布式通信有没有瓶颈等等。这些当然重要但训练侧的问题通常有个缓冲——任务挂了可以重跑跑慢了顶多晚点出模型影响的是迭代效率和成本。推理侧完全不是这个节奏一个已经上线的服务延迟升高、超时变多、并发一上来就雪崩伤害的是正在用产品的每一个用户。模型能力再好推不出去就是零。更关键的一点是训练任务通常是离线、批量、可重试的性能优化有足够的时间窗口去调参、换卡、改通信策略。推理服务是实时的请求在几十毫秒到几秒内就要返回你没法跟用户说“再等等我调一下批处理策略”。这就导致推理性能工程的约束条件多得多延迟、吞吐、显存、成本、稳定性全部纠缠在一起动一个参数可能伤害另一个指标。1.2 一条请求的完整生命周期也是性能账单的分布图大多数团队排查推理性能问题时会习惯性地把目光放在“GPU算得够不够快”上。但以我的经验一条请求从进入系统到返回时间消耗根本不是均匀分布在某一个环节。一次典型的LLM推理调用大体会经过这么几个阶段接入层和API网关鉴权、限流、负载均衡这里主要看网络和进程模型。预处理prompt模板填充、tokenizer编码、多模态场景的图像/音频预处理这个环节经常被低估。排队等待请求进入推理引擎后要等前面的请求跑完才能被调度这里是最容易被忽略的性能黑洞。模型推理整个自回归生成过程又细分为prefill阶段处理输入token和decode阶段逐token生成输出。后处理与返回解码结果、流式协议的封装、网络回传。我见过太多人盯着GPU利用率做优化最后发现P99延迟高得离谱的原因是请求在排队队列里干等。GPU利用率高和GPU在为用户服务这两件事之间不是等号。推理性能工程的第一个基本功是把请求的完整链路画出来在每个环节埋点知道每一毫秒花在了哪里。1.3 延迟必须拆成两个指标看TTFT和TPOT聊LLM推理延迟的时候只说“平均延迟”没有意义。大模型是流式输出的用户感知到的响应速度由两个不同阶段共同决定TTFTTime To First Token用户发出请求到收到第一个token的时间。这个指标直接决定“诶这个东西是不是卡死了”的初体验。TPOTTime Per Output Token生成后续每个token的平均耗时也可以换算成每秒输出多少token。这个指标决定用户等完第一个字之后后面的内容是不是一卡一卡地蹦出来。它们背后的硬件瓶颈完全不一样。TTFT主要受prefill阶段的计算量和排队时间影响属于计算密集和带宽密集混合的场景TPOT则几乎完全被decode阶段的访存带宽和KV Cache读写速度主导。把这两个指标混在一起看平均值很多问题都会被糊弄过去。后面讲到的调度策略、批处理设计本质上都是在为这两个指标做取舍。2. 一次P95告警的完整排查链路从入口日志查到CUDA内核2.1 表面现象流量没涨延迟却翻了三倍有段时间我们一个在线问答服务的P95延迟突然从400ms涨到1.2sP99更是冲到了3s。第一反应是看流量图结果总QPS和前一周几乎持平GPU的利用率也没见升高监控面板上甚至一切正常。这种“一切正常但又确实变卡了”的局面最磨人。我们当时的排查顺序是这样看网关日志确认不是某条业务线流量突增也排除了网络层面的丢包。看推理引擎自己的指标比如队列深度、运行中的请求数、排队中的请求数。用nvidia-smi和dcgm采集GPU利用率、显存、SM占用率、HBM带宽。对模型进程做性能剖析比如用py-spy dump Python调用栈看时间到底耗在哪个函数。2.2 关键证据GPU利用率波动剧烈显存碎片率上升那次的突破口在推理引擎的指标里。正常时候GPU上同时运行的请求数稳定在40左右但故障期间这个数字变成了一条剧烈锯齿线一会儿冲到45一会儿掉到10。排队请求数从个位数变成了上百。GPU的利用率在0%和98%之间反复横跳每个周期大概几秒钟。这个模式太典型了。说明推理引擎的调度器在频繁地创建大batch、跑完、释放、再创建而不是在一个稳定的batch上持续生成。结合显存监控看到碎片率也在缓慢升高我们基本锁定了问题方向某个上游业务做了一个改变让用户的输入长度分布产生了变化出现了一批特别长上下文的请求。这些请求占用了大量KV Cache导致后续请求放不进去调度器被迫反复拆批。2.3 真凶是decode阶段的访存瓶颈被动态批次放大了后来我们拉出同一时刻的CUDA内核分析才彻底确认。decode阶段每次生成一个token都要把模型权重从HBM里读一遍。这个阶段是访存密集的SM的算力根本没吃饱瓶颈在显存带宽。当调度器把并发batch从40压到10的时候理论吞吐直接掉了四分之三。而排队中的请求越积越多新来的请求TTFT被拉长到不可接受形成了我们看到的延迟雪崩。这次排障给我们留下的方法论很有价值推理性能问题不能只看“GPU忙不忙”要看“GPU在怎么忙”。是算力打满还是带宽打满是稳定打满还是周期性打满队列是在变长还是在变短这四句话问完80%的推理性能事故都能定位到根因。3. 动态批处理、KV Cache和量化推理优化的三板斧3.1 连续批处理Continuous Batching为什么是推理引擎的标配先明确一个常识LLM推理和传统深度学习推理不一样每个请求生成结果的速度天然不同。有些请求输出短几秒钟就结束有些请求输出长可能要跑一分钟。如果按传统静态batching的方式一个batch里所有请求必须等最慢的那个处理完了才能一起释放短请求的用户体验会被长请求拖累GPU也在反复等待中产生大量气泡。连续批处理Continuous Batching的思路是把这个等待粒度从“请求级”缩小到“迭代级”每一步推理结束时处理完的请求直接从batch里退出新请求立刻从队列里补进来。相当于把一个大batch拆成了动态的工作流每跑完一个step就重新编排一次队伍。这套机制最早在Orca那篇论文里提出来现在主流的vLLM、TensorRT-LLM、TGI都已经内置支持。我个人的经验是只要你的推理引擎支持连续批处理最好优先用起来。它往往不需要改任何业务代码就能在相同硬件上把吞吐提升一倍以上。但也别以为换了新引擎就万事大吉了连续批处理是“充分条件”而不是“充要条件”它只解决了调度效率问题显存规划里的KV Cache仍然是你的头号敌人。3.2 KV Cache到底吃多少显存我建议你亲手算一次自回归模型每次生成新token时都需要把历史token的Key和Value再算一遍。为了避免重复计算引擎会把之前算好的K和V缓存在显存里这就是KV Cache。它的大小不随模型权重而变而是跟着并发数和上下文长度走。这个特性让很多人栽了跟头模型权重明明没多大并发一上去显存就爆了。KV Cache的估算公式不复杂我拿一个GQA结构、32层、kv_heads为4、head_dim为128的7B模型举例每个token的KV Cache 2 × 层数 × kv维度 × 字节数 2 × 32 × (4 × 128) × 2B 64KB单条4K上下文的请求 4096 × 64KB 256MB32路并发时KV Cache总占用 32 × 256MB 8GB看到问题没有一个7B模型的FP16权重才14GB左右你稍微把并发拉高一点KV Cache就能吃掉和权重一个量级的显存。而真实生产里上下文长度往往不只是4K如果你的业务经常出现32K甚至128K的输入KV Cache的占用还会线性增长最后变成“模型还没算显存先满了”。为了控制这里的内存碎片vLLM提出的PagedAttention把KV Cache按固定大小的页block管理类似操作系统里的分页存储内存碎片率明显下降。如果你的服务并发波动大引擎默认给的block大小又不合适试着调整一下页大小和预分配比例经常能意外收获一批可用并发。为了让你有个直观感受我用这张表总结了不同模型规模和并发下的显存趋势注意具体数值会随模型结构变化但量级感受是真实的模型规模FP16权重显存4K上下文32并发KV Cache估算结论7B32层GQA约14GB约8GB权重和KV基本五五开13B40层GQA约26GB约12GBKV占比开始显著70B80层GQA约140GB约25GB权重是大头但KV依然不可忽略3.3 量化是加速手段但不是银弹很多人一听到推理变慢就想着把模型从FP16量化到INT8甚至INT4觉得显存和带宽压力都会下降。这个方向是对的但要有预期管理。权重变小确实直接降低了decode阶段需要读的显存量访存瓶颈会被明显缓解但量化之后的模型在复杂推理、代码生成、多轮对话上的效果可能有肉眼可见的下降尤其当你在用GPTQ、AWQ这类后训练量化方案时校准数据集和业务真实分布越像损失越小。我做过的实测里INT8量化在7B模型上能把decode吞吐提升大概30%到50%INT4则能翻倍但随之而来的精度变化需要逐业务验收。另外一个容易忽略的点是很多新硬件有专门的INT8/INT4计算单元你只把权重量化了但推理框架没有走量化kernel等于白忙活。上线前一定要确认引擎日志里有对应的量化算子被执行而不是默默把权重转回FP16再算。4. 延迟敏感业务下的调度策略别让短请求被长任务堵死4.1 FIFO队列看起来公平体验上一点都不公平很多自研推理服务最早实现的排队逻辑都是FIFO谁先来谁先跑。在高并发和长短请求混合的场景下这种“公平”最坑人。你想一下一个用户发了一条超长文档让它总结另一个用户只是问了一个“今天天气怎么样”的简单问题。如果系统老老实实按FIFO排短请求可能要在队列里等长请求跑完几十秒TTFT直接爆炸。行业里对这个问题的解法通常是设置超时和超时抢占。最简单粗暴但有效的策略是给prefill阶段的等待时间设置上限超过阈值的请求直接从队尾提到队前。更精细一点的是引入优先级队列交互式请求聊天、问答走快速通道离线批量任务批量摘要、数据清洗走普通通道。这个思路和操作系统里的进程调度异曲同工——你既要保证交互式任务低延迟又要保证批量任务别饿死。4.2 prefill和decode其实应该分开调度大模型推理的前两个阶段——prefill和decode——对硬件的需求差异非常大。prefill要一次性处理你全部的输入token它属于计算密集、需要高算力的场景decode是逐token生成的属于访存密集的场景。把这两种请求混在同一个batch里调度器怎么排都不舒服。现在一些推理框架已经开始支持把prefill和decode分离运行或者使用类SEDASplit-Equalize-Dynamic-Admit的多阶段架构。如果你们的场景里长输入和短输入混杂特别严重我建议试试把prefill阶段做独立资源预算。比如设定prefill的最大并发占整个SM资源的一半剩下的留给decode避免一个长文本请求进来时把正在生成token的其他请求全挤掉。4.3 排队指标比GPU利用率更能反映服务质量我个人的一个强烈建议是把监控面板里的GPU利用率优先级调低把队列长度、排队请求平均等待时间、最大等待时间这些指标放最显眼的位置。一个服务如果队列一直清空说明资源有余量GPU利用率低一点也不可耻反之如果队列在变长哪怕GPU利用率显示100%你的用户体验也已经在下滑了。推理调度的本质是流量控制和资源分配不是死磕硬件跑满。5. 没有基线就没有优化如何搭一套能复用的性能测试体系5.1 压测载荷要模拟真实世界的长尾分布很多团队的压测方案是拿固定长度的prompt和固定长度的output去打比如统一用1024个输入token、1024个输出token。这样压出来的结果除了写汇报材料没有太多参考价值。真实用户的输入长度服从长尾分布有些请求只有几十个token有些请求是几十K的文档输出长度更是天差地别。一个用固定长度压出来的性能数字在真实流量下可能完全失真。我们后来搭压测时的做法是把线上请求日志脱敏后做流量回放按真实比例混入各类长度。如果没有脱敏通道至少要构造一个“短中长混合”的载荷比如40%的短输入短输出、40%的中等输入中等输出、20%的长输入长输出。这样压测出来的TTFT、TPOT和吞吐才和线上有可比性。5.2 指标口径定义到首token级SLO才有意义我曾经看到有人写SLO写着“平均延迟小于1秒”这等于没写。在LLM推理里至少要分三条线来定义指标含义典型SLO参考TTFT P50一半请求的响应速度小于300msTTFT P95排除异常后的体感上限小于1sTPOT P95生成速度的稳定性小于80ms/token即每秒12 token我建议再加上一个**抖动率Jitter**指标也就是每两个token之间间隔的标准差。Web端对token抖动敏感度其实比平均速度更高如果一个服务一会儿每秒飙30 token、一会儿卡顿两秒钟用户会明显感觉“这个AI在思考人生”交互体验非常糟糕。5.3 性能回归测试要跟上模型升级节奏模型换了、框架升级了、引擎参数调了性能就可能悄悄变化。别等到线上用户反馈才反应过来。我们现在的流程是每次模型迭代都要跑一遍上面说的混合载荷压测把TTFT、TPOT、吞吐、显存峰值和缓存命中率这些关键指标存到同一个回归报告里对比差异。这个回归测试跑起来很费时但它是“用一次性成本买长期安心”的典型。还有一个容易被忽略的环节升级后不仅要看快了没有更要看稳定性。统计上判断两组压测数据的P99差异是否有显著性别只比较均值就草率上线。6. 生产环境里的隐藏坑位显存碎片、上下文长度和过载保护6.1 显存碎片是长稳运行的头号敌人很多推理服务刚上线时一切正常跑一两天后开始莫名OOM。这种情况十有八九是显存碎片化在作祟。推理引擎预分配显存、动态申请KV Cache、请求不定期释放时间长了显存会被切成大量不连续的小块。新的较大内存块申请不上即便总显存还有剩余进程也只能报错。处理和预防的经验有三条一是尽量使用支持显存池化和分页缓存机制的推理框架让引擎自己管理复用二是定期监控显存碎片率通常框架会暴露类似gpu_cache_usage、block_manager相关的指标如果碎片率持续上升考虑定时重启实例或动态缩容重建对显存做一次腾挪三是把最大的KV Cache块预分配好别让它和权重在运行期争抢预留空间。6.2 max_tokens设置过大会悄悄吃掉并发能力不少后端同学图省事给模型配置的max_tokens直接拉到上限比如4096甚至8192想着“反正输出不够长可以截断”。但这个参数直接影响KV Cache的预留策略推理引擎为了容纳最大长度的输出要为每个请求预留足够的内存。max_tokens设得越大同一块显存能容纳的并发请求就越少吞吐自然上不去。合理的做法b不太复杂统计业务真实输出长度的P99分布把max_tokens设置在阈值附近再留20%到30%的余量。比如P99输出长度是600 token设成1024就足够舒适了没必要给到4096。这个参数调小之后并发能力立竿见影地涨而且用户几乎感觉不到变化。6.3 过载保护比扩容更值得先做整个行业都有一种默认的心理性能不够了加机器。但在大规模部署的时候无脑扩容经常带来两个问题一是成本失控二是下游依赖比如向量数据库、外部API、存储系统被高并发拖垮反而引发更大面积故障。更稳妥的做法是先做好过载保护设置全局并发上限超出的请求直接返回排队提示或降级走缓存答案保障核心体验不崩。这里要区分“限流”和“过载保护”的区别。限流是提前挡掉进来的流量过载保护是让系统在满负载下优雅地拒绝一部分请求保住剩下的请求都能按期完成。LLM推理因为单请求消耗变异太大更适合用过载保护配合动态队列长度来控制。6.4 多模型共存时的GPU分配别忘了计算“隔离度”最后再提一个平台侧容易踩的坑你手上一张卡既想跑一个在线聊天模型又想跑一个离线批量处理模型图省事直接把两个模型部署到同一个GPU上。短期看显存还够但推理是典型的内存带宽敏感场景两个模型同时跑会互相抢占HBM带宽结果在线服务延迟飘得没法看。我们后来严格区分两类资源池在线实时服务至少独占整卡顶多做推理引擎内部的多模型复用离线任务再单独往另外的卡上调度。这一点在采购GPU的时候就要规划好一个在线服务实例和一堆离线任务争抢同一片显存省下的成本会成倍地花在为业绩事故善后上面。性能工程到头来是门取舍的学问很多坑不是不知道而是知道了以后用优先级去守住。我在实际项目里最深的一个体会是推理性能优化不存在一锤子买卖。今天你调好了批处理明天用户的输入分布一变原先的参数可能又变成瓶颈。保持监控的敏感性保留每一次压测和调参的记录把性能数字当作和代码一样需要长期维护的东西比临时抱佛脚找优化大招管用得多。这套方法我们用了快两年稳定性的提升非常明显希望这篇里的经验也能帮你在自己的系统上少走几趟弯路。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →