ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

KV Cache面板26%却塞不进请求:调度器准入逻辑与参数调优

KV Cache面板26%却塞不进请求:调度器准入逻辑与参数调优

1. 这个"面板显示 26% 却塞不进请求"到底卡在哪

线上跑推理服务的人,大概率都撞见过这个画面:监控面板上 KV Cache 用量明晃晃写着 26%,看着还有大把余量,可调度器那边一条新请求都排不进去,客户端一直转圈,日志里全是等待调度的记录。第一反应通常是"面板坏了"或者"是不是有内存泄漏",然后开始重启、扩容、加机器,折腾一圈发现根本没解决问题。

这个现象的本质,是面板统计口径和调度器准入判断口径不一致。面板上的 26% 通常是"当前已分配的 KV 块占全部 KV 块的比例",而调度器决定能不能放一条请求进来,看的是"这条请求需要的最大 token 数对应的 KV 块,能不能在剩余块里连续凑出来"。一个是全局平均视角,一个是单请求峰值视角,两者天然会对不上。再加上分页管理、块粒度、并发上限、抢占策略这些机制叠在一起,就会出现"看着空、实际满"的诡异状态。

这篇内容适合正在用 vLLM 这类推理引擎做线上部署的工程师、负责容量规划的运维同学,以及被"面板和实际不一致"折磨过的调度方向开发者。我会把 KV 缓存的分页机制、调度器的准入逻辑、面板指标的采集来源这三条线拆开讲清楚,再给出一套可复现的排查流程和参数调整方案。看完你应该能自己判断:到底是真满了,还是统计骗了你,以及该动哪个参数。

2. 先搞懂 KV 缓存和调度器到底在算什么

2.1 KV 缓存不是一整块内存,而是被切成了固定大小的块

很多人脑子里的 KV 缓存是一整块连续显存,用多少算多少。实际主流推理引擎早就改成了分页式管理,把 KV 缓存切成一个个固定大小的块(block),每个块能装固定数量的 token 的 key/value 向量。比如块大小设成 16,那一个块就存 16 个 token 的 KV。

为什么要这么切?因为大模型推理的请求长度是动态的,有的请求几百 token,有的几万 token。如果按连续内存分配,长请求会把内存切得七零八落,短请求进来找不到连续空间,碎片化严重。分页之后,一个请求的 KV 可以分散在任意多个不连续的块里,靠一张块表(block table)记录逻辑位置到物理块的映射,跟操作系统的虚拟内存分页是一个思路。

这个设计直接决定了后面所有"看起来矛盾"的现象。因为块是最小分配单位,一个请求哪怕只多一个 token,也可能要多占一整个块。块大小设得越大,浪费越明显;设得越小,块表管理开销越大。这是第一个需要权衡的参数。

2.2 调度器准入看的是"最坏情况",不是"当前平均"

调度器每来一条新请求,要做的事是:预估这条请求最多会用到多少 KV 块,然后检查当前空闲块够不够。注意这里的关键词是最多。

一条请求的输入长度是已知的,但输出长度是未知的。调度器必须按"输入长度 + 最大输出长度"来预留空间,否则跑到一半没块了,要么中断请求,要么触发抢占把别的请求踢出去。所以哪怕当前只用了 26% 的块,只要剩余块数凑不出一条新请求的"输入 + 最大输出"所需块数,调度器就会拒绝。

举个具体的数:假设总共有 1000 个块,块大小 16,当前用了 260 个块(26%),剩 740 个块。如果最大输出长度设成 4096,输入长度 512,那一条请求最坏需要 (512+4096)/16 = 288 个块。740 够 288,理论上能放两条。但如果最大输出长度设成了 8192,单请求就要 (512+8192)/16 = 544 个块,740 只够放一条,第二条就进不来。这时候面板还是显示 26%,但调度器已经"满"了。

提示:面板百分比低不代表能塞请求,关键看"剩余块数"能不能覆盖"单请求最坏块数"。这两个数才是调度器真正在比的。

2.3 面板的 26% 是怎么算出来的,为什么它天生偏低

面板上的 KV 用量,采集来源通常是引擎内部维护的块分配计数器。常见算法是已分配块数 / 总块数。但这里有几个坑:

第一,已分配块数只统计"正在被请求占用"的块,不包括那些被预留但还没实际写入的块。调度器准入时会预留,但预留的块在真正写入前,可能不计入"已用"统计,导致面板偏低。

第二,抢占和重计算会产生临时块。当显存紧张时,调度器会抢占低优先级请求,把它们的块释放,等有空间了再重算。这个过程中块的归属频繁变化,统计有延迟。

第三,不同版本的统计口径不一样。有的版本把"空闲但未归还"的块也算进已用,有的不算。升级版本后同一个负载下面板数字可能差很多,这不是 bug,是口径变了。

所以看到 26% 这个数,先别急着下结论,得确认它统计的到底是哪一部分块。

3. 把调度器的准入逻辑拆到能自己算

3.1 一条请求从进来到被调度,中间经历了什么

请求进到引擎后,大致走这么几步:先是 tokenize,把文本变成 token 序列;然后进等待队列(waiting queue);调度器每轮从等待队列里挑请求,检查能不能分配 KV 块;能分配就进运行队列(running queue),开始 prefill;prefill 完进入 decode 循环,每步生成一个 token,每步都可能需要新块。

关键卡点在"检查能不能分配"这一步。调度器的判断逻辑通常是:需要的块数 <= 空闲块数。需要的块数 = ceil((输入长度 + 最大输出长度) / 块大小)。空闲块数 = 总块数 - 已分配块数 - 预留块数。

注意这里有个容易忽略的点:最大输出长度是配置项,不是实际输出长度。如果你把 max_tokens 或者 max_model_len 设得很大,调度器就会按这个大值预留,哪怕实际输出只有几十个 token。这是"面板低但塞不进"最常见的元凶。

3.2 块大小、最大长度、并发数三者的连锁关系

这三个参数是互相牵制的,动一个会影响另外两个的实际效果。

参数作用调大的后果调小的后果
块大小(block_size)每个 KV 块存多少 token块表开销小,但内部碎片多,短请求浪费严重碎片少,但块表变大,管理开销上升
最大长度(max_model_len)单请求最长 token 数单请求预留块数暴涨,并发能力骤降长请求被截断,可能影响业务
最大并发(max_num_seqs)同时运行的请求数上限显存压力大,容易触发抢占吞吐上不去,GPU 利用率低

举个实际算例。假设显存能放 2000 个块,块大小 16,max_model_len 设成 8192。单请求最坏需要 8192/16 = 512 个块。那理论最大并发就是 2000/512 ≈ 3.9,也就是最多 3 条请求同时跑。这时候面板可能只显示 30% 多,但第 4 条请求就是进不来,因为剩下的块不够第 4 条的最坏需求。

如果把 max_model_len 降到 4096,单请求最坏 256 个块,并发能到 7 条。面板数字没变多少,但能塞的请求翻倍。这就是为什么盲目调大 max_model_len 是并发杀手。

3.3 抢占机制:为什么有时候请求进去了又被踢出来

当运行中的请求需要新块但空闲块不够时,调度器会触发抢占。抢占策略一般是挑"最不划算"的请求——比如已经跑了很久但还没结束的,或者优先级低的——把它的块释放,让给新请求或更需要的请求。

被抢占的请求不会消失,它会回到等待队列,等有空间了重新 prefill。但重算是有代价的:已经生成的 token 白算了,得从头再来。如果抢占频繁发生,会出现"请求反复进出、吞吐暴跌"的雪崩。

这也是"面板 26% 却塞不进"的另一种解释:不是没块,而是块被抢占逻辑锁住了,调度器在等一个安全的时机才放新请求进来。这种情况下面板数字会来回跳,不是稳定在 26%。

4. 一套可复现的排查流程

4.1 第一步:确认面板数字的真实口径

别急着调参数,先把面板的统计来源搞清楚。最直接的办法是看引擎暴露的指标接口,找到 KV 相关的几个计数器,对比它们的值。

通常会有这么几个指标:总块数、已用块数、空闲块数、被抢占次数、等待队列长度。把这几个数拉出来看,如果"已用 + 空闲 ≠ 总块数",说明有块被预留或统计延迟,面板的百分比就是失真的。

我一般会写个小脚本定时抓这些指标,画成曲线。如果发现"已用块数"长期不动但"等待队列"一直涨,那基本可以确定是准入判断卡住了,不是真的没内存。

# 伪代码:抓取引擎指标并计算真实可用块 def check_kv_status(metrics): total = metrics['kv_total_blocks'] used = metrics['kv_used_blocks'] free = metrics['kv_free_blocks'] waiting = metrics['waiting_queue_size'] preempted = metrics['preempted_count'] # 真实空闲 = 总 - 已用(如果 free 字段不可信) real_free = total - used # 面板百分比 panel_pct = used / total * 100 print(f"面板显示: {panel_pct:.1f}%") print(f"真实空闲块: {real_free}") print(f"等待队列: {waiting}, 抢占次数: {preempted}") # 如果等待队列 > 0 且真实空闲块 > 0,说明是准入逻辑问题 if waiting > 0 and real_free > 0: return "准入逻辑卡住,检查 max_model_len 和块大小" return "正常"

4.2 第二步:反推单请求最坏块数

拿到 max_model_len、块大小、当前输入长度分布,算出单请求最坏需要多少块。公式是:

单请求最坏块数 = ceil((输入长度 + 最大输出长度) / 块大小)

如果这个数接近甚至超过总块数,那并发能力必然极低。比如总块数 2000,单请求最坏 1800 块,那同时只能跑一条,第二条永远进不来,面板再低也没用。

这一步的目的是确认:到底是块不够,还是单请求胃口太大。很多时候问题不在总量,在于单请求预留太狠。

4.3 第三步:检查是否有"僵尸块"没释放

有些情况下,请求已经结束了,但它占的块没被正确归还,导致空闲块数虚高、实际可用块少。这种"僵尸块"在面板上表现为"已用块数"缓慢增长不下降,重启后恢复正常。

排查方法是看"已用块数"在请求全部结束后是否归零。如果不归零,说明有泄漏。常见原因是异常中断的请求没走完释放流程,或者抢占逻辑有边界 bug。这种情况升级引擎版本通常能解决,因为分页管理这块迭代很频繁。

4.4 第四步:用压测复现,别靠猜

线上环境变量太多,最好的办法是在测试环境用固定负载复现。构造一批固定输入长度、固定输出长度的请求,逐步加压,观察从第几条请求开始被拒绝,然后对比理论计算值。

如果实测拒绝点和理论值对得上,说明是参数配置问题,调参数就行。如果对不上,比如理论能放 5 条但第 3 条就进不去,那可能是碎片化或者统计口径问题,需要更深入看块表状态。

5. 参数怎么调才能让面板和实际对上

5.1 块大小:16 是默认,但不是万能

块大小默认一般是 16。这个值在大多数场景下是平衡点:块表开销可接受,内部碎片不算太严重。但如果你的请求普遍很短(比如平均输出几十 token),可以试试调到 8 甚至 4,减少浪费。如果请求普遍很长且稳定,调到 32 能降低块表管理开销。

调整块大小需要重启引擎,而且会影响已有缓存的复用。生产环境调这个参数要谨慎,最好在低峰期做,并且准备好回滚。

5.2 max_model_len:按业务实际需求设,别图省事设最大

这是最容易被滥用的参数。很多人直接设成模型支持的最大长度,觉得"反正用不到那么多"。但调度器是按这个值预留的,设大了并发直接腰斩。

正确做法是统计业务请求的长度分布,取 P99 或者 P999 作为 max_model_len。比如 99% 的请求输出不超过 2048 token,那就设 2048,别设 8192。剩下 1% 的长请求可以单独走一个长文本队列,用不同的配置处理。

5.3 并发上限和显存预留:留出安全边际

max_num_seqs 控制同时运行的请求数。设太高会导致频繁抢占,设太低浪费显存。经验值是根据"总块数 / 单请求最坏块数"算出理论上限,然后取 70% 到 80% 作为实际配置,留出安全边际应对突发长请求。

另外,显存利用率(gpu_memory_utilization)这个参数也别拉满。留 5% 到 10% 的余量给框架本身和临时分配,能显著减少 OOM 和抢占。

参数保守值激进值建议
block_size832短请求多选 8,长请求多选 32
max_model_len业务 P99模型最大值按 P99 设,长请求单独处理
max_num_seqs理论上限 70%理论上限 100%留 20% 到 30% 余量
gpu_memory_utilization0.850.95留 5% 到 10% 给框架

5.4 开启前缀缓存,减少重复占用

如果业务里有大量请求共享相同前缀(比如固定的系统提示词),开启前缀缓存(prefix caching)能让这些共享部分只占一份块,多个请求复用。这能显著降低实际块占用,让面板数字和真实可用空间更接近。

开启方式一般是在启动参数里加对应开关,具体名称看引擎版本文档。开启后要注意:前缀缓存的块释放策略和普通块不同,统计口径可能又变,面板数字要重新校准。

6. 踩过的坑和常见问题速查

6.1 升级版本后面板数字突变

这是最常见的"假故障"。新版本改了统计口径,比如把预留块也算进已用,面板数字会突然涨一截。这时候别慌,先看实际吞吐和延迟有没有变化。如果业务指标正常,只是面板数字变了,那就是口径问题,调整监控告警阈值即可。

6.2 请求进去了但一直不输出

有时候请求被调度进去了,但 decode 阶段卡住不动。这通常不是 KV 问题,而是采样或者模型前向的问题。但如果伴随"已用块数"不涨,可能是块分配了但没写入,属于引擎内部状态不一致,重启能解决,根治要等版本修复。

6.3 并发上不去但显存还有富余

这就是本文的核心场景。排查顺序是:先看 max_model_len 是不是设太大,再看块大小是不是不合适,然后看有没有僵尸块,最后看是不是抢占太频繁。大部分情况调小 max_model_len 就能解决。

6.4 常见问题速查表

现象可能原因排查方法解决方向
面板低但塞不进max_model_len 过大算单请求最坏块数调小 max_model_len
面板数字来回跳抢占频繁看抢占计数降并发或加显存
已用块数不归零僵尸块泄漏请求结束后看计数升级版本或重启
升级后数字突变统计口径变化对比业务指标调整告警阈值
并发低但显存空块大小不合适看请求长度分布调整 block_size

提示:排查这类问题,永远先算数再动手。把总块数、单请求最坏块数、当前并发这三个数写下来,大部分疑惑当场就解开了。

6.5 一个容易被忽略的细节:输入长度也在预留里

很多人只盯着输出长度,忘了输入长度也要占块。如果业务里有超长输入的请求(比如整篇文档做摘要),输入部分可能就占掉几百个块,加上输出预留,单请求胃口极大。这种情况下面板显示很低但就是塞不进第二条,因为第一条就把块吃光了。

处理办法是把长输入请求单独隔离,用专门的实例或者队列处理,别和普通短请求混在一起。混在一起的结果就是长请求把并发能力全占了,短请求饿死。

7. 我个人的几点实操体会

调这类问题调多了,会发现一个规律:面板数字是给人看的,调度器算的是另一套账。想让两者对上,要么改统计口径让面板反映真实准入状态,要么改参数让准入判断别那么保守。前者需要改代码,后者改配置就行,所以实际工作中都是从参数入手。

我一般会先做一个"容量账本":把总块数、块大小、max_model_len、业务长度分布这四个数固定下来,算出理论并发上限,然后拿这个上限去对比实际能跑的量。差距在 20% 以内算正常,超过 50% 就一定有配置问题。

还有一点,别迷信默认值。默认的 max_model_len 往往是模型支持的最大长度,对大多数业务来说都太大了。我见过太多案例,把 max_model_len 从 8192 降到 2048,并发直接翻三倍,面板数字反而更真实了。这个调整成本极低,收益极高,值得每个部署的人都试一次。

最后分享一个小技巧:在监控面板上除了 KV 百分比,再加一条"等待队列长度"和一条"单请求最坏块数"的曲线。这三条线放一起看,什么时候是真满、什么时候是假满,一眼就能分辨。光看一个百分比,永远会被它骗。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进