MoreRSS

site iconLixueduan | 李学端修改

博客名:指月小筑。专注云原生,Go,坚持分享最佳实践、经验干货。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Lixueduan | 李学端的 RSS 预览

vLLM PD 分离实战:从请求流程理解 Prefill、Decode 与 KV Cache 传输

2026-09-15 04:00:00

vLLM PD 分离实战:Prefill、Decode 与 KV Cache 传输

上一篇 https://www.lixueduan.com/posts/ai/27-why-pd-disaggregation/ 介绍了为什么要把 Prefill 和 Decode 拆开。本文继续往下走,在单节点 GPU 环境中搭建一个可以从头复现的 PD 分离 Demo,先用最小实现把整个链路跑通。

本文不引入复杂组件,只使用 vLLM 和一个轻量 Proxy,把 Prefill 与 Decode 两个阶段串起来。

Demo 主要看两件事:

  • PD 分离后的请求执行流程,观察同一个请求如何依次经过 Proxy、Prefill 和 Decode;
  • PD 分离中的 KV Cache 数据流,确认 Prefill 生成的 KV Cache 如何通过 KV Connector/NIXL 交给 Decode。

TL;DR

请求先由 Proxy 发送到 Prefill 计算 Prompt 和 KV Cache,再携带 kv_transfer_params 请求 Decode;KV Cache 由 Decode 通过 NIXL 直接从 Prefill 拉取,不经过 Proxy。

vLLM PD 分离应用的请求流程

1. 环境准备

1.1 准备 GPU 环境

本文不展开 GPU 基础环境的安装过程,大家可以先参考前置文章了解 GPU 环境搭建指南:如何在物理机、Docker、K8s 等环境中使用 GPU

1.2 准备模型

从 ModelScope 下载模型,为了简化直接使用 Qwen2.5-0.5B-Instruct 这个小模型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
python3 -m pip install --upgrade modelscope

mkdir -p /opt/models/Qwen2.5-0.5B-Instruct

modelscope download \
 --model Qwen/Qwen2.5-0.5B-Instruct \
 --local_dir /opt/models/Qwen2.5-0.5B-Instruct

test -f /opt/models/Qwen2.5-0.5B-Instruct/config.json
test -f /opt/models/Qwen2.5-0.5B-Instruct/model.safetensors
du -sh /opt/models/Qwen2.5-0.5B-Instruct

2. PD 分离

2.1 Connector 选择

Connector 可以理解为 Prefill 和 Decode 之间传递和访问 KV Cache 的具体机制

  • Prefill 是 KV Cache 的生产者,负责生成并保存 KV Cache;
  • Decode 是 KV Cache 的消费者,负责找到并加载这部分 KV Cache,然后继续生成。

vLLM 提供了多种 KV Connector,用于不同场景下的 KV Cache 传输和管理,例如 NixlConnector、LMCacheConnectorV1、MooncakeConnector 等。

不同版本支持的 Connector 及参数可能发生变化,实际使用时应以对应版本的文档和源码为准。

本文选择 NixlConnector

  • Prefill 使用 kv_producer 角色生成 KV Cache
  • Decode 使用 kv_consumer 角色通过 NIXL 拉取 KV Cache。

这样可以在不引入额外缓存服务的前提下,直接观察一次跨 vLLM 实例的 KV Cache 传输过程,并从 Decode 日志中确认传输是否成功。

2.2 用 nerdctl 启动单容器和 P/D 进程

简单起见,我们直接使用 nerdctl 启动一个 GPU 容器提供完整的 vLLM 运行环境,再在容器内启动三个相互独立的进程:Prefill、Decode 和 Proxy。

虽然都在一个容器中运行,但是 Prefill 和 Decode 仍然是两个独立的 vLLM 实例,分别监听不同的 HTTP 端口,拥有各自的 KV Cache 管理和 NIXL Connector,它们只是为了简化 Demo,共享同一个容器的网络命名空间和一张 GPU。

先下载与 vLLM v0.27.1 匹配的 toy Proxy。下载到宿主机的工作目录,随后通过目录挂载让容器读取:

1
2
3
4
5
6
7
mkdir -p /tmp/vllm-pd-demo

# toy_proxy_server.py 源码(v0.27.1):
# https://github.com/vllm-project/vllm/blob/v0.27.1/tests/v1/kv_connector/nixl_integration/toy_proxy_server.py
curl -fL --retry 3 --retry-delay 2 \
 "https://raw.githubusercontent.com/vllm-project/vllm/v0.27.1/tests/v1/kv_connector/nixl_integration/toy_proxy_server.py" \
 -o /tmp/vllm-pd-demo/toy_proxy_server.py

创建并启动一个后台容器,三个服务进程稍后分别通过 nerdctl exec 启动:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
nerdctl run -d \
 --name vllm-pd-demo \
 --gpus all \
 --ipc=host \
 --ulimit memlock=-1 \
 --ulimit stack=67108864 \
 -p 8192:8192 \
 -p 8100:8100 \
 -p 8200:8200 \
 --volume /opt/models/Qwen2.5-0.5B-Instruct:/models:ro \
 --volume /tmp/vllm-pd-demo:/work \
 --entrypoint /bin/bash \
 docker.m.daocloud.io/vllm/vllm-openai:v0.27.1 \
 -lc "sleep infinity"

确认容器可以看到 GPU:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
root@lixd-test-gpu:~# nerdctl exec vllm-pd-demo nvidia-smi
Wed Aug 26 03:14:55 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.173.02 Driver Version: 580.173.02 CUDA Version: 13.0 |
+-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 Tesla T4 Off | 00000000:00:06.0 Off | 0 |
| N/A 46C P0 29W / 70W | 6847MiB / 15360MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+

接下来打开三个终端。每个终端先执行下面的命令进入同一个容器:

1
nerdctl exec -it vllm-pd-demo bash

终端一:启动 Prefill

在第一个容器终端中执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export VLLM_NIXL_SIDE_CHANNEL_HOST=127.0.0.1
export UCX_NET_DEVICES=all
export UCX_TLS=tcp,cuda_copy,cuda_ipc

VLLM_NIXL_SIDE_CHANNEL_PORT=5600 vllm serve /models \
 --served-model-name=qwen-pd-demo \
 --host=0.0.0.0 \
 --port=8100 \
 --max-model-len=1024 \
 --max-num-seqs=4 \
 --gpu-memory-utilization=0.30 \
 --enforce-eager \
 --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_producer","kv_load_failure_policy":"fail"}' \
 2>&1 | tee /work/prefill.log

终端二:启动 Decode

在第二个容器终端中执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export VLLM_NIXL_SIDE_CHANNEL_HOST=127.0.0.1
export UCX_NET_DEVICES=all
export UCX_TLS=tcp,cuda_copy,cuda_ipc

VLLM_NIXL_SIDE_CHANNEL_PORT=5601 vllm serve /models \
 --served-model-name=qwen-pd-demo \
 --host=0.0.0.0 \
 --port=8200 \
 --max-model-len=1024 \
 --max-num-seqs=4 \
 --gpu-memory-utilization=0.30 \
 --enforce-eager \
 --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_consumer","kv_load_failure_policy":"fail"}' \
 2>&1 | tee /work/decode.log

这里 Prefill 和 Decode 使用不同的 NIXL side channel 端口 56005601,避免两个进程在同一个容器的网络命名空间中发生端口冲突。

VLLM_NIXL_SIDE_CHANNEL_HOST 使用 127.0.0.1,是因为两个 vLLM 实例位于同一个容器内。

这两个命令会以前台进程运行,日志会直接显示在各自终端;命令末尾的 tee 还会把日志分别保存到 /work/prefill.log/work/decode.log

2.3 启动 Proxy 进程

普通的 OpenAI-compatible Proxy 只负责转发请求,不会自动完成 PD 分离。这里使用 vLLM v0.27.1 仓库中与该版本匹配的 toy_proxy_server.py:它会先请求 Prefill,再把 Prefill 返回的 kv_transfer_params 交给 Decode。

在第三个容器终端中执行:

1
2
3
4
5
6
7
8
python3 /work/toy_proxy_server.py \
 --host=0.0.0.0 \
 --port=8192 \
 --prefiller-hosts=127.0.0.1 \
 --prefiller-ports=8100 \
 --decoder-hosts=127.0.0.1 \
 --decoder-ports=8200 \
 2>&1 | tee /work/proxy.log

Proxy 通过 127.0.0.1:8100127.0.0.1:8200 访问同一容器内的 Prefill、Decode,客户端则通过宿主机发布的 8192 端口访问 Proxy。

2.4 验证

Proxy 启动后,可以先通过 /healthcheck 确认 Proxy 已正常启动,并识别到配置的 Prefill 和 Decode 实例:

1
2
root@lixd-test-gpu:~# curl -fsS http://127.0.0.1:8192/healthcheck
{"status":"ok","prefill_instances":1,"decode_instances":1}

确认 Prefill 和 Decode 都完成模型加载后,再发送完整请求。

1
2
curl -fsS http://127.0.0.1:8100/health
curl -fsS http://127.0.0.1:8200/health

发送一次完整请求

不要分别手工请求 Prefill 和 Decode,也不要手工复制 kv_transfer_params。Proxy 会自动完成这几步:

  1. 把请求改写成 max_tokens=1,并发送给 Prefill;
  2. 读取 Prefill 返回的 KV Transfer 元数据;
  3. 恢复客户端原始生成参数,把请求发送给 Decode;
  4. 将 Decode 的结果返回给客户端。

从宿主机通过发布的 Proxy 端口发送请求:

1
2
3
4
5
6
7
8
9
curl -sS --max-time 180 http://127.0.0.1:8192/v1/completions \
 -H "Content-Type: application/json" \
 --data-binary '{
 "model": "qwen-pd-demo",
 "prompt": "Explain the request flow between Prefill and Decode in PD disaggregation.",
 "max_tokens": 8,
 "temperature": 0,
 "stream": false
 }'

成功时会返回 HTTP 200 和一个 completion 响应,通常包含:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
 "id": "cmpl-...",
 "model": "qwen-pd-demo",
 "choices": [
 {
 "finish_reason": "length"
 }
 ],
 "usage": {
 "prompt_tokens": 15,
 "completion_tokens": 8
 },
 "kv_transfer_params": null
}

最终响应中的 kv_transfer_paramsnull 是正常的,因为它是 Decode 生成并返回给客户端的最终响应。KV Transfer 元数据只在 Proxy 内部从 Prefill 传给 Decode。

检查日志确认 PD 分离

HTTP 200 只能证明接口返回成功,还需要结合 Decode 日志确认 KV Cache 传输。按照本文命令启动时,三个服务的日志既会显示在终端,也会写入 /work。在第四个终端中执行:

1
2
3
nerdctl exec vllm-pd-demo \
 grep -E "NIXL compatibility check passed|KV Transfer metrics|POST /v1/completions" \
 /work/prefill.log /work/decode.log /work/proxy.log

Decode 至少应该出现类似日志:

1
2
3
4
5
NIXL compatibility check passed
KV Transfer metrics: Num successful transfers=1
Avg xfer time (ms)=...
Avg MB per transfer=...
Throughput (MB/s)=...

其中 Decode 的 Num successful transfers=1 是判断 KV Cache 确实被拉取的关键证据。Prefill 和 Decode 的请求日志应该都返回 200 OK,Proxy 日志应该出现 POST /v1/completions ... 200 OKNIXL compatibility check passed 只能说明两端配置兼容,不能单独证明本次请求已经完成 KV Cache 传输。

一次已验证的运行记录

下面是完成一次推理请求后,从三个日志文件中筛选出的关键日志。查询命令的输出包含多次请求记录,下面保留与这次验证相关的关键行:

1
2
3
4
5
/work/prefill.log:(APIServer pid=40) INFO: 127.0.0.1:45150 - "POST /v1/completions HTTP/1.1" 200 OK
/work/decode.log:(EngineCore pid=198) INFO 08-25 07:35:17 [base_worker.py:680] NIXL compatibility check passed (hash: b5f080ccb62b60a662e683a3e38305f1609bc636242f37736e2a8bba912cba59)
/work/decode.log:(APIServer pid=52) INFO 08-25 07:50:17 [metrics.py:103] KV Transfer metrics: Num successful transfers=1, Avg xfer time (ms)=1.51, P90 xfer time (ms)=1.51, Avg post time (ms)=1.51, P90 post time (ms)=1.51, Avg MB per transfer=0.188, Throughput (MB/s)=124.172, Avg number of descriptors=24.0
/work/decode.log:(APIServer pid=52) INFO: 127.0.0.1:58494 - "POST /v1/completions HTTP/1.1" 200 OK
/work/proxy.log:INFO: 10.4.0.1:57586 - "POST /v1/completions HTTP/1.1" 200 OK

其中 KV Transfer metricsNum successful transfers=1 是本次验证最关键的证据,表示 Decode 侧至少完成了一次 NIXL KV Cache 传输。Avg MB per transfer=0.188 表示本次平均传输了约 0.188 MB 的 KV Cache。Prefill、Decode 和 Proxy 的请求都返回了 200 OK,说明请求链路也完整走通。

清理

停止并删除 Demo 容器:

1
nerdctl rm -f vllm-pd-demo

3. PD 分离的请求执行流程

前面的验证确认了请求确实经过了 Prefill、Decode。下面把一次请求拆开,分别看控制请求和 KV Cache 数据是如何流动的。

一次请求的执行流程如下:

vLLM PD 分离应用的请求流程

3.1 Client -> Proxy:请求进入编排层

客户端只需要发送一次普通的 OpenAI-compatible Completion 请求,例如设置 max_tokens=1024。客户端不需要知道 Prefill、Decode 的地址,也不需要自己构造 kv_transfer_params

Proxy 为请求生成 request ID,同时保留客户端原始生成参数。发送给 Prefill 的请求会基于原始请求进行改写,后续再使用原始参数构造 Decode 请求。

3.2 Proxy -> Prefill:只做 Prefill

Proxy 将请求发送给 Prefill 时,会把 max_tokens 改为 1,同时附加类似下面的 kv_transfer_params

1
2
3
4
5
6
{
 "kv_transfer_params": {
 "do_remote_decode": true,
 "do_remote_prefill": false
 }
}

这表示当前实例负责 Prefill,后续 Decode 由远端实例完成。Prefill 处理完整 Prompt,生成对应的 KV Cache。由于请求被设置为 max_tokens=1,Prefill 还会产生一个单 Token 响应,同时返回 KV Transfer 元数据。

这个 Token 后续会被 Proxy 丢弃。

这些元数据包含 Prefill 侧的 Engine ID、请求 ID、KV block ID,以及 NIXL side channel 的地址和端口等信息,它们是下一阶段 Decode 找到 KV Cache 的定位信息。在本文单容器 Demo 中,实例地址使用 127.0.0.1;实际多容器或多节点部署时,应替换成 Decode 可以访问的地址。

3.3 Prefill -> Proxy -> Decode:交接 KV Cache

Proxy 会把 Prefill 返回的第一个 Token 直接丢弃,然后取出其中的 kv_transfer_params,覆盖到原始请求上,并恢复客户端要求的 max_tokens

发送给 Decode 的请求大致包含:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
 "max_tokens": 8,
 "kv_transfer_params": {
 "do_remote_prefill": true,
 "do_remote_decode": false,
 "remote_engine_id": "...",
 "remote_request_id": "...",
 "remote_block_ids": [[1]],
 "remote_host": "127.0.0.1",
 "remote_port": "<NIXL side-channel port>"
 }
}

这里有两条不同的链路:

  • 控制请求通过 Proxy 的 HTTP 端口到达 Prefill 和 Decode;
  • KV Cache 不经过 Proxy,Decode 根据元数据通过 NIXL 直接从 Prefill 读取。

因此,Proxy 负责请求编排,Connector/NIXL 负责 KV Cache 传输,两者是不同的职责。单容器只让 HTTP 地址和 NIXL 地址都表现为回环地址,并没有把两个 vLLM 实例合并成一个 Engine。

3.4 Decode -> Proxy -> Client:继续生成并返回结果

Decode 收到请求后,根据 remote_engine_idremote_request_idremote_block_ids 等信息,通过 NIXL 拉取 Prefill 生成的 KV Cache。KV Cache 加载完成后,Decode 不再重复计算完整 Prompt,而是从已有上下文继续逐 Token 生成。

最终返回给客户端的是 Decode 的响应,Prefill 的临时 Token 不会出现在最终结果中。Decode 日志中的 NIXL compatibility check passedNum successful transfers=1,分别说明传输能力协商成功,以及这次请求确实完成了 KV Cache 传输。

4. PD 分离在生产环境中的挑战

前面的 Demo 有意把环境压到了最小:只有一个 Prefill、一个 Decode,而且两个实例还运行在同一个容器和同一张 GPU 上。这样很适合观察请求和 KV Cache 的流向,但真正到了多实例、跨节点环境后,很多问题才会出现。

在真正的生产环境中,PD 分离还需要解决很多更复杂的问题,例如:

  • KV Cache 管理:不仅要解决 KV Cache 如何从 Prefill 传输到 Decode,还要考虑缓存的生命周期、释放与回收、传输超时与失败重试,以及跨节点场景下的网络带宽和传输开销。
  • P/D 实例调度:生产环境通常会同时运行多个 Prefill 和 Decode 实例。Router 需要根据实例负载、KV Cache/Prefix Cache 命中情况等信息选择合适的 P、D 实例,而不是简单地做一次请求转发。
  • P/D 容量配比与扩缩容:Prefill 和 Decode 的资源特征不同,Prompt 长度、输出长度以及请求并发度变化后,两侧的压力也会发生变化,因此需要分别进行容量规划、负载均衡和弹性扩缩容。
  • 故障处理:Prefill 成功但 Decode 失败、KV Cache 传输异常、请求超时或取消等情况,都需要处理请求重试、状态清理以及 KV Cache 回收等问题。
  • 性能与可观测性:PD 分离并不会凭空提高吞吐。它主要解决的是 Prefill 和 Decode 相互干扰的问题,让 TTFT 和 ITL 可以分别优化,特别是改善 tail ITL。代价也很直接:多了一层请求调度和 KV Cache 传输。生产环境还需要持续关注 P/D 排队时间、KV Cache 传输耗时、传输带宽和失败情况,判断拆分带来的收益是否真正大于额外成本。

这篇文章先把 PD 分离最基础的链路跑通。到了多实例、跨节点环境后,关注点就会从“KV Cache 能不能传过去”,转向实例怎么调度、缓存怎么管理、失败怎么处理,以及拆分之后到底能不能改善实际的延迟指标。

5. 小结

本文通过一个基于 vLLM、NixlConnector 和 toy Proxy 的最小 Demo,把 PD 分离之后的请求执行流程和 KV Cache 数据流串了起来。

PD 分离之后,请求流程如下:

  1. Client 将原始请求发送给 API Proxy。
  2. API Proxy 将请求改写为 max_tokens=1,并发送给 Prefill 实例。
  3. Prefill 计算完整 Prompt,生成 KV Cache,同时返回一个临时 Token 和 KV Transfer 元数据。
  4. API Proxy 丢弃 Prefill 返回的临时 Token,只保留 KV Transfer 元数据。
  5. API Proxy 将原始请求再次发送给 Decode,并携带 kv_transfer_params,恢复原始的 max_tokens=N
  6. Decode 根据传输元数据,通过 NIXL 直接从 Prefill 拉取 KV Cache,这部分数据不经过 API Proxy。
  7. Decode 基于已经加载的 KV Cache 继续生成完整 Token,并将最终结果返回给 API Proxy。
  8. API Proxy 将 Decode 的最终结果返回给 Client。

其中,API Proxy 负责请求编排,Prefill 负责 Prompt 计算和 KV Cache 生成,Decode 负责拉取 KV Cache 并继续生成。一个容器降低了环境准备和网络配置的复杂度,但 Prefill、Decode 仍然是两个独立的 vLLM 实例,NixlConnector 仍然负责两者之间的 KV Cache 传输。

KubeClipper 1.7.0 发布:Operation 优化与 Kubernetes 1.37 支持

2026-09-08 04:00:00

kubeclipper-release-1.7.0.jpg

KubeClipper 1.7.0 发布了。

本次版本新增 API 驱动的 Operation v2,移除了旧的 NATS Operation 投递链路,集群创建、扩容、删除等操作统一使用新的 Operation v2。新增 kcctl statuskcctl doctor 平台诊断命令,新增了对 Kubernetes 1.37 版本的支持,并对镜像仓库管理、离线镜像加载和部署预检查进行了优化。

KubeClipper 是一个轻量便捷的 Kubernetes 多集群全生命周期管理工具,旨在提供易使用、易运维、极轻量、生产级的 Kubernetes 多集群管理服务,让运维工程师从繁复的配置和晦涩的命令行中解放出来,实现一站式管理跨区域、跨基础设施的多 K8S 集群。

🚀 5分钟快速体验

第一次用 KubeClipper,可以先按下面这几步走一遍:

  1. 安装工具curl -sfLk https://oss.kubeclipper.io/get-kubeclipper.sh | bash -
  2. 部署服务kcctl deploy
  3. 创建集群kcctl create cluster --name demo --master YOUR_IP --untaint-master
  4. 打开控制台:访问 http://YOUR_IP,默认账号 admin/Thinkbig1

四步,从零开始创建一个 Kubernetes 集群。部署完成后,还可以使用 kcctl statuskcctl doctor 检查平台状态。

1. KubeClipper 1.7.0 新特性详解

1.7.0 的主要变化集中在以下几个方面:Operation v2、平台诊断、镜像仓库管理和部署稳定性。

本次更新的主要亮点:

  • 🆕 Operation v2:使用 API 和 etcd 管理集群操作,支持任务状态查询、重试、取消和重启恢复
  • 🩺 平台诊断命令:新增 kcctl statuskcctl doctor
  • 🔄 Kubernetes 1.37 支持:默认版本升级至 v1.37.0
  • 📦 Registry 与离线镜像:支持选择 Registry 资源,离线模式支持加载节点本地镜像
  • 🔧 稳定性提升:增加部署预检查,修复传输校验、操作清理和 Agent 运行等问题

1.1 Kubernetes 1.37 与组件升级

1.7.0 支持的版本如下:

Kubernetes Calico Containerd
v1.37.0(默认) v3.31.5 v2.2.4
v1.36.4 v3.31.5 v2.2.4
v1.35.8 v3.31.5 v2.2.4

1.6.0 的默认 Kubernetes 版本为 v1.36.1,1.7.0 已升级至 v1.37.0,v1.35 和 v1.36 也同步更新到最新维护版本。Calico 和 Containerd 继续使用 v3.31.5 和 v2.2.4。

1.2 Operation v2,集群操作更加可靠

1.7.0 新增 API 驱动的 Operation v2,Operation 和 OperationTask 作为 API 资源保存到 etcd,支持查看任务状态、重试、取消、超时处理和重启恢复,集群创建、扩缩容、删除、备份恢复等操作都统一使用这套机制。

同时修复了 Operation 状态持久化、集群状态并发更新冲突、操作历史清理和删除校验 panic 等问题,并优化了关键状态读取和事件分发效率。

1.3 kcctl statuskcctl doctor

1.7.0 新增了平台状态查看和诊断命令:

1
2
kcctl status
kcctl doctor

kcctl status 用于查看 kc-serverkc-etcdkc-agent 的整体健康状态;kcctl doctor 用于进一步检查平台配置和服务状态,在异常时给出排查信息。doctor 只做诊断,不会自动修改或重启服务。

1.4 Registry 管理与离线镜像支持

1.7.0 将 Cluster.imageRegistry 调整为引用已配置的 Registry 资源,创建或升级集群时直接传入资源名称即可。KubeClipper 会自动读取 Registry 的地址、协议、认证和证书配置,并用于 Kubernetes、Containerd、Calico 等组件的镜像。

旧的 image repository/local registry 字段和 CLI 别名已移除。同时修复了 HTTP Registry 协议丢失的问题,离线模式且未指定 Registry 时,会直接加载离线安装包中随包分发到节点的镜像;在线模式未指定 Registry 时,各组件继续使用自己的默认镜像仓库。

1.5 部署与运行稳定性提升

这一版包含多项部署和运行稳定性修复:

  • 部署预检查:新增服务端口占用检查,预检查异常会明确显示节点 IP 和角色;部署 kc-server 前会等待 etcd Endpoint 通过健康检查。
  • 部署配置:支持通过 --temp-dir 指定安装包暂存目录
  • 运行稳定性:修复操作历史清理、Agent 命令输出缓冲、子进程管道阻塞和 watch 事件恢复等问题

2. 安装部署 KubeClipper 1.7.0

2.1 环境要求

  • CPU:2 核及以上
  • 内存:4GB 及以上
  • 操作系统:推荐 Ubuntu 22.04 / Ubuntu 24.04
  • 网络:节点间网络互通,并可通过 SSH 连接
  • 基础工具sudo / curl / wget / tar
  • 架构:amd64 / arm64

主机最好保持相对干净,避免已有容器运行时或系统配置与安装过程产生冲突。

2.2 安装 kcctl 工具

1
2
3
4
5
# 下载最新版本的 kcctl
curl -sfLk https://oss.kubeclipper.io/get-kubeclipper.sh | bash -

# 验证安装
kcctl version

如果需要安装指定版本,可以通过 KC_VERSION 指定:

1
2
curl -sfLk https://oss.kubeclipper.io/get-kubeclipper.sh | \
 KC_VERSION=v1.7.0 bash -

2.3 部署 KubeClipper

1
2
3
4
5
6
7
# 对于 AIO 环境使用 kcctl deploy 即可完成部署
kcctl deploy

# 更多参数参考 kcctl deploy -h
# kcctl deploy --server $IPADDR_SERVER --agent $IPADDR_AGENT \
# --pk-file /root/.ssh/id_rsa --pkg $PKG \
# --ip-detect=interface=ens3 --temp-dir /data/kc-tmp --v 5

看到下面的 banner 就说明部署完成了:

1
2
3
4
5
6
7
8
 _ __ _ _____ _ _
| | / / | | / __ \ (_)
| |/ / _ _| |__ ___| / \/ |_ _ __ _ __ ___ _ __
| \| | | | '_ \ / _ \ | | | | '_ \| '_ \ / _ \ '__|
| |\ \ |_| | |_) | __/ \__/\ | |_) | |_) | __/ |
\_| \_/\__,_|_.__/ \___|\____/_|_| .__/| .__/ \___|_|
 | | | |
 |_| |_|

安装过程中需要下载离线安装包,具体耗时取决于网络速度。

2.4 访问 Web UI

安装完成后,打开浏览器,访问 http://$IP 即可进入 KubeClipper 控制台。

kc-console-login.jpg

您可以使用默认帐号密码 admin / Thinkbig1 进行登录。

3. 快速上手体验

3.1 创建 K8s 集群

部署成功后可以使用 kcctl 工具或者通过控制台创建 K8s 集群,这里使用 kcctl 工具进行创建。

查看当前 Agent 节点:

1
kcctl get node

创建集群:

1
kcctl create cluster --name demo --master YOUR_IP --untaint-master

1.7.0 默认使用 Kubernetes v1.37.0、Calico v3.31.5 和 Containerd v2.2.4。创建过程中可以查看实时操作:

1
2
kcctl operation list -c demo
kcctl operation logs -c demo

如果操作失败,可以查看详情、重试或取消:

1
2
3
kcctl operation describe <OPERATION_ID>
kcctl operation retry <OPERATION_ID>
kcctl operation cancel <OPERATION_ID>

集群进入 Running 状态后,用 kubectl 查看集群健康状况:

1
2
kubectl get node -owide
kubectl get pods -A

几条命令,一个单节点 K8s 集群就创建完成了。

3.2 平台状态检查

部署和集群创建完成后,可以使用下面的命令检查 KubeClipper 平台状态:

1
2
kcctl status
kcctl doctor

kcctl status 用于查看平台整体健康状态,kcctl doctor 用于进一步定位配置、服务和节点问题。

3.3 KubeClipper 的工作负载管理功能

KubeClipper 的工作负载管理功能在 1.7.0 中仍然可以直接通过 Web UI 管理 Deployment、StatefulSet 等 Kubernetes 工作负载。

kc-console-workload3.png

4. 小结

这一版主要干了三件事:升级集群操作机制、补充平台诊断命令、优化镜像和部署流程。

组件升级:

  • Kubernetes 1.37: 支持 v1.37.0、v1.36.4 和 v1.35.8
  • Containerd v2.2.4: 延续 1.6.0 的默认版本
  • Calico v3.31.5: 延续 1.6.0 的默认版本

操作与诊断:

  • Operation v2: 使用 API 和 etcd 管理集群操作
  • kcctl status: 查看平台整体健康状态
  • kcctl doctor: 提供平台故障诊断信息

稳定性提升:

  • Registry 管理: 使用 Registry 资源配置集群镜像仓库
  • 离线镜像: 离线模式未指定 Registry 时支持加载离线安装包中的本地镜像
  • 部署优化: 增加端口预检查和 etcd 健康检查,完善节点错误信息和传输失败处理
  • 运行修复: 修复操作清理、Agent Worker 和 watch 事件相关问题

PD 分离详解:Prefill 与 Decode 的瓶颈、拆分与代价

2026-09-03 04:00:00

为什么要 PD 分离

LLM 推理服务的性能瓶颈,不只在模型大小和 GPU 算力。副本增加后,请求落到哪个实例、长 Prompt 是否干扰流式输出、KV Cache 能否复用,都会直接影响延迟、吞吐和成本。

其中,Prefill/Decode 分离(PD 分离)是把两类截然不同的计算负载拆到独立资源池中的方案。本文从问题出发,说明它解决什么、代价是什么,以及何时不该使用它。

推理服务真的无状态吗?

先问大家一个问题:推理服务是无状态的吗

是,也不是。

只要客户端每次请求都带上完整的对话上下文,把请求调度到哪个后端服务,通常都不会影响推理的语义正确性。模型看到的上下文是完整的。

至于采样带来的输出差异,和请求落在哪个副本上是两回事。

但平台看到的是另一层状态。每个实例都维护着自己的请求队列、Prefix Cache 和 KV Cache。随机或者轮询不会破坏结果的正确性,却可能让请求反复落到没有对应缓存的实例上,造成 Cache Miss、重复 Prefill 和更多 GPU 计算,最终表现为更高的 TTFT、更低的吞吐和更高的资源成本。

同一段系统提示词、长文档或者多轮对话前缀,可能已经在实例 A 上完成 Prefill,并进入 Prefix Cache。下一次请求如果仍然落到实例 A,就有机会复用计算结果;如果被分给实例 B,实例 A 上的缓存没有损坏或失效,只是实例 B 无法使用,只能重新执行 Prefill。

即:LLM 推理服务在用户层面是无状态的,但在运行时层面却不是。

如果只关心接口是否可用、推理结果是否正确,可以把 LLM 推理服务看成无状态服务;但只要开始关注延迟、吞吐和 GPU 成本,就不能再忽略实例上的运行时状态。

普通负载均衡不懂 LLM 的运行时状态。它可能知道哪些实例 Ready,可以完成基础的流量分配,却不知道哪个实例的队列已经堆满、KV Cache 还剩多少,也不知道当前请求的前缀曾经在哪个实例上计算过。

所以,多副本 LLM 服务需要的不只是负载均衡,而是能够感知负载和缓存局部性的智能路由

为什么要拆分 Prefill 和 Decode?

Prefill 与 Decode 的计算特征不同

一次 LLM 推理请求可以粗略拆成 Prefill 和 Decode 两个阶段:

  • Prefill 阶段处理输入 Prompt,并生成后续 Decode 所需的 KV Cache。Prompt 越长,需要完成的计算越多,因此这一阶段通常是 compute-bound,主要受 GPU 计算能力限制。

  • Decode 利用已有 KV Cache,一次生成一个 Token。生成过程中,Attention 需要持续读取此前 Token 对应的 KV Cache;上下文越长,需要读取的历史 KV 越多,因此这一阶段通常是 memory-bandwidth-bound,更依赖显存带宽与 KV Cache 容量。

性能指标与资源差异

两个阶段对应的性能指标也不同:

  • Prefill 侧主要关注 TTFT(Time To First Token):也就是从请求发出到收到第一个 Token 的时间。它不仅包含 Prefill 计算,还包括此前的排队和路由等开销。

  • Decode 侧主要关注 TPOT(Time Per Output Token)ITL(Inter-Token Latency)

    • TPOT 表示第一个 Token 之后,平均生成一个输出 Token 所需的时间;
    • ITL 表示相邻两个输出 Token 之间的时间,更能反映流式输出是否稳定、有没有明显抖动。

在 Long Context 场景下,Prefill 和 Decode 对计算、显存带宽与容量的需求更加不平衡。

如果把它们拆开,Prefill Pool 可以偏向计算能力更强的 GPU,Decode Pool 则可以偏向显存带宽更高、容量更大的 GPU

两边的 Batch 策略也不同。Prefill 的 Batch 增大到一定程度后,计算单元会逐渐饱和,继续增加 Batch 对吞吐的提升趋缓,还可能推高 TTFT,因此通常需要控制 Batch Size。Decode 每一步只为每条活跃序列生成一个 Token,适当增大 Batch 可以提高 GPU 利用率与整体输出吞吐,但仍然要受 TPOT、ITL 和 KV Cache 容量约束。

不同 Batch Size 和输入长度下 Prefill、Decode 两个阶段的吞吐变化

图源:《DistServe: Disaggregating Prefill and Decoding for Goodput-Optimized Large Language Model Serving》,Figure 3。展示了模型在不同 Batch Size 与输入长度下的测试结果。具体数值取决于模型、硬件和推理引擎,这里主要观察两条趋势:Prefill 吞吐较早趋于平缓,而 Decode 吞吐可以随 Batch Size 增大继续提升。

在同一设备上同时运行 Prefill 和 Decode,意味着这颗芯片必须同时具备强大的算力(满足 Prefill 需求)巨大的显存容量/带宽(满足 Decode 的 KV Cache 需求)

然而,受限于芯片面积、功耗、良率和成本,芯片设计需要在"计算单元"和"显存接口/HBM 容量"之间做面积与功耗的 trade-off。因此,一颗"通吃"两颗阶段需求的芯片必然成本高昂,且在任一单一阶段都难以达到最优性价比。

PD 分离的本质,就是承认这种硬件层面的 trade-off,让 Prefill 实例和 Decode 实例各自采用最适合自己工作负载特征的硬件配置(如 Prefill 用高算力卡,Decode 用高 HBM 容量卡),从而在系统层面实现总体拥有成本(TCO)的最优

共置部署为什么会互相干扰

即使每次都选对了实例,也只解决了路由问题。Prefill 和 Decode 如果继续放在同一个实例里,就会共享 GPU 和调度队列。长 Prompt 带来的 Prefill 峰值,可能抬高其他请求的 Token 间延迟。

这就是执行层的问题:怎样减少 Prefill 和 Decode 共享 GPU、共享调度队列带来的相互干扰。对应的解决思路,就是进一步把 Prefill 和 Decode 拆到不同实例,也就是 PD 分离。

PD 分离到底拆了什么?

PD 分离(Prefill/Decode Disaggregation) 将 LLM 推理的两个阶段解耦,部署到独立的 Prefill 实例组和 Decode 实例组:

  • Prefill 实例组接收完整输入 Prompt,构建本次请求所需的 KV Cache,并生成首个输出 Token;
  • Decode 实例组通过高速网络获取 Prefill 产出的 KV Cache,以首 Token 为起点进行自回归解码,逐 Token 生成直至请求结束;

两个阶段通过高效的 KV Cache 传输机制衔接,并可以独立扩缩容,从而在合适的资源配比和传输网络下,同时优化吞吐量与延迟。

聚合式推理与 PD 分离对比

这里拆分的是执行位置和资源池,不是模型的计算顺序。一次请求仍然要先完成 Prefill,才能进入 Decode,只是两个阶段不再运行在同一个实例里。

因此,一次请求会像接力一样经过两组实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
请求进入
Prefill Pool:处理 Prompt,生成 KV Cache 和首 Token
Decode Pool:取得 KV Cache,继续生成后续 Token
返回响应

拆到这里,一个新的问题也随之出现:Prefill 和 Decode 不在同一个实例,KV Cache 怎么交接?

KV Cache 可以理解为模型处理 Prompt 后留下的中间状态。Decode 要从 Prefill 结束的位置继续生成,就必须获得本次请求对应的 KV Cache(否则就需要重新计算)。

聚合式推理中,两个阶段在同一个实例内运行,Decode 可以直接继续使用 Prefill 已经构建的 KV Cache。PD 分离后,两个阶段位于不同实例,各自管理本地显存,因此必须增加一次显式的交接:

  1. Prefill 完成计算,将 KV Cache 保留在本地,同时向请求编排组件返回 KV Transfer 元数据。
  2. 编排组件携带这些元数据发起 Decode 请求。
  3. Decode 根据元数据,通过 NIXL 等 KV Connector 从 Prefill 实例获取 KV Cache,然后继续逐 Token 生成。

这里有两条不同的链路:编排组件传递的是如何取得 KV Cache 的元数据;真正的 KV Cache 数据不经过 Router / Proxy。至于数据是在 Prefill 和 Decode 之间 P2P 传输,还是经由共享存储或缓存服务交接,则取决于具体使用的 Connector。

这也不等于 Prefix Cache 命中。

  • Prefix Cache 复用解决的是 Prefill 能否利用以前计算过的相同前缀,从而少做一部分计算;
  • KV Cache 传输解决的是本次 Prefill 的结果如何交给 Decode。即使没有命中 Prefix Cache,Prefill 也可以完整处理 Prompt,再把新生成的 KV Cache 传给 Decode。

KV Cache 共享方式有哪些?

在 vLLM 中,KV Cache 的交接由 KV Connector 抽象。它让作为 KV producer 的 Prefill 实例,将缓存交给作为 KV consumer 的 Decode 实例;调度器侧的 Connector 负责安排传输,Worker 侧的 Connector 负责实际读写数据。

不同 Connector 的差异,主要在于 KV Cache 放在哪里,以及 Prefill 和 Decode 之间通过什么方式传输:

  • P2P 传输NixlConnector 通过 NIXL 在实例之间传输 KV Cache,适合高速网络和异步收发场景。
  • 共享存储或缓存服务ExampleConnector 使用本地共享目录,主要用于验证 Connector 接口和 PD 分离流程;LMCacheConnectorV1 由 LMCache 管理 KV Cache,可使用 NIXL 作为底层传输,也支持独立的 LMCache Server;MooncakeConnectorFlexKVConnectorV1 则分别使用 Mooncake 和 FlexKV 作为 KV Cache 的传输、管理或分布式存储层。
  • 卸载、多级缓存与特定硬件OffloadingConnector 将 KV Cache 转移到 CPU 内存或文件系统等更低层级的存储中;MultiConnector 按顺序组合多个 Connector,形成多级 KV Cache 路径;MoRIIOConnector 面向 ROCm 环境。

vLLM 通过 KVTransferConfig 选择 Connector,并传递具体实现需要的参数。例如 NIXL Connector 的配置可以写成:

1
2
--kv-transfer-config \
'{"kv_connector":"NixlConnector","kv_role":"kv_producer"}'

Connector 解决的是 KV Cache 如何被生产、保存和消费,不负责决定请求应该选择哪个 Prefill 或 Decode 实例。后者属于路由和请求编排问题,两者可以组合使用,但不是同一层能力。

PD 分离的收益与代价

PD 分离之后,最直接的收益是隔离两种计算负载。 vLLM 官方文档特别强调了 tail ITL:

  • 聚合模式会在 Decode 过程中插入 Prefill 工作,导致部分 Token 的生成间隔突然升高;
  • PD 分离以后,长 Prompt 留在 Prefill Pool,不再直接占用 Decode Pool 的调度队列,逐 Token 输出会更稳定。

Prefill 与 Decode 还可以独立设置副本数、GPU 类型和并行策略

  • 文档问答通常输入很长、输出较短,可能需要更多 Prefill 能力;
  • 聊天或推理场景输出很长,则可能更需要 Decode 能力。 通常可以用 xP:yD 表示两组实例的比例,具体取值要根据真实的输入长度、输出长度、并发和延迟目标压测出来。

PD 分离也不是减少阶段干扰的唯一方案。

vLLM 默认支持 Chunked Prefill,它会把较长的 Prefill 拆成较小的 Chunk,优先调度 Decode,再利用剩余的 Token Budget 插入 Prefill。这样可以在同一实例中平衡 compute-bound 的 Prefill 和 memory-bound 的 Decode,部署与调度也更简单; 但两个阶段仍然共享 GPU,max_num_batched_tokens 的取值也需要在 TTFT、ITL 和吞吐之间权衡。只有当这种共置优化仍无法同时满足两个阶段的 SLO,或者两边需要完全不同的资源配置和扩缩容策略时,PD 分离的价值才会更加明显。

当然,PD 分离也不是银弹

  • 模型权重要在 Prefill 和 Decode 两组 GPU 上分别加载
  • 一次请求多了 Endpoint 选择和阶段编排
  • KV Cache 还要跨实例传输
  • 还需要额外考虑 NIXL、RDMA、故障恢复和两组工作负载的独立扩缩容。

如果模型很小、Prompt 很短,或者网络只能走普通 TCP,这些额外开销可能超过隔离带来的收益。

因此,单 GPU vLLM 不会因为换成 PD 架构就自动变快,vLLM 也明确提醒,Disaggregated Prefilling 本身并不保证提高吞吐。 只有当 Prefill 对 Decode 的干扰已经影响 TTFT 或 ITL,或者两边的资源需求明显不对称时,PD 分离才值得引入

小结

PD 分离的核心原因,是 Prefill 和 Decode 的瓶颈并不相同:

  • Prefill 通常是 compute-bound,更依赖 GPU 算力;
  • Decode 则通常是 memory-bandwidth-bound,更依赖显存带宽和 KV Cache 容量。

把两个阶段放在同一个实例里,就要求同一套硬件同时满足两种不同的资源需求。

这不表示不存在同时拥有强算力和大 HBM 的高端芯片。问题在于,增加计算单元不一定能改善 Decode,堆叠更大的 HBM 和更宽的显存接口又会带来额外的面积、功耗和成本。硬件本身就在算力与内存系统之间做取舍,不存在能在所有输入输出比例、延迟目标和成本约束下都对两个阶段最优的芯片配置。

因此,PD 分离不是把一次推理变成两次推理,而是把同一次推理中的 Prefill 与 Decode 放到各自合适的资源池:Prefill 可以使用偏算力的硬件,Decode 可以使用偏显存容量和带宽的硬件,再通过 KV Cache 交接把两个阶段衔接起来。

当然,PD 分离不是银弹,这套架构也需要额外的调度、网络和容量规划。先用 Chunked Prefill 等共置优化解决问题;只有 TTFT、ITL 或资源配比已经成为明确瓶颈时,再引入 PD 分离。

KServe 集成 KEDA:基于 vLLM 指标自动扩缩容

2026-08-27 04:00:00

KServe 集成 KEDA:基于 vLLM 指标自动扩缩容

模型服务运行后,固定副本数很难同时兼顾突发请求和资源利用率。请求增加时需要扩容,流量回落后又希望及时缩容。

本文通过一个简单 Demo,为 KServe 模型服务接入 KEDA,根据 vLLM 的请求指标自动调整副本数,并验证服务从 1 -> 2 -> 1 的扩缩容过程。

环境准备

实现思路

需要在额外安装 KEDA 和 Prometheus,工作流程如下:

  1. KServe 创建 Predictor Deployment 启动模型服务,底层推理引擎 vLLM 通过 /metrics 暴露运行指标。
  2. Prometheus 采集这些指标,例如 vllm:num_requests_runningvllm:num_requests_waiting
  3. KEDA 查询 Prometheus,并通过 External Metrics API 将查询结果暴露为 HPA 可以使用的外部指标。
  4. HPA 比较指标当前值与目标值,计算期望副本数并调整 Predictor Deployment。

KServe 集成 KEDA 的自动扩缩容链路

安装 KEDA

本文实测 KEDA 2.17.2:

1
2
3
4
5
6
7
helm repo add kedacore https://kedacore.github.io/charts
helm repo update

helm upgrade --install keda kedacore/keda \
 -n keda --create-namespace \
 --version 2.17.2 \
 --wait

安装后确认 Operator、Admission Webhook 和 Metrics API Server 都已启动:

1
2
kubectl get pod -n keda
kubectl get apiservice v1beta1.external.metrics.k8s.io
1
2
3
4
5
6
keda-admission-webhooks-... 1/1 Running
keda-operator-... 1/1 Running
keda-operator-metrics-apiserver-... 1/1 Running

NAME SERVICE AVAILABLE
v1beta1.external.metrics.k8s.io keda/keda-metrics-apiserver True

只有 CRD 不够。external.metrics.k8s.io 不可用时,ScaledObject 可以创建,但 HPA 读不到 KEDA 提供的指标。

部署 Prometheus

本文使用 kube-prometheus-stack 部署 Prometheus 和 Prometheus Operator,Grafana、Alertmanager、Exporter 和默认告警规则都关闭,只保留指标采集需要的组件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
helm upgrade --install prometheus \
 oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack \
 -n monitoring --create-namespace \
 --version 88.2.0 \
 --set grafana.enabled=false \
 --set alertmanager.enabled=false \
 --set kubeStateMetrics.enabled=false \
 --set nodeExporter.enabled=false \
 --set defaultRules.create=false \
 --set kubeApiServer.enabled=false \
 --set kubelet.enabled=false \
 --set kubeControllerManager.enabled=false \
 --set coreDns.enabled=false \
 --set kubeDns.enabled=false \
 --set kubeEtcd.enabled=false \
 --set kubeScheduler.enabled=false \
 --set kubeProxy.enabled=false \
 --set prometheusOperator.admissionWebhooks.enabled=false \
 --set prometheusOperator.tls.enabled=false \
 --wait

先确认 Prometheus Service 已经创建。下面的 InferenceService 和后续查询都使用这个 Service;如果命令没有输出,应先检查 Helm Release 和 Prometheus Pod,不要继续创建 InferenceService。

1
2
kubectl get svc prometheus-kube-prometheus-prometheus -n monitoring
kubectl get pod -n monitoring

当前配置不创建 PrometheusRule,因此一并关闭了只用于校验规则的 Admission Webhook。安装完成后,集群中应保留 Prometheus Operator 和 Prometheus 两个 Pod:

1
kubectl get pod,svc -n monitoring

配置自动扩缩容

创建 InferenceService

当前测试节点只有一张 GPU,而 Demo 需要把服务扩到两个副本,因此继续使用 HAMi DRA 共享 GPU。

创建 ResourceClaimTemplate 和完整的 InferenceService,同时配置每个副本的 GPU 配额、模型和 KEDA 自动扩缩容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
cat <<'EOF' > qwen-llm.yaml
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
 name: qwen-hami-gpu
 namespace: kserve-test
spec:
 spec:
 devices:
 requests:
 - name: gpu
 exactly:
 deviceClassName: hami-core-gpu.project-hami.io
 allocationMode: ExactCount
 count: 1
 capacity:
 requests:
 memory: 3Gi
 cores: "20"
---
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
 namespace: kserve-test
 annotations:
 serving.kserve.io/deploymentMode: Standard
 serving.kserve.io/autoscalerClass: keda
spec:
 predictor:
 minReplicas: 1
 maxReplicas: 2
 resourceClaims:
 - name: gpu
 resourceClaimTemplateName: qwen-hami-gpu
 autoScaling:
 metrics:
 - type: External
 external:
 metric:
 backend: prometheus
 serverAddress: http://prometheus-kube-prometheus-prometheus.monitoring.svc:9090
 query: >-
 sum(vllm:num_requests_running{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
 +
 sum(vllm:num_requests_waiting{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
 target:
 type: Value
 value: "1"
 model:
 modelFormat:
 name: huggingface
 image: docker.m.daocloud.io/kserve/huggingfaceserver:v0.18.0-gpu
 storageUri: pvc://qwen-model
 args:
 - --model_name=qwen
 - --max_model_len=4096
 - --max-num-seqs=32
 - --gpu-memory-utilization=0.8
 resources:
 requests:
 cpu: "1"
 memory: 4Gi
 limits:
 cpu: "2"
 memory: 6Gi
 claims:
 - name: gpu
EOF

kubectl apply -f qwen-llm.yaml

kubectl wait --for=condition=Ready \
 inferenceservice/qwen-llm -n kserve-test --timeout=10m

每个 Predictor 副本通过 qwen-hami-gpu 申请 3Gi 显存和 20% GPU 核心,因此单 GPU 节点可以同时运行两个副本。
KServe 创建 Predictor Deployment 和 ScaledObject,KEDA 再创建 HPA 管理副本数。

扩缩容配置解析

这份 YAML 中扩缩容配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
metadata:
 annotations:
 serving.kserve.io/autoscalerClass: keda
spec:
 predictor:
 minReplicas: 1
 maxReplicas: 2
 autoScaling:
 metrics:
 - type: External
 external:
 metric:
 backend: prometheus
 serverAddress: http://prometheus-kube-prometheus-prometheus.monitoring.svc:9090
 query: >-
 sum(vllm:num_requests_running{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
 +
 sum(vllm:num_requests_waiting{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
 target:
 type: Value
 value: "1"

各参数含义:

  • autoscalerClass: keda:使用 KEDA 进行扩缩容
  • minReplicasmaxReplicas:限制最小、最大副本数
  • serverAddress:Prometheus 地址
  • query:使用 running + waiting,同时统计正在执行和已经进入 vLLM 队列的请求
  • target.value:每个副本期望承载的目标值

Prometheus 查询得到的是所有 Predictor Pod 的请求总数,可以理解为:

1
Query = running 请求数 + waiting 请求数

target.value: 1 表示希望每个副本平均处理 1 个请求。当前副本范围是 1~2,扩缩容过程可以直接这样看:

  • 当前只有 1 个副本时,如果 Query = 01,保持 1 个副本。
  • 当前只有 1 个副本时,如果 Query >= 2,说明请求超过单个副本的目标值,扩到 2 个副本。
  • 扩容后即使请求继续增加,也不会超过 maxReplicas: 2
  • 负载停止后,Query 回到 01,等待 300 秒缩容稳定窗口结束,再缩回 1 个副本。

配置 ServiceMonitor

Prometheus Stack 不会自动采集 qwen-llm。创建 ServiceMonitor,通过 InferenceService Label 选择 KServe 生成的 Predictor Service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
cat <<'EOF' > qwen-llm-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
 name: qwen-llm
 namespace: monitoring
 labels:
 release: prometheus
spec:
 namespaceSelector:
 matchNames:
 - kserve-test
 selector:
 matchLabels:
 serving.kserve.io/inferenceservice: qwen-llm
 endpoints:
 - port: qwen-llm-predictor
 path: /metrics
 interval: 5s
EOF

kubectl apply -f qwen-llm-monitor.yaml

ServiceMonitor 不会通过 Service 的 ClusterIP 随机抓取一个后端。Prometheus Operator 会读取 Service 对应的 EndpointSlice,并把每个 Predictor Pod 都作为独立 Target。

检查指标和扩缩容资源

通过 Kubernetes Service Proxy 检查 Target。当前只有一个 Predictor,因此应看到一行 qwen-llm-predictor-*,状态为 up

1
2
3
kubectl get --raw \
 '/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/targets?state=active' | \
 jq -r '.data.activeTargets[] | select(.labels.namespace == "kserve-test") | [.labels.pod, .health, .scrapeUrl] | @tsv'

再检查空闲时的指标:

1
2
3
kubectl get --raw \
 '/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/query?query=sum(vllm:num_requests_running%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)%2Bsum(vllm:num_requests_waiting%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)' | \
 jq -r '[.status, .data.resultType, (.data.result | length), .data.result[0].value[1]] | @tsv'

实测输出是:

1
success vector 1 0

KServe 创建 Deployment 和 ScaledObject,KEDA 再创建并管理 HPA:

1
2
3
4
5
6
kubectl get inferenceservice qwen-llm -n kserve-test
kubectl get scaledobject qwen-llm-predictor -n kserve-test
kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test
kubectl get deployment qwen-llm-predictor -n kserve-test
kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm

空闲状态下,各条命令输出中的关键字段是下面这些。这里是整理后的字段,不是上一段单条命令的原始输出:

1
2
3
qwen-llm-predictor READY=True ACTIVE=False MIN=1 MAX=2
keda-hpa-qwen-llm-predictor TARGETS=0/1 REPLICAS=1
qwen-llm-predictor READY=1/1

直接读取 HPA,确认缩容稳定窗口:

1
2
kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test \
 -o jsonpath='{.spec.behavior.scaleDown.stabilizationWindowSeconds}{"\n"}'

当前结果是:

1
300

验证自动扩缩容

验证扩容

先查询 Envoy Gateway 的地址,发送一次推理请求,确认服务正常:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
ENVOY_SERVICE=$(kubectl get service -n envoy-gateway-system \
 -l gateway.envoyproxy.io/owning-gateway-name=kserve-ingress-gateway \
 -o jsonpath='{.items[0].metadata.name}')

NODE_PORT=$(kubectl get service -n envoy-gateway-system "$ENVOY_SERVICE" \
 -o jsonpath='{.spec.ports[?(@.port==80)].nodePort}')

NODE_IP=$(kubectl get node \
 -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')

GATEWAY_ADDR="${NODE_IP}:${NODE_PORT}"

curl -H 'Host: qwen-llm-kserve-test.example.com' \
 -H 'Content-Type: application/json' \
 "http://${GATEWAY_ADDR}/openai/v1/chat/completions" \
 -d '{
 "model": "qwen",
 "messages": [{"role": "user", "content": "Answer only with the number: 2+3"}],
 "max_tokens": 8,
 "temperature": 0
 }'

产生持续负载

使用以下命令产生负载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
BODY='{"model":"qwen","messages":[{"role":"user","content":"Write a detailed numbered list from 1 to 300, with a complete sentence for every item."}],"max_tokens":768,"temperature":0.7}'
export BODY GATEWAY_ADDR

seq 12 | xargs -n 1 -P 12 bash -c '
 while true; do
 curl -sS --max-time 180 \
 -H "Host: qwen-llm-kserve-test.example.com" \
 -H "Content-Type: application/json" \
 "http://${GATEWAY_ADDR}/openai/v1/chat/completions" \
 -d "$BODY" >/dev/null
 done
'

这条命令会一直占用当前终端,并将并发数保持为 12 持续发送请求。

观察扩容结果

另开两个终端,同时观察 Prometheus 指标以及扩缩容资源:

1
watch -n 5 'kubectl get --raw "/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/query?query=sum(vllm:num_requests_running%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)%2Bsum(vllm:num_requests_waiting%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)" | jq -r ".data.result[0].value[1]"'
1
watch -n 5 'kubectl get scaledobject,hpa,deployment,pod -n kserve-test'

本次实测时间线是:

1
2
3
4
启动负载后 Prometheus 查询值升到 12
 ScaledObject ACTIVE=True
 HPA TARGETS=6/1,Deployment REPLICAS=2
约 250 秒后 第二个 vLLM 完成模型加载,两个 Pod 均为 1/1 Running

KServe 模型服务自动扩缩容时间线

指标值是整个 Deployment 的 running + waiting 总数。两个副本、总值 12 时,平均每个副本为 6,因此 HPA 的目标列显示约 6/1

扩容期间再调用一次 Chat Completions,仍然返回正确结果。至此可以确认 KEDA 已经根据 vLLM 指标把服务从 1 个副本扩到 2 个。

验证缩容

回到运行压测的终端,按 Ctrl+C 停止全部请求。

先重复前面的 Prometheus 查询,确认结果回到 0,再检查 ScaledObject 变成 ACTIVE=False。Deployment 不会立即缩容,因为当前生成的 HPA 带有 300 秒缩容稳定窗口:

1
2
3
behavior:
 scaleDown:
 stabilizationWindowSeconds: 300

实际缩容时间还会受到 KEDA 轮询、HPA 同步周期和 Pod 终止时间影响。用下面的命令等待 Deployment 回到一个 Ready 副本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
 --for=jsonpath='{.status.replicas}'=1 --timeout=8m
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
 --for=jsonpath='{.spec.replicas}'=1 --timeout=2m
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
 --for=jsonpath='{.status.readyReplicas}'=1 --timeout=2m

kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test
kubectl get deployment qwen-llm-predictor -n kserve-test \
 -o custom-columns=NAME:.metadata.name,SPEC:.spec.replicas,STATUS:.status.replicas,READY:.status.readyReplicas
kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm

本次停止负载后,Prometheus 查询值回到 0,ScaledObject 变成 ACTIVE=False;约 299 秒后,HPA 将 Deployment 从 2 缩回 1。最后重新执行前面的 Chat Completions 请求,仍然返回 5

总结

本文验证了模型服务从 1 -> 2 -> 1 的完整链路,最后总结一下整个流程:

  1. KServe 创建 Predictor Deployment 启动模型服务,底层推理引擎 vLLM 通过 /metrics 暴露运行指标。
  2. Prometheus 采集这些指标,例如 vllm:num_requests_runningvllm:num_requests_waiting
  3. KEDA 查询 Prometheus,并通过 External Metrics API 将查询结果暴露为 HPA 可以使用的外部指标。
  4. HPA 比较指标当前值与目标值,计算期望副本数并调整 Predictor Deployment。

KServe 集成 KEDA 的自动扩缩容链路

GPT-5.6 之后,Superpowers 可以卸载了

2026-08-17 04:00:00

GPT-5.6 之后,Superpowers 可以卸载了

之前我写过一篇文章 OpenSpec + Superpowers,SDD+TDD 双驱动 AI 编程工作流,记录了我当时把 OpenSpecSuperpowers 组合成一套工作流的尝试。

那时候我觉得这两个东西搭在一起还挺顺,OpenSpec 负责需求和 Spec 对齐,Superpowers 负责计划、TDD、子 Agent 实现和代码审查。

后来,我发现社区里很多人也在用这套工作流,先写需求和设计,拆任务,再实现和验证,大家都在试图给 Agent 加一套更稳定的工作方法。

GPT-5.6 之后,Superpowers 还需要吗

这件事本来挺美好的,直到 GPT-5.6 出现。

我用 GPT-5.6 Sol 做一个很简单的需求,本来以为读一下代码、改完、跑个测试就结束了,结果任务跑了很久。

看执行过程发现 Codex 并不是卡住了,而是 Superpowers 把头脑风暴、计划、子 Agent 实现、TDD 和代码审查一层层串了起来,一个简单需求就被升级成了一套完整的软件工程流程。

而在社区里,我也看到了很多类似的使用反馈,大致可以分成两类:

  • 卸载后的体感更轻。 Agent 思考的时间短了一些,做出来的结果却大差不差。
  • 通用流程出现重复。 Superpowers 会串起头脑风暴、计划、子 Agent、TDD 和代码审查,但 Codex 自己也在规划和验证,MCP 与本地 Skills 里还可能有另一套规则。

当然,这里也不能把变慢全部归到 Superpowers 身上。

从使用体感看,GPT-5.6 Sol 本身就比 GPT-5.5 慢。再叠加头脑风暴、计划和 Review 等完整流程,等待时间和额度消耗又被进一步放大。

所有讨论最后都落到了同一个问题上:GPT-5.6 Sol 模型能力已经这么强了,还要不要继续使用 Superpowers 这类 Skill?

Comet Native 工作流:放松过程,收紧结果

只靠使用体感,很难回答前面的问题,或者说可信度不够。

恰好 Comet 最近做了一次很有参考价值的调整,新增了 Native 模式。

Native 到底改了什么

0.4.0-beta.7 新增了面向强模型的 Native 模式。

原来的 Classic 模式会串联 OpenSpec 和 Superpowers,依次经过 Open、Design、Build、Verify、Archive。

Native 不再依赖这两个外部 Skill,只保留 Shape、Build、Verify、Archive 四个阶段:

  • Shape 阶段先和人讨论需求,把目标、范围、不做什么、验收示例和风险边界写清楚。
  • 进入 Build 之后,怎么规划、要不要写计划、测试做到多深、怎么调试和审查,都交给模型自己决定。
  • Verify 再拿前面定义的验收条件逐项核对,失败就带着未满足项返回 Build,通过后才进入 Archive。

它的思路很直接:过程放松,结果收紧。

Native 没有去掉验证,只是不再强制模型按照固定的方法完成任务。外层工作流负责锁住验收规则、验证证据和风险边界,具体怎么实现则交给模型决定。

具体如何完成任务交给模型自由发挥,我们要做的就是完成后验证任务是否真的完成

Comet Classic 与 Native 工作流对比

Comet 还做了一轮 NativeClassic 的官方对齐实验。两种模式处理相同的 16 个业务任务,每个任务各跑 3 次,每种模式 48 次,总共 96 次运行。

完成质量有没有下降

Comet Native 与 Classic 完成质量对比

  • pass@1 单次运行的成功率,反映模型不依赖重试、一次完成任务的能力。
  • pass@3 是三次里至少成功一次,代表这个任务能不能做。
  • pass^3 更严格,要求三次全部成功,看的则是能不能稳定地做。

两种模式的 pass@3 都是 100%,说明去掉 OpenSpec 和 Superpowers 之后,Native 没有丢掉这 16 个任务的覆盖能力。反而在单次严格通过率和三次连续成功率上更高。

执行成本降低了多少

为了避免「任务提前失败,所以看起来更省」这种误差,官方报告这里只统计 Native 和 Classic 都严格通过的 41 组配对样本。

Comet Native 相对 Classic 的执行资源降幅

效率图将 Classic 归一化为 100%;数据只统计两种模式都通过的 41 组配对样本,表中均为单个成功样本的平均值。

最明显的是,总 Token 下降了 76.8%,模型成本下降了 75.1%,Agent 轮次和工具调用也减少了一半以上。

在这组实验里,流程变轻之后,任务覆盖没有下降,执行成本却明显降低了。

当然,这只是 Comet 的第一方实验,不能直接外推到所有模型和任务。
但至少在这 16 个任务里,去掉固定方法论后,完成质量没有下降,执行成本却明显降低了。

通用能力正在被模型和 Runtime 内化

但数据只能告诉我们发生了什么,解释不了为什么。

我倒不觉得是 Superpowers 突然没用了。

上一篇文章 OpenSpec + Superpowers,SDD+TDD 双驱动 AI 编程工作流 里,我说用上 Superpowers 之后,代码质量比让模型直接动手好不少。这个判断放在当时依然成立。模型容易跳过设计、忘记测试,Superpowers 就用一套完整的需求讨论、计划、TDD 和 Review 流程把这些缺口补上。

但现在,大人,时代变了。😏

Superpowers 没变,变的是模型。

对强模型来说,能力瓶颈已经不在「会不会写代码」,而在「能不能被信任地完成任务」。

GPT-5.6 已经会主动理解需求、调查代码、形成方案,再根据改动风险选择测试和验证方式。很多过去需要 Superpowers 反复提醒的工作习惯,模型自己已经学会了。

这时再套上 Superpowers,一个简单需求就可能重新经历 brainstorming、计划、TDD 和独立 Review。两套流程未必会得出相反结论,但每多一个 Agent、每多一次 Review,都要重新读取上下文并调用模型。如果没有发现新的问题,增加的就只有 Token 和时间。

问题不是模型和 Skill 在打架,而是通用 Skill 的边际收益正在下降。

为什么会这样?我觉得有两层变化。

一部分能力,被模型学会了。

规划、调试、测试和自我检查都是通用方法,还能通过代码、测试与评测反复验证。使用轨迹积累得足够多,后续模型自然有机会把这些习惯吸收进去。

另一部分能力,被 Agent Runtime 接管了。

任务状态、子 Agent 调度、工具调用、上下文压缩和结果验证,已经逐渐成为 Codex、Claude Code 这类产品的原生能力。过去需要 Skill 串起来的执行循环,现在 Runtime 自己就能跑。

所以 Superpowers 不是变差了。它原本负责解决的问题,一部分进入了模型,另一部分进入了 Runtime。

什么样的 Skill 会被长期保留下来

我的答案很直接,垂直 Skill。

这里说的垂直,不只是某个行业专用的 Skill,而是那些带着具体团队、具体项目和真实业务环境的 Skill。

越接近通用方法论,Skill 的生命周期反而越短

因为它可以在大量任务里反复运行和评测,有效的方法最终可能进入模型训练,或者直接成为 Codex、Claude Code 这类 Agent Runtime 的原生能力。

就像一个特别好用的第三方插件,如果它解决的是所有用户都会遇到的问题,最后很可能被官方直接集成。能力还在,只是大家不再需要单独安装它。

Skill 也是一样。今天还要额外挂载的规划、测试和审查流程,明天可能就会成为默认能力。

但有些东西,通用模型不会预先知道。

真正会被长期保留下来的 Skill,往往包含三类东西:

  • 模型不知道的私有知识。 团队怎么协作、文档放在哪里、项目有哪些特殊约定。
  • 模型拿不到的执行能力。 内部系统、专用工具、账号与权限。
  • 模型不能擅自决定的边界。 什么结果才算完成,发布前需要谁确认,出了问题由谁负责。

强模型时代长期保留的 Skill

这些信息敏感、零散,还会随着业务不断变化,很难进入通用模型的训练数据,却决定了 Agent 能不能在真实环境里完成工作。

真正会留下来的 Skill,解决的是模型不知道、拿不到,也不能擅自决定的问题。

这类 Skill 往往更简单,也更轻。它不负责教 Agent 怎么思考,只负责告诉它,在这里工作需要知道什么、可以调用什么,以及做到什么才算完成。

小结

回到开头的问题,GPT-5.6 Sol 还需要 Superpowers 吗?

答案很明确:对于 Fable 5、GPT-5.6 这类已经能自主规划和验证的强模型,我更倾向于先移除 Superpowers,直接运行一段时间,再根据真实缺口补回必要的约束

Comet Native 的实验也提供了一个参考:在这 16 个任务里,去掉固定方法论之后,完成质量没有下降,Token、耗时和成本却明显减少。

现在的趋势是,通用 Skill 会逐渐被模型和 Runtime 吸收。真正长期留下来的,是模型不知道的私有知识、拿不到的执行能力,以及不能擅自决定的业务边界。

Skill 不会消失,它只是会回到自己原本该在的位置。

KServe + HAMi:一张 GPU 如何运行多个推理服务

2026-08-10 04:00:00

HAMi GPU 共享:KServe 原生 DRA 实战

前面已经用 KServe 跑起了 Qwen,但一个小模型独占一张 GPU 有些浪费。这篇则是在此基础上引入 HAMi,通过 HAMi 实现 GPU 共享,让多个小模型共用一张 GPU。

准备环境

本次测试环境如下:

1
2
3
4
5
6
7
Kubernetes v1.36.1
KServe v0.18.0
HAMi 0.2.1
HAMi NVIDIA DRA Driver v0.1.0
containerd 2.2.4
NVIDIA Driver 580.173.02
Tesla T4 15 GiB x 1

本文从 HAMi 安装和原生 DRA 资源声明开始,完整走通 KServe 集成 HAMi GPU 共享部分。

安装 GPU Operator

由 GPU Operator 安装 NVIDIA Driver、Container Toolkit 和监控组件,但关闭原生 NVIDIA Device Plugin,后续由 HAMi DRA Driver 管理 GPU:

1
2
3
4
5
6
7
8
9
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update

helm upgrade --install gpu-operator nvidia/gpu-operator \
 -n gpu-operator --create-namespace \
 --version=v26.3.1 \
 --set driver.enabled=true \
 --set devicePlugin.enabled=false \
 --wait

如果节点已经预装 NVIDIA Driver,可以把 driver.enabled 改为 false。无论驱动由谁安装,devicePlugin.enabled=false 都不能省略,否则原生 Device Plugin 和 HAMi-DRA 会同时管理同一设备。

安装 cert-manager

HAMi-DRA Webhook 需要 TLS 证书,测试环境使用 cert-manager 签发:

1
2
3
4
5
6
7
helm repo add cert-manager https://charts.jetstack.io
helm repo update

helm upgrade --install cert-manager cert-manager/cert-manager \
 -n cert-manager --create-namespace \
 --set crds.enabled=true \
 --wait

安装 HAMi-DRA

为需要接管的 GPU 节点添加 gpu=on 标签,再安装本文实测的 HAMi-DRA 0.2.1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
helm repo add hami-dra https://project-hami.github.io/HAMi-DRA/
helm repo update

GPU_NODE=lixd-test-gpu # 替换为实际 GPU 节点名
kubectl label node "$GPU_NODE" gpu=on

helm upgrade --install hami-dra hami-dra/hami-dra \
 -n hami-dra \
 --create-namespace \
 --version 0.2.1 \
 --wait

上面的命令适用于 GPU Operator 安装 Driver 的场景。如果 NVIDIA Driver 由宿主机预装,则增加:

1
--set drivers.nvidia.containerDriver=false

确认 GPU 共享容量

安装完成后,HAMi 创建了一个 DeviceClass:

1
2
3
root@lixd-test-gpu:~# kubectl get deviceclass
NAME AGE
hami-core-gpu.project-hami.io 3d4h

同时节点插件通过 ResourceSlice 发布 GPU 信息:

1
kubectl get resourceslice -o yaml

输出中只保留本文关心的字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
spec:
 driver: hami-core-gpu.project-hami.io
 nodeName: lixd-test-gpu
 devices:
 - name: hami-gpu-0
 allowMultipleAllocations: true
 attributes:
 productName:
 string: Tesla T4
 type:
 string: hami-gpu
 capacity:
 cores:
 value: "100"
 memory:
 value: 15Gi

allowMultipleAllocations: true 表示同一个设备可以接受多份分配。这里的 memorycores 是 HAMi 用于调度和限制的可消耗容量,不是 Node 上的传统扩展资源。

创建共享 GPU 推理服务

KServe 0.18 版本已经支持原生 DRA,可以在 Predictor 级引用 ResourceClaimTemplate,再由容器级 resources.claims 使用对应的 Claim。

我们只需要提前创建一个 ResourceClaimTemplate,然后在 InferenceService 中引用即可,完整的 YAML 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
cat <<'EOF' > qwen-llm-hami.yaml
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
 name: qwen-hami-gpu
 namespace: kserve-test
spec:
 spec:
 devices:
 requests:
 - name: gpu
 exactly:
 deviceClassName: hami-core-gpu.project-hami.io
 allocationMode: ExactCount
 count: 1
 capacity:
 requests:
 memory: 3Gi
 cores: "20"
---
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
 namespace: kserve-test
 annotations:
 serving.kserve.io/deploymentMode: Standard
spec:
 predictor:
 minReplicas: 2
 resourceClaims:
 - name: gpu
 resourceClaimTemplateName: qwen-hami-gpu
 model:
 modelFormat:
 name: huggingface
 image: docker.m.daocloud.io/kserve/huggingfaceserver:v0.18.0-gpu
 storageUri: pvc://qwen-model
 args:
 - --model_name=qwen
 - --max_model_len=4096
 - --max-num-seqs=32
 - --gpu-memory-utilization=0.8
 resources:
 requests:
 cpu: "1"
 memory: 4Gi
 limits:
 cpu: "2"
 memory: 6Gi
 claims:
 - name: gpu
EOF

kubectl apply -f qwen-llm-hami.yaml

kubectl wait --for=condition=Ready \
 inferenceservice/qwen-llm -n kserve-test --timeout=10m

minReplicas: 2 保证 Demo 期间至少存在两个 Predictor,用于验证它们能否同时获得共享 GPU 配额,不展开副本自动调整行为。

这里没有再声明 nvidia.com/gpunvidia.com/gpumemnvidia.com/gpucores。CPU 和内存仍使用普通 requests/limits,GPU 完全使用 DRA Claim 形式声明。

原生 DRA 通过 ResourceClaim 分配共享 GPU

确认 KServe 写入 DRA 引用

KServe 的 HuggingFace Runtime 原本根据 GPU limit 选择 -gpu 镜像。原生 DRA 配置里没有这个 limit,因此本文显式指定已经验证过的 GPU 镜像。docker.m.daocloud.io 是测试环境使用的镜像代理;如果环境可以直接访问 Docker Hub,可以改为 kserve/huggingfaceserver:v0.18.0-gpu

KServe 最终生成的 Deployment 保留了两级引用:

1
2
kubectl get deployment -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o yaml
1
2
3
4
5
6
7
8
9
spec:
 resourceClaims:
 - name: gpu
 resourceClaimTemplateName: qwen-hami-gpu
 containers:
 - name: kserve-container
 resources:
 claims:
 - name: gpu

Deployment 创建两个 Pod 后,Kubernetes 会根据同一个 ResourceClaimTemplate 为每个 Pod 生成独立 Claim。不能让多个副本直接引用一份固定 ResourceClaim,否则它们不会获得各自独立的 3Gi/20 配额。

验证 DRA 容量分配

查看 ResourceClaim 的申请与分配

Kubernetes 为每个 Pod 生成一份 Claim。下面只保留其中一份 Claim 的申请字段;它由 Pod 持有,Pod 删除后会一起清理:

1
2
CLAIM=$(kubectl get resourceclaim -n kserve-test -o name | head -n 1)
kubectl get -n kserve-test "$CLAIM" -o yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: resource.k8s.io/v1
kind: ResourceClaim
spec:
 devices:
 requests:
 - name: gpu
 exactly:
 allocationMode: ExactCount
 count: 1
 deviceClassName: hami-core-gpu.project-hami.io
 capacity:
 requests:
 memory: 3Gi
 cores: "20"

调度完成后,Claim 状态中的关键字段记录了实际分配:

1
2
3
4
5
6
7
8
9
status:
 allocation:
 devices:
 results:
 - device: hami-gpu-0
 driver: hami-core-gpu.project-hami.io
 consumedCapacity:
 memory: 3Gi
 cores: "20"

ResourceClaimTemplate 只定义申请规格,Kubernetes 为每个 Pod 生成 Claim,并完成设备选择和容量扣减。HAMi DRA Driver 随后响应 kubelet 的 NodePrepareResources,生成 CDI 配置并返回设备信息,最终由 containerd 把对应 GPU 和 HAMi-Core 运行环境应用到容器。

查看容器内的显存限制

进入其中一个 Predictor 容器执行完整的 nvidia-smi

1
2
3
4
5
POD=$(kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm \
 -o name | head -n 1)

kubectl exec -n kserve-test "$POD" -- nvidia-smi

可以看到 HAMi 把同一张 GPU 的可见显存限制为 3072 MiB。下面是本次实测的完整状态表;命令前后的 HAMI 初始化和退出日志不属于 nvidia-smi 输出,这里没有混入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Thu Aug 6 03:46:03 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.173.02 Driver Version: 580.173.02 CUDA Version: 13.0 |
+-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 Tesla T4 Off | 00000000:00:06.0 Off | 0 |
| N/A 44C P0 28W / 70W | 3015MiB / 3072MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+

验证两个副本共享同一张 GPU

检查 Pod 和 Claim

把 Predictor 的最小副本数设为 2,两份 Pod 使用相同的 3Gi、cores=20 配置。

整卡分配与 HAMi-DRA 共享对比

先查看 Pod 和 Claim:

1
2
3
4
5
6
7
8
9
kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o wide

kubectl get resourceclaim -n kserve-test

for claim in $(kubectl get resourceclaim -n kserve-test -o name); do
 kubectl get -n kserve-test "$claim" \
 -o jsonpath='{.metadata.name}{" device="}{.status.allocation.devices.results[0].device}{" memory="}{.status.allocation.devices.results[0].consumedCapacity.memory}{" cores="}{.status.allocation.devices.results[0].consumedCapacity.cores}{"\n"}'
done

本次新建的两份 Claim 都分配成功:

1
2
3
4
5
6
NAME STATE AGE
qwen-llm-predictor-544cc75b4-7fvcc-gpu-g8b6c allocated,reserved 3m3s
qwen-llm-predictor-544cc75b4-fz5hq-gpu-9lxw2 allocated,reserved 2m48s

qwen-llm-predictor-544cc75b4-7fvcc-gpu-g8b6c device=hami-gpu-0 memory=3Gi cores=20
qwen-llm-predictor-544cc75b4-fz5hq-gpu-9lxw2 device=hami-gpu-0 memory=3Gi cores=20

两个 Pod 都调度到 lixd-test-gpu,并分别看到 3072 MiB 显存:

1
2
3
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
qwen-llm-predictor-544cc75b4-7fvcc 1/1 Running 0 3m3s 172.25.118.13 lixd-test-gpu <none> <none>
qwen-llm-predictor-544cc75b4-fz5hq 1/1 Running 0 2m48s 172.25.118.3 lixd-test-gpu <none> <none>

检查两个容器的可见显存

逐个进入容器检查可见 GPU:

1
2
3
4
5
for pod in $(kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o name); do
 kubectl exec -n kserve-test "$pod" -- \
 nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
done

两个容器都返回:

1
2
Tesla T4, 3072 MiB
Tesla T4, 3072 MiB

发起推理请求

通过 Gateway 调用接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
ENVOY_SERVICE=$(kubectl get service -n envoy-gateway-system \
 -l gateway.envoyproxy.io/owning-gateway-name=kserve-ingress-gateway \
 -o jsonpath='{.items[0].metadata.name}')

NODE_PORT=$(kubectl get service -n envoy-gateway-system "$ENVOY_SERVICE" \
 -o jsonpath='{.spec.ports[?(@.port==80)].nodePort}')

NODE_IP=$(kubectl get node \
 -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')

GATEWAY_ADDR="${NODE_IP}:${NODE_PORT}"

curl -H 'Host: qwen-llm-kserve-test.example.com' \
 -H 'Content-Type: application/json' \
 "http://${GATEWAY_ADDR}/openai/v1/chat/completions" \
 -d '{
 "model": "qwen",
 "messages": [{"role": "user", "content": "Answer only with the number: 2+3"}],
 "max_tokens": 8,
 "temperature": 0
 }'

API 可以正常返回,说明在使用 GPU 共享之后,服务依旧可以正常运行。

清理资源

验证完成后删除 InferenceService 和 ResourceClaimTemplate:

1
2
kubectl delete inferenceservice qwen-llm -n kserve-test
kubectl delete resourceclaimtemplate qwen-hami-gpu -n kserve-test

两份由 Pod 生成的 ResourceClaim 会随 Pod 一起删除。

总结

KServe 可以直接通过 ResourceClaimTemplateresources.claims 使用 HAMi DRA 模式,不需要额外适配。每个 Predictor Pod 都会生成一份独立 ResourceClaim,再由 HAMi 分配显存和算力配额。

通过 HAMi 共享后,一张 GPU 可以同时承载多个推理副本或服务工作负载,避免小模型整卡独占,让空闲的显存和算力得到更充分的利用。