摘要
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):
helm repoaddkuberay https://ray-project.github.io/kuberay-helm/ helminstallkuberay-operator kuberay/kuberay-operator--version1.6.2 kubectl get pods# 确认 kuberay-operator Pod 处于 Running本地开发环境安装 Ray(用于编写/调试训练和数据处理代码):
pipinstall-U"ray[data,train,tune,serve]"# 支持 Python 3.10-3.13[data,train,tune,serve]是按用途拆分的可选依赖组,不需要 RLlib 或 Ray Core 的最小子集就不用装全量。
安装 vLLM(推理引擎,推荐用 uv 管理环境):
uv venv--python3.12--seed--managed-pythonsource.venv/bin/activate uv pipinstallvllm --torch-backend=auto官方明确建议不要从 conda 装 PyTorch 再装 vLLM——conda 版 PyTorch 会静态链接 NCCL,容易和 vLLM 自带的 NCCL 冲突,最好用全新虚拟环境安装。vLLM 预编译二进制默认基于 CUDA 12.9,支持 Python 3.10-3.13。
KubeRay 上拉起一个最小 Ray 集群:一个RayClusterCRD 由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 自动做背压控制:
importray ray.init()# 读取 Parquet 格式的文本数据(也支持 read_images / read_videos 等多模态格式)ds=ray.data.read_parquet("s3://your-bucket/raw-corpus/")defclean_and_tokenize(batch):# 这里放实际的清洗/分词逻辑,batch 是一个 dict of numpy arraysbatch["text"]=[t.strip()fortinbatch["text"]]returnbatch# 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+ScalingConfigAPI,配合 PyTorch 2.x 的 FSDP2(torch.distributed.fsdp.fully_shard)做模型分片训练:
importtorchimporttorch.nnasnnfromtorch.distributed.fsdpimportfully_shardimportray.trainfromray.train.torchimportTorchTrainerfromray.trainimportScalingConfig,RunConfigclassToyTransformerBlock(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))defforward(self,x):a,_=self.attn(x,x,x)returnself.ffn(x+a)classToyModel(nn.Module):def__init__(self,dim=256,n_layers=4):super().__init__()self.blocks=nn.ModuleList([ToyTransformerBlock(dim)for_inrange(n_layers)])defforward(self,x):forblockinself.blocks:x=block(x)returnxdeftrain_loop_per_worker(config):model=ToyModel()# FSDP2 用法:自底向上逐层包裹,再包裹根模块(PyTorch 官方推荐顺序)forblockinmodel.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()}")forstepinrange(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 模式把这两者编排在同一个训练循环里:
# 安装(官方推荐 Docker,这里给出核心两步)gitclone https://github.com/verl-project/verl&&cdverl pip3install-e".[vllm]"# 用 GRPO 算法跑后训练,rollout 阶段用 vLLM 做推理生成python3-mverl.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=1actor_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 包一层:
fromrayimportservefromray.serve.llmimportLLMConfig,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):
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 工具调用完全一样,直接连阶段四/五共用的那个地址:
fromopenaiimportOpenAIimportjson 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].functionprint(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 调用都是从官方文档和真实仓库逐字核实的——把演示配置换成生产规模的机器数和模型大小,就是一条能实际跑起来的生产链路。