做推理服务的人,一定经历过这种局面:模型在测试环境怎么压都稳,一上生产并发稍微上来就超时;或者本地单条请求响应快得飞起,线上看着GPU利用率不到一半,吞吐却死活上不去。更恼火的是,网上到处是零散的经验贴,看完还是一头雾水。这篇文章把大模型推理调优的思路串成一条主线:先弄懂衡量服务的指标和模型执行时的性能逻辑,再按故障分类排查,最后把关键调优参数一个个讲透。适合正在负责推理服务上线、优化线上延迟和吞吐的工程师,也适合想搞明白推理瓶颈到底在哪的算法同学。
1. 先搞清楚在优化什么:推理核心指标拆解
1.1 延迟类指标:TTFT和TPOT必须分开看
大模型推理的延迟和普通接口不一样,它不是一锤子买卖。用户发一个请求,服务端要先把整个prompt喂给模型算一遍,然后一个token一个token地往外吐。于是延迟被拆成两个关键数字。
TTFT(Time To First Token),首token延迟。从请求发送到收到第一个字的间隔,直观对应“用户感觉服务有没有响应”。TTFT超过两秒,体验就会明显变差。TTFT主要由prefill阶段决定,也就是模型把整段输入并行计算一遍的过程。
TPOT(Time Per Output Token),每个输出token的耗时,对应输出阶段的流畅度。如果TPOT超过100ms,用户会觉得打字速度慢得让人难受。实测想达到“顺畅阅读”的感觉,TPOT一般要压到50ms以内,也就是每秒至少能吐20个token。
除了这两个,还有一个端到端延迟(E2E Latency),指整条生成链路从请求进来到完整回答落地的总耗时。业务侧超时判断基本看它,但定位问题时要把E2E拆回TTFT加上TPOT乘以生成长度。
这里有个容易踩的坑:用E2E延迟对比不同配置,结果会被生成长度干扰。两个配置一个输出200字、一个输出800字,E2E自然差一大截,但这不代表服务性能差。正确做法是分开记录TTFT和TPOT,并用固定长度输出做基准。
1.2 吞吐类指标:QPS与token/s不是一回事
吞吐指标经常被混着报,但意义完全不同。QPS是每秒完成的请求数,适合衡量短输出、高并发的交互场景。token/s是每秒生成的token总数,适合衡量长文本、流式输出的生产场景。还有一个每车吞吐,比如每秒每GPU能生成的token数,用来横向对比机器规模。
服务端吞吐有个关键特征:总token/s约等于单请求TPOT的倒数乘以并发批大小。在prefill能力足够的前提下,并发数翻倍通常会让总吞吐明显上升,这一点后面调优会反复用到。
1.3 为什么延迟与吞吐必须放在同一张表里权衡
单独看任何一边都是耍流氓。把batch size拉得很大,吞吐可以冲得很高,但每个请求排队和计算的时间全被拉长,TTFT和TPOT一起恶化;反过来把并发压到1,单请求延迟漂亮,但GPU算力大部分时间在空转。
实际项目里,一个合理的做法是给三个指标设阈值:TTFT不超过2秒、TPOT不超过60ms、总吞吐达到预期业务量,然后再去调参。所有调优动作,本质是在这三个约束里找平衡点,而不是追求单一数字。
2. 推理执行模型与显存分配逻辑
2.1 Prefill和Decode是完全不同的两类计算
模型生成一段回答,底层是两段截然不同的计算阶段。Prefill阶段,模型拿到整个输入序列,把所有token并行计算,一个请求可以迅速算出第一轮所有Key和Value。它的特点是计算密集、时间紧凑。Decode阶段,模型每次只计算一个token,然后把这个token追加到序列末尾,通过缓存技术避免重算历史,逐字生成。它的特点是访存密集、串行执行。
同样是生成100个token,prefill可能只占零点几秒,decode却要几十毫秒乘一百次。这也是为什么线上很多服务平均延迟看着还行,输出阶段一样长到让人等得不耐烦。
注意:很多优化手段都围绕“两段式”展开。比如连续批处理,就是想办法把请求的prefill和decode交错到一起跑;前缀缓存目的则是砍掉重复prefill的时间。
2.2 KV Cache到底吃掉多少显存:一次手算
KV Cache是解码阶段的核心机制。每次生成一个token,模型都要算新的Key和Value向量,并把历史上所有token的Key和Value缓存下来,避免每次都从头重算。KV Cache的大小随序列长度线性增长,随并发序列数线性增长,这就是长上下文和大并发吃掉显存的根本原因。
可以快速估算:假设某个70B参数模型有80层、每组8个KV头、head维度128,用BF16存储,那么每个token每层需要 2×8×128×2字节=4KB,80层就是320KB。一个用户如果带了8000个token的上下文,这一个请求就占掉约2.5GB;10个并发用户就是25GB。算完这个数字再去看显存,大部分OOM的根因就一目了然了。
多卡并行时,权重按张量并行切分到各卡,KV Cache也按层切分,但预算思路不变:每张卡都要同时容纳权重切片、KV Cache、激活和运行时buffer。
2.3 完整显存账单:权重、KV、激活、运行时buffer
很多刚开始调优的同学下意识以为显存就是模型权重占用的空间,只盯着权重文件大小,结果一上线就爆。实际显存账单有四块:
- 模型权重:BF16下大约每10亿参数占2GB,70B就是140GB左右,这是基础盘。
- KV Cache:和上下文长度、并发数正相关,上面算过,随序列总长增长很快。
- 激活值:prefill阶段中间计算结果瞬时占用很高,长prompt尤其明显。
- 框架运行时缓存:CUDA context、显存碎片、临时buffer,通常占几GB到十几GB。
所以算显存预算时,这四样都要留空间。我的习惯是先大致算一遍,再额外留10%到15%作为安全余量,绝不在生产环境里把显存利用参数顶到极限。
2.4 为什么OOM容易出现在解码阶段
把整个过程拉长看,prefill阶段虽然激活值高,但KV Cache还没累积起来;decode阶段KV Cache随产出token数量一路涨,叠加并发序列越来越多,很容易在生成到一半时撞上显存上限。所以线上看到“单个请求没问题,并发一多就报显存错误”,先往KV Cache和并发序列数上想,往往不需要动权重。
3. 故障排查方法论:先量化,再定位,后修改
3.1 排障第一步:把性能指标完整接出来
排查性能问题最容易犯的错,就是还没量化就开始猜。我见过有人因为一次线上偶发超时,立刻把量化等级从FP16换成INT4,结果问题完全没解决,反而多了精度损失。正确流程是先拿到证据,再改配置。
先搭一套轻量级的可观测体系:把TTFT、TPOT、prefill时长、decode时长、排队等待时长、显存占用峰值、GPU利用率全部记录成直方图指标。没有这些前置数据,后续所有判断都靠猜。
给一个最基础的实时监控命令示例:
# 实时查看GPU利用率和显存变化 nvidia-smi dmon -s pucmet -d 1 -c 60也可以把指标输出到时序数据库,等故障波峰过去后拉出对应时间段的曲线。这一步通常能筛掉八成的问题。
3.2 常见故障症状与第一怀疑点对照
抛一张典型的故障现象定位表:
| 现象 | 第一怀疑点 | 定位方法 |
|---|---|---|
| 单请求正常,并发后OOM | KV Cache容量不足 | 看并发数、输入长度、显存曲线 |
| TTFT异常高 | prefill被长输入或重复前缀拖慢 | 看prompt长度分布、prefill耗时 |
| TPOT整体慢 | 模型过大、单卡算力不足 | 对比不同量化、换更大GPU |
| TPOT波动大 | 并发波动、排队挤压 | 看排队时长和in-flight请求数 |
| 输出质量诡异 | 采样参数被改、量化损失 | 复测固定样例、检查采样配置 |
3.3 一次典型TTFT恶化的完整推演
举一个复合例子。某个业务上线了问答服务,现象是白天高峰期TTFT逐步升到三秒以上,TPOT却保持正常。第一次看到这个组合,可以先把“TPOT正常”排掉,说明单次decode算力没退化,重点转向prefill和排队。
看指标,发现prefill耗时确实涨了,但瓶颈不在单条请求,而在于高峰期并发请求的大batch把一堆prefill任务堆在一起执行,每条都被拖慢。再往下查,发现大量重复的固定system prompt,每条请求都白算一遍相同的几千token。
对症下药就三步:给固定前缀做缓存,砍掉大量重复prefill;把超长prompt截断或做长度限制;调度上让prefill请求优先处理。改完之后TTFT从三秒降到六百毫秒。这个例子想说明的是,排查不是靠经验玄学,而是靠“指标分层—定位瓶颈—针对性改动”的顺序。
4. 性能调优的关键参数与实操配置
4.1 连续批处理与并发上限的关系
传统批处理方式是等一批请求凑齐了再推理,批大小固定,天然导致排队延迟。主流推理框架基本都改用了连续批处理:一个请求decode结束就立刻移出,新请求的prefill可以插到当前batch的空隙里,GPU始终在密集计算。
调这个能力最直接的参数是并发序列上限,也叫max_num_seqs或max_batch_size。调大它,吞吐会涨,但每个请求分到的算力变少,TPOT变差;调小,延迟稳定,但吞吐上限低。一个可操作的策略是:从单请求基准出发,逐步倍增上限,每次记录TTFT、TPOT和总吞吐,直到TPOT越过阈值再退回上一档。
参考配置片段:
{ "max_num_seqs": 16, "gpu_memory_utilization": 0.85, "max_model_len": 4096, "enable_prefix_caching": true }max_num_seqs从16试到64,吞吐和TPOT的变化通常会呈一条清晰的曲线,选中间最稳的点。
4.2 显存利用率与KV Cache预算的配置逻辑
被问得最多的就是:显存利用率参数到底设多少合适。设低了,KV Cache容量不够,高并发长上下文直接OOM;设高了,权重和激活空间被压缩,同样可能OOM。
我的经验是分两步走。第一步,先按权重大小、KV Cache估算值、激活峰值算出总预算,看显存还剩多少。第二步,把显存利用率参数从0.8起步,跑一组极限压力测试,尽量长文本、尽量多并发,然后逐步递增到0.9左右。如果压测中出现KV Cache不足的报错,优先降低并发上限,而不是继续压榨显存比例。
注意:显存利用率是“给框架分配显存的上限”,不是“KV Cache独占比例”。调到0.99看起来能最大化缓存,实际上留给其他模块的余量几乎为零,生产环境非常容易被临时峰值击穿。
4.3 量化选型的分场景思路
量化减少显存占用、提高计算效率,同时也在损失精度。选型时要区分两类场景。
对延迟敏感且用户能接受轻微输出变化的在线助手类场景,INT8是一个稳妥的中间选项:显存降低约一半,推理效率提升,精度损失通常辨析不出来。对显存极其紧张、模型又很大的场景,INT4级别的低比特量化能把大模型压进更小显存,但要注意有的层对量化特别敏感,输出质量和稳定性都可能波动。
有没有更优解?有。可以只对部分敏感层做高精度保留,用混合精度方案。这类方案配置复杂,除非已经明确精度问题由量化触发,否则不建议一上来就搞。
选型的判断标准也很直接:换完之后,固定跑同一组线上代表样本,对比关键字段是否一致、生成分布是否偏移。只盯benchmark数字不看业务结果,是选型中最容易犯的错。
4.4 前缀缓存:最容易被忽略的加速手段
很多应用的prompt由固定模板和用户输入拼接而成。如果是同一个固定前缀,模型在prefill阶段会在一模一样的位置算出一模一样的KV Cache。前缀缓存就是让这些固定部分只算一次,后续请求直接复用。
它对多轮对话场景尤其有效,因为历史对话内容会不断拼接进prompt,重复计算量非常大。开启后固定前缀命中时,TTFT往往能下降一个量级。
代价是前缀缓存需要额外显存存放已缓存的KV,且不同输入的前缀不一致时命中率低。线上设计系统提示词时,把固定部分放在最前面、内容改动尽量少,能大幅提高命中率。
4.5 投机解码的收益与适用条件
投机解码的思路是:先用一个小模型快速猜出接下来几个token,再让大模型一次验证多个token。如果猜得对,一次decode能“白赚”几个token的产出。
这个技术对TPOT的优化效果非常明显,但稳定依赖两个条件:草稿模型足够快,且猜中的概率足够高。实测下来,在代码生成、结构化JSON输出、固定格式文本这类低熵场景,接受率很高,收益很大;但在开放式创意写作、随机性很强的对话里,收益就没那么明显,有时还会因为猜测失败浪费算力。
所以不要看别人说投机解码好就无脑开,先拿自己的线上prompt做一轮小规模A/B测试,再决定是否全局开启。
5. 线上踩坑实录与问题速查
5.1 问题、根因、解法一览表
把典型线上问题浓缩成一张速查表:
| 问题现象 | 大概率根因 | 解决方向 |
|---|---|---|
| 并发一高就OOM | KV Cache预算不足或并发上限过大 | 调低并发上限、限制输入长度、优化KV Cache预留 |
| TTFT高企 | 固定前缀反复计算 / prefill与decode争抢算力 | 开启前缀缓存、调度优先prefill、限制超长prompt |
| TPOT波动明显 | 请求大量到达导致批大小抖动 | 设置稳定并发上限、启用平滑排队、压测确定容量 |
| 输出内容突然变差 | 量化损失 / 采样参数被改动 | 回滚参数、对比基准样本、局部高精度量化 |
| 生成停不下来 | 缺少长度兜底或终止条件失效 | 服务端设max_tokens、增加终止符检测 |
5.2 三个反复出现的坑
第一个坑:显存利用率参数拍脑袋填0.95。结果权重加载时直接报显存不足,或者一压测就崩。正确做法是先算清权重和激活的底线空间,再往KV Cache方向去加,而不是一上来就顶满。
第二个坑:只看平均延迟,不看p95和p99。某个配置平均延迟甚至更低了,但p99翻了一倍,这在线上等于把一部分用户直接推出门外。任何调优结论都必须同时看尾延迟,尤其服务端会同时服务多种类型请求时。
第三个坑:只顾GPU算力,忘了数据预处理和tokenizer开销。大并发时系统会先把大量prompt转成token、做长度校验、拼装prompt模板,这些CPU进程如果不做分离或限流,会成为隐蔽的排队瓶颈。我调过的某次“GPU利用率只有30%”的问题,根因就是CPU侧tokenizer线程数不够。
5.3 攻坚排障时我常用的老办法
如果配置已经调到看起来都合理的状态,但性能还不满足要求,我会回到一个老做法:构造一条明确的复现请求,固定它的输入和输出长度,在不同配置下反复跑。真到排障攻坚期,问题几乎都能在这样“可变条件最少”的实验里暴露出来,比东看一眼西猜一下高效得多。
这套方法对算法和工程同学都适用,核心就是把变量锁住,一次只动一个。
做推理调优这么久,我最大的感受是:大部分性能和故障问题都不是玄学,而是指标没拆清、资源没算透、变更没有对照导致的。每次上线前固定跑一遍同一批基准样本,把TTFT、TPOT和p95保存下来,任何改动都拿它做对照。这套流程看着笨,但真到线上出问题的时候,它能帮你省下整整一晚上的排查时间。之后再遇到推理卡顿,先对照速查表过一遍,再回到参数区手动调一轮,你会发现很多问题都没那么邪乎。