一次线上事故到现在我还记得。某天下午我的推理服务一直在报错GPU利用率从稳定输出瞬间变成上蹿下跳连原本顺畅的短对话都开始半天不出结果。翻监控日志定位半天罪魁祸首是一个超长文档问答请求——prompt足足有接近一万个token。这个请求进入那一刻整个GPU几乎都在为它做prefill其他请求全部被晾在一边。后来我去查vLLM日志里的调度信息追到两个关键词max_num_batched_tokens和Chunked Prefill。这可能是很多自建推理服务都遇到过的场景。max_num_batched_tokens是vLLM调度器里最容易被忽略、但影响面最大的参数之一它决定了一次前向传播里最多能塞进多少个token也决定了长请求和短请求之间的资源分配方式。这篇博文就把我当时排查、调优的过程完整写一遍从调度器原理到参数取值再到实测结果适合正在用vLLM跑服务、或者被长上下文请求折磨过的人。1. max_num_batched_tokens到底在卡什么调度器的单步预算1.1 每轮迭代里vLLM做了一次怎样的取舍vLLM的调度器不是一次处理一个请求而是按iteration迭代步运转的每步从等待队列里取一批请求组成一个batch喂给模型做一次前向传播。对生成任务来说每步通常只推进一个token。所以单步batch的大小直接决定吞吐和延迟。batch的大小受两个参数控制max_num_seqs一个batch里最多有多少个请求。max_num_batched_tokens一个batch里最多累计多少个token。可以类比成公交车max_num_seqs是这辆车最多能坐多少人max_num_batched_tokens是这辆车的载重上限。两个条件都要满足才能发车。但这里有个很关键的点车上乘客的“体重”差异太大了。一个prompt只有50个token的短请求和一个prompt有8000个token的长请求计算量天差地别。如果只看人数调度器很容易把车塞满结果开到半路直接超载。1.2 为什么只限制请求数不够如果只设max_num_seqs假设是256GPU这轮迭代可能承受得住但如果这256个请求里大部分都是几千token的prompt单次forward的计算量会爆炸显存峰值也完全无法预测。反过来如果批次里都是短请求你又希望尽可能多地开并发。max_num_batched_tokens就是在请求数量的维度之外给“总计算量”加一层保险。它本质上定义的是单次前向传播的token预算。也就是说它不是一个请求能带多少token的上限而是整个batch的上限调度器在每一轮迭代时都要卡着这个预算来拼装批次。理解这一点是后续所有调参的基础。早期vLLM的prefill机制和现在不太一样一个prefill请求必须作为一个整体被调度如果它的prompt长度超过了单步预算里的剩余额度调度器只能把它排到后面某轮。而且一旦选中它它就会作为一整块执行独占当前迭代。这种“all-or-nothing”的模式正是长请求害死邻居请求的根本原因一个超长prefill插进来整轮iteration都要等它跑完batch里其他decode请求的生成进度全被拖慢。Chunked Prefill要解决的就是把这个“一次性大块头”拆开塞进不同的轮次里。2. Chunked Prefill为什么值得研究Prefill和Decode的算力争夺2.1 Prefill是爆发型选手Decode是耐力型选手模型推理大致分两个阶段。Prefill阶段把用户输入的prompt一次性送进模型计算每一步处理大量token是典型的高计算密度矩阵运算GPU的算力单元能被充分打满Decode阶段是逐token生成的每个请求每轮只产生一个token矩阵规模很小系统更像是一个访存密集型任务——大部分时间花在读KV cache、写KV cache上而不是真的在算。这两类负载混在同一个GPU上的时候“打架”几乎是必然的。一个长prefill批次会占用大量流式多处理器SM和显存带宽把同一batch里的短decode请求挤到一边。我习惯用一个比喻假设地铁只有一个安检闸机。prefill像拎着大行李箱的人安检特别久decode像只带手机的人几秒钟就能过。如果没有分流一个大行李箱卡在闸机口后面所有带手机的人全部排队。Chunked Prefill的思路就是把这个大行李箱拆成几个小包裹让带手机的人能穿插着过闸机。2.2 chunk大小背后是让出GPU时间片的粒度chunked prefill的核心逻辑并不复杂如果一个prompt超过设定的阈值就把它切成多个chunk每个chunk只占max_num_batched_tokens预算的一部分和当前队列里的decode请求混在同一个iteration里执行。这样长prompt不再独占GPUdecode请求也不会长时间停摆。有一个很容易被忽略的实现细节chunk的粒度不是固定值而是每轮迭代动态算出来的。调度器先看当前batch里decode请求已经占了多少token预算剩下的额度才用来塞prefill chunk。所以当decode负载高的时候prefill chunk会被切得很碎decode负载低的时候prefill chunk可以相对大。这也解释了为什么max_num_batched_tokens如此重要它决定了调度器给prefill和decode“混排”提供多大的舞台。舞台大了单轮iteration能装更多token但一旦有长prefill进入单个chunk也可能变大导致这轮的执行时间变长。2.3 开了Chunked Prefill也不是免费的任何调度优化本质上都是取舍。chunked prefill带来吞吐和TTFT改善的同时有三个隐藏代价值得留意第一前向传播次数增加。一个8000 token的prompt如果chunk size是2048会被拆成四块每块都要单独做一次attention计算和算子调度GPU的kernel launch开销明显高于一次性执行。第二KV cache的写入虽然可以连续但attention计算时每个新chunk依然要读取前面chunk产生的KVchunk之间形成串行依赖——对单个请求来说总体计算时间不一定比一次性prefill短。第三多个chunk分开执行会让小batch的算子利用率下降如果本身负载就不高开启chunked prefill可能完全看不到收益。所以不是所有场景都适合无脑开。后面我会按场景给出判断方法。3. 参数配置的实操路径从默认值调到稳定3.1 动手前先算一笔显存账调参前先算清楚KV cache的账单否则后面的调整全是盲调。KV cache大小取决于模型的层数、KV头数和dtype。我给出两个代表性计算。一个7B规模的MHA结构模型假设32层、32个KV头、head_dim为128、fp16精度每个token的KV cache占用是2 × 32层 × 32头 × 128维 × 2字节 512KB。也就是说一个4K上下文的请求KV cache就接近2GB。另一个8B规模GQA结构的模型假设28层、8个KV头每token只有约112KB比MHA模型便宜了4倍以上。所以模型选型时GQA带来的KV cache优势会直接反映到调度参数上。跑起来之后还有一个非常实用的习惯查看vLLM日志里打印的# KV Cache Size。不同模型在同一显存预算下能分配的KV cache总量差异相当大打印出来的数值能直观告诉你当前配置下显存预留是否合理。这也是最直接的检查方法比反复压测OOM要省事得多。3.2 我从哪些参数开始调我一般按这个顺序来调第一步固定max_num_seqs。这个参数受显存约束一般设在64到512之间具体看业务的并发需求和模型本身的KV cache大小。要注意设太高也有风险如果KV cache池不够会频繁触发抢占preemption表现为TPOT突然飙升。第二步调max_num_batched_tokens。如果业务是短对话为主我通常把它设成max_num_seqs的八到十六倍比如max_num_seqs256时max_num_batched_tokens先设到4096或8192跑一轮对比如果是长文档场景设到2048到4096之间的小值配合chunked prefill用。第三步确认chunked prefill开关。不同版本的开关名不一样有的版本默认开启有的需要显式加--enable-chunked-prefill。拿到一个新环境时我会先跑一遍vllm serve --help确认当前版本支持的参数名再动手。启动命令大致长这样vllm serve /data/models/chat-7b \ --max-num-seqs 256 \ --max-num-batched-tokens 8192 \ --enable-chunked-prefill \ --gpu-memory-utilization 0.9如果你用的是旧版入口python -m vllm.entrypoints.openai.api_server参数一致只是命令前缀不同。版本之间参数命名可能有偏差但核心概念是通用的。3.3 一个勉强能抄的估算公式当拿不准取值时我会按这个思路估算max_num_batched_tokens ≈ max_num_seqs × 业务中最常见的prompt token数。但这里有个陷阱。比如你的业务80%请求都在500 token以内max_num_seqs256按这个公式算出来是128000这显然不合理因为单步batch如果塞128000个token显存和延迟都会直接爆炸。所以这个公式只适合业务请求长度分布比较均匀的时候做参考真正务实的做法是先按max_num_seqs的两倍起调比如512然后观察GPU利用率和吞吐曲线逐步翻倍到4096、8192在曲线拐点附近停下。翻倍的过程中同时盯着显存一旦出现OOM或者频繁preemption就退回上一档。说白了max_num_batched_tokens的真正意义不是让你去精确匹配某个业务值而是给调度器的混排提供一个足够大的“舞台”。舞台小了长请求和短请求没有腾挪空间舞台太大单轮iteration的执行时间又会失控。4. 三组实测场景短对话、长上下文、混合负载4.1 场景一短对话为主max_num_batched_tokens适度拉大先说短对话场景。请求prompt平均三五百token输出也短基本上一个请求总token在1K以内QPS高并发大。这个场景下max_num_batched_tokens设小反而会变成吞吐瓶颈。我在一台双卡环境上跑过对比max_num_batched_tokens从默认值调到8192之后整体吞吐提高了大概三成。原因很简单原来单步token预算太小调度器每步能塞进去的请求太少GPU一直在等队列利用率上不去调大以后每次forward能装下更多请求算力才被喂饱。这个场景如果没有超长prompt混入其实不开chunked prefill问题也不大。开了以后收益几乎可以忽略反而因为多了一道调度判断日志里的step时间还略有增加。所以我的结论是短对话为主、prompt长度分布集中的服务重点是调大max_num_batched_tokens和max_num_seqschunked prefill可以先不开。4.2 场景二长上下文问答用小chunk救TTFT另一个典型场景是文档问答服务用户经常粘贴几千甚至上万token的材料进去提问。这类服务最容易出现开头说的那种惨案——一个长prompt进来所有正在生成的请求都开始卡。这个场景下我直接把max_num_batched_tokens从8192降到了2048并打开chunked prefill。效果非常明显长prompt请求的TTFT从原来的2秒以上降到了800毫秒级别同时其他短请求的TPOT抖动也大幅收敛。关键就在于2048这个chunk粒度足够细长prompt虽然被切成了好几块但每一块都只占单步预算的一部分不会把整轮iteration的时间拖太久。不过要注意TTFT降下来背后的代价是单请求总耗时可能变长因为chunk之间是串行执行的。如果业务只关心“第一个字出来前等了多久”那这个取舍通常是值得的如果你更看重总吞吐而非首字延迟可能需要重新权衡。4.3 场景三混合负载盯住三个指标找折中点最后是混合负载一部分短对话实时请求一部分批量离线任务会提交超长prompt。这种场景最麻烦因为任何单一配置都会让某一侧不满意。我当时处理的方式是max_num_seqs固定256max_num_batched_tokens取4096打开chunked prefill。最终配置下短对话的TPOT比纯短对话场景多约15%但长任务的TTFT从2秒多降到1秒内整体GPU利用率稳定在85%左右没有出现之前那样的大幅波动。判断调优是否成功我建议同时盯三个指标TTFT首token延迟、TPOT单token生成时间和GPU利用率。如果TTFT和TPOT都还可以但利用率低说明max_num_batched_tokens还可以往上加如果利用率很高但TTFT和TPOT都在劣化说明单步batch太大长prefill和decode挤在一起了需要降档。我通常用表格对比几个配置点配置TTFTP95TPOTP95GPU利用率备注默认2300ms180ms55%长请求抢资源明显8192 chunk950ms140ms78%短任务略受影响4096 chunk820ms135ms85%折中点上面的数值只是示意但你能看到规律存在一个拐点过了拐点继续往上调收益很小反而开始损伤另一侧指标。5. 我在调参路上踩过的坑文档里不会写的那种5.1 坑一以为它是“单个请求的token上限”刚调这个参数时团队里有人犯过和我一样的错误以为把max_num_batched_tokens设成8192是限制每个请求最多携带8192个token于是把一个超过这个长度的prompt当成非法请求排查了半天完全搞错了方向。这个参数和单请求长度并没有直接绑定关系。它是整批的token预算分给谁、分多少由调度器动态决定。你设成8192十个请求各带800 token也能把这8192全部用掉一个请求带8000 token也能占掉大部分预算。不要把请求级别的长度思维套到这个参数上否则后续排查问题时会一直走弯路。5.2 坑二为了吞吐直接拉满撞上OOM有一版配置我图省事直接把max_num_batched_tokens设成了和模型上下文长度一样比如32768。逻辑看起来挺合理模型最多能接受32K上下文那单步预算就按32K来。结果服务一压测就OOM重启恢复后过段时间又出现。原因是max_num_batched_tokens直接影响的是单次前向传播中token的规模而前向传播的中间激活值是按batch里的token总量线性增长的。预算设得越大不仅KV cache池需要对应扩容激活值的峰值也越高。vLLM的显存账不是只算KV cache那部分。这个参数的合理取值应该远小于上下文长度上限它的作用是调度混排不是让你把一整段长prompt一次性塞进去。提示如果配置后日志里出现“Out of memory”或者频繁preemption第一步不是加显存而是把max_num_batched_tokens降下来看看。5.3 坑三只看GPU利用率来评判调优效果利用率不是越高越好。有段时间我把配置调完GPU利用率从60%干到98%觉得大功告成。结果业务方反馈说短请求的延迟变高了。后来抓日志才发现那98%里有一大半是长prefill请求在燃烧算力它们把短decode请求挤到了角落。利用率好看不等于用户体验好。更实用的判断方式是把TTFT、TPOT和利用率三个指标放一起看。利用率高但TPOT也在缓慢爬升说明decode请求正在被长prefill饿死配置要降温。只有当利用率提升的同时TPOT保持稳定或下降新增的算力才真正用在了刀刃上。5.4 坑四不同版本之间的参数行为不一致vLLM版本迭代很快参数行为一直在变。早期版本里chunked prefill默认关闭必须手动开后来某些版本里又变成默认开启不同启动命令入口的参数名也可能不一样。如果你从旧版本升级或者从网上抄来一套配置一定要先看当前版本的文档或者跑一遍--help确认不能盲目沿用。我有个简单的兜底做法每次版本升级后先拿一套经典配置跑一个小的压测脚本对比升级前后的TTFT、TPOT和日志里打印的# KV Cache Size。如果这些数值出现明显变化再去一个一个核对参数差异。这个方法帮我避开了很多“升级之后性能神秘劣化”的谜案。最后再说点个人经验。上手调这个参数时别一上来就同时改max_num_seqs和max_num_batched_tokens变量多了根本没有办法归因。我会先把max_num_seqs固定只动max_num_batched_tokens每个档位跑十分钟压测记下TTFT、TPOT和GPU利用率再决定下一步。这套流程看着笨但比靠直觉调参可靠得多。等技术指标稳定了再去动chunked prefill开关评估它对长尾请求的影响。这样一轮一轮下来配置不会太离谱。