
1. 三层调度各管一段先把边界划清楚很多人第一次把 vLLM 塞进 Kubernetes 集群、外面再套一层 Ray 的时候都会有一个直觉性的困惑这三个东西名字里都带调度那到底谁说了算我刚开始接触这套组合的时候也绕过弯一度以为 Ray 的调度能直接决定 GPU 上跑哪个 kernel或者以为 K8s 的 scheduler 能感知到 vLLM 内部的请求排队情况。实测下来这三者的调度完全不在一个层面上它们各自解决的是完全不同粒度的问题混在一起谈就会越想越乱。先把结论摆出来Kubernetes 调度的是Pod 落在哪台机器上Ray 调度的是任务/actor 落在哪个节点或哪个 GPU 上vLLM 调度的是这一批推理请求怎么拼成 batch、什么时候发给 GPU 执行。三者是嵌套关系不是竞争关系。K8s 在最外层决定资源归属Ray 在中间层决定计算任务的分布vLLM 在最内层决定单次前向传播里塞哪些请求。理解这个嵌套关系有个很实用的类比把整个集群想象成一栋写字楼。K8s 是物业负责把公司分配到具体楼层和房间它关心的是这层楼有没有电、有没有网、面积够不够。Ray 是公司内部的行政负责把员工分到具体工位它关心的是这个工位有没有显示器、是不是靠窗。vLLM 是员工本人负责决定手头这几份文件按什么顺序处理、哪些可以一起批处理。物业不会管你文件怎么批行政也不会管你 Pod 里跑的是什么模型。这个边界划清楚之后很多为什么我的 GPU 利用率上不去为什么扩缩容不生效的问题就有了排查方向。GPU 利用率低大概率是 vLLM 层的 batch 没拼满而不是 K8s 调度出了问题扩缩容不生效可能是 Ray 的 actor 没有正确释放资源导致 K8s 看到的指标和实际不符。下面我按从外到内的顺序把每一层的决策逻辑拆开讲。需要提前说明的是本文涉及的实操细节一部分来自我自己的部署经验一部分是基于这套技术栈的常见实践做的合理补充。不同版本之间行为可能有差异具体以你实际使用的版本为准。2. Kubernetes 调度它只认资源声明不认模型2.1 K8s scheduler 的决策输入到底是什么K8s 的调度器在决定一个 Pod 去哪个节点时看的东西非常物理节点的可分配资源CPU、内存、GPU 数量、节点亲和性、污点与容忍、Pod 亲和性与反亲和、拓扑分布约束。它完全不知道这个 Pod 里跑的是 vLLM 还是 nginx也不知道这个 Pod 要加载的是 7B 模型还是 70B 模型。它只认你在 YAML 里声明的resources.requests和resources.limits。这就是为什么 GPU 资源的声明方式特别关键。在 K8s 里GPU 是通过设备插件device plugin暴露成扩展资源的通常写作nvidia.com/gpu。你声明nvidia.com/gpu: 1调度器就找一个还有至少 1 张空闲 GPU 的节点。注意这里的关键词是空闲——它做的是计数不是能力匹配。一张 RTX 4060 Laptop 和一张 A100 在 K8s 眼里都是1 个 GPU 单位除非你用节点标签和亲和性手动区分。resources: limits: nvidia.com/gpu: 1 requests: nvidia.com/gpu: 1上面这段是 GPU Pod 的标准写法。有个坑我踩过requests 和 limits 必须都写而且值要相等。GPU 是不可压缩资源K8s 不允许你 request 0.5 张、limit 1 张这种写法除非用了 MIG 或时间片共享方案。如果你只写 limits 不写 requestsK8s 会默认 requests 等于 limits行为上没问题但显式写出来更清晰。2.2 为什么 GPU 节点常常看起来有空闲却调度不上去这是新手最容易困惑的场景nvidia-smi显示 GPU 显存还剩一大半但新 Pod 就是 Pending事件里写着Insufficient nvidia.com/gpu。原因在于 K8s 的 GPU 计数是独占式的——一张卡被一个 Pod 声明了整张卡就从可分配池里扣掉了哪怕那个 Pod 实际只用了 10% 的显存。K8s 不看你显存用了多少它只看设备插件上报的这张卡有没有被分配。要解决这个问题有几条路。一是用 MIGMulti-Instance GPU把一张物理卡切成多个逻辑实例每个实例作为独立资源暴露这样计数粒度就细了。二是用 GPU 共享方案比如时间片共享让多个 Pod 声明同一张卡。三是干脆在应用层做多模型共卡也就是一个 vLLM 进程里加载多个模型或者用 Ray 把多个小任务塞到同一个 GPU 上。选哪条路取决于你的模型大小和隔离要求MIG 隔离性最好但只支持特定卡型时间片共享灵活但会有相互干扰。还有一个隐蔽的坑节点上的 GPU 被非 K8s 管理的进程占用了。比如你手动在节点上跑了个训练脚本没关设备插件可能仍然上报这张卡可用但实际分配出去之后 vLLM 启动会 OOM。这种情况 K8s 层面看不出来得去节点上nvidia-smi确认。我现在的习惯是给所有 GPU 节点加一个启动前的健康检查脚本确认没有游离进程再让设备插件上报。2.3 拓扑感知调度多卡场景下不能忽略的一环单卡场景下 K8s 调度相对简单但一旦涉及多卡推理比如张量并行节点内的 GPU 拓扑就变得很重要。同一台机器上的多张卡它们之间的互联带宽可能不一样——有的走 PCIe有的走 NVLink。如果 vLLM 用张量并行跨了卡而这两张卡之间恰好是慢速互联性能会明显打折。K8s 本身不感知 GPU 拓扑但可以通过一些机制间接处理。一是用节点标签把同构的机器分组比如gpu-topologynvlink-8然后让 Pod 通过 nodeAffinity 落到对应组。二是用支持拓扑感知的设备插件部分厂商提供它会在分配时尽量把同一 Pod 的多张卡放在互联最快的组合上。三是干脆在应用层规避比如把张量并行度设成不超过单机 NVLink 域的大小。我个人的经验是如果你的集群里机器型号混杂一定要给节点打上足够细的标签至少区分 GPU 型号、卡数、互联类型。否则 K8s 会把你的 70B 模型调度到一台只有两张卡且互联很慢的机器上然后你会花半天时间怀疑是 vLLM 的问题。3. Ray 调度把计算任务铺到集群的中间层3.1 Ray 的调度单元是 task 和 actor不是 PodRay 这一层的调度粒度比 K8s 细但比 vLLM 粗。它的基本调度单元是task无状态函数调用和actor有状态的长生命周期进程。当你用 Ray Serve 部署 vLLM 时每个模型副本通常是一个 actorRay 负责把这个 actor 放到某个节点的某个 GPU 上。Ray 的调度决策依赖几个东西资源声明num_gpus、num_cpus、自定义资源、调度策略默认是 spread尽量分散也可以设成 pack尽量集中、以及节点上的可用资源。和 K8s 类似Ray 对 GPU 也是计数式的一个 actor 声明num_gpus1就占掉一个 GPU 单位。serve.deployment( ray_actor_options{num_gpus: 1, num_cpus: 4} ) class VLLMDeployment: ...上面这段是 Ray Serve 里声明 GPU 资源的典型写法。这里有个容易忽略的点Ray 的 GPU 计数和 K8s 的 GPU 计数是两套独立的账本。如果 Ray 跑在 K8s 之上也就是 Ray cluster 本身是 K8s 里的 Pod那么 K8s 先把 GPU 分配给 Ray 的 worker PodRay 再在这些 Pod 内部把 GPU 分配给具体的 actor。两层账本如果对不齐就会出现K8s 认为卡被占了但 Ray 认为卡还空着或者反过来Ray 想分配但 K8s 没给够的情况。3.2 Ray 和 K8s 的账本对齐问题这个对齐问题在实际部署里非常常见。举个具体场景你在 K8s 里起了一个 Ray clusterhead 节点 0 张卡两个 worker 节点各 4 张卡。K8s 层面这两个 worker Pod 各声明了nvidia.com/gpu: 4。Ray 层面这两个节点各上报 4 个 GPU 资源。看起来一致对吧但如果你在 Ray 的 worker Pod 里又跑了别的东西占用了 GPU或者 Ray 的资源配置没和 K8s 的声明完全对应账本就会错位。更隐蔽的是资源超卖Ray 允许你声明num_gpus0.5但 K8s 的 GPU 是不可分割的除非 MIG。如果 Ray 把一个 GPU 分给了两个各要 0.5 的 actor而 K8s 层面这张卡只被一个 Pod 声明那么这两个 actor 实际上在抢同一张卡可能互相 OOM。我的处理方式是在 Ray 层尽量用整数 GPU 声明和 K8s 的粒度保持一致。如果确实需要共享就在 K8s 层用 MIG 切好让每个 MIG 实例作为独立资源暴露然后 Ray 层按 MIG 实例数声明。这样两层账本天然对齐排查问题也简单。3.3 Ray 的调度策略选择spread 还是 packRay 默认的调度策略是 spread也就是尽量把 actor 分散到不同节点。这对无状态服务是合理的能提高容错。但对 vLLM 这种吃 GPU 带宽和显存的服务spread 未必最优。如果你的模型用了张量并行需要多个 GPU 协同那么把相关的 actor 放在同一节点甚至同一 NVLink 域内会更高效这时候就该考虑 pack 策略或者用 placement group 显式约束。pg placement_group( [{GPU: 2, CPU: 8}], strategyPACK )上面这段用 placement group 把 2 张 GPU 和 8 个 CPU 打包在一起Ray 会尽量把它们放在同一节点。对于张量并行度等于 2 的 vLLM 部署这种约束能避免跨节点通信带来的延迟。代价是资源碎片化——如果节点剩余资源凑不出这个包actor 就得等。所以 pack 策略适合资源充足、追求性能的场景spread 适合资源紧张、追求吞吐的场景。4. vLLM 调度真正决定 GPU 上跑什么的那一层4.1 EngineCore、Scheduler、Executor 的分工vLLM 内部的调度是三层结构里最贴近硬件的也是决定实际推理性能的关键。它的核心组件包括EngineCore、Scheduler和Executor。EngineCore 是引擎的主循环负责驱动整个流程Scheduler 决定每一步哪些请求进入 batchExecutor 负责把 batch 实际下发到 GPU 执行。这个流程可以这样理解请求进来先进 waiting 队列Scheduler 每一步从 waiting 和 running 队列里挑一批请求拼成一个 batch交给 Executor。Executor 调用模型的前向传播算出结果Scheduler 再根据结果更新每个请求的状态——有的完成了就移出有的还在生成就留在 running 队列有的因为显存不够被抢占就退回 waiting。这里的关键约束是KV cache 的显存。vLLM 的招牌技术 PagedAttention 把 KV cache 切成块来管理Scheduler 在拼 batch 时必须确保这些块的显存够用。如果不够它就得做抢占preemption——把某些请求的 KV cache 换出或直接丢弃腾出空间给新请求。这个决策直接影响吞吐和延迟抢占太激进请求反复重算延迟飙升抢占太保守显存利用率上不去吞吐受限。4.2 连续批处理vLLM 调度的核心机制vLLM 调度最核心的机制是连续批处理continuous batching。传统批处理是等一批请求凑齐了一起跑跑完再跑下一批中间 GPU 会空转。连续批处理则是每一步都重新组 batch——某个请求生成完了立刻从 waiting 队列补一个新请求进来GPU 几乎不停。这个机制对调度器的要求很高。它需要在每一步都做决策哪些 running 请求继续、哪些新请求加入、显存够不够、要不要抢占。vLLM 的 Scheduler 用了一套基于块的管理逻辑来处理这些。实测下来连续批处理在请求长度差异大的场景下优势特别明显——短请求不会被长请求拖住长请求也不会因为等短请求而浪费算力。但连续批处理也有代价。batch 里的请求长度不一时GPU 的有效利用率会下降因为要按最长的那个请求来 padding。vLLM 通过 PagedAttention 和 chunked prefill 等技术缓解这个问题但没法完全消除。如果你的场景里请求长度极其不均匀可能需要在应用层做一点请求整形比如把相似长度的请求路由到同一个副本。4.3 抢占与重算显存不够时的取舍当显存不够时vLLM 的 Scheduler 会触发抢占。抢占有两种方式swap把 KV cache 换到 CPU 内存和recompute直接丢弃需要时重算。swap 省算力但占 CPU 内存和带宽recompute 省内存但费算力。vLLM 会根据配置和当前状态选择。这个机制直接关系到你在 K8s 和 Ray 层看到的资源使用情况。如果 vLLM 频繁抢占GPU 利用率可能看起来很高但实际吞吐很低因为大量算力花在了重算上。这时候你去 K8s 层看GPU 是满的去 Ray 层看actor 是健康的但端到端延迟就是下不来。排查这类问题必须深入到 vLLM 的日志和指标看抢占次数、KV cache 使用率、batch 大小这些内部数据。我一般会在 vLLM 启动参数里显式设置gpu_memory_utilization默认是 0.9意思是拿 90% 的显存给 KV cache 和模型权重。如果你的模型权重就占了大部分显存留给 KV cache 的空间就少抢占就会频繁。这时候要么换更大的卡要么降低max_model_len要么调低gpu_memory_utilization给系统留余量。这几个参数的取舍需要根据实际负载压测来确定没有万能值。5. 三层调度串起来看一个请求的完整旅程5.1 从 HTTP 请求到 GPU kernel 的路径把三层串起来一个推理请求的完整路径是这样的请求先到 Ray Serve 的入口Ray 根据路由策略把它分给某个 vLLM actor。这个 actor 所在的 Pod 是 K8s 早就调度好的Pod 里的 vLLM 进程持有 K8s 分配的那张 GPU。请求进入 vLLM 后EngineCore 把它放进 waiting 队列Scheduler 在下一步把它拼进 batchExecutor 把 batch 下发到 GPUGPU 执行 kernel 算出 token结果逐层返回。这条路径上任何一层出问题表现可能都是请求慢或请求失败但根因完全不同。K8s 层的问题通常是 Pod 起不来、被驱逐、或者调度到了错误的节点。Ray 层的问题通常是 actor 分配不到资源、路由到了过载的副本、或者 actor 崩溃重启。vLLM 层的问题通常是 batch 拼不满、抢占频繁、或者模型本身的问题。排查的时候要从外到内逐层确认。先看 K8s 的 Pod 状态和事件确认 Pod 健康、调度正确。再看 Ray 的 dashboard 或日志确认 actor 分布合理、没有资源饥饿。最后看 vLLM 的指标确认 batch 大小、KV cache 使用率、抢占次数在合理范围。这个顺序能帮你快速定位问题在哪一层避免在错误的层面上瞎折腾。5.2 三层调度的资源账本对照为了更直观地看清三层的差异我整理了一张对照表维度KubernetesRayvLLM调度单元Podtask / actor请求 / batch资源粒度整卡或 MIG 实例整数或小数 GPUKV cache 块决策依据资源声明、亲和性、污点资源声明、调度策略、placement group显存、请求长度、优先级时间尺度分钟级Pod 生命周期秒级actor 生命周期毫秒级每步迭代失败表现Pending、Evicted资源饥饿、actor 重启抢占、OOM、延迟飙升调优手段标签、亲和性、MIGplacement group、策略batch 参数、显存比例这张表的核心信息是三层的决策时间尺度差了三个数量级。K8s 的决策几分钟才做一次Ray 几秒一次vLLM 每毫秒都在做。所以当负载快速变化时K8s 和 Ray 的反应是滞后的真正扛住瞬时压力的是 vLLM 的调度。这也解释了为什么单纯靠 K8s 的 HPA 扩缩容应对突发流量往往不够——等新 Pod 起来vLLM 那边可能已经抢占了好几轮了。5.3 一个真实的排查案例说个我实际遇到的例子。有段时间线上服务的 P99 延迟突然从 800ms 涨到 3s但 GPU 利用率、Pod 状态、actor 健康度看起来都正常。按从外到内的顺序排查K8s 层Pod 没有重启调度节点没变Ray 层actor 数量没变没有资源饥饿到 vLLM 层看日志发现抢占次数暴涨。进一步查发现是上游有个业务方改了请求模式把大量超长请求和超短请求混在一起发。超长请求占着 KV cache 不放超短请求进来发现显存不够触发抢占把超长请求的 cache 换出去然后超长请求又要重算来回折腾。根因不在调度层而在请求模式。解决方案是在 Ray Serve 层加了一个路由策略按请求的预估长度分流到不同的 vLLM 副本让长请求和短请求各自成批。改完之后 P99 回到了 900ms 左右。这个案例的教训是三层调度各自健康不代表整体健康。问题可能出在层与层之间的交互上或者出在输入模式上。排查时不能只看单层指标要把请求的完整路径串起来看。6. 部署时的几个实操取舍6.1 什么时候该用 Ray什么时候直接上 K8s不是所有场景都需要 Ray 这一层。如果你的服务很简单就是几个固定的 vLLM 副本每个副本独立处理请求那么直接用 K8s Deployment 管理 Pod 就够了Ray 反而是多余的复杂度。Ray 的价值在于需要动态调度、多模型编排、或者复杂的资源分配策略的场景。具体来说这几种情况值得引入 Ray一是你要在同一个集群里跑多个不同大小的模型需要根据负载动态调整各模型的副本数二是你要做模型组合比如一个请求要经过多个模型处理需要编排三是你要用 Ray 的 autoscaling 能力根据队列长度自动增减 actor。如果只是单模型固定副本K8s 的 HPA 加 vLLM 自身的批处理已经够用。我见过一些团队为了技术栈统一硬上 Ray结果运维复杂度翻倍收益却不明显。技术选型要看实际需求不要为了用而用。6.2 GPU 显存分配的三个关键参数在 vLLM 启动时有三个参数直接决定显存怎么分值得单独说。gpu_memory_utilization控制 vLLM 能用多少比例的显存默认 0.9。max_model_len控制单个请求的最大长度直接影响 KV cache 的单请求占用。max_num_seqs控制同时处理的请求数上限影响 batch 大小。这三个参数是相互制约的。显存总量固定max_model_len越大单个请求占的 KV cache 越多能同时处理的请求数就越少。max_num_seqs设得越大理论吞吐越高但每个请求分到的显存越少长请求更容易被抢占。调参的思路是先根据业务的实际请求长度分布定max_model_len然后根据显存余量定max_num_seqs最后用压测验证gpu_memory_utilization是否合适。我的经验是不要一上来就把gpu_memory_utilization拉到 0.95。留一点余量给 CUDA context、临时 buffer 和系统开销能避免很多莫名其妙的 OOM。0.85 到 0.9 之间通常是个稳妥的区间具体看模型和卡型。6.3 监控要看到哪一层三层调度意味着监控也得分层做。K8s 层看 Pod 状态、节点资源、调度事件Ray 层看 actor 分布、资源使用、队列长度vLLM 层看 batch 大小、KV cache 使用率、抢占次数、首 token 延迟和每 token 延迟。很多人只监控了 K8s 层和端到端延迟中间两层的指标缺失导致出问题时只能看到慢看不到为什么慢。我建议至少把 vLLM 的 Prometheus 指标接出来重点关注vllm:num_requests_running、vllm:gpu_cache_usage_perc、vllm:num_requests_swapped这几个。这几个指标能直接反映调度器的工作状态是定位性能问题的第一手数据。Ray 层如果用了它的 dashboard 自带不少指标但要注意 Ray 的指标和 K8s 的指标口径可能不一致对账的时候要小心。比如 Ray 上报的 GPU 使用率和 K8s 上报的可能有差异因为统计口径不同。以实际nvidia-smi的输出为准其他指标作为参考。7. 几个容易混淆的概念澄清7.1 调度这个词在三层里的不同含义最后澄清几个概念这些是我在和技术同行交流时发现最容易混淆的地方。K8s 的调度是放置placement把一个整体放到一个位置上放完就不怎么动了。Ray 的调度是分配allocation把任务分配到资源上任务结束就释放。vLLM 的调度是编排orchestration在资源约束下决定执行顺序和批次每一步都在变。这三个词在英文里其实有区分scheduling、allocation、orchestration但中文都翻译成调度就混了。理解了这个区别很多困惑就解开了。比如为什么 K8s 不能根据 GPU 利用率自动调度——因为它的职责是放置不是编排利用率这种动态指标不在它的决策范围内。7.2 GPU 利用率高不等于调度好还有一个反直觉的点GPU 利用率高不代表调度做得好。前面提过频繁抢占会让 GPU 一直忙但忙的是重算实际吞吐很低。真正健康的调度是 GPU 利用率高且抢占次数低且batch 大小稳定。单看利用率一个指标会误导判断。我一般会同时看三个指标GPU 利用率、KV cache 使用率、抢占率。理想状态是利用率高、cache 使用率在 80% 到 90% 之间、抢占率接近零。如果利用率高但抢占率也高说明显存不够得调参数或加卡。如果利用率低但 cache 使用率高说明 batch 拼得不好可能是请求长度太不均匀。这套判断逻辑我在多个项目里用过基本能覆盖大部分性能问题的初步定位。当然具体问题还得具体分析但有个清晰的判断框架排查效率会高很多。7.3 版本差异带来的行为变化最后提醒一点vLLM 的调度行为在不同版本之间可能有明显差异。它的迭代速度很快Scheduler 的实现、抢占策略、默认参数都可能变。我遇到过升级版本之后吞吐下降的情况排查发现是新版本的默认max_num_seqs变了。所以升级前一定要看 changelog升级后一定要压测对比。K8s 和 Ray 相对稳定但也不是完全不变。K8s 的调度器框架在演进Ray 的调度策略也在调整。生产环境升级任何一层之前都建议在测试环境跑一遍完整的负载测试确认行为符合预期再上。这套三层架构的复杂度摆在那里谨慎一点不亏。