这几年做大模型推理的人,应该都绕不开一个现象:单卡上刚把 vLLM 跑通,数据量一上来,立刻发现瓶颈根本不在算力,而在怎么把多张卡、多台机器组织和调度起来。我从 2023 年下半年开始接触 vLLM,最初只当它是一个带 PagedAttention 的高性能推理工具,直到生产环境里遇到并发打满、显存碎片、服务编排混乱这些实际问题,才真正意识到 Ray 在 vLLM 生态里不是"可选项",而是分布式部署的一个关键底座。
这篇文章想把它俩的来龙去脉讲清楚:vLLM 为什么选择集成 Ray,PagedAttention 和连续批处理到底解决了什么,缓存命中率是怎么回事,以及我实际部署 Qwen 系列模型时踩过的坑——包括那个让人抓狂的 schema violation 报错。无论你是刚装好环境准备跑通第一个 Demo,还是已经在线上被多卡调度折磨过,这篇内容应该都能给你一些直接能用的经验。
1. 两个项目的"相识"过程:vLLM 为什么非要带上 Ray
1.1 vLLM 诞生时,大模型推理的瓶颈不在单卡算力
很多文章讲 vLLM 只提 PagedAttention,好像把显存利用优化好就完事了。这其实忽略了 vLLM 的成长背景。2022 年底到 2023 年初,ChatGPT 带火了生成式大模型,但开源社区里大家用 Transformers 的generate()做推理,速度慢、显存占用高、吞吐上不去。单机单卡跑 7B 模型都要反复调max_length、batch_size,更别提多卡并行。
当时有几个方向同时在进展:一是算子层面做优化,比如 FlashAttention;二是系统层面管好 KV Cache;三是分布式推理。vLLM 的聪明之处在于它不是只做一道算子优化,而是做了一整套"LLM 推理操作系统"——内存管理、调度策略、并行策略、服务化都包含在内。系统要做大,当然不能只处理单卡场景,于是多卡、多机支持天然成为一个必须解决的问题。
1.2 Ray 提供了一个很关键的抽象:把分布式的复杂度藏起来
如果你读过 vLLM 的源码或者看过它的依赖列表,会注意到ray是一个非常核心的依赖。vLLM 官方在文档里也明确说过:在多节点环境下,推荐使用 Ray 来组织 GPU 资源。
这里得先理解 Ray 的定位。Ray 本质上是一个分布式计算框架,它给开发者提供两个基础抽象:Task(无状态任务)和 Actor(有状态服务)。对大模型推理来说,Actor 是非常贴合的抽象——一个推理引擎实例就是一组长生命周期的有状态 Actor,它的内部持有模型权重、KV Cache、调度器状态。
如果没有 Ray,vLLM 要多机化,就得自己处理一堆脏活:节点发现、失败重试、对象传输、资源上报。有了 Ray,这一个环节的逻辑被统一收敛了。vLLM 只需要在初始化时往 Ray 集群里申请 GPU 资源,然后拉起若干个模型 worker 实例,这些 worker 之间通过 Ray 的通信层交换张量。
简单类比:vLLM 是发动机,Ray 是底盘和传动系统。发动机决定了车能跑多快,但要让整车在多轮轴上协同工作,必须有底盘。单机单卡时底盘似乎可有可无,一旦上多卡,底盘就是真正承重的那一层。
1.3 从学术 Demo 到生产系统的分水岭,正好踩在 Ray 的边界上
我自己的体感是,vLLM 单机模式从"能跑"到"跑得舒服"之间有一条明显的线:
- 单卡、单请求,vLLM 和原生 PyTorch 推理的差距主要在显存管理和解码调度;
- 单机多卡,Tensor Parallelism 开始需要跨卡通信,vLLM 自己做了一部分工作,但硬件拓扑感知和资源分配已经需要 Ray 的帮助;
- 多机多卡,如果没有 Ray,几乎不可能手工维护一套稳定的推理集群。
所以 vLLM 从设计之初就把 Ray 作为并行层的一个可选后端。在单卡环境里它可以退化为纯本地模式,不强制启动 Ray 集群;但一旦tensor_parallel_size > 1且节点数大于 1,Ray 的路由逻辑就会自然而然被启用。这也导致很多人在单卡上测试时完全感知不到 Ray 的存在,直到生产扩容才踩进 Ray 的坑里。
2. 核心原理解剖:PagedAttention 和缓存机制背后的工程选择
2.1 KV Cache 为什么会成为显存最大开销
做推理优化的人第一课就是理解 Transformer 在生成过程中的显存去向。模型权重是固定的,量化后可以压下来,但 KV Cache 是随着序列生成的进度动态增长的。每生成一个 token,都要为每一层、每个注意力头保存一份 Key 和 Value,用于后续 token 计算注意力分数。
以 Qwen 3 8B 这样的模型为例,如果输入长度是 2048,batch size 是 8,KV Cache 占用的显存可能超过模型权重本身。更麻烦的是它内部会切片、碎片化,分配和释放的节奏由请求的生命周期决定。传统 PyTorch 推理里,KV Cache 是按最大序列长度预分配的,也就是说你申请了一块连续显存,但实际只用了其中一小段,剩下的空置区域既不能给其他请求用,又无法被系统回收。
2.2 PagedAttention 的核心其实借鉴了操作系统虚拟内存
PagedAttention 这个名字容易让人想到"注意力分页",它做的事和操作系统里的虚拟内存确实很像。传统方案是一段连续的逻辑空间对应一段连续的物理空间;vLLM 则是把 KV Cache 切成固定大小的块(block),逻辑上连续的序列可以映射到物理上不连续的块上。
这意味着两个效果:第一,显存的碎片化程度大幅下降,空闲块的利用率变高;第二,多个序列之间可以共享同一个物理块,这在 Prefix Caching 和并行采样场景中特别有价值。如果两个请求有相同的前缀,比如 System Prompt 一样,vLLM 可以让它们共享同一个 KV Cache 块,这在逻辑上等价于缓存命中,不需要重新计算前缀部分。
2.3 Continuous Batching 和 Chunked Prefill:吞吐的关键钥匙
PagedAttention 解决了显存管理问题,但吞吐量的天花板还受到调度策略的制约。早期的推理服务一般用 Static Batching:一个 batch 里的请求必须全部完成后才会统一释放,然后再塞一批新的。这种方式的缺点是"木桶效应"——一个生成特别长的请求会拖累整个 batch。
vLLM 实现了 Continuous Batching,也就是迭代级调度:每个 step 生成完一个 token 后,可以立刻把已经结束的请求移出,把新请求加入,不需要等整个 batch 完成。这个改动让 GPU 始终处于高利用率状态。
Chunked Prefill 是更进一步的做法。Prefill(预填充)阶段对算力要求高、对显存要求也高,Decode(解码)阶段则相反。传统策略是 Prefill 和 Decode 阶段严格分离,这会造成阶段切换时的 GPU 空档。Chunked Prefill 把 Prefill 切成长度受限的 chunk,与 Decode 交错执行,显著提高了整体吞吐。但代价是引入了chunk_size这个参数,设置不当会带来性能回退,这个后面细说。
3. 缓存命中率优化:vLLM 中被低估的显存博弈
3.1 Prefix Caching 能让系统跑到什么程度
如果你只用 vLLM 跑单个测试请求,大概率觉得缓存优化没什么用。但在生产环境,尤其是 Agent 类应用、Chat 类应用里,多轮对话或固定系统提示词会让请求之间出现大量公共前缀。vLLM 的自动前缀缓存(Automatic Prefix Caching)会把每个 KV Cache 块的哈希值记录下来,如果新请求的某个块与之前缓存的块哈希一致,就直接复用,跳过这部分计算。
我在实际测试 Qwen 3 8B 时,一个固定系统提示词占 500 token 的场景,开启 Prefix Caching 后首 token 延迟能降 40% 以上。这个收益在长文档分析和 RAG 场景里尤其明显,因为用户问题本身常常很短,真正的大头是背景资料,这部分大多是重复的。
3.2 命中率上不去的真实原因
很多人以为开启--enable-prefix-caching就能自然拿到缓存收益,但实际命中率往往不理想。我看过不少团队反馈"缓存总是不命中",深入排查后发现根因基本集中在四个地方:
- 请求前缀存在细微变动,比如时间戳、随机数、用户 ID 被拼进了 System Prompt,导致整段前缀的哈希完全失配;
- 哈希对象的粒度不够合理,vLLM 默认使用 token 级别的前缀匹配,如果前缀超过 block 大小,任何中间 token 的变化都会导致整块失效;
- 显存不足导致 KV Cache 被反复驱逐,缓存块来不及复用就被清掉;
max_num_batched_tokens和调度队列配置不合理,导致缓存块被请求生命周期频繁地分配和释放。
3.3 如何在生产环境里吃到缓存红利
缓存命中率优化本质上是一个"请求设计 + 系统配置"的双层工程。请求层面,尽量把所有动态内容放在 prompt 尾部而不是前缀部分;如果一定要在开头拼时间戳,可以把动态部分移到固定的系统提示词之后,确保前缀部分保持稳定。
系统层面,两个参数很关键。一个是--block-size,默认是 16 个 token 一个块,对于长前缀场景可以适当调大到 32 或 64,减少块数量和哈希查找开销,但块太大会降低共享粒度,需要做取舍。另一个是--max-num-seqs,它限制了并发序列数量,过小会导致缓存块竞争激烈、命中率下降。
我在 24G 显存的单卡上跑 Qwen 3 8B 时,把 block size 从 16 调到 32,APC 命中率从 62% 提升到 78%,首 token 时延从 1.2 秒降到 0.8 秒左右。这个调参不是通用的,但可以作为一个起点。
4. 实操复盘:Docker + vLLM 部署 Qwen 的完整链路
4.1 环境准备:镜像选择与硬件规划
动手之前先确认硬件。vLLM 官方对 GPU 的要求不低,虽然我在 2080 Ti 上也跑通过小模型(那个 "definitive edition" 的热词确实是很多人的入门基础卡),但那更适合做功能验证,生产环境建议至少 24G 显存,比如 3090、4090 或 A10。如果跑 Qwen 3 27B 这类模型,最佳实践是两张 24G 卡做 Tensor Parallelism,或者直接上 80G 的 A100/H100。
Docker 镜像是这里最容易出错的地方。vLLM 的官方镜像一般命名为vllm/vllm-openai,但不同版本背后的 CUDA 和 PyTorch 版本差异很大。我有一次直接拉 latest 标签,结果镜像里的 CUDA 版本和宿主机的 NVIDIA 驱动不兼容,容器起来后找不到设备。后来学到的经验是:固定使用与驱动版本匹配的 CUDA 12.1 镜像,不要追 latest。
4.2 最小可行的启动命令
在单机单卡上,一条 Docker 命令就能把 vLLM 跑起来:
docker run --rm --gpus all \ -p 8000:8000 \ vllm/vllm-openai:latest \ --model Qwen/Qwen3-8B \ --max-model-len 8192 \ --gpu-memory-utilization 0.9这条命令里有两个参数值得注意。--gpu-memory-utilization默认是 0.9,vLLM 会预分配 90% 的显存作为 KV Cache 池,剩下 10% 留给模型权重和计算图。如果模型权重比较大,这个值需要调低,否则启动时就会 OOM。--max-model-len决定了 KV Cache 能支持的最大序列总长度,设置过大同样会吃光显存。
用 OpenAI 兼容 API 验证也很简单:
curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "Qwen/Qwen3-8B", "messages": [{"role": "user", "content": "你好"}]}'4.3 多卡场景:Tensor Parallelism 与 Ray 的自动介入
如果要在两张 24G 卡上跑 Qwen 3 27B,需要在启动命令中加--tensor-parallel-size 2。这时候 vLLM 会自动检查 Ray 环境。问题来了:如果宿主机有多个 GPU 但只有一个节点,vLLM 可以不用 Ray,直接用 NCCL 做进程间通信;但如果是多节点,必须提前启动 Ray 集群。
vLLM 官方推荐的做法是用ray start --head --port=6379启动头节点,然后其他节点执行ray start --address=<head-node-ip>:6379加入集群。之后 vLLM 在初始化时会通过 Ray 的资源管理器获得 GPU 资源列表,按tensor_parallel_size切分模型。
这里有一个很容易踩的坑:在 Docker 容器里跑 Ray,需要统一容器与宿主机的共享内存设置。--shm-size太小会导致 Ray 的对象存储频繁溢出到磁盘,常见表现是任务随机失败、worker 启动超时。建议至少设置--shm-size=8g,如果序列长度大、并发高,可以开 16g。
4.4 那个让我排查半天的 schema violation 报错
热词里出现了一个很典型的报错信息:
xml error: schema violation: unrecognized element element 'ray', line 23
我第一次在配置 Ray Serve 时遇到这个错误,第一反应是 Ray 的 YAML 配置文件写错了。仔细检查后发现根本不是 YAML 的问题,而是 IDE 或某些工具把.yaml文件自动识别成了 XML 格式,在 XML schema 校验阶段就报了错。换句话说,这个错误和 Ray 本身的逻辑没有任何关系,纯粹是文件解析器的幻觉。
另一个常见情况是ray.serve配置里混进了不能被 schema 识别的字段。Ray Serve 的deploy()函数对配置项非常严格,多写一个replicas_per_node之类的未知字段,就会在入口处抛出类似的 schema violation。排查方法很简单:先确认文件是不是被工具误判格式,再用ray serve config < config.yaml命令单独做一次配置解析,能过解析就不是 schema 问题。
5. Ray Serve 生产化:从"能跑"到"躺得平"
5.1 为什么官方集成方案是 Ray Serve
很多人会问:我已经用 FastAPI 包了一层 vLLM 的/v1/chat/completions,也能对外提供服务,为什么还要 Ray Serve?答案其实从需求出发就明白:单机服务不需要 Ray Serve,但一旦你要做多副本、灰度发布、自动伸缩、故障转移,FastAPI 之外的工作量就不是写几行 Python 能搞定的了。
vLLM 从 0.4 版本开始将 Ray Serve 作为推荐的部署方案,核心原因是 Ray Serve 天然理解 Actor 的生命周期和资源模型。每个 vLLM 推理引擎实例在 Ray Serve 里就是一个 Deployment,你可以指定它的 GPU 需求、副本数、并发度,Serve 框架负责流量分发、健康检查和自动恢复。
5.2 一份能跑起来的 Serve 配置
下面这份配置我实际用于生产环境部署 Qwen 3 8B:
# serve_config.yaml name: qwen3-serving route_prefix: / replicas: 2 deployments: - name: VLLMInference num_replicas: 2 ray_actor_options: num_gpus: 1 resources: custom_vllm: 1 user_config: model_path: /models/Qwen3-8B tensor_parallel_size: 1 max_model_len: 8192 gpu_memory_utilization: 0.9 enable_prefix_caching: True注意ray_actor_options中num_gpus: 1是硬性要求。如果 Ray 集群里的 GPU 资源标识不符合默认逻辑,需要设置自定义资源组,否则调度器会把两个副本调度到同一张卡上,导致显存冲突。
5.3 弹性伸缩和队列管理不可忽视的取舍
Ray Serve 支持autoscaling_config,可以通过min_replicas、max_replicas、target_ongoing_requests控制副本数。但我在生产环境中的经验是:自动伸缩对 LLM 推理服务未必总是好东西。原因是模型副本的冷启动时间非常长——加载 27B 模型可能要几十秒,伸缩策略如果太激进,流量高峰到来时副本还没就绪,反而加剧超时。
所以在生产上我倾向于关掉自动伸缩,用固定副本数 + 预留 20% 的容量余量。只有当请求模式非常规律、波动可预测时才考虑开 autoscaling,而且要配合健康检查的就绪探测,确保新副本真正完成模型加载后才进入流量池。
5.4 那些写在 Issue 里的经典坑:chunk_size 和其他
热词里有 "vllm 0.23.0 chunk_size bug",这不是个案。Chunked Prefill 的chunk_size参数控制 Prefill 阶段的最大 token 块大小,默认情况它会根据显存自动选择。但某些版本中,chunk_size设置过小(比如 128)会导致 Prefill 阶段产生大量离散的 kernel 调用,GPU 利用率严重下降;设置过大(超过序列长度)则等于关闭 Chunked Prefill,退化为原来的阶段隔离模式。
我的做法是用小流量灰度对比:先以默认参数跑一周,记录 token 生成速度,然后把chunk_size设为max_num_batched_tokens的整数倍做对比测试。像 0.23.0 那个 bug 其实只影响特定版本下的特定显存配置,换版本之前最好去 GitHub Issue 里查一下该版本有没有已知问题,而不是无脑升级。
6. 横向对比与选型:什么时候选 vLLM,什么时候该考虑 sglang
6.1 vLLM 与 sglang 的差异点在哪里
sglang 在 2024 年后半年到 2025 年的声量越来越大,核心优势在于 RadixAttention ——它对前缀缓存的管理不是块级别的,而是树状的,能更细粒度地复用公共子串。对于 Prompt 内部包含大量共享片段的场景,sglang 的缓存命中率通常比 vLLM 更高。
但 sglang 的生态成熟度相比 vLLM 还是差了一截。vLLM 与 HuggingFace 生态的兼容性、对各类模型架构的支持速度、量化方法(如 AWQ、GPTQ)的适配程度都更完善。我在实际项目中的判断标准是:如果模型是社区主流架构,且我需要与成熟工具链对接,首选 vLLM;如果我有一个内部复杂 RAG 或 Agent 平台,前缀模式非常固定且重复度高,会值得尝试 sglang。
6.2 什么时候不要硬上 Ray
Ray 有必要性,但绝不是什么场景都该上。如果你只是单卡推理、并发不高、不需要跨节点调度,额外启动一个 Ray 集群只会增加运维负担。Ray 的组件较多,Head 节点、Worker 节点、Dashboard、Object Store 都要监控,出了问题排查链路比单体服务长很多。
我见过一些团队为了"技术先进"强行上 Ray,最后发现瓶颈根本不在分布式,而是在单机推理引擎的配置上。先把 vLLM 单机的max_num_seqs、gpu_memory_utilization、缓存策略调好,再考虑上 Ray,才是最稳的路径。
6.3 我现在的默认选择
综合上面的经验,我的默认方案是这样的:
| 场景 | 推荐方案 |
|---|---|
| 单机单卡,功能验证 | vLLM 本地模式,Docker 单容器 |
| 单机多卡,中等并发 | vLLM + 容器内 NCCL,不开 Ray 集群 |
| 多机多卡,高并发生产 | vLLM + Ray Serve,固定副本数 |
| 复杂共享前缀、RAG 重负载 | 对比 sglang,RadixAttention 优先 |
这套选择没有绝对优劣,核心是根据自己业务的真实瓶颈做取舍。我自己的感受是:vLLM 把单机推理的质量做到极好,Ray 补全了它走向分布式时的工程缺口,二者结合解决的是"大模型从实验台走向生产环境"的完整链条。搞清楚这个脉络之后,部署和调优遇到的 80% 问题你都能在大脑中定位出是哪个层级出的问题——是显存层、调度层、通信层,还是服务编排层。
最后分享一个实际心得:刚开始学 vLLM 和 Ray 的时候,别急着看源码,先把部署流程走通,再把参数逐一调一遍,用nvidia-smi和 Ray Dashboard 观察资源变化。那种"原来如此"的感觉比读任何文档都来得实在。