
摘要
K8s + Ray + PyTorch + vLLM 是当前大模型工程里出现频率很高的一套组合,但网上大多数文章只谈"为什么好",很少给出真正能跑、能验证的完整链路。这篇文章分两部分:先做定位分析------四个组件到底各自解决什么问题、边界在哪里;再给出一条贯穿数据处理、预训练、后训练(RLHF)、在线推理、Agent 五个阶段的最小可运行示例,每一段代码和命令都对照官方文档(Ray、vLLM、KubeRay、verl)核实过,不是道听途说的拼凑。
背景与问题
"K8s+Ray+vLLM 三件套"这个说法这两年很火,但仔细看会发现两个常见误区:
第一个误区是把四个组件当成平级的可替换选项,而不是分层职责。 K8s 管的是物理资源调度(Pod 放哪台机器),Ray 管的是应用层调度(进程/Actor 放哪个 Pod、CPU/GPU 阶段怎么编排),PyTorch 是训练框架,vLLM 是推理引擎。四者不是"选一个就够"的关系,而是叠在一起才完整------单独拿 vLLM 出来是没法处理数据、也没法调度多机训练的,它只负责"给定一个模型和一批请求,尽可能快地把结果吐出来"。
第二个误区是只看"训练"和"推理"两端,忽略了中间的后训练(RLHF/post-training)阶段其实是这套组合价值最集中的地方。 训练用 PyTorch 生态(FSDP、Megatron),推理用 vLLM,这两端分开看都好理解;难的是 RLHF 这种训练和推理要在同一个训练循环里反复切换的场景------策略模型每轮既要用 FSDP 算梯度,又要用 vLLM 生成 rollout,这正是 Ray 作为"全局调度层"存在的意义:由 Ray 的 Single-Controller 模式把训练 worker 和推理 worker 编排在一起,而不是各自为政。
这篇文章会按四层职责把每个组件的定位说清楚,再用一条完整链路把它们串起来验证。
核心思路与优势
四层职责定位
| 层级 | 组件 | 具体职责 |
|---|---|---|
| 物理资源调度 | Kubernetes | 把 Pod 调度到具体节点,管理 GPU 资源配额、网络、存储卷 |
| 应用层调度与编排 | Ray(KubeRay 负责在 K8s 上管理 Ray 集群) | 把 Actor/Task 分配到 Pod 内的具体进程,支持异构 CPU/GPU 资源请求、动态伸缩、细粒度容错 |
| 训练框架 | PyTorch 生态(FSDP2、Megatron-LM 等) | 提供分布式训练的具体实现------模型分片、梯度同步、混合精度 |
| 推理引擎 | vLLM | 用 PagedAttention 和 continuous batching 做高吞吐推理,同时提供 OpenAI 兼容 API |
这四层里,K8s 和 Ray 的边界最容易混淆:K8s 的调度粒度是 Pod,一旦 Pod 起来了,K8s 不管这个 Pod 内部怎么再分配任务;Ray 恰好接着往下管,把 Pod 内的资源再细分给不同的 Ray Actor,这才能实现"同一个 Pod 里跑 CPU 解码 Actor 和 GPU 推理 Actor"这种细粒度混合调度。KubeRay 就是这两层的粘合剂------它是一个 K8s Operator,提供 RayCluster、RayJob、RayService 三种 CRD,让 K8s 原生地管理 Ray 集群的生命周期。
安装配置
安装 KubeRay operator(管理 Ray 集群的 K8s Operator):
bash
helm repo add kuberay https://ray-project.github.io/kuberay-helm/
helm install kuberay-operator kuberay/kuberay-operator --version 1.6.2
kubectl get pods # 确认 kuberay-operator Pod 处于 Running
本地开发环境安装 Ray(用于编写/调试训练和数据处理代码):
bash
pip install -U "ray[data,train,tune,serve]" # 支持 Python 3.10-3.13
[data,train,tune,serve] 是按用途拆分的可选依赖组,不需要 RLlib 或 Ray Core 的最小子集就不用装全量。
安装 vLLM(推理引擎,推荐用 uv 管理环境):
bash
uv venv --python 3.12 --seed --managed-python
source .venv/bin/activate
uv pip install vllm --torch-backend=auto
官方明确建议不要从 conda 装 PyTorch 再装 vLLM------conda 版 PyTorch 会静态链接 NCCL,容易和 vLLM 自带的 NCCL 冲突,最好用全新虚拟环境安装。vLLM 预编译二进制默认基于 CUDA 12.9,支持 Python 3.10-3.13。
KubeRay 上拉起一个最小 Ray 集群: 一个 RayCluster CRD 由 headGroupSpec(头节点,暴露 6379 GCS 端口、8265 Dashboard 端口、10001 Client 端口)和一至多个 workerGroupSpecs(工作节点组,可配置 minReplicas/maxReplicas 做自动伸缩)组成,官方提供了从最小示例到生产级配置的完整样例(ray-cluster.complete.yaml),实际部署时建议直接改这个文件而不是从零写。
面向人群
- 正在搭建或评估大模型训练/推理基础设施的平台工程师,想知道这套组合具体怎么落地而不是停留在架构图层面
- 做 RLHF/后训练研究,需要在训练和推理之间高效切换资源的算法工程师
- 想理解"为什么不能只用 vLLM 就够了"这类定位问题的技术决策者
实践步骤:贯穿大模型全生命周期的最小可运行示例
以下五段代码覆盖数据处理→预训练→后训练→在线推理→Agent。受限于篇幅和硬件门槛,"预训练"和"后训练"两段用的是小模型/小规模配置做可验证的最小示例,不是生产级训练脚本------但用的都是官方真实 API 和真实命令行入口,把模型和数据规模换大就是生产配置,架构不需要重新设计。
阶段一:数据处理(Ray Data)
Ray Data 是这条链路里真正干"处理"这个词的引擎------不是 vLLM。它用流式执行模型,支持在同一条 pipeline 里混合 CPU 算子(解码、清洗)和 GPU 算子(embedding 提取),并按 stage 自动做背压控制:
python
import ray
ray.init()
# 读取 Parquet 格式的文本数据(也支持 read_images / read_videos 等多模态格式)
ds = ray.data.read_parquet("s3://your-bucket/raw-corpus/")
def clean_and_tokenize(batch):
# 这里放实际的清洗/分词逻辑,batch 是一个 dict of numpy arrays
batch["text"] = [t.strip() for t in batch["text"]]
return batch
# CPU 阶段:清洗
ds = ds.map_batches(clean_and_tokenize, batch_format="numpy", num_cpus=1)
# 写出处理后的分片,供下一阶段(预训练)读取
ds.write_parquet("s3://your-bucket/processed-corpus/")
map_batches 的 num_cpus/num_gpus 参数就是前面说的"Ray 在 Pod 内做细粒度资源分配"的具体体现------同一条 ds 链上不同的 map_batches 调用可以请求不同的资源,Ray Data 会自动做流水线编排。
阶段二:预训练(Ray Train + PyTorch FSDP2)
这一段用 Ray 官方验证过的 TorchTrainer + ScalingConfig API,配合 PyTorch 2.x 的 FSDP2(torch.distributed.fsdp.fully_shard)做模型分片训练:
python
import torch
import torch.nn as nn
from torch.distributed.fsdp import fully_shard
import ray.train
from ray.train.torch import TorchTrainer
from ray.train import ScalingConfig, RunConfig
class ToyTransformerBlock(nn.Module):
def __init__(self, dim=256):
super().__init__()
self.attn = nn.MultiheadAttention(dim, num_heads=4, batch_first=True)
self.ffn = nn.Sequential(nn.Linear(dim, dim * 4), nn.GELU(), nn.Linear(dim * 4, dim))
def forward(self, x):
a, _ = self.attn(x, x, x)
return self.ffn(x + a)
class ToyModel(nn.Module):
def __init__(self, dim=256, n_layers=4):
super().__init__()
self.blocks = nn.ModuleList([ToyTransformerBlock(dim) for _ in range(n_layers)])
def forward(self, x):
for block in self.blocks:
x = block(x)
return x
def train_loop_per_worker(config):
model = ToyModel()
# FSDP2 用法:自底向上逐层包裹,再包裹根模块(PyTorch 官方推荐顺序)
for block in model.blocks:
fully_shard(block)
fully_shard(model)
optimizer = torch.optim.AdamW(model.parameters(), lr=config["lr"])
ctx = ray.train.get_context()
print(f"worker rank={ctx.get_world_rank()} world_size={ctx.get_world_size()}")
for step in range(config["train_steps"]):
x = torch.randn(8, 32, 256) # 演示用随机数据,真实场景替换为阶段一产出的分片
loss = model(x).mean()
loss.backward()
optimizer.step()
optimizer.zero_grad()
trainer = TorchTrainer(
train_loop_per_worker=train_loop_per_worker,
train_loop_config={"lr": 1e-4, "train_steps": 100},
scaling_config=ScalingConfig(num_workers=4, use_gpu=True),
run_config=RunConfig(name="toy-pretrain-demo"),
)
result = trainer.fit()
ScalingConfig(num_workers=4, use_gpu=True) 就是"要几台机器、要不要 GPU"这个决策的完整代码化------把 num_workers 从 4 改成 400,把 ToyModel 换成真实的模型定义,就是从演示脚本变成生产预训练任务的完整路径,中间不需要改架构。完整的官方 FSDP2 教程见 docs.ray.io 的 Ray Train PyTorch FSDP2 示例。
阶段三:后训练/RLHF(verl,Ray + FSDP + vLLM 协同的地方)
这是整条链路里 Ray 价值最直接的一段:RLHF 训练循环里,策略模型既要用 FSDP 算梯度更新参数,又要频繁用 vLLM 生成 rollout 样本,两个角色的资源需求完全不同(训练要显存装优化器状态,推理要显存装 KV cache),verl(字节跳动开源的 HybridFlow 论文实现)用 Ray 的 Single-Controller 模式把这两者编排在同一个训练循环里:
bash
# 安装(官方推荐 Docker,这里给出核心两步)
git clone https://github.com/verl-project/verl && cd verl
pip3 install -e ".[vllm]"
# 用 GRPO 算法跑后训练,rollout 阶段用 vLLM 做推理生成
python3 -m verl.trainer.main_ppo \
algorithm.adv_estimator=grpo \
algorithm.use_kl_in_reward=False \
actor_rollout_ref.model.path=Qwen/Qwen2.5-0.5B-Instruct \
actor_rollout_ref.actor.use_kl_loss=True \
actor_rollout_ref.actor.fsdp_config.param_offload=False \
actor_rollout_ref.rollout.name=vllm \
actor_rollout_ref.rollout.tensor_model_parallel_size=1 \
actor_rollout_ref.rollout.gpu_memory_utilization=0.5 \
data.train_files=$HOME/data/gsm8k/train.parquet \
data.val_files=$HOME/data/gsm8k/test.parquet \
trainer.n_gpus_per_node=1 \
trainer.nnodes=1 \
trainer.total_epochs=1
actor_rollout_ref.rollout.name=vllm 这一行就是训练和推理交接的地方------verl 把 vLLM 作为可插拔的 rollout 后端(另一个选项是 SGLang),训练阶段用的是 actor_rollout_ref.actor.fsdp_config.* 系列参数控制的 FSDP/FSDP2(大规模场景可换成 Megatron-LM 参数)。这个命令是从 verl 官方仓库的真实 PPO / GRPO 示例脚本精简而来,把模型路径和 trainer.n_gpus_per_node/trainer.nnodes 换成实际规模,就是生产配置------verl 官方给出的参考规模是最高可扩展到 671B 模型和数百张 GPU。
阶段四:在线推理(Ray Serve LLM,vLLM 作为后端引擎)
单机部署直接 vllm serve 就够;需要多副本自动伸缩、和其他 Ray 应用共享集群资源时,用 Ray Serve LLM 把 vLLM 包一层:
python
from ray import serve
from ray.serve.llm import LLMConfig, build_openai_app
llm_config = LLMConfig(
model_loading_config={
"model_id": "qwen2.5-7b-instruct",
"model_source": "Qwen/Qwen2.5-7B-Instruct",
},
deployment_config={
"autoscaling_config": {"min_replicas": 1, "max_replicas": 2},
},
accelerator_type="A10G",
engine_kwargs={"tensor_parallel_size": 1},
)
app = build_openai_app({"llm_configs": [llm_config]})
serve.run(app, blocking=True)
这段代码的整体结构(LLMConfig 字段、build_openai_app 用法)逐字来自 Ray 官方文档(docs.ray.io/en/latest/serve/llm),官方示例原本用的是 Qwen2.5-0.5B-Instruct + tensor_parallel_size=2,这里换成 Qwen2.5-7B-Instruct + tensor_parallel_size=1(7B 模型单张 A10G 显存足够,不需要再切两张卡),原因见阶段五末尾的说明。engine_kwargs 里能传的参数和 vllm serve 命令行的参数基本对齐,也就是说 vLLM 本身的能力(PagedAttention、量化、多模态支持)在 Ray Serve LLM 这层完全没有损耗,Ray 只是加了一层自动伸缩和多模型路由。跑起来之后,serve.run 暴露的就是一个标准 OpenAI 兼容的 HTTP 端点,服务已经在跑,阶段五不需要再起一个新的。
阶段五:Agent(复用阶段四的服务,追加工具调用能力)
Agent 应用本质上是"LLM 推理 + 工具调用循环"。阶段四已经有一个 Ray Serve LLM 服务在跑了,这里不需要另起一个 vLLM 进程 ------Ray Serve LLM 的 engine_kwargs 会把 vLLM 的工具调用相关参数原样透传给底层引擎,只需要在阶段四的 LLMConfig 里追加两个 key,重新 serve.run 一次,同一个服务就同时具备了普通推理和工具调用能力(这个透传机制来自 Ray/Anyscale 官方文档:docs.anyscale.com/llm/serving/tool-function-calling):
python
llm_config = LLMConfig(
model_loading_config={
"model_id": "qwen2.5-7b-instruct",
"model_source": "Qwen/Qwen2.5-7B-Instruct",
},
deployment_config={
"autoscaling_config": {"min_replicas": 1, "max_replicas": 2},
},
accelerator_type="A10G",
engine_kwargs={
"tensor_parallel_size": 1,
"enable_auto_tool_choice": True, # 对应 vllm serve 的 --enable-auto-tool-choice
"tool_call_parser": "hermes", # 对应 vllm serve 的 --tool-call-parser
},
)
app = build_openai_app({"llm_configs": [llm_config]})
serve.run(app, blocking=True) # 重新部署同一个 app,端口和地址不变
tool_call_parser 选 hermes 是因为 Qwen2.5 系列的 tokenizer_config.json 已经内置了 Hermes 风格的工具调用模板(这一点来自 vLLM 官方工具调用文档 的模型支持列表),不需要像部分模型那样额外指定 --chat-template 文件------这也是选 Qwen2.5-7B 而不是别的模型的原因,能让阶段四和阶段五的配置差异只有这两行 engine_kwargs,不用节外生枝地处理模板文件。
客户端代码和标准 OpenAI SDK 工具调用完全一样,直接连阶段四/五共用的那个地址:
python
from openai import OpenAI
import json
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取给定位置的当前天气",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location", "unit"],
},
},
}]
messages = [{"role": "user", "content": "旧金山现在天气怎么样?"}]
response = client.chat.completions.create(
model="qwen2.5-7b-instruct",
messages=messages,
tools=tools,
tool_choice="auto",
)
tool_call = response.choices[0].message.tool_calls[0].function
print(f"模型请求调用函数:{tool_call.name},参数:{tool_call.arguments}")
# Agent 循环的下一步:实际执行 get_weather(**json.loads(tool_call.arguments)),
# 把结果作为 role="tool" 的消息追加回 messages,再发一次 chat.completions.create
# 拿到模型基于工具结果生成的最终回答------这就是最小可运行的 Agent 闭环。
这段客户端代码的结构(tools 定义、tool_choice="auto"、解析 tool_calls)参照 vLLM 官方工具调用文档 的示例写法,model 字段换成了阶段四/五 LLMConfig 里定义的 model_id("qwen2.5-7b-instruct"),因为请求打到的是 Ray Serve LLM 暴露的端点,模型标识要和部署时注册的 model_id 对齐,而不是 HuggingFace 上的原始模型路径。这也是整条链路首尾呼应的地方:数据处理产出的语料喂给预训练,预训练模型经 verl 做 RLHF 后训练,训练完的模型经 Ray Serve LLM 上线,上线后的同一个 OpenAI 兼容端点,加两行 engine_kwargs 就直接被 Agent 应用复用,全程只有一个服务、一套 K8s + Ray 调度,没有另起炉灶。
(如果只是想脱离 Ray、单独验证 vLLM 的工具调用能力,vllm serve Qwen/Qwen2.5-7B-Instruct --enable-auto-tool-choice --tool-call-parser hermes 单独跑起来也能达到同样效果------但这是一条独立的调试路径,不是这条全链路 pipeline 的一部分。)
小结
回到最初的问题:这套组合不是"四选一",而是分层叠加------K8s 管物理资源,Ray 管应用层编排(尤其是训练和推理要交替的 RLHF 场景),PyTorch 提供训练能力,vLLM 提供推理能力。上面五段代码里,除了预训练和后训练用的是小规模演示配置,其余的安装命令和 API 调用都是从官方文档和真实仓库逐字核实的------把演示配置换成生产规模的机器数和模型大小,就是一条能实际跑起来的生产链路。