PD 分离整个流程是怎么样的, 怎么创建进程的?中文回答
PD分离(Prefill/Decode分离)流程详解
整体架构流程
PD分离是vLLM的实验性功能,将LLM推理的prefill和decode阶段分离到不同的vLLM实例中,以独立优化TTFT和ITL 1 。
核心组件
- Proxy服务器:作为请求路由器,协调prefill和decode实例
- Prefill实例:处理初始prompt,计算KV cache
- Decode实例:基于传输的KV cache进行token生成
- KV Connector:负责KV cache在实例间的传输
请求处理流程
Decode Prefill Proxy Client Decode Prefill Proxy Client 发送请求 发送prefill请求(max_tokens=1) 返回KV transfer参数 发送原始请求+KV参数 流式返回结果 返回最终结果
具体实现中,代理服务器首先将请求发送到prefill实例(设置max_tokens=1),获取KV传输参数后,再将原始请求转发给decode实例 2 。
进程创建机制
1. 通过Shell脚本启动
典型的启动流程通过shell脚本实现,如run_accuracy_test.sh:
bash
# 启动prefill实例
CUDA_VISIBLE_DEVICES=0 \
VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve Qwen/Qwen3-0.6B \
--port 8100 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both"}'
# 启动decode实例
CUDA_VISIBLE_DEVICES=1 \
VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve Qwen/Qwen3-0.6B \
--port 8200 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both"}'
# 启动代理服务器
python toy_proxy_server.py \
--port 8192 \
--prefiller-hosts localhost \
--prefiller-ports 8100 \
--decoder-hosts localhost \
--decoder-ports 8200
```[3](#1-2)
### 2. 多实例部署
对于多实例部署,脚本会循环启动多个prefill和decode实例:
```bash
# 启动多个prefill实例
for i in $(seq 0 $((NUM_PREFILL_INSTANCES-1))); do
GPU_ID=$((i % $(get_num_gpus)))
PORT=$((8100 + i))
# 启动命令...
done
# 启动多个decode实例
for i in $(seq 0 $((NUM_DECODE_INSTANCES-1))); do
GPU_ID=$(((i + NUM_PREFILL_INSTANCES) % $(get_num_gpus)))
PORT=$((8200 + i))
# 启动命令...
done
```[4](#1-3)
### 3. 进程管理
- **GPU分配**:通过`CUDA_VISIBLE_DEVICES`控制每个实例使用的GPU
- **端口管理**:prefill实例使用8100+端口,decode实例使用8200+端口
- **侧通道通信**:通过`VLLM_NIXL_SIDE_CHANNEL_PORT`设置KV传输的侧通道端口
- **进程清理**:使用`pkill -f "vllm serve"`清理所有vLLM进程 [5](#1-4)
## KV传输机制
### NIXL连接器
生产环境主要使用NIXL连接器进行RDMA传输:
1. **握手阶段**:decode实例通过ZMQ连接prefill实例获取元数据
2. **内存注册**:双方注册GPU内存区域
3. **数据传输**:通过RDMA直接传输KV cache数据 [6](#1-5)
### P2P NCCL连接器
另一种实现使用P2P NCCL:
- 每个实例创建`P2pNcclEngine`
- 通过ZMQ建立控制通道
- NCCL组仅包含两个rank(prefill和decode) [7](#1-6)
## 调度策略
代理服务器实现轮询调度:
```python
def get_next_client(app, service_type: str):
if service_type == "prefill":
client_idx = next(app.state.prefill_iterator)
return app.state.prefill_clients[client_idx]
elif service_type == "decode":
client_idx = next(app.state.decode_iterator)
return app.state.decode_clients[client_idx]
```[8](#1-7)
## Notes
- PD分离主要用于优化延迟而非吞吐量
- 生产环境建议使用专用的负载均衡器替代简单的轮询代理
- 不同connector(NIXL、P2P NCCL、Mooncake等)适用于不同的网络环境
- 进程创建通常通过容器编排系统(如Kubernetes)管理,而非手动shell脚本
Wiki pages you might want to explore:
- [KV Cache Transfer and Disaggregated Serving (vllm-project/vllm)](/wiki/vllm-project/vllm#9.4)
### Citations
**File:** docs/features/disagg_prefill.md (L8-16)
```markdown
## Why disaggregated prefilling?
Two main reasons:
- **Tuning time-to-first-token (TTFT) and inter-token-latency (ITL) separately**. Disaggregated prefilling put prefill and decode phase of LLM inference inside different vLLM instances. This gives you the flexibility to assign different parallel strategies (e.g. `tp` and `pp`) to tune TTFT without affecting ITL, or to tune ITL without affecting TTFT.
- **Controlling tail ITL**. Without disaggregated prefilling, vLLM may insert some prefill jobs during the decoding of one request. This results in higher tail latency. Disaggregated prefilling helps you solve this issue and control tail ITL. Chunked prefill with a proper chunk size also can achieve the same goal, but in practice it's hard to figure out the correct chunk size value. So disaggregated prefilling is a much more reliable way to control tail ITL.
!!! note
Disaggregated prefill DOES NOT improve throughput.
File: docs/features/nixl_connector_usage.md (L74-111)
markdown
```bash
# 1st GPU as prefiller
CUDA_VISIBLE_DEVICES=0 \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve Qwen/Qwen3-0.6B \
--port 8100 \
--enforce-eager \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both","kv_load_failure_policy":"fail"}'
Consumer (Decoder) Configuration
Start a decoder instance that consumes KV caches:
bash
# 2nd GPU as decoder
CUDA_VISIBLE_DEVICES=1 \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve Qwen/Qwen3-0.6B \
--port 8200 \
--enforce-eager \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both","kv_load_failure_policy":"fail"}'
Proxy Server
Use a proxy server to route requests between prefiller and decoder:
bash
python tests/v1/kv_connector/nixl_integration/toy_proxy_server.py \
--port 8192 \
--prefiller-hosts localhost \
--prefiller-ports 8100 \
--decoder-hosts localhost \
--decoder-ports 8200
**File:** tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh (L106-110)
```shellscript
cleanup_instances() {
echo "Cleaning up any running vLLM instances..."
pkill -f "vllm serve" || true
sleep 2
}
File: tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh (L138-150)
shellscript
# Start prefill instances
for i in $(seq 0 $((NUM_PREFILL_INSTANCES-1))); do
# Calculate GPU ID - we'll distribute across available GPUs
GPU_ID=$((i % $(get_num_gpus)))
NEXT_GPU=${GPU_ID}
# If PREFILLER_TP_SIZE is more than 1
for (( j=1; j < PREFILLER_TP_SIZE; j++ )); do
NEXT_GPU=$(((GPU_ID + j) % $(get_num_gpus)))
GPU_ID="${GPU_ID},${NEXT_GPU}"
done
# Calculate port number (base port + instance number)
PORT=$((8100 + i))
File: docs/design/p2p_nccl_connector.md (L41-47)
markdown
### P2P Communication via ZMQ & NCCL
As long as the address of the counterpart is known, point-to-point KV cache transfer (using NCCL) can be performed, without being constrained by rank and world size. To support dynamic scaling (expansion and contraction) of instances with PD disaggregation. This means that adding or removing P/D instances does not require a full system restart.
Each P/D instance only needs to create a single `P2pNcclEngine` instance. This instance maintains a ZMQ Server, which runs a dedicated thread to listen on the `zmq_addr` address and receive control flow requests from other instances. These requests include requests to establish an NCCL connection and requests to send KVCache metadata (such as tensor shapes and data types). However, it does not actually transmit the KVCache data itself.
When a P instance and a D instance transmit KVCache for the first time, they need to establish a ZMQ connection and an NCCL group. For subsequent KVCache transmissions, this ZMQ connection and NCCL group are reused. The NCCL group consists of only two ranks, meaning the world size is equal to 2. This design is intended to support dynamic scaling, which means that adding or removing P/D instances does not require a full system restart. As long as the address of the counterpart is known, point-to-point KVCache transmission can be performed, without being restricted by rank or world size.
VLLM_NIXL_SIDE_CHANNEL_PORT 含义解析
VLLM_NIXL_SIDE_CHANNEL_PORT 是 vLLM 中用于配置 NIXL 连接器侧通道通信端口的环境变量 1 。
NIXL 简介
NIXL (NVIDIA Infrastructure eXtension Library) 是一个高性能的 GPU 内存传输库,支持 RDMA(远程直接内存访问)等传输方式。在 vLLM 的 PD 分离架构中,NIXL 负责在 prefill 和 decode 实例之间传输 KV cache 数据 2 。
侧通道端口的作用
侧通道端口主要用于建立控制通道,实现以下功能:
- 握手通信:decode 实例通过 ZMQ 连接 prefill 实例获取元数据
- 元数据交换:传输内存地址、张量形状、数据类型等信息
- 连接建立 :为实际的 RDMA 数据传输做准备 3
使用示例
在启动 prefill 和 decode 实例时,需要设置不同的侧通道端口:
bash
# Prefill 实例
VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve model --port 8100 \
--kv-transfer-config '{"kv_connector":"NixlConnector"}'
# Decode 实例
VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve model --port 8200 \
--kv-transfer-config '{"kv_connector":"NixlConnector"}'
```[4](#2-3)
## 端口分配规则
- **默认值**:5600 [5](#2-4)
- **多实例部署**:每个实例需要唯一端口,通常按 `base_port + rank` 分配 [6](#2-5)
- **跨主机部署**:不同主机可以使用相同端口号 [7](#2-6)
## 通信流程
```mermaid
sequenceDiagram
participant D as Decode Instance
participant P as Prefill Instance
D->>P: ZMQ连接(VLLM_NIXL_SIDE_CHANNEL_PORT)
D->>P: 发送GET_META_MSG请求
P->>D: 返回NixlHandshakePayload
D->>D: 解析元数据,建立RDMA连接
D<->>P: 通过RDMA传输KV cache数据
Notes
- 侧通道仅用于控制信令,实际的 KV cache 数据通过 RDMA 传输
- 每个 vLLM worker 进程都需要唯一的侧通道端口
- 在生产环境中,建议使用专用的网络配置优化 NIXL 性能
Wiki pages you might want to explore:
Wiki pages you might want to explore:
- Configuration and Initialization (vllm-project/vllm)
- KV Cache Transfer and Disaggregated Serving (vllm-project/vllm)
Citations
File: vllm/envs.py (L178-179)
python
VLLM_NIXL_SIDE_CHANNEL_HOST: str = "localhost"
VLLM_NIXL_SIDE_CHANNEL_PORT: int = 5600
File: vllm/envs.py (L1288-1290)
python
"VLLM_NIXL_SIDE_CHANNEL_PORT": lambda: int(
os.getenv("VLLM_NIXL_SIDE_CHANNEL_PORT", "5600")
),
File: docs/features/nixl_connector_usage.md (L1-4)
markdown
# NixlConnector Usage Guide
NixlConnector is a high-performance KV cache transfer connector for vLLM's disaggregated prefilling feature. It provides fully asynchronous send/receive operations using the NIXL library for efficient cross-process KV cache transfer.
File: docs/features/nixl_connector_usage.md (L74-98)
markdown
```bash
# 1st GPU as prefiller
CUDA_VISIBLE_DEVICES=0 \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve Qwen/Qwen3-0.6B \
--port 8100 \
--enforce-eager \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both","kv_load_failure_policy":"fail"}'
Consumer (Decoder) Configuration
Start a decoder instance that consumes KV caches:
bash
# 2nd GPU as decoder
CUDA_VISIBLE_DEVICES=1 \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve Qwen/Qwen3-0.6B \
--port 8200 \
--enforce-eager \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both","kv_load_failure_policy":"fail"}'
**File:** docs/features/nixl_connector_usage.md (L115-119)
```markdown
- `VLLM_NIXL_SIDE_CHANNEL_PORT`: Port for NIXL handshake communication
- Default: 5600
- **Required for both prefiller and decoder instances**
- Each vLLM worker needs a unique port on its host; using the same port number across different hosts is fine
- For TP/DP deployments, each worker's port on a node is computed as: base_port + dp_rank (e.g., with `--data-parallel-size=2` and base_port=5600, dp_rank 0..1 use port 5600, 5601 on that node).
File: vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py (L690-723)
python
@staticmethod
def _nixl_handshake_listener(
encoded_data: dict[int, Any],
ready_event: threading.Event,
stop_event: threading.Event,
port: int,
):
"""Background thread for getting new NIXL handshakes."""
# NOTE(rob): this is a simple implementation. We will move
# to a better approach via HTTP endpoint soon.
# Listen for new requests for metadata.
host = envs.VLLM_NIXL_SIDE_CHANNEL_HOST
path = make_zmq_path("tcp", host, port)
logger.debug("Starting listening on path: %s", path)
with zmq_ctx(zmq.ROUTER, path) as sock:
sock.setsockopt(zmq.RCVTIMEO, 1000)
ready_event.set()
while True:
try:
identity, _, msg = sock.recv_multipart()
except zmq.Again:
if stop_event.is_set():
break
continue
# Decode the message which contains (GET_META_MSG, rank)
msg, target_tp_rank = msgspec.msgpack.decode(msg)
logger.debug(
"Received message for tp rank %s",
target_tp_rank,
)
if msg != GET_META_MSG:
logger.warning("Connection listener got unexpected message %s", msg)
sock.send_multipart((identity, b"", encoded_data[target_tp_rank]))
File: tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh (L151-161)
shellscript
# Calculate side channel port. Avoid clash with with TP workers.
SIDE_CHANNEL_PORT=$((5559 + i))
echo "Starting prefill instance $i on GPU $GPU_ID, port $PORT"
# Build the command with or without model-specific args
BASE_CMD="CUDA_VISIBLE_DEVICES=$GPU_ID \
VLLM_KV_CACHE_LAYOUT='HND' \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=$SIDE_CHANNEL_PORT \
vllm serve $model_name \
The user wants a detailed explanation of the code. Let me break it down comprehensively in Chinese.
详细解释:vLLM 分离式预填充代理服务器
一、整体架构
┌─────────────────────────────────────────────────────────┐
│ 客户端 (Client) │
└─────────────────────┬───────────────────────────────────┘
│ HTTP请求
┌─────────────────────▼───────────────────────────────────┐
│ ProxyServer (port:8000) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Proxy │ │
│ │ ┌─────────────┐ ┌──────────────────────┐ │ │
│ │ │ APIRouter │ │ SchedulingPolicy │ │ │
│ │ │ /v1/... │ │ RoundRobin轮询 │ │ │
│ │ └─────────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└───────────┬──────────────────────┬──────────────────────┘
│ │
┌───────────▼──────┐ ┌──────────▼──────────┐
│ Prefill 节点组 │ │ Decode 节点组 │
│ localhost:8100 │ │ localhost:8200 │
│ localhost:8101 │ │ localhost:8201 │
└──────────────────┘ └─────────────────────┘
二、常量与初始化
python
AIOHTTP_TIMEOUT = aiohttp.ClientTimeout(total=6 * 60 * 60)
# 设置超时时间为6小时
# 因为大模型推理可能非常耗时,避免连接被提前断开
logger = logging.getLogger()
logging.basicConfig(level=logging.INFO)
# 配置日志系统,INFO级别会输出普通运行信息
三、调度策略(抽象 + 实现)
3.1 抽象基类
python
class SchedulingPolicy(ABC):
@abstractmethod
def schedule(self, cycler: itertools.cycle):
raise NotImplementedError("Scheduling Proxy is not set.")
作用:定义调度接口规范
设计模式:策略模式 (Strategy Pattern)
好处:可以轻松替换不同调度算法
比如:轮询、最小负载、随机等
3.2 轮询调度实现
python
class RoundRobinSchedulingPolicy(SchedulingPolicy):
def schedule(self, cycler: itertools.cycle) -> str:
return next(cycler)
itertools.cycle 原理:
instances = ["A", "B", "C"]
cycler = itertools.cycle(instances)
next(cycler) → "A"
next(cycler) → "B"
next(cycler) → "C"
next(cycler) → "A" ← 无限循环
next(cycler) → "B"
...
效果:请求均匀分配到每个节点
四、Proxy 核心类详解
4.1 初始化
python
def __init__(
self,
prefill_instances: list[str], # 预填充节点列表
decode_instances: list[str], # 解码节点列表
model: str, # 模型名称
scheduling_policy: SchedulingPolicy,
custom_create_completion: Callable | None = None, # 自定义完成接口
custom_create_chat_completion: Callable | None = None, # 自定义聊天接口
):
self.prefill_instances = prefill_instances
self.decode_instances = decode_instances
# 为prefill和decode分别创建独立的循环器
self.prefill_cycler = itertools.cycle(prefill_instances)
self.decode_cycler = itertools.cycle(decode_instances)
self.model = model
self.scheduling_policy = scheduling_policy
# 支持自定义路由处理函数(扩展点)
self.custom_create_completion = custom_create_completion
self.custom_create_chat_completion = custom_create_chat_completion
self.router = APIRouter()
self.setup_routes() # 注册路由
4.2 路由注册
python
def setup_routes(self):
# POST /v1/completions - 文本补全接口
# dependencies 用于请求前置验证(必须是JSON)
self.router.post(
"/v1/completions",
dependencies=[Depends(self.validate_json_request)]
)(
# 支持自定义handler,默认用内置的
self.custom_create_completion
if self.custom_create_completion
else self.create_completion
)
# POST /v1/chat/completions - 聊天接口
self.router.post(
"/v1/chat/completions",
dependencies=[Depends(self.validate_json_request)]
)(
self.custom_create_chat_completion
if self.custom_create_chat_completion
else self.create_chat_completion
)
# GET /status - 查看当前节点状态
self.router.get("/status", response_class=JSONResponse)(self.get_status)
# POST /instances/add - 动态添加节点(需要API Key认证)
self.router.post(
"/instances/add",
dependencies=[Depends(self.api_key_authenticate)]
)(self.add_instance_endpoint)
五、安全认证
5.1 Content-Type 验证
python
async def validate_json_request(self, raw_request: Request):
content_type = raw_request.headers.get("content-type", "").lower()
if content_type != "application/json":
raise HTTPException(
status_code=415, # Unsupported Media Type
detail="Unsupported Media Type: Only 'application/json' is allowed",
)
流程:
请求进来 → 检查 Content-Type Header
→ 必须是 "application/json"
→ 否则返回 415 错误
防止:恶意或错误格式的请求
5.2 API Key 认证(管理接口保护)
python
def api_key_authenticate(self, x_api_key: str = Header(...)):
# 从环境变量读取正确的Key
expected_api_key = os.environ.get("ADMIN_API_KEY")
# 如果服务器没配置Key,直接报500
if not expected_api_key:
logger.error("ADMIN_API_KEY is not set in the environment.")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Server configuration error.",
)
# Key不匹配,返回403
if x_api_key != expected_api_key:
logger.warning("Unauthorized access attempt with API Key: %s", x_api_key)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Forbidden: Invalid API Key.",
)
使用方式:
curl -X POST http://localhost:8000/instances/add \
-H "X-Api-Key: your_secret_key" \
-H "Content-Type: application/json" \
-d '{"type":"decode","instance":"localhost:8202"}'
环境变量配置:
export ADMIN_API_KEY="your_secret_key"
六、核心请求流程
6.1 文本补全 create_completion
python
async def create_completion(self, raw_request: Request):
try:
request = await raw_request.json()
# ① 构造预填充请求(只生成1个token)
kv_prepare_request = request.copy()
kv_prepare_request["max_tokens"] = 1
# 目的:触发prefill节点处理prompt
# 生成KV Cache后通过vLLM内部机制传给decode节点
# 自身只生成1个token(最小代价)
# ② 选择prefill节点(轮询)
prefill_instance = self.schedule(self.prefill_cycler)
# ③ 发送到prefill节点,等待完成
try:
async for _ in self.forward_request(
f"http://{prefill_instance}/v1/completions",
kv_prepare_request
):
continue # 消耗掉响应,不关心内容
except HTTPException as http_exc:
# prefill失败 → 移除该节点
self.remove_instance_endpoint("prefill", prefill_instance)
raise http_exc
# ④ 选择decode节点
decode_instance = self.schedule(self.decode_cycler)
# ⑤ 发送完整请求到decode节点,流式返回给客户端
try:
generator = self.forward_request(
f"http://{decode_instance}/v1/completions",
request
)
except HTTPException as http_exc:
self.remove_instance_endpoint("decode", decode_instance)
raise http_exc
return StreamingResponse(generator)
except Exception:
exc_info = sys.exc_info()
print("Error occurred in disagg proxy server")
print(exc_info)
完整流程图:
客户端: "给我讲个故事" (max_tokens=100)
│
▼
┌─────────────┐
│ Proxy代理 │
└──────┬──────┘
│
│ ① 修改请求: max_tokens=1
▼
┌─────────────────┐
│ Prefill节点 │ ← 处理"给我讲个故事"
│ localhost:8100 │ 生成KV Cache
│ 只生成1个token │ 通过共享内存/网络传KV Cache
└─────────────────┘
│
│ ② KV Cache 传输完成
▼
┌─────────────────┐
│ Decode节点 │ ← 接收KV Cache
│ localhost:8200 │ 开始逐token生成
│ 生成100个token │ "从前有座山..."
└────────┬────────┘
│
│ ③ 流式返回
▼
客户端
6.2 聊天补全 create_chat_completion
python
async def create_chat_completion(self, raw_request: Request):
try:
request = await raw_request.json()
kv_prepare_request = request.copy()
kv_prepare_request["max_tokens"] = 1
# 额外处理 max_completion_tokens 参数
# (OpenAI新API用这个字段替代max_tokens)
if "max_completion_tokens" in kv_prepare_request:
kv_prepare_request["max_completion_tokens"] = 1
# 后续流程与 create_completion 相同
prefill_instance = self.schedule(self.prefill_cycler)
async for _ in self.forward_request(
f"http://{prefill_instance}/v1/chat/completions",
kv_prepare_request
):
continue
decode_instance = self.schedule(self.decode_cycler)
generator = self.forward_request(
f"http://{decode_instance}/v1/chat/completions",
request
)
return StreamingResponse(content=generator)
except Exception:
exc_info = sys.exc_info()
error_messages = [str(e) for e in exc_info if e]
# 聊天接口:错误也以流式返回
return StreamingResponse(
content=iter(error_messages),
media_type="text/event-stream"
)
七、HTTP转发核心
python
async def forward_request(self, url, data, use_chunked=True):
async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session:
# 转发OpenAI API Key
headers = {
"Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}"
}
try:
async with session.post(url=url, json=data, headers=headers) as response:
# 2xx 成功 或 4xx 客户端错误 → 正常处理
if 200 <= response.status < 300 or 400 <= response.status < 500:
if use_chunked:
# 流式传输:每次读取1024字节
async for chunk_bytes in response.content.iter_chunked(1024):
yield chunk_bytes # 这是个异步生成器
else:
# 非流式:一次性读取全部
content = await response.read()
yield content
# 5xx 服务器错误 → 抛出异常
else:
error_content = await response.text()
try:
error_content = json.loads(error_content)
except json.JSONDecodeError:
error_content = error_content
raise HTTPException(
status_code=response.status,
detail=f"Request failed: {error_content}",
)
except aiohttp.ClientError as e:
# 网络连接错误
raise HTTPException(
status_code=502, # Bad Gateway
detail="Error communicating with upstream server.",
) from e
状态码处理策略:
200-299 ✅ 成功响应 → 流式转发给客户端
400-499 ⚠️ 客户端错误 → 也转发(让客户端知道请求有问题)
500-599 ❌ 服务器错误 → 抛出异常,不转发
网络异常 ❌ → 返回502 Bad Gateway
八、动态节点管理
8.1 添加节点
python
async def add_instance_endpoint(self, request: Request):
data = await request.json()
instance_type = data.get("type") # "prefill" 或 "decode"
instance = data.get("instance") # "localhost:8202"
# ① 参数验
app = FastAPI()
app.include_router(self.proxy_instance.router)
config = uvicorn.Config(app, port=self.port, loop="uvloop")
server = uvicorn.Server(config) self.router = APIRouter() 这是在干嘛
这几行代码是在**创建并启动一个 FastAPI 异步 Web 服务器**,具体作用如下:
```python
self.router = APIRouter()
- 创建一个 APIRouter 实例。
- APIRouter 是 FastAPI 提供的路由子模块,用于将一组相关的 API 端点(如
/v1/completions、/status等)组织在一起,便于后续挂载到主应用上。
python
app = FastAPI()
- 创建 FastAPI 主应用实例。
- 这是整个代理服务的入口,负责处理请求、解析参数、执行依赖等。
python
app.include_router(self.proxy_instance.router)
- 将之前创建的
APIRouter(挂载了所有代理端点)包含到主应用中。 - 这样,主应用就能识别并路由到
/v1/completions、/instances/add等具体路径。
python
config = uvicorn.Config(app, port=self.port, loop="uvloop")
server = uvicorn.Server(config)
- 使用 uvicorn 配置 ASGI 服务器:
app:要运行的 FastAPI 应用。port:监听端口(例如 8000)。loop="uvloop":指定使用uvloop事件循环(比 asyncio 默认事件循环性能更高)。
- 创建
uvicorn.Server实例,用于实际启动服务器。
最后调用 server.run() 就会启动一个异步 HTTP 服务 ,接收外部请求并交给 FastAPI 处理。简单说,这些代码就是在构建并运行一个基于 FastAPI + uvicorn 的高性能代理网关。
续挂载到主应用上。 主应用什么意思?
"主应用"在这里指的是 FastAPI 的核心应用对象 (即 app = FastAPI() 创建的那个实例)。你可以把它理解为一个 Web 服务的总入口 ,所有客户端请求(例如 http://localhost:8000/v1/completions)都会先到达这个主应用。
"挂载" 的意思是:将 APIRouter 中定义的路由(如 /v1/completions、/status)注册到主应用的路由表中 。主应用本身一开始并不知道有哪些 URL 路径需要处理,通过 app.include_router(router) 后,主应用就知道:"当收到 /v1/completions 请求时,调用 router 中定义的那个函数来处理"。
举个例子:
- 没有
include_router时,主应用对/v1/completions会返回 404。 - 执行
app.include_router(self.proxy_instance.router)之后,主应用就能正确响应/v1/completions了。
这样做的好处是模块化 :你可以把不同功能(比如管理接口、用户接口、健康检查)分别写在不同的 APIRouter 中,最后统一挂载到同一个主应用上,保持代码清晰。
可以,分别解释。
1. 为什么 CUDA 初始化后不能 fork
核心原因一句话:
fork会把父进程的内存状态"复制"给子进程,但 CUDA 运行时、GPU 上下文、驱动状态、内部线程/锁并不能被安全地这样复制。
所以如果父进程已经"碰过 CUDA",再 fork 子进程,很容易出问题。
1.1 fork 做了什么
在 Linux/Unix 下,fork 会创建一个子进程:
- 子进程一开始几乎就是父进程的"拷贝"
- 包括 Python 解释器状态
- 内存里的对象
- 一些库的内部状态
- 锁、线程相关信息的一部分状态
但注意:
fork复制的是"进程内存视角",不是把 GPU 驱动、CUDA runtime、后台线程都完整重建一遍。
这就埋下了隐患。
1.2 CUDA 初始化后,进程里会有什么状态
当你做了类似这些操作后:
python
import torch
torch.cuda.is_available()
torch.cuda.set_device(0)
x = torch.randn(10, device="cuda")
通常就意味着 CUDA 相关组件已经初始化。此时进程里可能已经有:
- CUDA runtime 状态
- GPU context
- device handle
- 已分配的显存信息
- cuBLAS / cuDNN / NCCL 等库的内部状态
- 某些后台线程
- 某些锁、线程局部存储(TLS)
这些东西很多都不是 fork-safe。
1.3 fork 后为什么会坏
因为 fork 之后:
- 子进程继承了父进程"看起来已经初始化过"的 CUDA 状态
- 但底层驱动/线程/上下文并没有被正确重新建立
- 某些锁可能处于"复制时的中间状态"
- 某些线程在子进程里根本不存在,但它们持有的状态却被复制过来了
结果可能出现:
- 程序卡死
- 第一次调用 CUDA 就报错
- NCCL 初始化失败
- 显存状态异常
- 训练/推理过程随机崩溃
1.4 一个通俗比喻
你可以把 CUDA 初始化理解成:
父进程已经和 GPU、驱动、后台服务"建立了一套复杂连接"。
fork 相当于:
把这个"已经在运行中的控制室"直接拍个快照复制一份。
问题是:
- 复制了桌子上的文件
- 复制了控制面板上显示的数字
- 但没复制真正连接外部设备的那套实时线路
- 甚至原来控制室里运行的工作人员线程也没完整带过去
所以新控制室看起来像真的,实际一操作就乱。
1.5 为什么 spawn 没这个问题
spawn 的方式不是复制当前进程,而是:
- 启动一个全新的 Python 解释器
- 从头导入模块
- 从头初始化 CUDA
- 从头建立自己的 GPU context
这就相当于:
子进程自己独立、干净地连接 GPU,而不是继承父进程那套半残的状态。
因此更安全。
1.6 PyTorch / CUDA 生态里这是常识
这不是 vLLM 独有问题,而是整个 Python + CUDA + 多进程生态都要注意的事。
典型经验就是:
- 碰 GPU 之前 fork,通常还行
- 碰 GPU 之后再 fork,高风险
所以很多框架会要求:
- 多进程启动前不要初始化 CUDA
- 或者干脆统一使用
spawn
2. 为什么 Ray actor 为什么只能 spawn
更准确地说,不一定是"Ray 从理论上 100% 只能 spawn",而是:
在 vLLM 这种场景里,处于 Ray actor 内部再开 worker 进程时,最安全、最兼容的做法是用
spawn。
因为 Ray actor 本身已经是一个被 Ray 管理的进程,里面状态很复杂。
2.1 Ray actor 是什么
Ray actor 可以理解成:
- 一个长期存活的远程 Python 进程
- 由 Ray 调度和管理
- 它内部有 Ray runtime 的连接状态
- 可能已经启动了线程、网络连接、RPC 状态、对象存储交互等
也就是说:
Ray actor 不是一个"干净普通"的本地 Python 主进程。
2.2 在 Ray actor 里再 fork 的问题
如果在 actor 内部再用 fork 开子进程,相当于:
- 把当前 actor 进程的内部状态整个复制一份
- 其中包括 Ray runtime 的各种连接状态、socket、线程、锁、事件循环等
这会导致很多潜在问题:
可能的问题
- 子进程继承了不该继承的 Ray 连接
- 网络连接状态错乱
- 后台线程状态不一致
- 锁处于脏状态
- actor 和子进程都以为自己拥有某些资源
- 子进程无法正确连接回 Ray 集群
- 任务执行、日志、资源管理混乱
本质上和 CUDA 类似:
Ray actor 进程已经不是"fork-safe"的干净初始状态了。
2.3 为什么代码里还设置 RAY_ADDRESS
这段代码里有:
python
os.environ["RAY_ADDRESS"] = ray.get_runtime_context().gcs_address
意思是:
- 如果要用
spawn启子进程 - 子进程不会自动继承父进程里那些 Python 对象状态
- 所以要通过环境变量告诉它:Ray 集群地址在哪
- 这样 spawned 子进程才能重新连接 Ray
也就是说:
fork:继承太多,反而危险spawn:继承很少,但需要显式传必要信息
这里就显式传了 RAY_ADDRESS。
2.4 为什么说"can only be spawned"
日志里这句:
python
"In a Ray actor and can only be spawned"
表达的是工程层面的结论:
在这种上下文里,vLLM 决定只支持/只信任
spawn,因为别的方式容易出错。
不是说 Python 语法上绝对不能 fork,而是说:
- 这么做不安全
- 不可靠
- 框架不打算支持这种组合
3. vLLM 里这个环境变量具体影响什么
这个环境变量是:
python
VLLM_WORKER_MULTIPROC_METHOD
它影响的是:
vLLM 在启动内部 worker 进程时,使用哪种 Python multiprocessing 启动方式。
通常就是这些方式之一:
forkspawn- 有时也可能涉及
forkserver
但实际最常见的是 fork 和 spawn。
3.1 vLLM 为什么要启动 worker 进程
vLLM 为了做高性能推理,通常会有多个进程参与工作,比如:
- engine 进程
- worker 进程
- 不同 GPU 上的执行单元
- tensor parallel / pipeline parallel 的工作进程
- 数据预处理、调度相关进程
所以它内部需要多进程机制。
3.2 这个环境变量控制什么行为
当 vLLM 需要创建这些 worker 时,它会读取:
python
VLLM_WORKER_MULTIPROC_METHOD
然后决定类似:
python
multiprocessing.get_context("spawn")
还是:
python
multiprocessing.get_context("fork")
也就是说它控制:
- worker 进程是"复制父进程"启动
- 还是"全新解释器"启动
3.3 不同取值的影响
如果是 fork
优点:
- 启动快
- 父进程对象继承方便
- 内存复制开销在写时复制机制下看起来较低
缺点:
- 对 CUDA 不安全
- 对 Ray actor 不安全
- 对有线程/复杂运行时的进程不安全
- 更容易出现隐蔽 bug
如果是 spawn
优点:
- 进程状态干净
- 对 CUDA / Ray / 线程环境更安全
- 更稳定
缺点:
- 启动更慢
- 子进程要重新 import 模块
- 某些对象需要可 pickle 才能传过去
- 初始化成本更高
3.4 对 vLLM 用户有什么实际影响
这个变量会直接影响你在使用 vLLM 时的稳定性和兼容性。
例如:
场景 A:本地单机,没提前初始化 CUDA
如果环境简单,fork 有时能工作,启动还更快。
场景 B:你先 import/初始化了 torch.cuda,再启动 vLLM
这时再用 fork 很危险,vLLM 就可能自动改成 spawn。
场景 C:你在 Ray 集群上跑 vLLM
这时通常必须用 spawn,否则 worker 进程管理会很不稳。
3.5 为什么要"覆盖"用户设置
代码里有一句:
python
Overriding VLLM_WORKER_MULTIPROC_METHOD to 'spawn'
意思是:
- 就算用户原来没设置
- 或者设置成别的值
- vLLM 检查后发现当前环境必须用
spawn - 它就会直接覆盖
这是为了避免更难排查的问题。因为如果继续按不安全的方式启动,可能后面报出各种莫名其妙的 CUDA/Ray 死锁或崩溃。
4. 一句话总结
CUDA 初始化后不能 fork
因为 CUDA 上下文、驱动状态、线程和锁不是 fork-safe,复制父进程状态会导致子进程中的 GPU 运行时异常。
Ray actor 为什么只能 spawn
因为 actor 进程已经包含复杂的 Ray runtime 状态、线程和连接,再 fork 会继承脏状态;spawn 才能让子进程干净启动并重新连接 Ray。
这个环境变量影响什么
它决定 vLLM 启动内部 worker 进程时用 fork 还是 spawn,直接影响性能、稳定性以及对 CUDA/Ray 场景的兼容性。
如果你愿意,我还可以继续给你讲:
fork/spawn/forkserver三者的区别- vLLM 在什么情况下会提前初始化 CUDA
- 为什么有时你只是
import torch就可能触发相关问题