上一篇,我们通过 Gateway API、InferencePool 和 llm-d Router,为模型服务增加了推理感知路由能力。
现在,一个请求不再只能通过普通 Kubernetes Service 转发,而是可以先经过 Endpoint Picker,根据模型和后端状态选择合适的推理实例。
不过,上一篇主要解决的是流量入口问题。
我们还没有深入研究:当多个 GPU、多个 vLLM 实例共同服务一个模型时,怎样才能让它们真正高效地协作?
假设未来我们的实验室拥有四张 GPU。
最容易想到的方案,是启动四个 vLLM 实例,然后把请求分配给它们。
Inference Gateway
|
+----------+----------+
| | |
v v v
vLLM A vLLM B vLLM C
GPU 0 GPU 1 GPU 2
这个架构可以提高并发处理能力。
但它可能存在几个问题。
第一,不同 GPU 上的请求负载不一样。
某个 GPU 正在处理长上下文请求,另一个 GPU 却很空闲。即使请求数差不多,实际计算压力也可能相差很大。
第二,不同实例具有各自的 KV Cache。
如果一个请求的 Prompt 与 GPU 0 上已经缓存的内容高度重合,将它交给 GPU 1,可能需要重复执行大量 Prefill 计算。
第三,Prefill 和 Decode 之间存在资源竞争。
一个长 Prompt 的 Prefill 可能影响其他请求正在进行的 Decode,造成生成速度波动。
当 GPU 数量继续增加,这些问题就不再只是某个 vLLM 实例内部的优化问题。
我们需要站在整个 GPU 集群的角度,决定:
-
请求应该交给哪个实例?
-
怎样利用已经存在的 KV Cache?
-
什么时候应该牺牲缓存亲和性来平衡负载?
-
Prefill 和 Decode 是否必须运行在同一个 GPU 上?
-
如果拆分到不同 GPU,KV Cache 应该怎样传输?
这正是 llm-d 希望解决的一类问题。
本篇主要研究四个核心知识:
Load-aware Routing、Prefix-cache-aware Routing、KV Cache Locality,以及 Prefill/Decode Disaggregation。
与前几篇不同,我们暂时不增加新的真实模型,也不强行在单张 RTX 3060 上部署多 GPU 架构。
实验分为三个层次:
-
复用已经部署的 llm-d Router,观察真实控制与路由组件。
-
在单机上构建可重复的调度实验,理解负载与缓存之间的权衡。
-
从多 GPU 架构角度分析 P/D 分离、KV Cache 传输,以及未来的扩展方式。
需要说明,本文中的 Router 测试命令和配置检查步骤以现有环境为基础;涉及实际集群输出的部分需要在实验机上运行后填写。文中不会把示例输出当作已经测得的真实结果。
一、背景问题:为什么多个 vLLM 实例还不等于高效分布式推理?
1. 单个 vLLM 已经解决了什么?
我们在第⑪篇研究过 vLLM 的性能优化,包括 PagedAttention、Continuous Batching 和 KV Cache。
这些机制主要作用于单个推理引擎内部。
例如:
vLLM Engine
|
Request Scheduler
|
Continuous Batching
|
GPU Execution
|
KV Cache
vLLM 可以把多个请求组织成批次,提高 GPU 利用效率。
但是,当我们部署多个独立的 vLLM 实例时:
vLLM A ---- GPU 0 ---- KV Cache A
vLLM B ---- GPU 1 ---- KV Cache B
vLLM C ---- GPU 2 ---- KV Cache C
每个实例仍然管理自己的请求队列、执行状态和 KV Cache。
默认情况下,A 并不知道 B 缓存了哪些 Prompt,B 也不会自动把自己的 KV Cache 共享给 C。
因此,单个推理引擎的高效率,不意味着整个集群已经达到最优。
2. 从单机调度到集群调度
考虑三个实例:
| 实例 | Waiting | KV Cache 使用率 | 请求前缀已缓存 |
|---|---|---|---|
| A | 2 | 40% | 80% |
| B | 0 | 20% | 0% |
| C | 5 | 75% | 50% |
现在到来一个长 Prompt 请求。
如果只看等待队列,B 最空闲。
如果只看缓存复用,A 最有优势。
如果只看可用 KV Cache 容量,B 可能更合适。
问题在于:
这几个判断可能互相矛盾。
A 虽然需要排队,但如果能够复用大量已有 KV Cache,可能依然比 B 更快。
然而,如果 A 已经严重过载,即使缓存命中率很高,把请求交给它也可能造成更大的延迟。
这意味着集群级路由不能简单寻找:
Least Busy GPU
而应该尝试寻找:
Best Endpoint for This Request
这两个目标并不完全相同。
3. llm-d 的定位
llm-d 是围绕 Kubernetes 和模型推理引擎构建的分布式推理框架。
它可以与 vLLM 协作,管理多个推理实例之间的请求调度、KV Cache 位置感知以及 Prefill/Decode 分离。
需要注意:
llm-d 不是 vLLM 的替代品,也不是一个比 Kubernetes 更高级的容器调度器。
两者主要关注不同层面。
| 组件 | 主要职责 |
|---|---|
| Kubernetes | Pod、节点、GPU 资源调度 |
| KServe | 模型服务生命周期管理 |
| Inference Gateway | HTTP 流量入口与转发 |
| llm-d Router | 推理感知的请求调度 |
| vLLM | 模型执行、批处理和 KV Cache 管理 |
| NIXL 等传输组件 | 不同推理实例之间的 KV 数据传输 |
上一篇已经介绍过 InferencePool 和 Endpoint Picker。
本篇不再重复其基础概念,而是关注 llm-d Router 内部怎样选择 Endpoint,以及这些选择怎样影响 GPU 执行。

二、原理:llm-d 怎样理解负载、缓存与 GPU?
1. Load-aware Routing:不能只看请求数量
对于普通 HTTP 请求,负载均衡器可能根据连接数或简单的请求数选择后端。
但 LLM 的资源使用具有明显的不均匀性。
例如:
Request A
Input: 128 Tokens
Output: 32 Tokens
Request B
Input: 8192 Tokens
Output: 1024 Tokens
两者虽然都是一个 HTTP 请求,但计算成本相差很大。
因此,llm-d 可以结合推理服务提供的状态信息,例如:
Running Requests
Waiting Requests
KV Cache Utilization
In-flight Token Load
判断候选实例的负载。
在 llm-d 的路由实现中,可以通过 Filter、Scorer 等插件组合完成调度。
Filter 负责筛选候选实例。
Scorer 负责对候选实例评分。
Picker 根据调度结果选择 Endpoint。
但具体使用哪些指标,取决于实际启用的插件与配置,不是所有 llm-d 安装都会默认使用同一套算法。
例如在面向 Prefill 负载的配置中,可以使用 token-load-scorer 关注尚待处理的 Token 工作量,而不仅是当前请求数。
对于以长时间 Decode 为主的场景,活跃请求数也可能是更有价值的信号。
这里有一个重要区别:
负载感知并不是准确预测每个请求的执行时间,而是利用当前可观测的信息减少明显不合理的分配。
实际调度还会受到指标刷新间隔、路由决策延迟和并发请求变化的影响。
2. Prefix-cache-aware Routing:为什么相同前缀应该尽量进入同一实例?
假设我们构建了一个 AI 编程助手。
每次请求都包含一段很长的 System Prompt:
你是一位 AI Infra 技术助手。
你熟悉:
Linux
Kubernetes
GPU Operator
vLLM
KServe
llm-d
回答时请遵循以下规范......
用户随后提出不同问题。
如果多个请求拥有相同的 Token Prefix,那么理论上可以复用这一前缀对应的部分 KV Cache。
例如:
Request 1:
[Shared System Prompt][Question A]
Request 2:
[Shared System Prompt][Question B]
假设第一次请求已经在 GPU A 上执行。
其部分 KV Cache 仍然保留:
GPU A:
Shared Prefix Cache
第二次请求如果仍然进入 GPU A,就可能跳过部分重复 Prefill 计算。
但如果进入 GPU B:
GPU B:
No Matching Cache
就可能需要重新计算。
这就是 Prefix-cache-aware Routing 的价值。
不过,要实现这一目标,Router 首先需要回答:
哪个实例现在真正拥有这些可复用的 KV Cache?
这引出了两种不同的实现方式。
近似缓存感知
第一种方式不直接维护精确的 GPU KV Cache 状态,而是根据请求历史、前缀关联以及路由记录估计缓存位置。
例如:
Prefix X -> Previously Routed to A
Prefix Y -> Previously Routed to B
Router 根据历史请求建立一种近似的前缀亲和关系。
这种方式比较轻量,不一定需要模型服务持续上报每一个 KV Block 的变化。
但它存在误差。
请求曾经进入 A,不代表对应的 KV Cache 现在仍然存在。
GPU 显存可能已经发生缓存驱逐。
Pod 也可能已经重启。
因此:
Historical Affinity
!=
Actual Cache Residency
精确缓存感知
另一种方式,是让推理引擎发布 KV Cache 变化事件。
例如:
BlockStored
BlockRemoved
AllBlocksCleared
llm-d 的相关组件可以订阅这些事件,维护不同实例的缓存块索引。
逻辑类似:
KV Block Hash
|
+-- GPU A: Present
|
+-- GPU B: Absent
|
+-- GPU C: Present
当新的请求到来时,Router 可以结合 Token Block 信息,查找哪些实例拥有能够连续复用的前缀缓存。
相比历史请求推断,这种方式更接近真实缓存状态。
不过它并非没有成本。
首先,Router 需要获得与模型一致的 Token ID。
其次,需要持续接收缓存分配和驱逐事件。
如果事件丢失、索引恢复不完整或状态更新滞后,Router 的判断仍可能不准确。
对于大规模集群,缓存索引本身也成为需要管理的分布式状态。
所以:
Prefix-cache-aware Routing 并不是简单地把同一个用户固定到同一台 GPU,而是在尝试把请求和真实可复用的计算状态匹配起来。
这比普通 Session Affinity 更进一步。
3. KV Cache Locality:缓存位置为什么成为架构问题?
过去我们研究 Model Cache 时,关注的是模型文件存储在哪里。
例如:
Hugging Face
|
v
Local NVMe / PVC
|
v
GPU Model Weights
而 KV Cache Locality 关注的是另一个问题:
请求执行过程中产生的中间计算状态,位于哪块 GPU、哪台主机或者哪个存储层级?
在单个 vLLM 实例中,这通常属于推理引擎自己的管理范围。
但在多 GPU 集群中,KV Cache 可能分布在:
GPU HBM
|
v
Host Memory
|
v
Local NVMe
|
v
Remote Storage / Peer Instance
这里可以用计算机体系结构中的 Cache Locality 来理解。
访问已经位于本地 GPU 显存中的 KV Cache,通常比从远端重新传输更加直接。
如果没有缓存,则可能需要重新执行 Prefill。

因此,对于一个请求,至少存在几种可能:
| 情况 | 可能产生的成本 |
|---|---|
| 本地 GPU 已有匹配 KV Cache | 直接复用 |
| 本地缓存部分命中 | 计算缺失的部分 |
| 远端实例拥有缓存 | 可能通过网络传输 |
| 所有实例均无缓存 | 重新 Prefill |
值得强调的是,知道缓存在哪里,不等于已经具备把缓存转移过来的能力。
Prefix-aware Routing 主要解决应该把请求送到哪里。
KV Transfer 或共享缓存机制解决的是如何移动、读取或复用已有状态。
两者相关,但不能混为一谈。
4. 缓存亲和性与负载均衡之间存在冲突
假设某个实例 A 具有非常高的缓存命中率。
如果 Router 始终选择 A:
Shared Prefix
|
v
GPU A
一段时间后,A 可能积累大量请求。
此时:
GPU A:
Cache Hit: High
Queue: Very Long
GPU B:
Cache Hit: Low
Queue: Empty
继续选择 A,虽然避免了重复 Prefill,却可能造成严重排队。
因此,合理的调度策略必须在两个目标之间权衡:
Cache Reuse
vs
Load Balancing
llm-d 的部分优化配置采用一种可以概括为:
Sticky Until Saturated(未饱和时保持亲和,过载后分散流量)
的思想。
正常负载时,优先考虑缓存亲和性。
当缓存所在实例的负载超过相应阈值时,允许请求转移到其他实例。
这也解释了为什么一个合理的智能路由器,不应该永远把相同前缀送到同一个 Endpoint。

三、P/D Disaggregation:为什么要把一次推理拆到不同 GPU?
这是本篇最值得深入研究的部分。
1. Prefill 与 Decode 的资源特点不同
我们以前已经知道一次 LLM 请求主要包含两个阶段:
Prompt
|
v
Prefill
|
v
Decode
|
v
Generated Tokens
Prefill 需要处理输入 Prompt,生成初始 KV Cache。
它通常具有较高的计算密集度。
Decode 则逐步生成新 Token,反复读取模型权重和已有 KV Cache。
在常见推理工作负载下,Decode 往往更容易受到显存带宽以及数据访问效率的限制。
因此,两者的性能特点不同。
| 对比 | Prefill | Decode |
|---|---|---|
| 主要任务 | 处理输入 Prompt | 逐 Token 生成 |
| 常见资源瓶颈 | 计算吞吐 | 显存带宽 |
| 对用户体验的影响 | TTFT | TPOT / ITL |
| 工作负载特征 | 较集中的计算 | 持续迭代执行 |
当然,这是一种典型情况的概括,具体瓶颈还受到模型、Batch Size、上下文长度和硬件的影响。

2. 为什么同一个 GPU 上执行两种任务可能互相干扰?
考虑两个用户。
用户 A 正在进行长文本生成。
User A:
Decode Token 1
Decode Token 2
Decode Token 3
...
与此同时,用户 B 提交一个很长的 Prompt。
User B:
Prefill 16000 Tokens
如果两个请求进入同一个推理实例,虽然 vLLM 可以通过调度机制改善并发处理,但两种工作负载仍然需要竞争 GPU 资源。
大规模 Prefill 可能干扰正在执行的 Decode。
从用户 A 的角度看,表现为:
Token 1: Fast
Token 2: Fast
Token 3: Slow
Token 4: Slow
Token 5: Fast
也就是 Inter-token Latency 出现波动。
对于交互式应用,稳定的生成速度往往和平均吞吐一样重要。
3. P/D Disaggregation 的基本思想
既然 Prefill 和 Decode 的特点不同,能不能将它们部署到不同实例?
答案是可以。
这就是:
Prefill/Decode Disaggregation。
一个简化的架构:
Client
|
v
llm-d Router
|
Select P / D
|
+---------+---------+
| |
v v
Prefill Pool Decode Pool
| |
v v
P Worker D Worker
GPU 0 GPU 1
| ^
| KV Cache |
+-------------------+
Transfer
P Worker 负责处理输入 Prompt。
D Worker 负责继续生成输出 Token。
两者之间通过 KV Transfer 机制传递必要的 KV Cache 状态。
在 llm-d 的一种典型实现中,Router 选择 Prefill 和 Decode Worker,实际请求经过 Decode 侧的 Routing Proxy Sidecar,由它协调 Prefill 请求以及后续 Decode。
KV Cache 数据并不是通过普通 HTTP 响应完整传回客户端,再由客户端发给 Decode。
它通常通过专门的 KV Transfer 数据通道完成传输。
4. NIXL 在其中扮演什么角色?
P/D 分离的一个核心挑战是:
Prefill 完成后,怎样让另一块 GPU 获得对应的 KV Cache?
llm-d 可以结合 vLLM 的 KV Transfer 能力与 NVIDIA Inference Transfer Library(NIXL)。
NIXL 提供推理实例之间的数据传输抽象,可以通过不同底层通信技术实现数据移动。
例如:
Prefill GPU
|
| KV Cache Transfer
v
NIXL / Transport
|
| RDMA / Supported Transport
v
Decode GPU
在适当硬件环境下,可以利用 InfiniBand、RoCE、EFA 等高速网络互连能力。
对于开发测试,部分配置可以使用 TCP 传输,但不能认为普通千兆网络与数据中心级互连具有相同效果。
这里存在一个容易忽略的成本问题。
Prefill 与 Decode 分开后,虽然降低了执行阶段之间的资源干扰,却增加了 KV Cache Transfer。
如果传输本身很慢,收益可能被抵消。
5. KV Cache 到底有多大?
这里做一个简单估算。
对于采用标准形式 KV Cache 的 Transformer,可以近似表示为:
KV Cache Size
≈ 2 × Layers
× KV Heads
× Head Dimension
× Tokens
× Bytes per Element
假设一个示例模型:
Layers: 32
KV Heads: 8
Head Dimension: 128
KV Data Type: BF16
Token Length: 8192
那么:
2 × 32 × 8 × 128 × 8192 × 2 Bytes
= 1 GiB
这里忽略了内存对齐、Block 管理、传输元数据及其他开销。
实际模型由于 GQA、MLA、KV Cache 量化等差异,计算结果也可能不同。
但这个例子足以说明:
一次长上下文推理产生的 KV Cache,可能达到数百 MiB,甚至更大。
如果需要跨节点移动这些数据,网络就可能成为关键性能因素。
假设需要传输 1 GiB:
Effective Bandwidth: 1 GiB/s
Transfer Time: ≈ 1 second
Effective Bandwidth: 10 GiB/s
Transfer Time: ≈ 0.1 second
这还没有计入额外的协议处理、同步和排队成本。
因此,在多节点 P/D 架构中,网络性能绝不是无关紧要的基础设施细节。
6. P/D 分离是不是一定更快?
不一定。
在没有明显资源竞争的情况下,单个 vLLM 实例直接执行:
Prefill -> Decode
省去了跨实例传输。
而 P/D 分离增加了:
Request Orchestration
KV Transfer
Cross-worker Coordination
因此,在小模型、短 Prompt、低并发情况下,P/D 分离完全可能没有性能收益,甚至变慢。
其主要价值通常出现在:
-
较大模型;
-
长上下文请求;
-
较高并发;
-
Prefill 与 Decode 干扰明显;
-
对生成稳定性有较高要求;
-
具备足够 GPU 和高速互连资源。
所以 P/D 分离不是越早引入越好。
它是一种针对特定工作负载的架构优化。
四、架构图:从单个 vLLM 到多 GPU llm-d
现在把整个体系组合起来。
1. 普通多副本模式
Client
|
v
Inference Gateway
|
v
llm-d Router
|
+----------+----------+
| | |
v v v
vLLM A vLLM B vLLM C
| | |
GPU 0 GPU 1 GPU 2
| | |
KV Cache KV Cache KV Cache
A B C
每个 vLLM 实例独立执行 Prefill 和 Decode。
llm-d Router 可以结合负载、Prefix Cache 等信息选择实例。
这种方式可以理解为:
多个完整推理副本之间的智能路由。
2. P/D 分离模式
Client
|
v
Inference Gateway
|
v
llm-d Router
|
P/D Scheduling
|
+------------+------------+
| |
v v
Prefill Pool Decode Pool
| |
+----+----+ +----+----+
| | | |
v v v v
P0 P1 D0 D1
GPU 0 GPU 1 GPU 2 GPU 3
| | ^ ^
+---------+---------------+---------+
|
KV Transfer
NIXL
其中:
Prefill Pool 可以根据输入计算量独立扩容。
Decode Pool 可以根据生成请求数量、Token 输出负载以及延迟目标独立调整。
但这不是简单把两个 Deployment 创建出来就能工作。
还必须具备协调请求流、KV Transfer,以及对应的模型运行配置。
3. 不要把 P/D 分离与 Tensor Parallel 混淆
这是一个非常值得区分的概念。
我们经常看到:
TP = 2
TP = 4
Tensor Parallelism 是将一次模型计算分配到多个 GPU 上。
例如:
One Model
|
+-- GPU 0
|
+-- GPU 1
P/D Disaggregation 则是将推理的不同阶段放在不同执行实例上。
Prefill -> GPU 0
Decode -> GPU 1
两者可以组合。
例如:
Prefill:
TP = 1
Replicas = 2
Decode:
TP = 2
Replicas = 1
此时 Prefill 使用两张 GPU 运行两个实例。
Decode 使用两张 GPU 运行一个 TP=2 的实例。
合计四张 GPU。
但这四张 GPU 不是四个完整的数据并行推理副本。
它们承担的角色不同。
同样,Pipeline Parallelism 是按照模型层组织流水线,不能直接等同于 Prefill/Decode 分离。
五、实验:单张 RTX 3060 上研究 llm-d 的调度行为
1. 实验环境与目标
继续使用前几篇的实验机。

| 项目 | 当前环境 |
|---|---|
| 主机 | Lenovo REN7000K-28IMB |
| CPU | Intel i5-10400F |
| GPU | NVIDIA RTX 3060 12GB |
| OS | Ubuntu 24.04 |
| Kubernetes | v1.37.1 |
| Container Runtime | containerd |
| 模型服务 | vLLM / KServe |
| Gateway | agentgateway |
| Router | llm-d Router |
| Namespace | ai-gateway |
第⑳篇已经部署:
qwen-sim: 3 Replicas
qwen-router:
InferencePool
Endpoint Picker
inference-gateway:
Gateway Proxy
另外还有用于 Model-aware Routing 的 DeepSeek Simulator。
本次不重新安装这些组件。
我们重点验证四件事:
第一,确认现有 Router 实际启用了什么调度策略。
第二,研究缓存收益与后端负载怎样影响 Endpoint Selection。
第三,使用已有 Simulator 构造不同的请求负载,观察路由层与后端指标。
第四,理解多 GPU P/D 架构的资源组织和 KV Transfer 路径。
由于缺少第二张独立 GPU,最后一项不进行真实跨 GPU 性能测试。
2. 实验一:检查 llm-d Router 的真实配置
先确认第⑳篇的资源仍然存在:
kubectl get inferencepool -n ai-gateway
kubectl get deployments -n ai-gateway
kubectl get pods -n ai-gateway -o wide
查看 Helm Release:
helm list -n ai-gateway

按照上一篇的命名,我们安装过:
qwen-router
deepseek-router
先查看 qwen-router 的配置:
helm get values qwen-router \
-n ai-gateway --all


再检查 EPP:
kubectl logs \
-n ai-gateway \
deployment/qwen-router-epp \
--tail=100


接着查看相关 ConfigMap:
kubectl get configmaps \
-n ai-gateway

找到 Router 使用的配置文件,检查其中的插件。
比如我们这次检查 qwen-router-epp:
kubectl get configmap qwen-router-epp \
-n ai-gateway \
-o yaml

重点寻找:
queue-scorer
kv-cache-utilization-scorer
prefix-cache-scorer
prefix-cache-affinity-filter
token-load-scorer
这些名称代表不同的调度能力,并不意味着必须全部同时出现。尤其要注意:
EPP 已经 Ready,不代表所有高级 Scorer 都已经启用。
如果实际配置仅包含简单的 Endpoint 选择策略,那么我们不能宣称已经验证 Load-aware 或 Precise Prefix-cache-aware Routing。
这一点需要以当前 Helm Release 渲染出的真实配置为准。
如果准备进一步修改策略,先保存当前配置:
helm get values qwen-router \
-n ai-gateway \
> qwen-router-backup-values.yaml
同时可以查看本次所用 Chart 版本支持的参数:
helm show values \
oci://ghcr.io/llm-d/charts/llm-d-router-gateway \
--version v0.9.0 \
> router-chart-values.yaml
这里沿用第⑳篇的 Chart 版本。

不要直接从项目主分支复制最新 EPP 插件配置到旧版本中,因为 API、插件参数和默认策略可能已经变化。
本篇首先观察当前配置,不为了演示新功能而贸然升级整个 Serving Stack。
3. 实验二:用一个可重复的调度模型理解路由决策
为了理解 Cache Locality 与 Load-aware Routing 的权衡,我们先编写一个非常简单的 Python 调度实验。
这个实验不模拟真实 GPU 执行,也不是 llm-d 官方算法的实现。
它只是将影响调度的几个重要变量明确表达出来。
创建:
nano routing-policy-lab.py
内容如下:
from dataclasses import dataclass
@dataclass
class Endpoint:
name: str
waiting: int
kv_usage: float
cached_tokens: int
def estimate_cost(ep, prompt_tokens):
uncached = max(
0, prompt_tokens - ep.cached_tokens
)
# Toy cost model, unit: arbitrary score
prefill_cost = uncached * 0.7
queue_cost = ep.waiting * 200
kv_pressure = ep.kv_usage * 500
return prefill_cost + queue_cost + kv_pressure
def schedule(endpoints, prompt_tokens):
results = []
for ep in endpoints:
cost = estimate_cost(ep, prompt_tokens)
results.append((ep.name, cost))
results.sort(key=lambda x: x[1])
for name, cost in results:
print(f"{name}: {cost:.0f}")
print("Selected:", results[0][0])
print()
endpoints = [
Endpoint("A", 1, 0.40, 3500),
Endpoint("B", 0, 0.10, 0),
Endpoint("C", 3, 0.25, 1500),
]
print("Scenario 1: Cache-aware")
schedule(endpoints, 4000)
endpoints[0].waiting = 15
print("Scenario 2: A is overloaded")
schedule(endpoints, 4000)
执行:
python3 routing-policy-lab.py
运行这一段代码,可以得到:
Scenario 1: Cache-aware
A: 750
C: 2475
B: 2850
Selected: A
Scenario 2: A is overloaded
C: 2475
B: 2850
A: 3550
Selected: C
这里的 Cost 是我们自行定义的教学评分,并不代表毫秒,也不代表 llm-d 实际计算出的调度分数。

但它说明了一个重要现象。
在第一种情况下:
Endpoint A:
Waiting = 1
Cached Tokens = 3500
虽然 B 的等待队列更短,但 A 拥有大量匹配的缓存。
因此选择 A。
第二种情况下,我们将 A 的等待请求增加到 15。
此时缓存收益已经无法抵消严重排队带来的代价。
于是调度结果改为 C。
这个结果体现了:
Cache Affinity
|
v
Load Pressure
|
v
Routing Trade-off
真实 llm-d 的 Filter、Scorer、状态采集及权重配置更复杂,但我们已经理解了它为什么不能简单地对所有请求进行 Round Robin。
4. 实验三:使用已有 Simulator 观察真实路由链路
接下来回到第⑳篇的 Kubernetes 环境。
首先检查三个 Qwen Simulator:
kubectl get pods \
-n ai-gateway \
-l app=qwen-sim \
-o wide
上一篇使用的模拟模型名称是:
Qwen/Qwen3-32B
这里不需要真正加载一个 32B 模型。
启动 Gateway Port Forward:
kubectl port-forward \
-n ai-gateway \
svc/inference-gateway \
8080:80

另开一个终端,构造不同的请求序列。
先检查之前配置的 Rate Limit
kubectl get agentgatewaypolicy -n ai-gateway
如果存在 request-limit,查看:
kubectl get agentgatewaypolicy request-limit \
-n ai-gateway -o yaml
重点看是否有:
requests: 5
unit: Minutes
以及它是否作用于 qwen-router。

如果确认 request-limit 只用于之前的限流实验,可以先备份:
kubectl get agentgatewaypolicy request-limit \
-n ai-gateway -o yaml \
> ~/request-limit-backup.yaml
然后临时删除:
kubectl delete agentgatewaypolicy request-limit \
-n ai-gateway

实验结束后可以此命令恢复:
kubectl apply -f ~/request-limit-backup.yaml
为了避免手动执行几十次 curl,我们编写一个 Python 客户端。
创建:
nano routing-traffic.py
内容如下:
import json
import time
import math
import sys
import urllib.request
import urllib.error
from concurrent.futures import ThreadPoolExecutor
URL = "http://localhost:8080/v1/completions"
MODEL = "Qwen/Qwen3-32B"
MODE = sys.argv[1] if len(sys.argv) > 1 else "shared"
COUNT = 40
CONCURRENCY = 8
shared_prefix = (
"你是一位 Linux、Kubernetes 和 AI Infra 专家。"
"请从系统架构、资源管理、性能优化角度回答问题。"
) * 60
def send_request(index):
if MODE == "shared":
prompt = shared_prefix + f"\n问题编号:{index}"
else:
prompt = (
f"这是独立上下文 {index}。"
+ f"技术文档片段 {index}。"
* 200
)
payload = {
"model": MODEL,
"prompt": prompt,
"max_tokens": 32,
"stream": False,
}
body = json.dumps(payload).encode()
req = urllib.request.Request(
URL,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
begin = time.perf_counter()
try:
with urllib.request.urlopen(
req, timeout=120
) as resp:
resp.read()
code = resp.status
except urllib.error.HTTPError as exc:
code = exc.code
except Exception:
code = -1
elapsed = time.perf_counter() - begin
return code, elapsed
with ThreadPoolExecutor(
max_workers=CONCURRENCY
) as executor:
results = list(executor.map(send_request, range(COUNT)))
ok = sorted(
latency for code, latency in results
if code == 200
)
def percentile(values, p):
if not values:
return None
return values[math.ceil(len(values) * p) - 1]
print("Mode:", MODE)
print("Success:", len(ok), "/", COUNT)
if ok:
print("P50:", percentile(ok, 0.50))
print("P95:", percentile(ok, 0.95))
首先执行相同前缀请求:
python3 routing-traffic.py shared
再执行不同前缀请求:
python3 routing-traffic.py unique
两组请求的目的不同。

第一组:
Shared System Prompt
|
+-- Question 1
+-- Question 2
+-- Question 3
用于观察前缀相似的请求。
第二组:
Context A
Context B
Context C
用于观察缺少明显共享前缀的请求。
这里需要注意,我们使用的是模拟推理服务器。
所以程序返回的 P50、P95,只是当前 Simulator 与 Gateway 组合下的请求完成时间。
它不能代表 RTX 3060 上运行真实 Qwen3-32B 的推理延迟。
另外,这两组 Prompt 的实际 Token 长度并不严格一致,因此也不是可以直接用于计算缓存收益的公平性能基准。
它们主要用于产生两类不同的路由输入。

5. 观察 EPP 和后端状态
发送请求的同时,观察 EPP 日志:
kubectl logs \
-n ai-gateway \
deployment/qwen-router-epp \
--since=5m
如果 EPP 配置允许输出 Endpoint Selection 信息,可以关注哪些 Pod 被选中。
然后检查后端指标。
先选择一个 Simulator Pod:
POD=$(kubectl get pods \
-n ai-gateway \
-l app=qwen-sim \
-o jsonpath='{.items[0].metadata.name}')
建立 Port Forward:
kubectl port-forward \
-n ai-gateway \
pod/$POD \
18000:8000

另一个终端:
curl -s localhost:18000/metrics \
| grep -E 'vllm:|kv_cache|prefix_cache'


根据实际 Simulator 版本和启用的功能,可能观察到:
vllm:num_requests_running
vllm:num_requests_waiting
vllm:kv_cache_usage_perc
vllm:prefix_cache_hits_total
vllm:prefix_cache_queries_total
并非所有版本都会暴露完全相同的指标。
可以分别对三个模拟后端执行这一检查,记录各 Pod 的请求计数和缓存指标变化。
对于 Router 自身,还可以观察相关调度 Metrics。
不过,应以真实 /metrics 输出为准,不要预先假定某个指标必然存在。
尤其需要检查 Simulator 是否真正启用了 KV Cache 模拟。
如果没有启用,那么即使重复发送相同的 Prompt,也不能据此证明 Precise Prefix-cache-aware Routing 有效。
要验证精确缓存路由,必须进一步具备对应的缓存事件发布、Router 订阅、Token 解析和索引能力。
这部分可以参考 llm-d 的 Precise Prefix Cache Routing 官方实验。
对于目前的单机环境,建议先完成 Router 配置检查和负载实验,而不是为了追求所有功能齐全,直接替换已经正常运行的整个 EPP 配置。
六、观察数据:我们应该关注什么?
这一篇的实验需要把控制链路数据与实际 GPU 性能数据严格区分。
1. 本地调度模型的结果
前面的 Python 调度实验可以得到:
| 场景 | A Score | B Score | C Score | 选择 |
|---|---|---|---|---|
| A 缓存较多,负载正常 | 750 | 2850 | 2475 | A |
| A 严重排队 | 3550 | 2850 | 2475 | C |
这是教学模型的确定性结果,而非真实 llm-d 运行结果。
它说明:
在负载正常时,缓存复用可能比单纯选择最短队列更有价值。
当缓存所在实例过载后,调度器应该有能力放弃一部分缓存收益。
2. 实际 Router 实验记录
实际执行第㉑篇的 Kubernetes 测试后,把 routing-traffic.py 换成下面 routing-traffic-updated.py 后:
#!/usr/bin/env python3
"""llm-d Simulator routing experiment: two traffic modes + per-Pod metrics deltas.
Requirements: Python 3.9+, kubectl configured to access the cluster, an existing
Gateway port-forward at localhost:8080. No third-party Python packages.
Run: python3 routing-traffic-updated.py
"""
import argparse
import collections
import concurrent.futures
import json
import math
from pathlib import Path
import re
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
METRIC_NAMES = (
"vllm:request_success_total",
"vllm:prefix_cache_hits_total",
"vllm:prefix_cache_queries_total",
)
METRIC_RE = re.compile(r"^(vllm:[a-zA-Z0-9_:]+)(?:\{[^}]*\})?\s+([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*$")
def run_kubectl(*args):
result = subprocess.run(
["kubectl", *args], capture_output=True, text=True,
timeout=15, check=False,
)
if result.returncode:
raise RuntimeError(result.stderr.strip() or "kubectl 执行失败")
return result.stdout
def find_pods(namespace, selector):
raw = run_kubectl("get", "pods", "-n", namespace, "-l", selector, "-o", "json")
objs = json.loads(raw)["items"]
pods = []
for item in objs:
name = item["metadata"]["name"]
ready = (
item.get("status", {}).get("phase") == "Running"
and all(x.get("ready", False) for x in item.get("status", {}).get("containerStatuses", []))
and bool(item.get("status", {}).get("containerStatuses", []))
)
if ready:
pods.append(name)
else:
print(f"[警告] Pod 尚未 Ready: {name}", file=sys.stderr)
return sorted(pods)
def parse_metrics(content):
found = collections.defaultdict(float)
seen = set()
for line in content.splitlines():
m = METRIC_RE.match(line.strip())
if not m:
continue
name, value = m.groups()
if name in METRIC_NAMES:
found[name] += float(value)
seen.add(name)
return {name: found[name] if name in seen else None for name in METRIC_NAMES}
def snapshot(pods, namespace, port):
readings = {}
for pod in pods:
url = f"/api/v1/namespaces/{namespace}/pods/{pod}:{port}/proxy/metrics"
try:
readings[pod] = parse_metrics(run_kubectl("get", "--raw", url))
except (RuntimeError, subprocess.TimeoutExpired, ValueError) as exc:
print(f"[提示] 无法读取 {pod} 的 /metrics:{exc}", file=sys.stderr)
readings[pod] = {name: None for name in METRIC_NAMES}
return readings
def delta(before, after, pod, metric):
old = before.get(pod, {}).get(metric)
new = after.get(pod, {}).get(metric)
if old is None or new is None or new < old:
return None
return new - old
def pct(latencies, p):
if not latencies:
return None
return sorted(latencies)[max(0, math.ceil(len(latencies) * p) - 1)] * 1000
def request(args, mode, index):
shared = (
"你是一位 Linux、Kubernetes 和 AI Infra 专家。"
"请从系统架构、资源管理、性能优化角度回答问题。"
) * 60
if mode == "shared":
prompt = shared + f"\n问题编号:{index}"
else:
# 与旧 routing-traffic.py 保持相同的 unique prompt 构造。
prompt = f"这是独立上下文 {index}。" + f"技术文档片段 {index}。" * 200
payload = json.dumps({
"model": args.model,
"prompt": prompt,
"max_tokens": 32,
"stream": False,
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
args.url, data=payload,
headers={"Content-Type": "application/json"}, method="POST",
)
start = time.perf_counter()
pod, error = None, None
try:
with urllib.request.urlopen(req, timeout=args.timeout) as response:
response.read()
status = response.status
pod = response.headers.get("x-inference-pod")
except urllib.error.HTTPError as exc:
status = exc.code
error = str(exc)
exc.close()
except Exception as exc:
status = -1
error = str(exc)
return status, time.perf_counter() - start, pod, error
def measure(args, mode, pods):
print(f"\n[{mode}] 采集三个 Pod 的实验前计数...")
before = snapshot(pods, args.namespace, args.backend_port)
with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as pool:
results = list(pool.map(lambda i: request(args, mode, i), range(args.count)))
after = snapshot(pods, args.namespace, args.backend_port)
statuses = collections.Counter(result[0] for result in results)
successful = [r for r in results if r[0] == 200]
latencies = [r[1] for r in successful]
pod_counts = {}
for pod in pods:
count = delta(before, after, pod, "vllm:request_success_total")
pod_counts[pod] = int(round(count)) if count is not None else None
# Fallback for setups where the apiserver Pod proxy is unavailable.
header_counts = collections.Counter(r[2] for r in successful if r[2])
if any(v is None for v in pod_counts.values()):
if sum(header_counts.values()) == len(successful) and successful:
pod_counts = {pod: header_counts[pod] for pod in pods}
source = "HTTP x-inference-pod Header"
else:
source = "无可靠数据(Pod metrics 与 Header 均不完整)"
else:
source = "Pod /metrics 的 request_success_total 前后差值"
hits = [delta(before, after, p, "vllm:prefix_cache_hits_total") for p in pods]
queries = [delta(before, after, p, "vllm:prefix_cache_queries_total") for p in pods]
if all(v is not None for v in hits + queries) and sum(queries) > 0:
hit_rate = f"{sum(hits) / sum(queries) * 100:.2f}%(模拟 Token 命中)"
else:
hit_rate = "未测得(未启用模拟或无指标)"
print(f"[{mode}] HTTP 状态:{dict(sorted(statuses.items()))}")
print(f"[{mode}] 成功 {len(successful)}/{args.count},P50={pct(latencies, .50)!s} ms,P95={pct(latencies, .95)!s} ms")
print(f"[{mode}] Pod 计数来源:{source}")
print(f"[{mode}] Pod 分布:{pod_counts}")
total = sum(v for v in pod_counts.values() if v is not None)
if all(v is not None for v in pod_counts.values()) and total != len(successful):
print(f"[警告] Pod 计数增量合计 {total} != HTTP 200 数量 {len(successful)};"
"可能有其他并发流量、Pod 重启、指标更新差异,请勿直接当作精确分布。", file=sys.stderr)
if len(successful) != args.count:
err_examples = [r[3] for r in results if r[3]]
print(f"[警告] 测试未全部成功。错误示例:{err_examples[:2]}", file=sys.stderr)
return {
"success": len(successful), "count": args.count,
"p50": pct(latencies, .50), "p95": pct(latencies, .95),
"pod_counts": pod_counts, "hit_rate": hit_rate,
"source": source, "status": dict(statuses),
"consistent": all(v is not None for v in pod_counts.values()) and total == len(successful),
}
def format_latency(value):
return f"{value:.2f} ms" if value is not None else "未测得"
def make_markdown(pods, shared, unique):
labels = {pod: chr(ord("A") + i) for i, pod in enumerate(pods)}
def count(row, pod):
value = row["pod_counts"][pod]
return str(value) if value is not None and row["consistent"] else "待核实"
rows = [
("成功请求数", f"{shared['success']}/{shared['count']}", f"{unique['success']}/{unique['count']}"),
("请求完成时间 P50", format_latency(shared["p50"]), format_latency(unique["p50"])),
("请求完成时间 P95", format_latency(shared["p95"]), format_latency(unique["p95"])),
]
rows += [(f"Pod {labels[pod]} 请求数增量", count(shared, pod), count(unique, pod)) for pod in pods]
rows += [("Prefix Cache 实际命中率", shared["hit_rate"], unique["hit_rate"])]
lines = [
"| 观察项目 | Shared Prefix | Unique Prefix |",
"|---|---:|---:|",
]
lines.extend(f"| {name} | {left} | {right} |" for name, left, right in rows)
lines += ["", "Pod 名称对应:"]
lines.extend(f"- Pod {labels[pod]}:`{pod}`" for pod in pods)
lines += [
"", "注:P50/P95 为客户端观察到的 Simulator 端到端请求完成时间,不代表真实 GPU 推理性能。",
"Pod 请求数来自每轮测试前后的成功计数器差值;若测试期间存在其他请求,增量可能包含额外流量。",
"Prefix Cache 指标不存在时写'未测得',不解释为 0% 命中。",
"Shared/Unique 两组 Prompt 长度不严格一致,不能将两组延迟差直接解释为缓存优化效果。",
]
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser(description="llm-d Qwen Simulator 调度测试与统计")
parser.add_argument("--url", default="http://localhost:8080/v1/completions")
parser.add_argument("--namespace", default="ai-gateway")
parser.add_argument("--selector", default="app=qwen-sim")
parser.add_argument("--model", default="Qwen/Qwen3-32B")
parser.add_argument("--backend-port", type=int, default=8000)
parser.add_argument("--count", type=int, default=40)
parser.add_argument("--concurrency", type=int, default=8)
parser.add_argument("--timeout", type=float, default=60.0)
parser.add_argument("--output", default="routing-traffic-results.md")
args = parser.parse_args()
if args.count < 1 or args.concurrency < 1:
parser.error("--count 和 --concurrency 必须大于 0")
if not shutil_which_kubectl():
parser.error("找不到 kubectl,请先安装并配置 kubeconfig")
host = urllib.parse.urlparse(args.url)
if host.hostname in ("localhost", "127.0.0.1"):
try:
with socket.create_connection((host.hostname, host.port or 80), timeout=2):
pass
except OSError:
parser.error("连接不到 Gateway,请先运行:kubectl port-forward -n ai-gateway svc/inference-gateway 8080:80")
try:
pods = find_pods(args.namespace, args.selector)
except (RuntimeError, subprocess.TimeoutExpired, KeyError) as exc:
parser.error(f"无法获取 Simulator Pod:{exc}")
if len(pods) != 3:
parser.error(f"预期 3 个 Ready Simulator Pod,实际得到 {len(pods)} 个:{pods}")
print("实际 Pod 映射:")
for i, pod in enumerate(pods):
print(f" {chr(ord('A') + i)} = {pod}")
print("请不要同时运行其他 Qwen 请求;如有 Rate Limit,请先临时停用该策略。")
shared = measure(args, "shared", pods)
unique = measure(args, "unique", pods)
md = make_markdown(pods, shared, unique)
print("\n=== 可直接粘贴到博客的 Markdown 数据表 ===\n")
print(md)
Path(args.output).write_text(md, encoding="utf-8")
print(f"结果已写入:{args.output}")
def shutil_which_kubectl():
from shutil import which
return which("kubectl") is not None
if __name__ == "__main__":
main()
可以直接填写下表:

| 观察项目 | Shared Prefix | Unique Prefix |
|---|---|---|
| 成功请求数 | 40/40 | 40/40 |
| 请求完成时间 P50 | 5.41 ms | 6.06 ms |
| 请求完成时间 P95 | 138.97 ms | 9.05 ms |
| Pod A 请求数增量 | 10 | 14 |
| Pod B 请求数增量 | 16 | 13 |
| Pod C 请求数增量 | 13 | 13 |
需要注意,这里的 P50、P95 是客户端观察到的整体请求完成时间,不是模型专用的 TTFT 或 TPOT。
如果要严格对比两类流量,还需要控制 Token 长度、缓存预热、并发与请求总数。
如果发现 Shared Prefix 请求明显集中在少数 Pod,可以进一步调查是否存在前缀亲和策略。
但仅仅观察到请求集中,并不能证明它们实际复用了 KV Cache。
更有价值的证据是:
Router Selected Endpoint
+
Backend Prefix Cache Hits
+
Actual Request Performance
三者共同发生符合预期的变化。
3. 未来真实多 GPU 测试应该怎样设计?
如果以后拥有足够的 GPU,建议采用四组对照:
| 方案 | 架构 |
|---|---|
| A | 普通多副本负载均衡 |
| B | Load-aware Routing |
| C | Load + Prefix-cache-aware Routing |
| D | P/D Disaggregated Serving |
同时保持模型、硬件、请求负载和并发条件一致。
重点观察:
P50 / P95 TTFT
P50 / P95 TPOT / ITL
Output Tokens per Second
KV Cache Hit Rate
GPU Utilization
Request Queue Depth
KV Transfer Time
Error Rate
其中,P/D Disaggregation 应特别关注:
Prefill Time
+
KV Transfer Time
+
Decode Performance
只有证明资源分离带来的收益超过额外协调和传输成本,才能说这种架构在对应工作负载下真正有效。
七、三个值得注意的架构问题
这一篇不再列举 Helm 安装失败、镜像下载等普通环境问题。
我们重点讨论三个可能直接影响架构正确性的地方。
1. Prefix Cache 的位置判断可能是过期的
假设 Router 认为:
Prefix X -> GPU A
但实际上 GPU A 已经发生 KV Cache 驱逐。
此时 Router 仍把请求交给 A,就可能无法获得预期的缓存收益。
这也是为什么精确的 KV Cache Routing 需要维护缓存变化事件。
不过,精确索引同样可能由于事件丢失、Router 重启或事件重放不足出现不完整状态。
因此:
Cache Locality 是一种动态状态,不是静态的 Endpoint 标签。
2. P/D 分离之后,网络可能变成新的瓶颈
当 Prefill 和 Decode 处于不同节点:
Prefill GPU
|
| KV Cache
v
Network
|
v
Decode GPU
每次需要跨实例传递的 KV Cache 都会增加数据移动成本。
如果网络带宽不足、传输排队严重或者互连延迟较高,P/D 分离可能反而导致性能退化。
所以不能只研究 GPU 数量,还要研究 GPU 之间的通信拓扑。
这也说明,分布式推理不只是 Kubernetes 的 Pod 调度问题。
3. 单机模拟不能证明多 GPU 性能收益
这是当前实验室最重要的边界。
使用 Simulator,我们确实可以研究:
Gateway Routing
EPP Endpoint Selection
Request Scheduling Policies
Simulated KV Cache Events
甚至可以模拟 P/D 不同阶段的延迟和数据传输成本。
但我们无法仅通过 Simulator 证明:
Real GPU Compute Efficiency
Real NIXL Transfer Performance
Real GPU Memory Pressure
Real P/D Throughput Improvement
这些仍然需要实际 GPU 资源和真实模型负载。
因此,我们应该将当前实验定位为:
学习分布式推理的调度原理与控制机制。
而不是宣称已经完成一个真实的生产级多 GPU llm-d 性能集群。
八、总结:llm-d 真正解决的不是"多运行几个 Pod"
回顾整个 AI Infra 实验室系列。
最初,我们研究如何让 GPU 运行在 Kubernetes 中。
接着研究 vLLM,理解单个推理引擎如何通过 KV Cache、Continuous Batching 等机制提高性能。
随后使用 KServe,把模型作为一种可以声明和管理的 Kubernetes 资源。
再后来研究 Autoscaling 和 Cold Start,理解为什么扩容一个模型实例比普通 Web 服务复杂。
上一期通过 Inference Gateway,开始让流量分发理解模型和推理请求。
这一篇,我们进一步深入到集群级的推理执行效率。
llm-d 希望解决的核心问题,可以归纳为四个方面。
第一,Load-aware Routing。
根据真实推理负载选择实例,而不是简单按 HTTP 请求数量分配。
第二,Prefix-cache-aware Routing。
尽量复用已经产生的 KV Cache,避免不必要的 Prefill 计算。
第三,KV Cache Locality。
将缓存的位置和生命周期作为调度的重要输入,让路由层理解计算状态究竟位于哪里。
第四,Prefill/Decode Disaggregation。
允许 Prefill 和 Decode 独立部署、扩容和优化,并通过高效 KV Transfer 完成协作。
它们共同指向一个目标:
让整个 GPU 集群按照推理任务的计算特征进行协作,而不只是把更多 GPU 放在同一个 Kubernetes 集群里。
但这里也必须强调一个现实问题。
对于目前只有一张 RTX 3060、少量并发请求的个人实验室,单个 vLLM 实例仍然是非常合理的选择。
没有必要为了使用 llm-d 而强行部署复杂的 P/D 分离系统。
因为系统架构的价值不在于引入了多少组件,而在于它能否改善可测量的性能、稳定性或资源成本。
只有当单实例已经出现明确瓶颈,多副本之间的负载与缓存问题足够突出,或者 Prefill/Decode 的相互干扰明显影响服务质量时,分布式推理优化才会显示出更大的价值。
从这个意义上讲,llm-d 并不是让单张 GPU 变得更强。
它真正尝试解决的是:
当我们拥有越来越多 GPU 时,怎样避免这些 GPU 只是各自孤立地工作。
九、下一篇为什么会出现?
到目前为止,我们已经沿着 Kubernetes 原生 AI Infra 的路线,逐步构建了模型推理服务体系。
从 GPU Operator 管理 GPU,到 vLLM 提供高性能推理,再到 KServe 管理模型生命周期、Inference Gateway 实现智能路由,以及本篇的 llm-d 分布式推理优化。
这条路线的特点是:
以 Kubernetes 为基础,通过专门的组件解决 LLM Serving 的不同问题。
但是,如果未来我们需要的不只是运行一个大语言模型呢?
假设一个 AI 应用包含:
-
一个 LLM,负责生成回答;
-
一个 Embedding 模型,负责文本向量化;
-
一个 Reranker,负责检索结果排序;
-
一个 Python 预处理服务,负责数据转换;
-
多个 CPU 和 GPU Worker,执行不同的计算任务。
这些组件不仅需要独立扩缩容,还可能需要组合成一条完整的推理流水线。
例如:
Client
|
v
API Endpoint
|
v
Text Preprocessing
|
v
Embedding
|
v
Vector Retrieval
|
v
Reranker
|
v
LLM
|
v
Response
当然,我们完全可以继续使用 Kubernetes Deployment、Service、KServe 和 Gateway,把这些服务组合起来。
但这也引出了一个新的问题:
能不能使用一套统一的分布式计算框架,管理这些模型、Worker 以及它们之间的执行关系?
这就是 Ray 提供的另一种思路。
Ray 并不只关注 LLM 推理,而是面向更广泛的分布式计算任务,通过 Task、Actor 等抽象组织跨进程、跨节点的执行。
在此基础上:
-
KubeRay 负责让 Ray 集群及相关工作负载运行在 Kubernetes 中;
-
Ray Serve 提供模型服务部署、请求处理、Replica 管理以及多阶段服务组合等能力;
-
vLLM 仍然可以作为底层 LLM 推理引擎,与 Ray Serve 集成。
这和 llm-d 形成了一个值得研究的对比。
llm-d 更侧重于 LLM 推理过程本身的集群级优化,例如 KV Cache 感知路由和 Prefill/Decode 分离。
Ray Serve 则提供更通用的分布式模型服务编排能力,适合研究复杂 AI 应用中多个模型和计算组件怎样协作。
两者并不是完全互斥的替代方案,而是关注的抽象层次和优化重点有所不同。
对于我们的单机实验室,Ray 还有一个实际优势:即使只有一张 GPU,也可以先使用 CPU Worker 和轻量级服务,验证 Ray 的分布式执行与服务管理机制。
因此,下一篇我们将暂时从 llm-d 的 LLM 专用调度体系转向 Ray,研究另一条 AI Serving 技术路线。
下一篇:《从零搭建一个 AI Infra 实验室㉒:KubeRay + Ray Serve------另一条分布式 AI Serving 路线》
重点研究 Ray Cluster、Ray Actor、KubeRay Operator、RayService、Ray Serve Deployment,以及如何在现有 Kubernetes 实验室中部署一套简单的分布式推理服务。
最终回答一个问题:
当 AI Serving 从单个 LLM 扩展到多个模型和复杂计算流水线时,Ray 能提供什么不同于 Kubernetes 原生组件组合的能力?
参考资料
-
llm-d 官方项目:项目概览、架构和技术文档。
-
llm-d GitHub:完整项目及部署指南。
-
llm-d Router:Endpoint Picker、调度插件与路由实现。
-
Optimized Baseline:Load-aware 与 Prefix-cache-aware Routing。
-
Precise Prefix Cache Routing:KV Cache Events、精确索引与缓存路由。
-
P/D Disaggregation:Prefill/Decode 分离及 KV Transfer。
-
llm-d Inference Simulator:无 GPU 的推理模拟器及测试方法。
-
vLLM llm-d Integration:vLLM 与 llm-d 的集成说明。
-
NVIDIA NIXL:推理数据传输框架。