利用 vLLM 中的双向 KV 传输优化多轮 Agent 工作负载的 TTFT
双向 KV 传输让 vLLM 的 prefill 节点在后续对话轮次里复用 decode 节点先前算好的 KV,省掉重复的 prefill 重算,长多轮提示下 TTFT 最多降低 3.3 倍。
中文
复制

多轮 Agent 工作负载建立在不断累积的上下文之上:Agent 进行推理、调用工具,并将先前的结果纳入其中。每开启一轮新对话,客户端都会重新发送整段对话历史,包括系统指令、此前的提示、工具返回结果以及模型回复,导致不断增长的上下文被反复处理。在 Prefill-decode(P-D)分离架构中,每个请求被拆分到不同职责的节点上:prefill 节点处理提示并构建其 KV cache,随后 decode 节点取回这些 KV 块并生成回复。
然而,传统的分离式服务只在一个方向上传输 KV cache:从 prefill 节点到 decode 节点。这种单向流动给多轮工作负载带来两处低效。其一,生成回复后,decode 节点同时持有提示和新生成 token 的 KV cache 条目。处理下一轮时,prefill 节点无法取回这些条目,只能重新计算上一轮回复 token 的 KV 投影。其二,会话状态由 decode 节点维护,prefill 节点则不维护,这使得 prefill 节点上活跃会话的 cache 被淘汰的概率更高。一旦 cache 完全未命中,prefill 节点就必须重新计算整段对话历史的 KV 投影,包括系统指令、此前的用户请求、工具返回结果以及模型生成的回复。随着对话变长,这些重复计算占用越来越多的加速器资源,并推高首 token 延迟(TTFT)。
对于构建对话助手和 Agent 应用的客户而言,交互历史越长,延迟越高,重复的 prefill 计算也会持续消耗加速器资源。本文将展示双向 KV cache 传输如何让 prefill 节点和 decode 节点跨轮次复用此前已计算的上下文,从而提升加速器利用率,帮助应用在对话不断增长时仍保持稳定的 TTFT。
在我们基于 Amazon EC2 P5en 实例、通过 AWS Elastic Fabric Adapter(AWS EFA,一种用于 AWS 服务器间通信的低延迟 RDMA 网络)互联的 1P1D 部署上开展多轮基准测试时,双向 KV 传输将 Qwen3-32B 的 TTFT 最多降低 1.6 倍,将 zai-org/GLM-5-FP8 的 TTFT 最多降低 3.3 倍(参见性能数据一节);具体结果取决于工作负载和配置。
我们最初在通过 EFA 互联的 AWS Trainium 实例集群上构建并验证了这一特性,并将其作为与加速器无关的能力贡献给 vLLM。本文给出的结果来自配备 EFA 的 Amazon EC2 P5en GPU 集群,表明这些收益同样适用于 GPU。本文将介绍双向传输架构、router 元数据流转、KV block 保留与重算控制、性能结果,以及如何复现该基准测试。
双向 KV 传输
双向 KV 传输是一种机制,允许 decode 节点在对话的后续轮次中把此前计算出的 KV 返回给 prefill 节点,从而避免 prefill 节点重复计算共享上下文,并由可配置的 KV 重算阈值加以控制。它同时缓解了前文描述的两种计算冗余场景,而要实现这一点,KV 必须在其产生的那一轮之后继续存活,并沿反方向传输,由具备 KV cache 感知能力的 router 协调完成。
面向双向传输的 NIXL KV connector 增强
我们扩展了 NVIDIA Inference Xfer Library(NIXL)的 KV connector,让 prefill 节点可以消费远端 KV block,这与 decode 节点消费 prefill 节点提供的 KV block 的方式类似。这不是一处简单的改动,因为两类节点在架构上并不相同:prefill 节点为新 prompt token 计算 KV 表示,能够提供完整的 prompt KV block。decode 节点则不具备生成完整 KV 表示的能力(或效率),只能提供此前已缓存的 KV block。因此,prefill 和 decode 节点仍然各自保留自己的角色,以及计算远端和本地 KV block 元数据的逻辑。不过,实际的 KV 传输机制(一次 NIXL READ)在 P->D 和 D->P 两个方向上都是通用的。
下图首先用蓝色展示了现有流程:router 将请求发送到 prefill node,prefill node 计算 prompt KV 并返回其 block metadata,随后 router 将请求和 metadata 转发给 decode node,执行标准的 P→D NIXL READ。带编号的红色路径则展示了双向 KV 传输新增的部分:(1) decode node 在生成后保留符合条件的 KV block,(2) 在流式响应中返回其 metadata,(3) router 缓存该 metadata,供下一次 prefill 请求使用。之后 prefill node (4) 在应用本地 cache 匹配后检查远端可复用的 KV,(5) 当可复用 token 数达到阈值时排队一次异步接收,(6) 在计算剩余 suffix 之前执行 D→P NIXL READ。

KV-cache 感知路由器
vLLM 在连接器侧支持双向 KV 传输,但要在多轮对话之间协调 KV 复用,还需要一个 KV-cache 感知路由器。vLLM 的 GitHub 仓库提供了一个示例代理,实现了本文所述的流程,可根据你的环境进行调整。
KV-cache 感知路由器是位于客户端、prefill 节点和 decode 节点之间的一个有状态轻量控制面组件。它在内存中维护一份映射,从客户端提供的 conversation_id 指向 decode 的 kv_transfer_params。路由器只保存块位置元数据,不保存 KV tensor,并且这些元数据来自响应流,而不是另外向 decode 节点查询。
下图展示了路由器如何在多轮对话之间管理 KV 元数据。第一轮时缓存未命中,请求走标准的 P→D 流程;随后路由器从响应流中保存 decode 的元数据。之后的轮次中,路由器用 conversation_id 取回该元数据,使 prefill 能够复用 decode 的 KV,并在生成结束后刷新元数据,供下一轮使用。

Block 保留 TTL
通常,decode 实例会在生成结束后立即释放请求的 block。在双向模式下,它会将这些 block 保留一段可配置的时间(decoder_kv_blocks_ttl,默认 480 秒),并发布其位置和过期时间,因此对话的 KV——无论是 prompt 前缀还是生成的回复——都能保留下来供下一轮使用。如果下一轮在 TTL 到期前到达,prefill 节点会复用这些 KV;如果到达得更晚,则回退到普通 prefill。由于该 TTL 固定且不会续期,应根据预期的轮次间隔来调整它,以在 KV 复用与加速器内存占用之间取得平衡。一个自然的改进方向是把心跳续期的 lease 扩展到 decode 侧,让 decode 实例在对话空闲时立即回收 block,而不是等到固定计时器结束。
KV 重算阈值
KV 复用并不总是比本地重算更快。对于较短的可复用前缀,互联与协调开销可能超过重算这些 token 所需的时间。kv_recompute_threshold 设置控制这一决策,默认值为 64 个 token。当需要计算的 token 数低于阈值时,prefill 节点会重算;达到或超过阈值时,则从 decode 节点拉取 KV。部署时可以根据模型计算时间、KV 大小和互联性能来调整该值。
性能数据
评估中,我们在 1P1D 部署上使用了 Qwen3-32B 和 GLM-5-FP8 模型,实例为通过 EFA 互联的 Amazon EC2 P5en。为了模拟 prefill 节点已驱逐本地缓存、或路由器选择了另一个没有该对话 KV block 的 prefill 节点的场景,我们通过 --no-enable-prefix-caching 关闭了 prefill 节点上的前缀缓存。
在两个模型上,开启双向 KV 传输都能减少多轮对话中的 prefill 计算量,且收益通常随 prompt 长度增长。关闭该功能时,prefill 实例每轮都要重算整个增长的上下文,TTFT 急剧上升:Qwen3-32B 从 2k 时的 158 ms 升至 24k 时的 778 ms,GLM-5-FP8 从 2k 时的 500 ms 升至 20k 时的 2,334 ms。开启后,decode 实例保留的 KV 被复用而非重算,TTFT 曲线平坦得多:Qwen3-32B 在同一区间内仅从 122 ms 升至 460 ms(最长 prompt 下约降低 1.6 倍),GLM-5-FP8 则从 358 ms 升至 702 ms,在长 prompt 下比重算路径低至多 3.3 倍。prefill 耗时的拆解说明了原因。
双向传输会增加少量 KV 传输时间(Qwen3-32B 约为 30 ms 到 130 ms;GLM-5-FP8 约为 29 ms 到 110 ms),但它省下的 prefill 计算量远超这部分开销。因此,开启传输后的总 prefill 时长(Qwen3-32B:128 ms 到 414 ms;GLM-5-FP8:316 ms 到 541 ms)仍远低于纯重算路径(Qwen3-32B:153 ms 到 744 ms;GLM-5-FP8:500 ms 到 2,331 ms)。GLM-5-FP8 模型更大,绝对差距和相对差距都更明显:它每个 token 的重算时间更长,因此随着模型和上下文规模增长,KV 复用的收益更大。
Qwen3-32B
下图展示了 Qwen3-32B 的 TTFT 随 prompt 长度的变化,对比了双向 KV 传输开启与关闭两种情况。

下一张图表将同一批运行的预填充时长拆分为重计算和 KV 传输两部分。

两张图放在一起看:开启该功能后,TTFT 几乎持平;关闭时则随着上下文增长迅速攀升,因为省下的重算时间远远超过那点 KV 传输开销。
zai-org/GLM-5-FP8
GLM-5-FP8 同样给出这两个视角。第一张图是开启与关闭该功能时 TTFT 随 prompt 长度的变化。

第二个将 prefill 时长拆分为重计算和 KV 传输两部分。

GLM-5-FP8 的差距远大于 Qwen3-32B:在长 prompt 下,其 TTFT 最多低约 3.3 倍。原因在于它单 token 重算的耗时更高,因此上下文越长,KV 复用带来的收益就越明显。
如何运行 benchmark
如需复现我们的 1P1D benchmark 配置——一个 prefill 节点加一个 decode 节点,使用带 EFA 的 Amazon EC2 P5en 实例——请按照下方复现我们的结果指南中的分步说明操作。该指南涵盖环境搭建、启动 vLLM prefill 与 decode 服务、运行 benchmark 以及收集结果。该特性也可在其他受支持的配置上运行,但性能结果会因硬件、模型、负载和网络配置而异。
结论
本文展示了双向 KV cache 传输如何在 agent 对话历史增长时降低 TTFT。在 P-D 分离部署中,该特性让 prefill 节点能够跨轮次复用 decode 节点保留的 KV block,从而减少重复计算。可配置的 block 保留阈值与重算阈值决定何时发生复用。在使用 EFA 互联的 Amazon EC2 P5en 实例构建的 1P1D 部署上,在测试的最长 prompt 长度下,我们观察到 Qwen3-32B 的 TTFT 降低 1.6 倍,zai-org/GLM-5-FP8 降低 3.3 倍;具体结果取决于负载与部署配置。要在你的环境中实现这一点,请使用 vLLM v0.21.0 或更高版本并配合 NixlConnector,在 prefill 和 decode 服务上均启用 bidirectional_kv_xfer,使用支持 KV cache 感知的路由器,并参照复现指南。
相关资源
致谢
感谢 Nicolò Lucchesi(Mistral)、Mark McLoughlin(Red Hat)以及 vLLM 社区在维护该特性方面给予的支持。
附录:复现我们的结果
拓扑为两台 AWS p5en 实例(prefill 在 :8100,decode 在 :8200)并启用 EFA,另有一台小型节点在 :8000 上运行代理和 benchmark 客户端。
这篇文章附带三个脚本:serve_pd_multiturn.sh 用于在开启或关闭该功能的情况下启动任一 leg,run_multiturn_sweep.sh 用于运行 prompt 长度扫描,cleanup.sh 用于拆除部署。
测试环境
两个 GPU 节点的配置完全一致:
| 实例类型 | p5en.48xlarge(8 张 NVIDIA H200),一个用于 prefill,一个用于 decode |
| 区域 / AZ | ap-south-1(孟买),ap-south-1b(AZ ID aps1-az3),两个节点位于同一 AZ |
| 置放 | 未使用集群置放群组;两个实例均启动在定向的 On-Demand Capacity Reservation 中,采用默认 tenancy |
| AMI | 面向 Ubuntu 24.04 x86_64 的公共 AWS ParallelCluster 3.14.2 镜像,aws-parallelcluster-3.14.2-ubuntu-2404-lts-hvm-x86_64-202602121713(ami-0133f195033a4baae 中的 ap-south-1;AMI ID 因区域而异)。该镜像自带内核 6.14.0-1018-aws、NVIDIA 驱动 570 和 CUDA 12.8,因此需要将驱动、CUDA toolkit 和 EFA installer 升级到下面列出的版本。 |
| 操作系统 / 内核 | Ubuntu 24.04.4 LTS,6.14.0-1018-aws |
| NVIDIA 驱动 / CUDA | 驱动 580.126.16(CUDA 13.0 driver API);CUDA toolkit 13.0 |
| EFA 接口 | 每个实例 16 个,每张网卡一个(网卡 0-15,接口类型均为 efa),每个 200 Gb/s,合计 3.2 Tb/s。每个 NUMA 节点上有 8 个接口,每张 GPU 与其中 2 个接口共用同一个 PCIe switch。 |
| EFA 软件 | EFA installer 1.50.0,EFA 内核模块 3.3.0g,libfabric 2.6.0amzn1.0,rdma-core 64.0amzn0 |
NIXL 如何选择 EFA 设备。 LIBFABRIC 后端使用 efa provider 将全部 16 个 EFA 设备作为 rail 打开,然后只在与某张 GPU 共用 PCIe switch 的那 2 条 rail 上注册该 GPU 的 KV cache,因此每次传输都留在 PCIe 本地。没有设置任何 NIXL 或 libfabric 设备覆盖项;这就是默认选择。要在自己的节点上确认这一点,用 NIXL_LOG_LEVEL=DEBUG 启动服务端,然后查看注册日志:
grep -E "Created rail [0-9]+ \(device=|mapped to rail" server.log
1. 准备节点
在两个 GPU 节点上安装 EFA 用户态软件栈,并确认 provider 存在。我们使用的是 installer 1.50.0:
curl -O https://efa-installer.amazonaws.com/aws-efa-installer-1.50.0.tar.gz
tar -xf aws-efa-installer-1.50.0.tar.gz && cd aws-efa-installer
sudo ./efa_installer.sh -y
fi_info --version # libfabric 2.6.0amzn1.0
fi_info -p efa -t FI_EP_RDM # verify the EFA provider is present on both GPU nodes
2. 在两个 GPU 节点上安装 vLLM 和 NIXL
python3 -m venv ~/venv-pd && source ~/venv-pd/bin/activate
pip install --upgrade pip && pip install vllm==0.29.0 nixl==1.4.1
在两个节点上各应用一次 vLLM PR #40186。它会把 kv_transfer_params 附加到流式响应上,而多轮代理正是靠这一点将各轮串联起来:代理始终用 stream=True 查询 decode 节点,所以不打这个补丁,decode 节点永远不会返回 block 元数据,每一轮都会变成 cache miss。
SITE=$(python -c 'import site; print(site.getsitepackages()[0])')
curl -sL https://github.com/vllm-project/vllm/pull/40186.diff -o /tmp/40186.diff
patch -p1 -d "$SITE" --fuzz=3 < /tmp/40186.diff
3. 下载模型
export HF_HOME=/fsx/huggingface # a large shared volume
hf download Qwen/Qwen3-32B
4. 启动 P/D 实例对
在每个 GPU 节点上运行一次 serve_pd_multiturn.sh。A/B 两组之间唯一变化的就是 BIDIR,且两个节点上必须保持一致:
# on the prefill node
P_IP=<P_IP> D_IP=<D_IP> ROLE=prefill BIDIR=false ./serve_pd_multiturn.sh
# on the decode node
P_IP=<P_IP> D_IP=<D_IP> ROLE=decode BIDIR=false ./serve_pd_multiturn.sh
在 prefill 侧展开后为:
export FI_PROVIDER=efa
export VLLM_NIXL_SIDE_CHANNEL_HOST=<P_IP> VLLM_NIXL_SIDE_CHANNEL_PORT=5600
vllm serve Qwen/Qwen3-32B --served-model-name Qwen --dtype bfloat16 \
--tensor-parallel-size 8 --max-model-len 32768 --max-num-seqs 8 \
--no-enable-prefix-caching --port 8100 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_buffer_device":"cuda",
"kv_role":"kv_producer","engine_id":"prefill-engine-001","kv_rank":0,
"kv_parallel_size":2,"kv_connector_extra_config":{"backends":["LIBFABRIC"],
"kv_recompute_threshold":64,"decoder_kv_blocks_ttl":480,
"bidirectional_kv_xfer":false}}'
--no-enable-prefix-caching 只在 prefill 节点上设置——这正是基准测试模拟的缓存被驱逐场景。decode 节点上保持 prefix caching 开启。等待两个 /health 端点都返回 200。
5. 启动多轮代理
python3 -m venv ~/venv-proxy && source ~/venv-proxy/bin/activate
pip install fastapi uvicorn httpx pandas numpy aiohttp transformers tqdm
sudo dnf install -y jq || sudo apt-get install -y jq
git clone --depth 1 --branch v0.29.0 https://github.com/vllm-project/vllm.git ~/vllm_src
export VLLM_SRC=~/vllm_src
python3 $VLLM_SRC/examples/disaggregated/disaggregated_serving/disagg_proxy_multiturn.py \
--port 8000 --prefiller-host <P_IP> --prefiller-ports 8100 \
--decoder-hosts <D_IP> --decoder-ports 8200 \
2>&1 | tee ~/proxy_$(date +%Y%m%d_%H%M%S).log
代理接收的是 host 和 port,不是 URL。复用由客户端发送 conversation_id 触发,所以这里没有开关需要打开。
6. 先跑 OFF 组,再跑 ON 组
VLLM_SRC=~/vllm_src LEG=off ./run_multiturn_sweep.sh
跑 ON 组时,先停掉两个 server 和代理,在每个节点上用 BIDIR=true 重新执行第 4 步,重启代理(它的对话缓存是进程内的),然后:
VLLM_SRC=~/vllm_src LEG=on ./run_multiturn_sweep.sh
对 GLM-5-FP8 用 MODEL=zai-org/GLM-5-FP8 SERVED_NAME=GLM 把两组各重复一遍。
7. 收集数据
三个量,各来自一个来源:
- TTFT,来自
--stats-json-output写出的基准测试逐请求统计 JSON。每条记录在input_num_tokens和conversation_id之外还带有ttft_ms。这是 TTFT 图表的 y 轴。 - Prefill 耗时,来自代理日志,它把每个请求的 prefill 段记为
Prefill done in <N>ms。这是 prefill 耗时拆解图中每根柱子的总高度。 - KV 传输时间和传输量,来自 prefill 节点的日志,其中会输出
KV Transfer metrics:行,包含平均和 P90 传输时间、每次传输的字节数以及吞吐。这是 ON 组每根柱子中的 KV-xfer 部分。
NIXL 遥测由发起传输的一方记录,所以 D->P 回读出现在 prefill 节点的日志里,而普通的 P->D 拉取出现在 decode 节点的日志里。
按 prompt 长度对每个量取平均,然后绘图:TTFT 对 prompt 长度画两条线(ON 和 OFF),prefill 耗时按 prompt 长度画成成对的堆叠柱,其中 ON 柱拆分为 prefill 计算和 KV 传输。两个视角之间的差异才是重点——柱状图暴露出来的传输开销,正是 TTFT 曲线所转化成的节省。
故障排查
- ON 运行中代理始终记录
cache MISS—— 缺少--send-conversation-id,PR #40186 未在两个节点上应用,或BIDIR=false遗留在 decode 节点上,此时它完全不会返回kv_transfer_params。 - prefill 节点上出现
Declining expired remote read—— decode 节点的 block 在下一轮请求到达前已失效。调大decoder_kv_blocks_ttl(默认 480 秒),并让代理的缓存 TTL 保持在其之下。 fi_info -p efa为空或回退到 socket —— 缺少 EFA provider。重新安装aws-efa-installer,并确认实例启动时启用了 EFA 接口;否则 NIXL 要么初始化失败,要么悄悄走 TCP,传输数据也就失去了意义。
清理
./cleanup.sh proxy # on the proxy / benchmark driver node
./cleanup.sh server # on each GPU node
然后终止实例,确认没有实例仍在运行。