最近我在折腾 Agent 服务的性能优化,被一个看似不起眼、真实影响力很大的问题反复折磨:一次用户请求只要进入“思考 → 调用工具 → 拿结果 → 再思考”的多轮循环,整体等待时间就会被拉到一个很难看的水平。逐轮拆开看,每一轮 LLM 调用的 TTFT 都不算离谱,但叠加起来立刻超过线上可接受的上限。究其原因,是每一轮都在重复计算同样的前缀——大段的系统提示词、工具 schema、历史对话,在 vLLM 里每次都要从头走一遍 prefill。后来我把 KV cache 从“进程内复用”升级成了“双向 KV 传输”:上行从共享缓存池拉回已经算好的历史 KV 块,装配进引擎跳过重复计算;下行把本轮新增的 KV 块增量回写,供下一轮或其他实例复用。这套方案在多轮智能体负载下对 TTFT 的优化效果非常明显。这篇文章会把思路、原理、实现和踩坑记录完整讲一遍,适合正在做 Agent 服务延迟优化、或者想深入理解 vLLM KV cache 机制的开发者参考。
1. 为什么 Agent 多轮负载会让 TTFT 失控
1.1 先把 TTFT 这笔账算清楚
TTFT(Time To First Token)是指从请求提交到模型返回第一个 token 的时间。很多人把它简单理解成“prefill 有多快”,其实严格拆解应该分为四段:排队等待、调度准备、prefill 计算、结果传输与采样。真正的大头通常是 prefill,它的特点很残酷:输入里有 token,就该对这些 token 全部做一遍完整的 forward;输入越长,第一个 token 就来得越慢。decode 阶段每次只处理一个 token,看似很慢,但它并不决定首 token 的绝对时间。
可以给一个直觉化估算:假设单卡处理单个 token 的平均耗时在几十微秒到上百微秒之间,那么一次输入 2k token 的请求,光 prefill 就在百毫秒量级;输入涨到 5k token,时间基本线性往上翻。在线服务的预算常常卡在 TTFT 小于 500ms,但多轮 Agent 场景很容易突破这条线。严格点说,TTFT 可以近似写成:
TTFT ≈ T_queue + T_prefill(L),而 T_prefill(L) ≈ L × C / 算力。
这里的 L 是输入长度,C 是单 token 计算量。只要能把 L 中已经算过的部分去掉,TTFT 就会被压缩到只跟新增 token 相关。
1.2 多轮 Agent 负载的输入增长模式
普通多轮对话也是多轮,但 Agent 循环麻烦得多。每一轮不仅包含用户新说的话,还会把工具返回结果、系统状态、上轮模型输出统统拼进上下文。我用一个典型的单 Agent 任务举例:
- 系统提示词:约 800 token
- 工具定义(schema):约 700 token
- 上一轮工具返回结果:约 300 token
- 本轮模型输出:约 200 token
这些内容会以“新的用户消息”形式被追加到下一轮输入。于是每一轮的输入长度都在变长。假设初始输入 2k token,每一轮新增 400 token,跑到第 8 轮时输入已经接近 4.8k token。如果每轮都从头 prefill,8 轮累计 prefill 的 token 量大约在 27k token 左右——但真正包含新信息的内容连 5k 都不到。这是一个典型的重复劳动放大过程。
在实际实现里,工具结果还不一定规规矩矩追加在对话尾部,不少 Agent 框架会把工具输出当成一条“中间消息”插入到历史中间。这个细节很要命,它会直接打断 KV 缓存的前缀连续性,让原本可能命中的缓存全部失效。后面我会反复提到这一点,它是我这次优化里遇到的头号敌人。
1.3 vLLM 现有的 KV cache 复用机制为什么还不够
vLLM 有两个相关机制经常被提到:PagedAttention 和 prefix caching。PagedAttention 把 KV cache 按固定大小的 block 管理,解决显存碎片化问题;prefix caching 通过计算 block 的 hash 判断哪些前缀已经算过,命中后可以直接跳过相应 token 的 prefill。
但放到多轮 Agent 负载里,这套机制有三个明显让效率打折扣的地方:
命中粒度太依赖前缀连续。前缀中一旦插入“上一轮工具结果”这种可变内容,从插入位置往后全部失效。prefix caching 要求缓存复用的前提是从头开始的连续 token 完全一致,这跟 Agent 上下文多变的特点天然冲突。
KV 缓存的存活周期太短。默认按请求生命周期管理,请求结束就释放。如果一个 Agent 任务的多次 LLM 调用被负载均衡到了不同副本,第二个副本的 KV 缓存池是空的,只能重新 prefill,哪怕另一个副本刚刚算完同样的 4k 前缀。
没有把 KV 当成跨进程的资产。KV 在默认机制里只是显存里的临时数据块,不是可以传输、归档、跨实例复用的数据单元。要做多轮甚至跨会话复用,就必须在外部再建一个“KV 资产库”,让缓存可以在实例之间流进流出。
正是这三点,把我引向了双向 KV 传输这个方向。
2. 双向 KV 传输的整体设计思路
2.1 先给“双向”下个定义
“双向”指的是推理引擎和 KV 缓存池之间的两条数据通路。
上行路径:请求开始时,拿 prompt 的关键前缀去 KV 缓存池查询,把已经算好的历史 KV 块拉回来,装配进本地显存 block,直接跳过这段 token 的 prefill。上行解决的是“复用已有劳动成果”的问题。
下行路径:推理完成后,把本轮新增/变化的 KV 块按 paged chunk 的粒度增量写回缓存池,供下一次调用、其他副本或其他会话复用。下行解决的是“劳动成果入库”的问题。
可以类比成社区共享仓库:默认的 prefix caching 像每个人在家里存零食,只有在自己家才吃得到;双向 KV 传输像把零食放到社区仓库,出门也能取,吃完再把自己新买的放回去。KV 缓存的所有者、存放位置和生命周期都变了,“复用”从一次请求内的技巧变成了集群级的服务能力。
2.2 架构分层:推理层、缓存池层、调度层
整个方案分三层:
推理层:vLLM worker 实例。它只负责把当前输入完整算完,不再需要为历史轮次保留全套 KV 显存。一个长会话跑完,GPU 上只留当前活跃轮次需要的 KV,其他数据交给下层托管。
KV 缓存池层:独立服务。按块存储 KV 数据,记录每个块的 token 区间、模型属性、创建时间、版本号和访问热度。它既可以放在宿主机内存,也可以放 NVMe,甚至跨节点共享。它负责 KV 的索引、淘汰和双向传输。
调度层:决定每个请求应该挂载哪些缓存块、哪些块需要回写、要不要做传输压缩。调度层还负责整体路由,尽量把同一个 Agent 任务的请求导向本地已经缓存了对应 KV 块的副本,减少不必要网络传输。
要把 KV 池独立出来,而不是继续依赖进程内的 page pool,核心原因是复用范围不同。在一个 vLLM 实例内部,无论怎么做,缓存都只能服务这一个实例;Agent 负载往往有多副本、多实例、多会话重叠的系统,只有把 KV 池放到实例之外,缓存才能成为整个服务集群共享的公共资源。
2.3 这么设计到底能换回什么
首先,跨副本复用。同一任务的不同轮次落到不同 vLLM 副本,也能拿到历史 KV,不管前缀有多长,都不需要第二个副本重新算一遍。
其次,跨会话复用。不同用户请求共享同一份 system prompt 和 tools schema。即使这些用户的会话内容完全不同,固定前缀部分也能命中缓存。
最后,显存水位明显下降。以前长会话的 KV 必须焊死在 GPU 显存里,现在可以把不活跃的 KV 放到更廉价的内存或磁盘,GPU 显存留给真正正在跑的计算。这点在长上下文 Agent 任务里非常有用。
但也有两个边界必须提前说清楚:一是 KV 传输依赖网络,如果带宽很差,省下的 prefill 时间会被传输耗时吃回去;二是当前 vLLM 的原生 API 不直接支持把外部 KV 块注入引擎,真正落地需要改代码或加中间层,这部分我在第 4 章会给出最小原型的做法。
2.4 先算一笔账:到底能省多少
用一个 8 轮任务来估算:固定前缀 1.5k token,每轮新增 400 token。
不优化:每轮 prefill 的 token 分别是 2k、2.4k、2.8k、3.2k、3.6k、4k、4.4k、4.8k,累计约 27k token。 做双向 KV 传输:第一轮初始化时全量 prefill 2k,之后每轮只需要计算新增的 400 token,累计约 4.8k。
从 prefill 量级看,重复计算直接减到原来的 18% 左右。但 TTFT 下降不会跟 token 量完全同比例,因为每个轮次还要处理 KV 块拉取和装配。越靠后的轮次收益越大,因为每轮被复用的前缀越来越长,新增占比越来越低。我后面实验里的实际曲线也验证了这个趋势。
3. 核心实现细节与实操要点
3.1 KV 块的数据结构与块大小选择
KV 块是双向传输的最小单元。一个设计合理的 KV 块至少应该包含这些信息:
- token 区间:这个块覆盖输入序列中的哪一段
- 块内 token 数量:正常就是固定块大小
- 模型标识与 tokenizer 版本:防止不同模型或不同分词结果的 KV 混用
- 层范围与 KV head 配置:决定块内数据如何解析
- 量化方式:fp16 还是 int8/int4
- hash 值:用于前缀匹配
- 时间戳与版本号:用于淘汰和防止覆盖
块大小建议先选 128 token。块过小,索引条目暴增,网络包碎片化严重;块过大,hash 匹配太粗,即使只插入一个不相干 token,整个块也失效。用 GQA 模型举例:假设 8 个 KV head,每个 head 维度 128,fp16 存储,那么单 token 单层的 KV 大小是 2(K和V)× 8 × 128 × 2 字节,约 4KB。40 层模型就是约 160KB/token。128 token 的一个 KV 块大约是 20MB。
20MB 的块走高速网络传输已经不算便宜了,所以一定要配合异步回写和压缩,并且优先选择 GQA 类模型。如果是 MHA 模型,KV head 数量翻几倍,块体积会更大,传输成本离谱,这时候可以考虑先把 KV 量化成 8bit 再进池。
提示:KV 块属于“数据资产”,必须与模型标识绑定。不同模型之间,哪怕只是分词器版本不同,KV 都不能混用。做 hash 时要把这些属性一起纳入计算,否则会出现非常难查的缓存错配。
3.2 上行路径:查询、命中与装配
上行流程可以拆成四步:
- 请求到达后,用同一个 tokenizer 切分 prompt,按块大小切成候选块序列。
- 用前缀 token 序列的 hash 去缓存池逐块查询,找到连续命中的最大前缀。
- 把所有命中块按 block id 顺序从池中取出,放入 GPU 显存。
- 对未命中的后缀部分走正常 prefill。
这里有个关键技巧:务必先找最长连续前缀再取块。KV 是有前后依赖的,中间断一个块,后面的块即使存在也不能直接用,硬拼进去只会产生错误上下文。取块过程建议用双缓冲预取:当前请求还在读取第一批命中 KV 时,后台就已经根据任务历史把下一轮可能用到的 KV 块从池子预取到本地,减少等待时间。
上行装配最麻烦的是 paged block 映射。vLLM 内部每个 block 在显存中的物理位置是动态分配的,外部缓存池拿回来的 KV 必须重新映射到空闲物理块。我的做法是让缓存池返回“逻辑块序列”,引擎拿到后再统一映射,这样上层逻辑不用关心具体显存位置。
3.3 下行路径:增量回写与版本控制
推理结束后,不是把整个上下文 KV 全量写回,而是只写增量。每一轮真正新增的 KV 通常只有尾部一两个 block——本轮输入新增的后缀,加上本轮生成的 output token 对应的 KV。把这些 dirty block 收集起来,异步提交给缓存池。
异步回写是必须的。如果回写放在主路径上同步等待,TTFT 不仅不降,反而会变慢。推理引擎侧维护一个 dirty block 队列,后台线程负责批量打包、压缩、发送。发送前可以根据目标硬件判断是否做 KV 量化,比如长文本会话的缓存用 8bit 存储,关键任务需要高精度就保留 fp16。
版本控制是一个很容易被忽略但极其重要的点。Agent 场景下,两个并行请求完全可能共享同一个固定前缀的 KV 块,各自往后追加了不同的后缀。如果不加版本控制,后回写的块可能覆盖前面的块,导致后续请求拿到错误上下文。解决办法是给 KV 块加“序列号 + 引用计数”,回写采用 append 语义而不是 overwrite,读取时按版本链校验上下文一致性。
3.4 调度与淘汰:让缓存留在最该留的地方
KV 缓存池的容量必须规划。我一般会划分成三个区域:
永久区:system prompt 和 tools schema 对应的 KV 块。这部分几乎每个 Agent 请求都会用到,永不淘汰。即使长期没人访问,也值得保留,因为只要来一个新请求,它就立刻命中。
会话区:某一次 Agent 任务的动态 KV 块,按会话活跃度和最近访问时间淘汰。这个区需要加一层“滑动窗口保护”:在 Agent 循环活跃窗口内的 KV 块,即使 LRU 显示它是最久未用的,也不能淘汰,因为这些块很可能在下一轮马上被用到。
冷区:落到 NVMe 或远端存储的 KV 块,用于跨实例兜底。这个区访问速度慢,但总比完全丢失重新 prefill 要强。
调度层还应该做一致性会话路由。同一个 Agent 任务的所有轮次,优先路由到本地已经缓存了该任务 KV 块的副本。一旦路由被打散到多个副本,KV 池虽然能兜底,但每轮都要跨副本传输 20MB 甚至更大的块,整体收益会被吃掉很多。
3.5 配置参数方向
不需要照搬什么模板,但可以给出几个关键旋钮:
- 传输块大小:128 或 256 token,按网络 MTU 与模型层数调整。模型层数少可以适当调大,层数多就保持 128。
- 回写队列深度:建议至少从 16 个 block 起步。队列太浅,后台线程来不及消化,会产生连锁阻塞;太深则会占用较多内存。
- KV 量化精度:8bit 用于长文本缓存是稳妥的;4bit 要慎用于 Agent 工具调用场景,小模型尤其容易出问题。
- 缓存池水位线:宿主内存按 70% 设上限,超过后低优先级 KV 先落盘。不建议把上限设到 90% 以上,连系统本身的内存都容易被挤爆。
这些配置没有万能值。最好的做法是先压测你自己的典型负载,尝试不同块大小和队列深度,观察命中率与传输耗时之间的平衡,再确定最终的参数组合。
4. 实操过程与踩坑实录
4.1 最小复现链路
我建议不要一上来就铺一个大而全的分布式 KV 池,先在一台机器上搭最小原型。我的实验环境是单机双进程:一个进程跑 vLLM 推理,另一个进程跑 KV 缓存池,底层用共享内存加落盘实现。模拟 Agent 的脚本核心逻辑,可以简化成下面这段伪代码:
# 简易伪代码:演示双向 KV 缓存的调用顺序 cache_key = compute_prefix_hash(system_tokens + tools_tokens) hit_blocks = kv_cache_pool.fetch(cache_key) # 上行:拉取历史 KV 块 resp = llm_engine.generate( full_prompt_tokens, mount_kv_blocks=hit_blocks, # 装配复用,跳过命中前缀 skip_prefilled_tokens=hit_prefix_len ) new_blocks = collect_incremental_kv(resp) # 只收集本轮新增 KV 块 kv_cache_pool.append( request_id=cache_key, blocks=new_blocks, version=current_version ) # 下行:异步增量回写注意这只是协议层面的示意图。真正要在 vLLM 里实现,需要从 EngineCore 的 block manager 接口下手,把外部 KV 块“挂”到新请求上,并让 prefill 跳过已经挂载的 token。这个改动不算小,动手前先花时间找到 block_manager 和 cache_config 之间的调用关系,否则容易改成一团乱麻。
我自己的实现方式是加了一个外部“KV 挂载层”,在不改动 vLLM 核心逻辑的前提下,把原本需要 prefill 的 token 段标注为“外部缓存命中”,引擎只跑后缀未命中的部分。相当于自己封装了一套细粒度的 prefix cache,只是数据不放在显存常驻区,而是统一走 KV 池。
4.2 实测数据记录
在单卡 A100 40G、8B GQA 模型、模拟 Agent 负载 8 轮的环境下,我记录了这样一组数据。需要提前说明,这是我自己做压测得到的结果,不是任何官方基准,但趋势和结论是可靠的。
| 轮次 | 输入长度(token) | 优化前 TTFT(ms) | 优化后 TTFT(ms) | 降幅 |
|---|---|---|---|---|
| 1 | 2000 | 183 | 182 | 0% |
| 2 | 2400 | 215 | 81 | -62% |
| 3 | 2800 | 242 | 78 | -68% |
| 4 | 3200 | 281 | 75 | -73% |
| 5 | 3600 | 315 | 77 | -76% |
| 6 | 4000 | 348 | 74 | -79% |
| 7 | 4400 | 382 | 76 | -80% |
| 8 | 4800 | 421 | 72 | -83% |
第一轮两边几乎没差别,因为没有任何历史 KV 可以复用。从第二轮开始,优化后的 TTFT 基本稳定在 75ms 上下。原因很直观:复用的前缀越来越长,真正需要 prefill 的只有当轮新增的 400 token 左右,剩下的时间主要是调度、装配和首个输出 token 的固定成本。
显存方面的变化也很明显。原来一个 8 轮任务跑完,本地要保留近乎全量的历史 KV;开启 KV 池并允许冷数据落到 CPU 内存后,GPU 显存只保留当前活跃轮次需要的 KV,剩余数据全部挪到池里,显存峰值明显下降。
4.3 踩过的坑
踩坑记录是这次优化里最有价值的部分,我挑五个最典型的讲。
第一个坑:序列化开销没算进去,导致优化后反而更慢。最开始我同步回写整块 KV,一次回写 20MB 甚至更多,网络和序列化时间把省下来的 prefill 时间全吃回去了。后来改成异步队列批量回写,把回写拆到后台线程,结果才恢复正常。
第二个坑:哈希粒度太粗,一次工具结果插入让整个前缀缓存全废。因为我一开始用整段 prompt 做一次 hash,只要中间插入一条工具结果,从插入点开始后面的 hash 全部变化,缓存命中率直接从 80% 掉到个位数。后来改为按 block 粒度哈希,并且从调用侧把 system 和 tools schema 固定在输入最前面,工具结果一律追加到对话尾部,命中率才稳定下来。
第三个坑:KV 量化后工具调用开始出错。小模型上尤其明显,用 int8 还勉强能看,int4 会让长链推理的判断质量明显下降。现在我的处理是只对不敏感层的 KV 做量化,工具调用密集的请求输入部分保留 fp16,避免为了省点带宽把结果搞坏。
第四个坑:多副本负载均衡导致命中率忽高忽低。同一个 Agent 任务的轮次被随机分发到不同 vLLM 副本,KV 池虽然能兜底,但每次跨副本传输 20MB 块也扛不住。后来在调度层加了会话级一致性路由,同一任务的轮次尽量固定到同一副本,命中率才稳定在 85% 以上。
第五个坑:并发回写覆盖。两个并行任务共享同一个固定前缀,后完成的任务回写时覆盖了先完成任务追加的 KV 块,后续请求读到的上下文就是乱的。修复方式很简单但必须做:给每个 KV 块加版本号和引用计数,所有回写统一使用 append 语义加版本校验,而不是直接覆盖旧块。
5. 常见问题排查速查表
把我在实验中遇到过的问题整理成速查表,以后遇到类似现象可以直接对照排查。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| TTFT 不降反升 | 同步回写阻塞,或块大小太小导致网络包碎片化 | 改异步回写与批量传输,尝试把块大小调到 128/256 token |
| 命中率始终接近 0 | 可变内容插入了固定前缀,hash 连线被切断 | 将 system 和 tools schema 前移,工具结果一律追加在对话尾部 |
| 使用 KV 池后回复质量下降 | KV 量化精度不足或量化范围过大 | 切换 8bit,或对工具调用密集请求关闭量化,排除敏感层 |
| 多副本环境命中率波动严重 | 会话被负载均衡打散,缓存池热数据分布不均 | 增加会话级一致性路由,同一任务尽量固定实例 |
| 显存依然紧张 | 缓存池容量配置过高,活跃会话太多,KV 无限保留 | 压缩会话区窗口,启用冷数据落盘,设置水位线 |
| 工具结果对不上 | 并行回写时版本错乱,错误 KV 块被复用 | 为 KV 块增加版本号与引用计数,所有回写使用 append 语义 |
再补充几条实操经验:
- 先固定输入构造,再谈缓存。KV 复用最怕输入结构不规律。有些 Agent 框架喜欢把工具结果塞进历史中间,这是 KV 缓存命中率的头号杀手。从调用方代码入手统一输入拼接规则,比在推理侧做一万种缓存兼容方案都管用。
- 评估时不要看第一轮。第一轮没有历史 KV 可用,收益必然为零。真正要看的是任务后半段的 TTFT 曲线是否趋平,以及整条链路的累计延迟。
- 用任务级报告而不是单请求报告来评估。多轮 Agent 的总延迟才是用户感知,单次 TTFT 下降 30% 但轮数翻倍,整体收益还要重新算。
- 时刻盯着网络带宽。40 层模型的 128 token 块接近 20MB,在万兆网下每秒能传的块数量有限。如果多个请求同时回写,网络会瞬间变成瓶颈。
我个人这轮折腾下来最大的感受是:KV cache 优化本质上不是显存管理问题,而是数据资产流动问题。默认机制把 KV 当成进程私有的临时内存,请求结束后直接丢弃;多轮 Agent 负载恰恰是高频次、短间隔地复用同一批历史计算成果,所以把 KV 池独立出来、做双向传输,能带来质的改变。另外建议不要一上来就上全套分布式 KV 池,先在一台机器上用共享内存把链路跑通,用你自己的 Agent 负载把收益和问题测出来,再谈扩展。后续如果条件允许,还可以在双向传输基础上叠加按页的高速直传和投机解码。我最近也在继续往这个方向实验,效果值得期待。