2026-10-28 08:00:00

上一篇 PD 分离详解:Prefill 与 Decode 的瓶颈、拆分与代价 介绍了为什么要把 Prefill 和 Decode 拆开, vLLM PD 分离实战:从请求流程理解 Prefill、Decode 与 KV Cache 传输 则用一个 P、一个 D 和轻量 Proxy,跑通了请求编排与 KV Cache 传输。
完成 Prefill 和 Decode 解耦之后,可以根据场景单独扩缩容 P、D 实例。
不过多副本推理还需要考虑缓存局部性:当多个实例都开启了前缀缓存,每次请求应该交给谁,才能复用已经计算过的 KV Cache?
本文便基于这个缓存局部性问题展开,通过 llm-d Router 对比随机路由与前缀感知路由。
本文通过两个 vLLM 副本,对比随机路由与前缀感知路由,观察请求选点对缓存命中、Prefill 计算量和首字延迟的影响。
vLLM 的 Automatic Prefix Caching(APC,自动前缀缓存)可以减少重复计算。两次请求包含相同的上下文前缀,就可以复用前缀对应的 KV Cache,减少 Prefill 计算。
但这里有一个前提,两次请求要落到相同的实例,才能用到缓存。
假设第一次请求分给实例 A,A 完成 Prefill 并保留了该上下文前缀对应的 KV Cache。第二次请求如果仍然分给 A,就有机会复用;如果分给 B,B 没有这份缓存,还是需要重新计算。

APC 负责单个实例内的缓存复用,多副本之间还需要路由配合。
常见的路由方式有以下几种:
本文主要验证第三种方式。为了先看清缓存复用的效果,测试时全局并发设为 1,暂时不叠加负载评分。
为了把路由效果单独看清,我们使用两个普通 vLLM 副本做实验,不把它与 PD 分离的传输开销混在一起。
前面的 PD Demo 只有一个 P 和一个 D,实例地址确定后,请求按顺序执行即可。扩展到多副本,Router 就需要分别考虑 Prefill 与 Decode 的选择。
| 选择对象 | 需要考虑的问题 |
|---|---|
| Prefill 实例 | 哪个实例保有可复用前缀,可以少做多少 Prefill,以及需要排队多久 |
| Decode 实例 | 哪个实例有足够的 KV 容量、负载是否合适,以及取得所需 KV 的传输成本 |
例如,上下文前缀对应的 KV Cache 已保留在 P1 上,下一次请求如果分给没有对应缓存的 P2,就可能重新计算整个输入;如果只命中部分前缀,则只需要计算剩余部分。
PD 分离不会自动让所有 P 实例共享前缀缓存。
Decode 侧则不能简单理解为“需要拉取缓存就是选错了 D”。P → D 的 KV 传输本来就是分离后的正常步骤。若某个 D 已保有可复用前缀,并且当前缓存与 Connector 实现支持相应复用,选点可能影响需要补齐的 KV 数量;同时还要考虑排队和显存容量。
PD 分离拆开计算阶段,缓存感知路由则在同类候选实例之间选点。 两者可以组合,也可以分别使用。
这里使用 llm-d-router 的 Standalone 模式,主要包含两个组件:
请求处理流程如下:

EPP 中和前缀感知相关的处理,可以分成三步:
approx-prefix-cache-producer 根据请求前缀和历史路由记录,查找哪些副本可能缓存了相同内容。prefix-cache-scorer 根据前缀匹配信息,为候选副本打分。v1.0.0 默认使用匹配块占比。max-score-picker 选择评分更高的副本;分数相同时,使用轮转方式处理同分候选。Router 优先选择可能已有缓存的实例,实际能否复用仍然由 vLLM 决定。这里没有跨实例传输 KV Cache,也不涉及 PD 分离。
不同项目的实现各有差异,但都需要解决:Router 如何知道缓存在哪里这个问题。下面以 llm-d 为例介绍。
常见思路包括根据历史请求估算、根据引擎上报的缓存事件维护索引以及将突发到达、共享前缀的请求集中分配到同一实例。
approx-prefix-cache-producer 会将请求的上下文前缀划分成块,维护这些前缀块与实例之间的对应关系。
例如,第一次请求被分配给实例 A 后,Router 就记录该请求的上下文前缀与 A 的关联。后续请求包含相同前缀时,插件便能查询这些记录,推测 A 上可能保留着对应的 KV Cache,并将匹配信息交给后续评分和选点插件,让 A 更容易被选中。
这里记录的是请求的历史分配情况,没有向推理引擎确认缓存是否仍然存在,因此属于近似估算。
这种方式部署比较简单,不依赖引擎上报 KV Events。但记录的是历史的路由选择,不是推理引擎的实际缓存清单;如果缓存已经被驱逐,或者实例重启了,估算就可能与实际状态不符。

precise-prefix-cache-producer 则是订阅引擎的 KV Events,根据缓存块保存、驱逐等事件维护索引。收到新请求后,使用请求上下文对应的真实 token 序列查询索引,判断各实例持有哪些可复用的前缀块。
例如,实例 A 上某段上下文前缀的 KV Cache 被驱逐后, Router 收到缓存驱逐事件,然后去更新索引,移除对应的缓存记录。
注意:Precise 也需要处理事件延迟、丢失以及索引重建等问题。
还有一种情况:突然有许多共享相同上下文前缀的请求到达,但所有实例都还没有缓存。此时就没有可供优先选择的热实例,那么这些请求可能会被分散到多个副本,分别计算同一段前缀。
burst-prefix-cache-producer 会在一个短时间窗口内收集请求,将上下文相同的请求分组,并根据配置条件,尽量把共享上下文前缀的请求分配到同一实例,减少重复的前缀计算。
它解决的是冷缓存场景下的请求调度问题,收集请求的窗口会增加等待时间,也不保证所有并发请求只计算一次前缀。
前缀匹配信息是选点的依据之一,实际路由还可以结合实例负载、缓存容量等因素。
本文先只考虑前缀复用:由 Approximate 估算各实例的缓存匹配情况,交给 prefix-cache-scorer 评分,再对比随机选择和按评分选择的效果。
本次测试环境如下:
| 项目 | 配置 |
|---|---|
| Kubernetes | v1.36.1,单节点,8 vCPU、约 15 GiB RAM |
| 模型 | Qwen2.5-0.5B-Instruct,同一份本地模型文件 |
| vLLM | v0.31.0,FP16、eager、APC 开启 |
| Router | llm-d-router v1.0.0,Standalone EPP + Envoy v1.33.2 |
| 请求 | 约 5200 token 上下文前缀,输出上限 32 token,全局并发 1 |
两个副本使用相同的模型和推理参数,各自维护独立的 KV Cache。
将模型挂载到 /model,两个 vLLM 使用以下参数启动:
|
|
其中,--enable-prefix-caching 用于开启 APC,--kv-cache-memory-bytes 将每个实例的 KV Cache 预算固定为 256 MiB。本次启动日志显示,每个实例的 GPU KV Cache 容量为 21840 token。
KV Cache 预算和进程总显存不是一回事,模型权重等内容还需要额外占用显存。
两个 vLLM Pod 都带上 app: kv-demo 标签,Standalone Router 根据标签发现后端,直接访问 Pod 的 8000 端口。核心 Helm values 如下:
|
|
对比两种路由策略:
random-picker,忽略缓存评分,随机选择实例。max-score-picker,按照前缀缓存评分选择实例。前缀感知路由的 EPP 配置如下:
|
|
测试随机路由时,将配置中的两处 max-score-picker 替换为 random-picker 即可。
另外,需要避免正常实例被意外排除。utilization-detector 会检查实例指标的更新时间:如果指标太旧,就可能认为实例状态不可靠,暂时不让它参与选点。
本次指标每 1 秒刷新一次,但默认过期阈值为 200 ms,这里将过期阈值调整为 5 秒。
如果一直重复发送相同的长上下文前缀,随机路由也可能把两个副本都预热。等到两个副本上都有缓存,无论请求分给谁都能命中,自然就看不出差异了。
因此,这里基于每段新的上下文前缀构造一对请求:
C + Summarize the observations.,等待完整响应。Which measurements should engineers collect next?,再次请求同一个 Router。两次请求的结构如下。context_prefix 表示两次请求共享的上下文前缀,实验脚本会先把它展开成实际文本,再发送 JSON;这里省略约 5200 token 的正文:
|
|
两次都请求 /v1/chat/completions,并使用相同的生成参数:
|
|
每组测试包含两次请求:第一次发送一段新的上下文前缀和问题 1;响应完成后,保持前缀不变,只把末尾换成问题 2。重点观察第二次请求是否回到原来的实例,以及是否真正复用了缓存。
本实验未启用会话亲和性插件。每条请求使用独立的
X-Request-Id,用于关联 Router 日志与请求记录。
同一轮中,两种策略使用完全相同的 10 组上下文前缀;不同轮使用新的前缀集合。三轮执行顺序依次为“随机 → 前缀感知”“前缀感知 → 随机”“随机 → 前缀感知”。 每次切换策略前,都清空两个 vLLM 实例的前缀缓存,并重建 Router 的缓存索引,避免受上一组测试留下的记录影响。
我们先通过 request_id 关联 EPP 选中的 endpoint,再找到对应的 vLLM Pod 的请求成功计数和缓存指标增量。
主要观察以下指标:
|
|
这些值都是引擎的累计指标,需要取请求前后的增量。全局并发为 1 时,只有一个副本的 request_success_total 增加,便可据此核对本次实际处理请求的实例。
前缀 token 命中率使用 Δhits / Δqueries;多条请求汇总时,先分别累加 hits 和 queries,再计算比例。它与“第二次请求回到首次副本的比例”是两个指标:回到同一副本不保证缓存仍在,换了副本也可能命中通用模板。
实际 Prefill token 数通过 histogram 的 _sum 增量观察。Router 的评分只说明选点依据,vLLM 的命中与实际计算量才说明缓存有没有被复用。
TTFT(Time To First Token) 表示从客户端发起请求,到收到第一个非空 delta.content 的时间。流式响应开头的 role 或空事件不算首字。
每种策略执行 3 轮,每轮 10 对请求,共 120 条主实验请求。下表汇总每对请求中的第二次请求,每种策略各 30 条:
| 指标 | 随机路由 | 前缀感知路由 |
|---|---|---|
| 回到首次处理该前缀的副本 | 17/30(56.7%) | 30/30(100%) |
| 前缀 token 命中率 | 56.56% | 99.60% |
| TTFT P50 | 155.1 ms | 127.3 ms |
| TTFT P95 | 2018.0 ms | 159.2 ms |
| 实际 Prefill token 均值 | 2261.5 | 21.0 |
随机组有 17 次请求回到了首次处理该前缀的副本,这部分请求同样很快,TTFT 中位数为 132.2 ms。另外 13 次落到冷副本,中位数则为 2005.3 ms。
前缀感知路由的主要收益来源于减少请求落到冷副本后产生的重复 Prefill。
随机路由中仍有一半左右的后续请求回到了首次处理该前缀的副本,这部分请求同样有机会复用缓存,因此两种策略的 TTFT P50 仅相差约 28 ms。更明显的变化在 P95,从 2018.0 ms 降到了 159.2 ms,而不是所有请求都快了十几倍。
缓存命中率高,不一定就代表请求延迟更低。
实际场景可能出现另一种情况,实例 A 有缓存,但队列已经很长;实例 B 没有缓存,却可以马上开始计算。此时就需要比较,复用缓存省下的 Prefill 时间,能不能抵消额外排队的时间。
在这类场景下,前缀评分还需要和负载感知配合使用。会话粘滞可以保持同一会话的亲和性,前缀匹配可以处理跨会话复用,而热点和并发场景还需要结合实例队列等负载信息做判断。
APC 和前缀感知路由解决的是两个层次的问题:
本次通过两个 vLLM 副本验证了这个过程。随机路由的 30 次后续请求中,有 17 次回到首次处理该前缀的副本;前缀感知路由则是 30 次全部回到首次处理该前缀的副本,前缀 token 命中率从 56.56% 提升到了 99.60%。
从实际 Prefill token 和 TTFT 也可以看到,请求回到已有缓存的实例后,确实减少了重复计算。对于长上下文前缀、共享提示词等存在前缀复用的场景,可以通过缓存感知路由,提高多副本部署下的缓存复用率,减少重复 Prefill。
2026-10-20 06:00:00

Kubernetes 1.37 已于 2026 年 8 月 26 日发布,今天分享其中两项值得关注的新特性。 原生 Gang Scheduling 和 HPA Scale to Zero 都进入 Beta 了。前者让调度器能够按一组 Pod 来判断资源是否够用,后者让空闲的 Pod 自动缩到零,新任务来了再启动。
这篇主要分享这两个特性,顺便搭一个集群实际跑一下,看看用起来是什么效果。
Kubernetes 1.37 更新主要在资源管理和调度方面:
更多新特性可以参考 Kubernetes v1.37 官方发布公告。
这篇主要挑 Gang Scheduling 和 HPA Scale to Zero 展开,看看实际怎么用。
Gang Scheduling 并不是什么新概念,在 k8s 生态中,早在 2018 年左右就受到关注了,大致发展分为以下几个阶段:
1.37 除了将核心 API 提升至 Beta,还让 PodGroup 直接作为调度队列中的单元,允许动态修改 minCount,并将 Workload-Aware Preemption 合并到 GenericWorkload 开关中。
原生 Gang Scheduling 引入了两个核心对象:Workload 和 PodGroup。
Workload 保存调度策略模板。 比如一个任务需要两个 Worker 一起调度,可以这样定义:
|
|
这里定义了一个名为 workers 的模板,要求至少两个成员满足调度条件。Workload 本身不会创建 Pod,也不会因为创建了模板就自动生成 PodGroup;这些工作需要相应的控制器完成。
PodGroup 表示实际参与调度的一组 Pod,保存这组的调度要求和状态。 根据上面的模板创建的 PodGroup 可以写成:
|
|
workloadRef 标明使用了哪个模板,实际调度规则则保存在 PodGroup 自己的 schedulingPolicy 中。Pod 通过下面的字段加入这个组:
|
|
这样,同一个 Workload 模板可以用于创建多个 PodGroup,每个组分别记录自己的调度状态。Pod 仍由 Job 等控制器或用户创建,PodGroup 负责表达组调度要求。
PodGroup 也可以独立使用,不需要先创建 Workload。 后面的 Demo 就采用这种方式,直接创建 PodGroup 和 Pod,观察资源不足时的调度行为。
K8s 原生支持 Gang Scheduling 之后还有必要使用 Volcano 吗?
提到 Gang Scheduling 大家就会想到 Volcano,但是 Volcano 的功能却不止于此。 Volcano 还负责批处理作业管理,以及多个团队、多个作业之间的资源分配。这些需求并没有因为原生支持 Gang 而消失。
关键在于:K8s 原生 Gang Scheduling 解决的是“一组 Pod 如何一起调度”,Volcano 解决的则是“多组作业如何公平、高效地共享整个集群的资源”。
早在 Serverless 兴起时期,K8s 就在探索工作负载的零副本弹性伸缩。同样可以大致分为三个阶段:
Scale to Zero 并不难,最大的问题其实是零副本靠什么唤醒。
传统 HPA 常用 CPU、内存指标扩缩容,但副本缩到零之后,Pod 指标也没了,拿什么发现新任务?
所以零副本伸缩需要独立存在的 Object 或 External 指标。一般是通过额外的队列长度来解决这个问题,零副本之后新任务仍可以入队。HPA 持续读取这个独立存在的指标,就能知道什么时候该启动消费者。
minReplicas: 0至少需要一个 Object 或 External 指标,只有 CPU、内存指标的配置会被 API Server 拒绝。
1.37 中 HPAScaleToZero 在 kube-apiserver 和 kube-controller-manager 上默认开启,这里直接配置 HPA。后面会用 ScaledToZero Condition 区分自动归零和手动暂停。
KEDA、Knative 早就能缩到零,原生 HPA 补上这项能力后,它们仍各有用途:
| 方案 | 主要解决什么 | 零副本时如何恢复 |
|---|---|---|
| 原生 HPA | 根据指标调整工作负载副本数 | 持续读取 Object、External 指标,恢复副本;指标适配和请求缓冲需要另外提供 |
| KEDA | 对接消息队列、数据库等事件源,提供 Scaler、认证和触发器配置 | 在这里参考的 KEDA 2.18 架构中,由 Operator 处理 0↔1,生成的 HPA 处理 1↔N |
| Knative Serving(KPA) | 提供面向 HTTP 服务的自动伸缩和流量接入 | Activator 暂存请求并配合 Autoscaler 唤醒服务,就绪后转发请求 |
如果已经有可靠的 External Metrics 链路,只需要根据队列长度伸缩消费者,可以直接使用原生 HPA。需要方便地接入多种事件源时,KEDA 的 Scaler 和配置能力仍然有价值;需要 HTTP 请求触发唤醒、等待冷启动和转发流量时,Knative Serving 提供的是更完整的服务链路。
这里使用 KubeClipper v1.7.0 部署 Kubernetes v1.37.0 单节点集群,安装和创建集群的步骤参考之前的 KubeClipper 1.7.0 发布:Operation 优化与 Kubernetes 1.37 支持。
|
|
本次节点 Allocatable CPU 为 8 核,未设置控制平面 NoSchedule 污点。其他规格的环境需要相应调整示例中的 CPU requests。
Gang Scheduling 在 1.37 中进入 Beta,但默认没有开启。需要在控制平面组件中打开 GenericWorkload,同时启用对应的 API。
先备份静态 Pod 配置:
|
|
编辑 /etc/kubernetes/manifests/ 下的三个文件,在各自的 spec.containers[].command 参数列表中增加:
| 文件 | 增加的参数 |
|---|---|
kube-apiserver.yaml |
--feature-gates=GenericWorkload=true、--runtime-config=scheduling.k8s.io/v1beta1=true
|
kube-controller-manager.yaml |
--feature-gates=GenericWorkload=true |
kube-scheduler.yaml |
--feature-gates=GenericWorkload=true |
例如,kube-apiserver 中的配置如下,其他已有参数保持原样:
|
|
保存后 kubelet 会自动重建对应的静态 Pod。等待控制平面组件恢复为 1/1 Running,再次确认实际运行进程的启动参数:
|
|
下面只保留进程名和相关参数,其余输出省略:
|
|
三个组件的实际运行进程都带有 --feature-gates=GenericWorkload=true,说明开关已在运行的组件中启用。kube-apiserver 还应带有 --runtime-config=scheduling.k8s.io/v1beta1=true。
然后确认对应的 API 也已开放:
|
|
|
|
资源列表中应能看到 podgroups 和 workloads,API 版本为 scheduling.k8s.io/v1beta1。至此,组件开关和 API 均已确认启用,接下来通过 Demo 验证实际调度效果。
HPA Scale to Zero 在 1.37 中默认开启,无需额外修改
HPAScaleToZero。
下面通过一个简单示例,对比资源不足时普通调度和 Gang 调度的表现。

为了方便测试调度 Pending 的情况,这里先创建一个占位 Pod,这样剩余资源只能创建一个 Pod 了,便于测试:
|
|
确认 blocker 为 Running 后,再创建两个普通 Pod:
|
|
稍等后再次查看,可以看到一个 Worker 运行,另一个 Pending:
|
|
普通调度会逐个处理 Pod,能放下的先运行,不会等待其他成员。
删除刚才的普通 Pod,保留占位 Pod,保证两次实验的资源条件一致:
|
|
创建独立的 PodGroup,minCount 要求至少两个成员满足调度条件:
|
|
再创建两个 Pod,通过 schedulingGroup.podGroupName 加入这个组。其他配置与普通 Pod 一致:
|
|
稍等一会儿再次查看,两个成员都保持 Pending,且都没有绑定节点:
|
|
这次成员已经齐全,等待的原因是资源不足。查看 PodGroup 的状态和事件:
|
|
PodGroup 明确报告无法调度。即使节点还能容纳一个 Worker,调度器也不会先调度,因为无法满足 minCount: 2,这也正是 Gang 调度的语义。
删除占位 Pod,再查看两个成员的状态:
|
|
稍等后,两个成员都变为 Running:
|
|
两次实验的区别就很直观了:普通调度允许部分 Pod 先运行;Gang 调度在无法满足最小成员数时整组等待,资源满足后才推进调度。 这里保证的是初始调度门槛,容器启动仍有先后;成员后续被删除或驱逐时,也不保证运行数量始终满足 minCount。
实验结束后清理:
|
|
本次 HPA 实验已在 hpa-zero 命名空间预先部署 Redis、Redis Exporter、Prometheus 和 Prometheus Adapter。下面展示关键配置和运行结果,省略基础组件的安装过程。
简单起见,我们用 Redis List 来保存任务,重点看队列长度怎样传给 HPA。

先把队列长度暴露为 Prometheus 指标。 Redis Exporter 通过 --script 加载下面的 Lua 脚本,读取 jobs 队列长度:
|
|
这里用 LLEN 直接读取长度,空队列返回 0,避免 List 清空、key 消失后指标也跟着消失。
Prometheus 采集 Exporter,并通过采集配置和 relabel 补上命名空间、队列标签。采集后的指标类似这样,其他标签省略:
|
|
再把指标提供给 HPA。 Prometheus Adapter 将这条时间序列映射为 External Metric queue_depth,对应的 Helm Chart values 配置片段如下:
|
|
其中 namespace 对应 Kubernetes 命名空间,queue 用来选择队列。查询还检查采集状态和 redis_up,上游失败时不返回有效负载值,不能把“指标读不到”补成零。
最后确认 Kubernetes 能读到指标:
|
|
空队列时应返回 value: "0"。如果这条指标不可用,HPA 可能报告 ScalingActive=False / FailedGetExternalMetric,已经归零的消费者也无法自动唤醒。
消费者从 Redis 的 jobs 列表取任务,处理后写入 completed 列表,方便核对完成数量。直接创建 Deployment 和 HPA,Deployment 初始为 1 个副本:
|
|
这里用 LPOP 模拟消费,仅验证伸缩效果。任务出队后,若 Pod 在处理完成前退出,任务可能丢失;实际业务需要消息确认、失败重投和优雅退出,队列长度也不包含正在执行的任务。
本例使用 Value: 10 验证从零恢复:指标为 40 时,计算出的期望副本数为 4。这不表示始终按“每 10 个任务一个 Pod”伸缩,非零副本时还会考虑 Ready Pod 数量。按每副本平均积压量设置目标,应使用 AverageValue。
为了少等一会儿,把缩容稳定窗口改成了 30 秒,默认是 5 分钟。实际缩容还受指标刷新和 HPA 同步周期影响,不会刚好卡着队列清空后的第 30 秒发生。
先不放任务,看下 HPA:
|
|
另一个终端观察消费者:
|
|
HPA 会把初始的 1 个消费者缩成 0,等 Pod 全部退出后,查看 Condition:
|
|
关键状态如下:
|
|
ScaledToZero=True 表示 HPA 自己将副本缩到了零,后续会继续评估指标。手动将 Deployment 缩到零则保留暂停语义,不等同于这次自动归零。
接下来直接放 40 个任务,不用手动启动消费者:
|
|
这次 HPA 把期望副本数从 0 调成了 4,消费者启动后开始处理任务。唤醒之后,ScaledToZero 变成 False,Reason 是 NotScaledToZero。
等消费完成,核对一下队列和完成数量:
|
|
40 个任务都处理完了。再等缩容稳定窗口过去,消费者重新缩成 0,Pod 全部退出,ScaledToZero 也重新变成 True。
完整过程就是 1 → 0 → 4 → 0:
| 阶段 | 消费者 | 任务状态 |
|---|---|---|
| 空闲 | 自动缩到 0 | 队列为空 |
| 放入 40 个任务 | 起初为 0 | 40 个任务等待处理 |
| HPA 读到新指标 | 扩到 4,随后全部就绪 | 开始消费 |
| 消费完成 | 暂时保留副本 | 队列为 0,完成数为 40 |
| 稳定窗口过后 | 再次自动归零 | 消费者 Pod 全部退出 |
Gang Scheduling 和 HPA Scale to Zero 这次两个特性,都是把生态里使用较多的能力进一步补进 Kubernetes 原生体系。简单场景有了更直接的选择,而 Volcano 的队列和资源管理、KEDA 的事件源适配、Knative 的请求接入仍各有用途。是否替换现有方案,还是要看实际需要哪些能力。
2026-09-30 01:30:00

让 Agent 修一个 Bug,它可能很快就给出修改,还附上一句“测试通过”。接下来要做的事情却不少:看它改了哪些文件,核对运行过的测试,确认原来的行为有没有受到影响。
如果任务做到一半需要换一个 Session,还得留下当前进度。代码可以从 Git 里找到,但方案为什么这样定、哪个问题还没解决,需要另外记录。
这篇文章就从这些环节展开,讨论需求怎么写、代码该让 Agent 看哪些,以及改完以后怎么验收。
Andrej Karpathy 最初提出 Vibe Coding 时,描述的是一种跟着 AI 生成结果不断尝试的开发方式:用自然语言提出要求,运行代码,遇到问题再让 AI 修改,过程中未必逐行审查代码。
现在这个词也常被用来泛指通过自然语言让 AI 辅助编程。本文讨论的范围更具体:在已有项目中使用 Coding Agent,由它阅读仓库、分析问题、修改文件和运行测试,开发者负责检查方案和结果。
比如前面说的修 Bug,第一版代码很快就能拿到。接下来花多少时间,要看它有没有找对原因、测试是否覆盖了出问题的场景,以及修改后是否需要返工。
实际使用中,麻烦往往不是 AI 完全写不出代码,而是这些修改很难直接交付。比如,本来只想修一个 Bug,它却顺手重构了几个模块,甚至引入新依赖、改变公共接口;测试虽然通过,业务行为却不符合要求。
任务一长,还可能出现另一类问题:早期约束被遗漏,已经否决的方案又被拿出来,实际执行失败却被总结成“已完成”。到最后,连人自己也得重新翻记录,才能弄清楚当前改到了哪里。
等代码写完才追问这些问题,可能已经需要返工了。所以,任务开始前就要说清楚预期结果和修改范围,提供相关代码;开发过程中检查变更、执行验证,并留下确认过的决策和未完成项。
拿修 Bug 来说,开发者要先划定范围。如果现有接口必须保留,就提前说明;Agent 提议改变接口时,也得由开发者决定是否接受。
Agent 可以去读代码、找原因,给出方案后完成修改和补测试。开发者重点看方案是否合适,以及最后究竟改了什么。
验收还要看执行结果。项目要求的构建、测试、Lint,以及类型、Schema 等检查,都应留下记录。这些结果供开发者审查,也可以作为进入下一阶段或合并代码的必要条件。

“增加重试”听起来是个明确的任务。交给 AI 时,可能就只写了这一句:
帮我给这个功能增加重试机制。
Agent 接下来仍有不少事情需要确认。是所有错误都重试,还是只处理临时错误?能否加依赖?现有接口能不能动?如果这些要求都没写,它就可能按自己的理解实现。
我会先把任务写成一份简短的 Spec。Goal 写要修复的问题,Scope 划定修改范围,Constraints 留下不能违反的约定。最后补上 Acceptance Criteria 和 Verification,分别写预期结果和检查方法。

这最后两项要分开写。“API 恢复后,资源最终达到期望状态”是一条验收标准;“用集成测试复现临时错误和恢复过程”才是验证方法。只写前一句,改完以后仍然缺少检查依据。
假设一个 Controller 遇到临时 Kubernetes API 错误后,资源一直没有恢复。可以把任务写成下面这样:
|
|
这份 Spec 把目标写成了“恢复正常”,没有指定要新增一套重试代码。按照 controller-runtime 的接口约定,Reconcile 返回非终止性错误时,会触发退避重入队。Agent 应先检查现有实现,找出恢复过程断在了哪里,具体行为以项目实际依赖版本为准。
“不修改 CRD Schema”这样的限制,现在就能写清楚。至于要改哪段逻辑,等读过代码再讨论。
如果问题牵涉几个模块,我会先让 Agent 读代码,把准备怎么改写出来。这一步就是 Plan:在文件还没变动时,先看它有没有找对问题,确认方案后再动手。
工具有 Plan Mode 就直接用,也可以在任务里加上这段要求:
|
|
比如上面的恢复问题,如果 Plan 要求修改 CRD Schema,就需要追问:恢复调谐为什么必须改变资源定义?这个理由说不清楚,就先调整方案。等多个文件都改完,再找出哪些改动是多余的,会更费力。
改错别字这类小任务就可以省掉 Plan。问题和修改位置都清楚,再写一遍计划帮助不大。
读代码后发现需求有误,也应回头更新 Spec。例如,已有机制就能完成重试,任务可能只需要修正错误处理,不必继续按“新增机制”的方向做。
Spec 说明了资源应该恢复,却没有告诉 Agent 项目里有哪些现成代码可用。它如果没读过相邻的 Controller,就可能另写一个 retry helper,错误日志和测试写法也与项目里其他地方不同。
先找到相关实现和测试,看看现有逻辑怎样处理这类错误,再决定怎么改。
每次修 Bug 都重新解释项目怎么构建、接口能不能改,很容易遗漏。可以把这些约定放进工具支持的规则文件,比如 AGENTS.md、CLAUDE.md 或 Project Rules。
优先写清构建和测试命令、项目特有的约定,以及不能随意修改的区域。技术栈和目录用途简要说明即可,不必把仓库概述再写一遍。
下面是一份 Go Controller 项目的简短示例,命令需要按项目实际情况调整:
|
|
长文档可以只列路径,并说明什么时候需要阅读。比如改资源接口前先看兼容性约定,改调谐逻辑前先看模块设计。发现说明与代码不一致时,要更新对应文档,否则下一个任务还会碰到同样的误导。
规则文件也不是越长越好。《Evaluating AGENTS.md》 在所测任务与 Agent 中,没有发现仓库上下文文件普遍提高任务成功率,还观察到了推理成本增加。这不等于规则文件没用,但值得逐条检查:这条说明能减少什么具体错误,还是只增加了需要阅读的内容?
到了这次恢复问题,可以进一步指出要读的 Controller 和测试文件。如果另一个 Controller 已有合适的错误处理方式,也把它列出来,说明参考哪一部分:
|
|
参考前,也要确认这份实现仍然适用。旧代码可能有历史限制,不能照搬到新任务里。涉及多个模块时,还需要沿调用关系查看相关逻辑,不能只读一个文件就决定怎么改。
除了提供相关信息,还要避免无关内容干扰 Agent。
如果在同一个 Session 中连续处理新功能、Bug 修复和重构等互不相关的任务,之前的临时方案、错误假设和调试记录可能继续留在上下文中,影响后续判断。
对于相对独立的任务,我更倾向于开启新的 Session,让 Agent 从当前任务需要的信息开始,而不是在一段越来越长的对话中不断追加新需求。
任务做到一半换 Session,最容易丢失的往往是“为什么决定这样改”和“还有哪项没检查”。这些信息需要另有记录。
确认过的需求和决策放进 Spec 或项目文档,代码变更由 Git 保存;当前进度、失败检查和下一步操作,另留一份任务记录。后面的 Workflow State 小节会给出一个模板。
为了便于管理,可以把这些信息分成项目规则、任务参考资料和需要持续保存的决策与进度。下面这张图展示的是本文的组织方式,不是必须采用的标准分层。

代码改完,Agent 可能会在回复里写:
Implementation completed. All tests passed. No breaking changes.
这时可以打开执行记录,把实际运行的命令与项目要求对一下。如果只跑了一个测试文件,“All tests passed”就只能说明这次运行的测试通过了。命令中途退出、测试被跳过,也需要单独记录。
接着按 Spec 查看 Diff 和测试内容。恢复问题有没有对应测试,修改是否涉及无关模块,都能在这里核对。缺少执行记录或测试没有覆盖的验收项,先记为未验证,不能靠这句总结补上。
前面的 Controller 测试,要从临时 API 错误开始,检查错误怎样返回,再观察 API 恢复后资源能否继续调谐。还要重复执行调谐,确认没有创建重复资源。只测错误返回,无法覆盖整个恢复过程。
这里有个容易漏掉的区别:测试代码手动再次调用 Reconcile 后成功,不等于 Controller 会自动恢复。 要验证后者,需要让实际调谐循环运行起来。使用 envtest 时,还要注册被测 Controller 并启动 Manager;只启动 API Server 和 etcd,并没有运行你的 Controller。具体可参考 文末的 Kubebuilder envtest 测试说明。
这些场景检查完,再运行项目要求的构建和代码检查。例如,项目配置了下面这些命令:
|
|
命令只是示例,实际要执行哪些检查,以项目配置为准。运行记录需要对应当前代码,不能用修改前的测试结果为修改后的代码背书。
检查失败时,先结合错误输出、调用链和复现步骤定位原因,再修复和验证。不要为了让测试变绿就删除断言、吞掉错误,或者用硬编码结果伪造成功。Mock 可以帮助验证特定错误分支,但不能凭这类结果声称实际调谐链路已经通过验证。
如果失败来自环境不可用、缺少权限,或者继续修复需要改变已确认的方案,就应记录阻塞并交给开发者处理,而不是无限重试,也不能把未完成检查改写成“通过”。
Quality Gate 要明确的是:哪些检查必须通过,才能进入下一阶段或合并代码。这个条件最好由工具执行,而不只是写在 Prompt 里。
对于代码合并,可以通过 CI 的必需检查和分支保护落实门禁。同时要确认要求的测试确实执行过,没有被条件跳过,也没有忽略失败。以 GitHub 为例,部分必需检查的 skipped、neutral 状态也能满足合并要求,具体要结合分支保护规则检查(文末附官方文档)。
任务记录中的 passed 也不能代替执行结果。至少应能追溯到实际命令、日志,以及它们对应的代码版本。
工具检查之外,还需要人工审查。比如这次只修恢复问题,Diff 却改了公共接口,构建和测试可能照样通过,但这项改动已经超出约定范围。审查时要确认接口变化是否获批、原来的行为有没有受到影响,以及既有重试机制能否复用,避免为一次修复维护两套相似逻辑。
如果修复 Issue 时总要重复输入“先定位原因,再提出方案,改完补测试”,可以把这段做法保存下来,下次直接调用。任务更长、需要中途检查方案或跨 Session 继续时,再用 Workflow 安排阶段和记录进度。

不需要每次都启动完整 Workflow。简单任务用简单流程;影响范围大、需要跨会话推进的任务,再加入更多约束和状态管理。
修复 Issue 通常要经历阅读问题描述、定位原因、制定方案、修改代码、补充测试,最后验证和审查。如果这套步骤已经适用,就不必每次重新输入。
简单的固定提示,可以保存成工具支持的 Command;需要一起携带参考文档、模板或脚本,并按任务加载时,再考虑封装为 Skill。Agent Skills 的格式支持打包这些材料,但封装本身不能保证任务执行正确,验证仍然要做。
也不必所有 Skill 都自己开发。通用的调试、代码审查和测试流程,可以参考社区实现,再按项目需要调整。引入前要检查其中的指令、脚本和权限要求,不能只看名称和介绍。
当 Spec、Design、Coding 和 Verification 被串成 Workflow 后,还需要明确每个阶段怎样才算完成。例如:
|
|
部分条件可以用脚本检查,比如文档是否存在、必要字段是否完整、测试是否通过;方案是否获批,则要由指定人员确认。Agent 自己写入“已批准”,不能代替审批。
从 Design 进入 Coding 前,如果方案仍有争议,就先停下来确认,而不是因为已经生成了一份设计文档就继续编码。同样,进入交付阶段也不能只检查一份状态文件,仍要核对实际验证结果。
前面提到,跨 Session 的任务需要保存决策和进度。下面是一份交接模板,不代表前述 Controller 示例已经完成修改或验证:
|
|
不必每次重写整个任务经过,只更新发生变化的部分。测试结果与代码版本对不上时,接手者应先确认是否需要重跑,不能照着记录中的“通过”继续往下做。

流程增加以后,要看看返工和人工介入是否减少,又多花了多少时间和 Token。步骤更多,不一定就更划算。
可以拿同一组任务比较新旧流程,记录验收是否通过、返工次数、人工介入和执行成本。比较时使用相同的验收标准,并从相同的代码和环境起点分别运行,不要让后一组继承前一组改好的代码或历史记录。
样本不多时,先看具体失败在哪一步,不急着判断哪套流程更好。如果结果没有改善,等待时间和 Token 却增加了,就检查哪些步骤没有带来收益,能简化的就简化。
回到前面的 Controller 例子,验收要看三个结果:临时 API 错误没有被当成成功,API 恢复后资源能够继续调谐,重复执行也不会创建重复资源。Agent 有没有新增重试代码,并不是验收重点。
审查时,把这三个结果与实际 Diff、测试记录逐项对上。只通过编译,还不能说明资源会恢复;只检查了错误返回,也没有覆盖后续调谐。没有验证的部分就留下来,写清楚还缺什么检查。
下一个任务可以先这样做:提供相关代码,写下预期结果,改完以后核对变更和测试。等同一类问题反复出现,再把有效的做法写进项目规则或 Skill,不必一开始就搭建整套 Workflow。
AGENTS.md 作为导航,仓库知识作为事实源,以及可执行的反馈机制。Reconcile 返回非终止性错误时的重新入队、退避以及特殊错误的语义;不同版本应参考实际依赖的 API 文档。2026-09-28 06:00:00

前一篇《Ascend vNPU 实战:从原生 Docker 到 Ascend Docker Runtime》已经跑通了 vNPU 的两种用法,本文继续把这套能力接进 Kubernetes,用 HAMi 统一管理 vNPU。
310P3 的硬切并不是按任意比例切分显存和算力,而是从芯片支持的固定 vNPU 模板中选择一档。HAMi 会根据 Pod 的资源请求匹配合适的模板,由 Scheduler 选择具体设备并记录分配结果;随后 kubelet 调用 Ascend device-plugin 完成设备分配,最终由 Ascend Runtime 将对应的 vNPU 暴露给容器。
| 组件 | 版本或配置 |
|---|---|
| 操作系统 | Ubuntu 20.04.6 LTS,x86_64 |
| 节点 | 单节点,lixd-npu-test2
|
| NPU | Ascend 310P3,1 张 |
| Kubernetes | v1.37.0 |
| HAMi | v2.10.0 |
| ascend-device-plugin | v1.4.1 |
版本说明:本文记录的是 Ascend Driver 22.0.4 环境下的模板硬切分实测。
ascend-device-plugin v1.4.1官方部署文档将 Ascend Driver ≥ 25.5 列为环境要求;本文环境低于该要求,实测结果仅对应本文所述环境,不代表官方支持或推荐的版本组合。
宿主机初始状态如下:
|
|
添加 HAMi Helm 仓库:
|
|
安装 HAMi 2.10,并开启 Ascend 设备支持:
|
|
devices.ascend.enabled=true开启昇腾资源支持。
安装好后确认 HAMi 调度器已经正常运行:
|
|
昇腾的 device-plugin 默认通过 ascend=on 选择节点,因此需要给节点打上 label。
|
|
需要节点上已经装好 Ascend Docker Runtime 并注册了
ascendhandler。
然后创建 RuntimeClass:
|
|
安装 HAMi 后已经自动创建了全局配置 hami-scheduler-device,其中包含 Ascend 的 resourceName、切分模式和 vNPU 模板,不需要重复部署。
后续 device-plugin 还会挂载 hami-device-node-config,因此需要先手动创建节点级 ConfigMap:
|
|
节点级别的设置优先级高于全局。
使用 v1.4.1 tag 下的官方 YAML 部署:
|
|
v1.4.1 tag 中的 YAML 默认镜像仍然是 v1.4.0,因此部署后手动把 DaemonSet 镜像更新为 v1.4.1:
|
|
确认 DevicePlugin 启动成功:
|
|
DevicePlugin 启动后,验证节点资源情况:
|
|
|
|
为什么一张物理卡会显示 huawei.com/Ascend310P:7 ?
节点上出现的
huawei.com/Ascend310P: 7是 HAMi/device-plugin 按最小 vNPU 模板折算后,向 Kubernetes 上报的可调度资源份额,不代表节点上有 7 张物理 NPU。
当前 310P3 配置中的最小模板是
vir01=3072 MiB,所以计算是floor(21527 / 3072) = 7。这里使用的是memoryAllocatable。
因此 device-plugin 会按最小模板折算虚拟设备数量,并向 Kubernetes 上报 Ascend310P: 7;具体 Pod 能否继续分配到这张物理卡,则由 HAMi 结合设备显存等资源进行判断。
310P3 模板硬切使用设备数量和显存两个资源键:
|
|
huawei.com/Ascend310P:Ascend 设备数量资源。模板硬切时设置为 1,再结合 -memory 选择 vNPU 模板;huawei.com/Ascend310P-memory:显存请求。HAMi 会选择能够满足请求的最小模板;-memory 时表示申请整卡。查看 NPU 硬件支持的 vNPU 模板:
|
|
然后查看 HAMi 实际加载的 ConfigMap:
|
|
HAMi 当前针对 310P3 的默认配置只启用了三种模板:
| 模板 | 配置显存 | AI Core | AI CPU |
|---|---|---|---|
vir01 |
3072 MiB | 1 | 1 |
vir02 |
6144 MiB | 2 | 2 |
vir04 |
12288 MiB | 4 | 4 |
对应配置如下:
|
|
先从最小场景开始,创建一个申请 1024 MiB 显存的 Pod:
|
|
确认 Pod 已经 Running:
|
|
|
|
HAMi 把这次请求匹配为 vir01,分配注解如下:
|
|
|
|
进入 Pod 查看环境变量和设备信息:
|
|
完整输出:
|
|
Pod 已经 Running,ASCEND_VNPU_SPECS=vir01、分配注解里的 temp: vir01 和 Pod 内的 310Pvir01 三处证据也能互相对应,说明最基本的硬切链路正常。
接下来验证 HAMi 是否真的会按请求自动选择模板。我分别申请 1024、4096 和 7000 MiB,这些值都不是模板的精确显存。
三个 Pod 全部 Running:
|
|
匹配结果如下:
| 原始申请 | webhook 调整后 | HAMi 选择模板 | 容器内可见容量 |
|---|---|---|---|
| 1024 MiB | 3072 MiB | vir01 |
2690 MiB |
| 4096 MiB | 6144 MiB | vir02 |
5381 MiB |
| 7000 MiB | 12288 MiB | vir04 |
10763 MiB |
4096 MiB 请求在 Pod 内显示为 vir02:
|
|
7000 MiB 请求在 Pod 内显示为 vir04:
|
|
宿主机同时看到三个 vNPU:
|
|
这说明 HAMi 会选择能够满足请求的最小模板。
admission webhook 还会把 Pod 里的显存值改成选中模板的配置值,因此 Pod 创建后再执行
kubectl get pod -o yaml,看到的是3072/6144/12288。 模板配置显存与容器中npu-smi显示的可用容量并不完全相等,这是 Ascend vNPU 自身的保留开销。
310P3 最大模板是 vir04=12288 MiB。删除前面的切片并确认 NPU 空闲后,我又申请了 13000 MiB:
|
|
Pod 成功调度并运行:
|
|
再看 API Server 中保存的资源和 HAMi 分配注解,原始的 13000 MiB 已经被 webhook 提升为整卡可调度显存 21527 MiB:
|
|
Pod 内没有 ASCEND_VNPU_SPECS,npu-smi info 看到的是完整的物理 310P3:
|
|
宿主机同时显示 Total number of vnpu: 0。所以 13000 MiB 并没有匹配到一个更大的 vNPU,而是被转换成整卡申请。空闲卡上可以运行;卡上已有切片时,同样的请求会因 CardInsufficientMemory 保持 Pending。
单 Pod 跑通只能证明链路没断,真正使用时更关心多个任务能不能共卡。
两个 vir01 Pod 同时运行时,HAMi 给出的物理设备 UUID 相同,两个容器内都看到独立的 310Pvir01。随后我把 Deployment 副本数调到 8,每个副本都申请一个 vir01。
结果很直接:
|
|
7 个 Pod Running,第 8 个一直 Pending:
|
|
宿主机也正好看到 7 个 vir01:
|
|
这里同时验证了 Kubernetes 上报的 Ascend310P: 7 确实对应七个最小模板,以及容量用尽后 HAMi 会给出明确的 CardTimeSlicingExhausted,而不是把第 8 个 Pod 错误绑到节点。
不设置 huawei.com/Ascend310P-memory 就表示申请整卡:
|
|
webhook 会自动补成整卡可调度显存:
|
|
我把两个方向都测了一遍。
整卡 Pod 先运行,再创建 vir01 切片,切片 Pod Pending:
|
|
反过来,vir01 + vir02 + vir04 已经运行时再申请整卡,整卡 Pod 同样 Pending:
|
|
所以整卡与切片的互斥在 HAMi 2.10 上仍然成立,调度器会通过设备显存记账阻止两类工作负载同时使用同一张卡。
这一步第一次跑的时候,8 个 vir01 只有 3 个真的跑起来,另外 4 个一直 CrashLoopBackOff,容器根本没启动:
|
|
调度器认为卡是空的(前一步的 Pod 都删掉了),但硬件上并不是。原因是这个驱动上删除 Pod 不一定会立刻释放 vNPU,上一步"超过模板转整卡"的 Pod 在卡上留了一个 vir04 实例:
|
|
残留的 vir04 占掉 12 GiB,卡上只剩 2 GiB 可用,所以第 4 个 vir01 开始就创建失败。判断方法是对比两个视角:npu-smi info -t info-vnpu 里 Container ID 是 000000000000 的条目就是没人认领的残留实例,而 npu-smi info 的剩余显存会明显低于 HAMi 仍在播报的容量。
清理方式是重启设备插件触发它的空闲 vNPU 回收,或者直接销毁:
|
|
清理干净后重跑,结果就和预期一致:7 个 vir01 全部 Running,第 8 个 Pending,卡上正好 7 个 vir01 实例。所以每一步之间先删掉上一步的 Pod 并等 vNPU 释放,否则下一步看起来就像容量算错了。
前面的 Pod 都只运行 npu-smi 和 sleep,最多证明设备能创建出来。
为了确认 vNPU 里真的可以使用显存,我在 vir04 容器中通过 ACL 初始化设备并申请约 6 GiB 内存。空闲时 Pod 内看到:
|
|
分配期间变成:
|
|
释放后又恢复到:
|
|
这个结果证明 ACL 工作负载可以在 vir04 内分配和释放设备内存,也说明该 vir04 容器中的设备视图和显存分配受对应 vNPU 容量约束。
宿主机物理卡的 Memory-Usage 没有按相同幅度增长,因此不能用宿主机这一个字段反推单个硬切 Pod 的实时显存。310P3 旧驱动下,vNPU 内部统计、HugePages 和物理卡统计的口径并不相同。
显存能申请还不够,我又在同一个 vir04 中编译并运行了 CANN 自带的 AscendC 基础算子。
构建过程生成了 AI Core 目标文件并完成链接:
|
|
加法算子输出:
|
|
矩阵乘算子输出:
|
|
循环运行期间,宿主机采样到过 20% 的 AICore 利用率:
|
|
这是短算子脉冲负载,所以采样值在 0% 和 20% 之间跳动。它能证明任务确实进入 AI Core 执行,不能据此宣称 vir04 始终占用固定百分比算力。
HAMi scheduler 容器内部监听 9395,Service 通过 NodePort 31993 暴露 Prometheus 指标:
|
|
三个模板同时运行时:
|
|
Pod 维度的分配量也能看到:
|
|
这三个值分别是 3072、6144 和 12288 MiB 换算成字节后的结果,属于调度分配量,不是 Pod 的实时使用量。
这次在 Ascend 310P3 上,从 HAMi 2.10、ascend-device-plugin v1.4.1 到 Pod 内运行 AscendC,完整走通了模板硬切链路。
使用上也比较简单,只需要通过 -memory 指定显存,HAMi 就会自动匹配满足条件的最小 vNPU 模板。
2026-09-22 06:00:00

本文以 Ascend 310P3(Atlas 300I Pro)为例,介绍昇腾 NPU vNPU 的基本使用方法。 内容包括从原生 Docker 开始,演示手动创建 vNPU 并挂载到容器,以及 Ascend Docker Runtime 下的静态挂载和动态虚拟化。
后续我们会继续把 vNPU 接入 Kubernetes,并进一步看 HAMi 如何对 vNPU 做统一的资源管理和调度。本文先绕开 Kubernetes 和 HAMi,从 Ascend 自身提供的 vNPU 能力开始,跑通 vNPU。
昇腾的虚拟化实例叫 AVI(Ascend Virtual Instance)。它可以按照固定的虚拟化模板,把一个物理 NPU 划分成多个 vNPU,再把 vNPU 提供给容器使用。
从使用者的角度,原生 Docker 下主要需要完成三件事:

Ascend vNPU 在使用方式上可以类比 NVIDIA MIG(Multi-Instance GPU):两者都可以把一块物理加速卡的资源划分成更小的实例,再分别提供给容器或任务使用。
不过这只是使用方式上的类比。Ascend vNPU 与 NVIDIA MIG 在资源模型、虚拟化机制和具体实现上并不相同,因此不能简单把两者视为同一种底层技术。
| 对比维度 | Ascend vNPU | NVIDIA MIG |
|---|---|---|
| 虚拟实例 | AVI / vNPU | MIG Instance |
| 划分方式 | 根据 vir01、vir02 等模板划分 AICORE、内存、AICPU 和媒体处理资源 |
根据 GPU 支持的 profile 划分计算核心、显存和相关硬件资源 |
| 创建/注入方式 | 使用 npu-smi 创建静态 vNPU;Ascend Docker Runtime 可按 ASCEND_VNPU_SPECS 动态创建并注入,或挂载已有 vNPU |
使用 nvidia-smi mig / NVML 创建 MIG 实例;NVIDIA Container Toolkit 负责将已有 MIG 设备注入容器 |
| 容器视角 | 容器内看到一个 310Pvir01 等 vNPU 设备 |
容器内看到分配到的 MIG GPU |
| 共同点 | 都可以把物理加速器资源以更细粒度的实例提供给不同容器或任务使用 | 都可以把物理加速器资源以更细粒度的实例提供给不同容器或任务使用 |
本文后面的命令只针对 Ascend vNPU,NVIDIA MIG 仅作为帮助理解的参照。
vNPU 的底层能力相同,但不同运行环境负责的事情不一样:
原生 Docker:通过 npu-smi 手动创建 vNPU,再手动把 vNPU 设备和 Ascend 驱动环境挂载到容器中,下面会完整展开。
Ascend Docker Runtime:支持静态和动态两种方式。静态方式仍然需要提前创建 vNPU,但 vNPU 设备注入由 Runtime 完成;动态方式则在启动容器时通过 ASCEND_VISIBLE_DEVICES、ASCEND_VNPU_SPECS 等参数指定物理设备和 vNPU 模板,由 Runtime 动态创建并注入 vNPU,不需要提前创建虚拟实例。
集群调度组件:可以使用 Ascend Device Plugin 配合 Kubernetes Scheduler、Volcano 或其他调度组件。Device Plugin 负责设备发现、资源上报和节点侧设备分配,Scheduler 根据资源请求选择合适的节点;HAMi、Volcano 等增强方案还可以进一步参与资源调度和管理。
简单说,原生 Docker 是“用户自己创建、自己挂载设备”,Ascend Docker Runtime 是“Runtime 帮你注入设备”,集群调度则是在此基础上进一步统一管理资源。

本次使用环境关键信息如下:
| 项目 | 实测值 |
|---|---|
| 操作系统 | Huawei Cloud EulerOS 2.0(x86_64) |
| 主机名 | lixd-test-npu |
| NPU | Ascend 310P3 / Atlas 300I Pro,1 张 |
| 架构 | x86_64 |
| Ascend Driver | 22.0.4 |
| CANN | 6.0.0 |
| Docker | docker-engine 18.09.0 |
| Ascend Docker Runtime | 6.0.0 |
先确认 NPU 设备和芯片信息:
|
|
|
|
本机的关键结果是:
|
|
再确认产品类型:
|
|
|
|
再查询当前产品支持的 vNPU 模板:
|
|
|
|
本机可用的模板包括 vir01、vir02、vir02_1c、vir04、vir04_3c、vir04_3c_ndvpp 和 vir04_4c_dvpp。本文选择 vir01 做最小验证。
原生 Docker 下使用 vNPU 分成两步:先用 npu-smi 创建 vNPU,再把生成的 vNPU 设备映射到容器。
使用 dnf 安装 Docker。Huawei Cloud EulerOS 仓库里的包名是 docker-engine:
|
|
确认 Docker 服务和服务端信息:
|
|
|
|
本机使用 NPU ID 13、Chip ID 0 和 vir01 模板:
|
|
输出如下:
|
|
创建成功后,本机得到 vNPU ID 100,对应设备节点为 /dev/vdavinci100。
这里有几个容易混淆的设备编号:
npu-smi NPU ID:宿主机通过 npu-smi 查询和管理设备时使用的编号,例如本文中的 13,创建 vNPU 时通过 -i 13 指定;/dev/davinci* 对应的设备序号选择物理设备。本文对应 /dev/davinci0,因此动态虚拟化时使用 ASCEND_VISIBLE_DEVICES=0;100,对应 /dev/vdavinci100。使用 Runtime 挂载已有 vNPU 时,通过 ASCEND_VISIBLE_DEVICES=100 指定;npu-smi 还会显示容器视角下的设备编号,例如本文实测中的 NPU 13。这些编号来自不同的设备管理视角,不应该直接混用。
启动容器前,先确认 vNPU 设备节点和基础设备节点已经存在:
|
|
本次实测可以看到以下设备节点:
|
|
然后我们启动容器,使用 --device 参数把 vNPU 挂载到容器:
|
|
容器内输出如下:
|
|
输出中的 310Pvir01 表明容器内已经看到 vir01 类型的 vNPU。
这里的参数可以简单分为两类。它们不是用来创建 vNPU 的,vNPU 已经在前一步创建完成:
--device):把 /dev/vdavinci100 以及
/dev/davinci_manager、/dev/devmm_svm、/dev/hisi_hdc 等 Ascend 设备节点交给容器,
这样容器才能访问已经创建的 vNPU。-v、LD_LIBRARY_PATH):把宿主机的 npu-smi 和
/usr/local/Ascend 驱动/工具库以只读方式提供给容器,并让动态链接器能找到这些库。原生 Docker 路径验证完成后,再安装 Ascend Docker Runtime,继续验证 Runtime 的静态挂载和动态虚拟化。
本次使用 MindCluster 6.0.0 提供的 x86_64 安装包。官方安装包地址见 MindCluster v6.0.0 Release。
|
|
命令输出如下:
|
|
安装完成后重载并重启 Docker:
|
|
|
|
Runtime 安装时会把 ascend 注册到 /etc/docker/daemon.json 并设为默认 runtime:
|
|
安装 Runtime 后,可以复用前面通过 npu-smi 创建的静态 vNPU。与原生 Docker 的区别是:不再手工写 --device,而是把 vNPU ID 交给 Runtime,由 Runtime 完成设备注入。
本机前面创建的 vNPU ID 是 100。设置 ASCEND_RUNTIME_OPTIONS=VIRTUAL,告诉 Runtime ASCEND_VISIBLE_DEVICES 指向的是已经存在的 vNPU:
|
|
容器内输出如下:
|
|
相比原生 Docker,不再需要手工声明 –device、挂载宿主机驱动目录或配置 LD_LIBRARY_PATH。在使用 Ascend Runtime 后,只需要通过 ASCEND_VISIBLE_DEVICES 和 ASCEND_RUNTIME_OPTIONS 描述需要注入的 vNPU。Runtime 在 prestart hook 阶段自动完成三件事:
ASCEND_VISIBLE_DEVICES 对应的 NPU 设备挂载到容器;也就是说,Runtime 已经解决了原生 Docker 里最麻烦的那部分。
Ascend Docker Runtime 可以在容器启动时根据环境变量自动创建 vNPU,并在容器退出后释放动态实例。动态验证和上面的静态验证是两条独立路径,动态启动时设备从没有预创建 vNPU 的状态开始。
因此,先确认前面的静态 vNPU 容器已经退出,再销毁 vNPU 100,并确认物理 NPU 上已经没有预创建实例:
|
|
动态 Runtime 需要关闭 vNPU 配置恢复功能:
|
|
输出如下:
|
|
通过 ASCEND_VISIBLE_DEVICES 选择物理设备,通过 ASCEND_VNPU_SPECS 指定 vNPU 模板。
这里的 ASCEND_VISIBLE_DEVICES=0 对应宿主机 /dev/davinci0,与前面 npu-smi -i 13 使用的 NPU ID 属于不同的设备编号视角。
|
|
容器内输出如下:
|
|
输出中的 310Pvir01 表明 Runtime 已经在容器内注入动态创建的 vir01 vNPU。
容器退出后再次查询:
|
|
输出如下:
|
|
结果显示当前 vNPU 数量为 0,说明动态实例随容器生命周期自动释放,AICORE 和内存也都恢复到 8/8、21/21。
Docker 方式的职责可以概括为:
| 方式 | 设备和模板 | 容器内注入 |
|---|---|---|
| 原生 Docker 静态 vNPU | 用户执行 npu-smi 创建 |
用户手工 --device,并挂载 npu-smi 和驱动库 |
| Ascend Docker Runtime 静态 vNPU | 用户执行 npu-smi 创建,Runtime 接收 vNPU ID |
Runtime 根据 ASCEND_VISIBLE_DEVICES 和 ASCEND_RUNTIME_OPTIONS=VIRTUAL 自动注入 |
| Ascend Docker Runtime 动态 vNPU | 启动容器时设置 ASCEND_VISIBLE_DEVICES、ASCEND_VNPU_SPECS
|
Runtime 自动创建实例并注入,容器退出后释放 |
两者的核心差别在于谁负责把设备塞进容器:原生 Docker 靠用户手写 --device 和挂载,Ascend Docker Runtime 把这些交给 prestart hook 自动完成。这两种方式使用的是同一套底层 vNPU 能力。
到这里,我们先把 Ascend 自身提供的 vNPU 能力和两种 Docker 使用方式跑通了。下一步再把这套能力放进 Kubernetes,看看 Ascend Device Plugin 和 HAMi 分别解决什么问题。
2026-09-15 04:00:00

上一篇 https://www.lixueduan.com/posts/ai/27-why-pd-disaggregation/ 介绍了为什么要把 Prefill 和 Decode 拆开。本文继续往下走,在单节点 GPU 环境中搭建一个可以从头复现的 PD 分离 Demo,先用最小实现把整个链路跑通。
本文不引入复杂组件,只使用 vLLM 和一个轻量 Proxy,把 Prefill 与 Decode 两个阶段串起来。
Demo 主要看两件事:
请求先由 Proxy 发送到 Prefill 计算 Prompt 和 KV Cache,再携带 kv_transfer_params 请求 Decode;KV Cache 由 Decode 通过 NIXL 直接从 Prefill 拉取,不经过 Proxy。

本文不展开 GPU 基础环境的安装过程,大家可以先参考前置文章了解 GPU 环境搭建指南:如何在物理机、Docker、K8s 等环境中使用 GPU;
从 ModelScope 下载模型,为了简化直接使用 Qwen2.5-0.5B-Instruct 这个小模型。
|
|
Connector 可以理解为 Prefill 和 Decode 之间传递和访问 KV Cache 的具体机制:
vLLM 提供了多种 KV Connector,用于不同场景下的 KV Cache 传输和管理,例如 NixlConnector、LMCacheConnectorV1、MooncakeConnector 等。
不同版本支持的 Connector 及参数可能发生变化,实际使用时应以对应版本的文档和源码为准。
本文选择 NixlConnector:
kv_producer 角色生成 KV Cachekv_consumer 角色通过 NIXL 拉取 KV Cache。这样可以在不引入额外缓存服务的前提下,直接观察一次跨 vLLM 实例的 KV Cache 传输过程,并从 Decode 日志中确认传输是否成功。
简单起见,我们直接使用 nerdctl 启动一个 GPU 容器提供完整的 vLLM 运行环境,再在容器内启动三个相互独立的进程:Prefill、Decode 和 Proxy。
虽然都在一个容器中运行,但是 Prefill 和 Decode 仍然是两个独立的 vLLM 实例,分别监听不同的 HTTP 端口,拥有各自的 KV Cache 管理和 NIXL Connector,它们只是为了简化 Demo,共享同一个容器的网络命名空间和一张 GPU。
先下载与 vLLM v0.27.1 匹配的 toy Proxy。下载到宿主机的工作目录,随后通过目录挂载让容器读取:
|
|
创建并启动一个后台容器,三个服务进程稍后分别通过 nerdctl exec 启动:
|
|
确认容器可以看到 GPU:
|
|
接下来打开三个终端。每个终端先执行下面的命令进入同一个容器:
|
|
在第一个容器终端中执行:
|
|
在第二个容器终端中执行:
|
|
这里 Prefill 和 Decode 使用不同的 NIXL side channel 端口 5600 和 5601,避免两个进程在同一个容器的网络命名空间中发生端口冲突。
VLLM_NIXL_SIDE_CHANNEL_HOST使用127.0.0.1,是因为两个 vLLM 实例位于同一个容器内。
这两个命令会以前台进程运行,日志会直接显示在各自终端;命令末尾的 tee 还会把日志分别保存到 /work/prefill.log 和 /work/decode.log。
普通的 OpenAI-compatible Proxy 只负责转发请求,不会自动完成 PD 分离。这里使用 vLLM v0.27.1 仓库中与该版本匹配的 toy_proxy_server.py:它会先请求 Prefill,再把 Prefill 返回的 kv_transfer_params 交给 Decode。
在第三个容器终端中执行:
|
|
Proxy 通过 127.0.0.1:8100 和 127.0.0.1:8200 访问同一容器内的 Prefill、Decode,客户端则通过宿主机发布的 8192 端口访问 Proxy。
Proxy 启动后,可以先通过 /healthcheck 确认 Proxy 已正常启动,并识别到配置的 Prefill 和 Decode 实例:
|
|
确认 Prefill 和 Decode 都完成模型加载后,再发送完整请求。
|
|
不要分别手工请求 Prefill 和 Decode,也不要手工复制 kv_transfer_params。Proxy 会自动完成这几步:
max_tokens=1,并发送给 Prefill;从宿主机通过发布的 Proxy 端口发送请求:
|
|
成功时会返回 HTTP 200 和一个 completion 响应,通常包含:
|
|
最终响应中的 kv_transfer_params 为 null 是正常的,因为它是 Decode 生成并返回给客户端的最终响应。KV Transfer 元数据只在 Proxy 内部从 Prefill 传给 Decode。
HTTP 200 只能证明接口返回成功,还需要结合 Decode 日志确认 KV Cache 传输。按照本文命令启动时,三个服务的日志既会显示在终端,也会写入 /work。在第四个终端中执行:
|
|
Decode 至少应该出现类似日志:
|
|
其中 Decode 的 Num successful transfers=1 是判断 KV Cache 确实被拉取的关键证据。Prefill 和 Decode 的请求日志应该都返回 200 OK,Proxy 日志应该出现 POST /v1/completions ... 200 OK。NIXL compatibility check passed 只能说明两端配置兼容,不能单独证明本次请求已经完成 KV Cache 传输。
下面是完成一次推理请求后,从三个日志文件中筛选出的关键日志。查询命令的输出包含多次请求记录,下面保留与这次验证相关的关键行:
|
|
其中 KV Transfer metrics 的 Num successful transfers=1 是本次验证最关键的证据,表示 Decode 侧至少完成了一次 NIXL KV Cache 传输。Avg MB per transfer=0.188 表示本次平均传输了约 0.188 MB 的 KV Cache。Prefill、Decode 和 Proxy 的请求都返回了 200 OK,说明请求链路也完整走通。
停止并删除 Demo 容器:
|
|
前面的验证确认了请求确实经过了 Prefill、Decode。下面把一次请求拆开,分别看控制请求和 KV Cache 数据是如何流动的。
一次请求的执行流程如下:

客户端只需要发送一次普通的 OpenAI-compatible Completion 请求,例如设置 max_tokens=1024。客户端不需要知道 Prefill、Decode 的地址,也不需要自己构造 kv_transfer_params。
Proxy 为请求生成 request ID,同时保留客户端原始生成参数。发送给 Prefill 的请求会基于原始请求进行改写,后续再使用原始参数构造 Decode 请求。
Proxy 将请求发送给 Prefill 时,会把 max_tokens 改为 1,同时附加类似下面的 kv_transfer_params:
|
|
这表示当前实例负责 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 可以访问的地址。
Proxy 会把 Prefill 返回的第一个 Token 直接丢弃,然后取出其中的 kv_transfer_params,覆盖到原始请求上,并恢复客户端要求的 max_tokens。
发送给 Decode 的请求大致包含:
|
|
这里有两条不同的链路:
因此,Proxy 负责请求编排,Connector/NIXL 负责 KV Cache 传输,两者是不同的职责。单容器只让 HTTP 地址和 NIXL 地址都表现为回环地址,并没有把两个 vLLM 实例合并成一个 Engine。
Decode 收到请求后,根据 remote_engine_id、remote_request_id、remote_block_ids 等信息,通过 NIXL 拉取 Prefill 生成的 KV Cache。KV Cache 加载完成后,Decode 不再重复计算完整 Prompt,而是从已有上下文继续逐 Token 生成。
最终返回给客户端的是 Decode 的响应,Prefill 的临时 Token 不会出现在最终结果中。Decode 日志中的 NIXL compatibility check passed 和 Num successful transfers=1,分别说明传输能力协商成功,以及这次请求确实完成了 KV Cache 传输。
前面的 Demo 有意把环境压到了最小:只有一个 Prefill、一个 Decode,而且两个实例还运行在同一个容器和同一张 GPU 上。这样很适合观察请求和 KV Cache 的流向,但真正到了多实例、跨节点环境后,很多问题才会出现。
在真正的生产环境中,PD 分离还需要解决很多更复杂的问题,例如:
这篇文章先把 PD 分离最基础的链路跑通。到了多实例、跨节点环境后,关注点就会从“KV Cache 能不能传过去”,转向实例怎么调度、缓存怎么管理、失败怎么处理,以及拆分之后到底能不能改善实际的延迟指标。
本文通过一个基于 vLLM、NixlConnector 和 toy Proxy 的最小 Demo,把 PD 分离之后的请求执行流程和 KV Cache 数据流串了起来。
PD 分离之后,请求流程如下:
max_tokens=1,并发送给 Prefill 实例。kv_transfer_params,恢复原始的 max_tokens=N。其中,API Proxy 负责请求编排,Prefill 负责 Prompt 计算和 KV Cache 生成,Decode 负责拉取 KV Cache 并继续生成。一个容器降低了环境准备和网络配置的复杂度,但 Prefill、Decode 仍然是两个独立的 vLLM 实例,NixlConnector 仍然负责两者之间的 KV Cache 传输。