在开始之前,先说个背景。我最近在一家做模型推理服务的公司帮忙优化成本,老板盯着账单上的 GPU 费用看了半天,问我为什么同样一批请求,集群规模翻了一倍,吞吐量却只涨了三成。我排查了几天,最后结论就一句话:Cache Miss 太高,而且不是模型的问题,是调度和缓存策略的问题。今天这篇就围绕“大规模推理集群里 KV Cache 复用困难”这件事来写,顺便聊聊我为什么最终选择用 SageMaker 把这个问题绕过去,以及具体是怎么绕的。
KV Cache 这个词,用过生成式模型做在线推理的人应该都很熟。它本质上就是 Transformer 在解码阶段为了避免重复计算而缓存下来的 Key 和 Value 张量。理论上,同一段前缀序列如果能被不同请求复用,缓存命中率越高,推理吞吐就越高,延迟就越低。但理想很丰满,现实里在大规模集群上把 KV Cache 复用做起来,坑比想象中多得多。
先说我遇到的具体场景。一个多租户的文本生成服务,输入是几十个固定模板,用户请求基本都是在模板基础上改几个变量,Prompt 前缀高度重叠。这种业务天然适合做 Prefix Caching。我一开始的方案是自建推理网关,配合开源推理框架的 Prefix Cacher 来做,结果上线后发现命中率远低于预期,碎片化严重,跨节点复用基本为零。后来换到 SageMaker 上,事情反而简单了,不是因为它自带什么黑魔法缓存,而是它的部署形态和调度逻辑把缓存复用的前置条件给理顺了。
这篇文章我不讲那些营销话术,就讲实际拆解。核心会覆盖几个部分:KV Cache 复用为什么在大规模集群里这么难,SageMaker 的部署和调度到底解决了其中的哪些环节,以及如果你想在自己环境里复刻这套思路,应该怎么设计和落地。最后附上我踩过的几个坑,算是一个排障速查表。
1. KV Cache 复用的核心逻辑与大规模集群里的实际困境
1.1 KV Cache 到底在缓存什么,为什么会省算力
KV Cache 的适用前提是自回归生成。每生成一个 token,模型都要拿当前 token 的 Query 向量去和前面所有 token 的 Key 向量做 Attention,然后拿权重和 Value 向量加权求和。如果没有 Cache,每一步都要把前面所有 token 的 Key 和 Value 重新算一遍,相当于每生成一个 token 都要把整个输入序列重新前向传播一次,算力开销是序列长度的平方级。
有了 KV Cache,生成第 N 个 token 时,只需要计算当前这个 token 对前 N-1 个 token 的 Attention,不需要重新计算前 N-1 个 token 的 Key 和 Value。这部分的计算量从 O(N²) 降到了 O(N)。更关键的是,如果很多请求的前缀文本是相同的,那这些请求在前缀部分的 KV Cache 结果完全可以共享,新请求只需要在共享前缀的 Cache 基础上继续生成后面的内容就行,这一块的边际成本几乎为零。
1.2 理想复用与实际复用的落差
如果我把同一个系统提示词发给 1 万个请求,理想情况下,这一万个请求的公共前缀 KV Cache 只需要计算一次。但实际跑起来,你会发现缓存是碎片化的:一部分命中在节点 A,一部分命中在节点 B,还有一部分因为前缀长度参差不齐没法对齐前缀边界,直接 miss。
造成这种碎片化的原因有几个。第一,Prompt 内容虽然前缀相同,但不同请求的 stop token、max tokens 各不相同,导致解码路径不一样,KV Cache 的后续状态天然分叉。第二,LLM 的 Batch 调度如果按请求到达时间组织,前缀相同的请求未必被分到同一个 Batch 里,缓存就难以复用。第三,KV Cache 的存储和寻址方式如果不够高效,命中也未必能省时间,因为从内存里把几十 MB 的 Cache 找出来可能要几十毫秒,而模型每步生成才几毫秒,得不偿失。
1.3 大规模集群特有的三个放大器
单机部署时 KV Cache 复用问题不算严重,请求少,内存寻址也快。一旦到了多机多卡的大规模集群,问题会被放大成三个层面。
第一个是跨节点共享问题。两张卡上的显存物理隔离,A 节点的 Cache 没法直接给 B 节点的请求用。要让 Cache 跨节点复用,就只能做两件事:要么把请求路由到 Cache 所在的节点,这要求有一条全局路由表;要么把 Cache 序列化后在集群内广播,这要求一个高效的分布式缓存组件。自建的时候这两件事的开发量都不小。
第二个是内存容量和替换策略问题。KV Cache 占据的显存会随着序列长度线性增长。在长序列场景下,一个并发请求的 Cache 可能占用几十 GB 显存。集群里所有节点的 Cache 加在一起,总量非常大,谁来记录这些 Cache 的存活状态、谁来决定哪些 Cache 可以被淘汰,单靠应用层的哈希表根本扛不住。
第三个是 GPU 利用率与调度耦合问题。推理集群里 GPU 的利用率取决于 Batch Size 的大小。Batch 越大,单卡算力利用越满,但对 Cache 复用来说,Batch 越大意味着缓存对齐越困难。一个小 Batch 的缓存很容易命中,但吞吐上不去;大 Batch 吞吐高,但每个请求的 Cache 状态千差万别,公共前缀占比迅速下降。这个矛盾在分布式环境下尤其明显,因为 GPU 数量越多,请求被分散到不同设备上的概率就越大,本来能做前缀共享的请求被打散掉了。
2. SageMaker 解决复用难题的底层逻辑
2.1 SageMaker 的部署形态为什么是复用的前提
很多人一听到 SageMaker 就联想到托管的 Notebook,其实它的推理部分是完全不一样的产品逻辑。SageMaker 的推理 Endpoint 支持把模型部署成常驻服务,而且有一个关键特性:多模型终端节点。
这个特性的价值在于,当模型在多个实例上部署时,SageMaker 自带的 Router 能够根据请求的特征进行路由。你在创建 Endpoint 时指定一个 Model A,平台上会自动把流量分发到挂了 Model A 的实例上。对于 KV Cache 复用来说,这个机制的价值不是负载均衡意义上的分发,而是它天然地在做会话粘性:相同模型、甚至相同前缀的请求,有更高概率落在同一批实例上。
很多自建方案里,请求路由和模型实例是解耦的,Gateway 层分发时完全不知道每个实例上的 KV Cache 状态。SageMaker 的部署模型则把模型加载和请求入口绑定在一起,至少从架构上减少了“请求落在错误实例”的概率。
2.2 实例级缓存的生命周期管理
SageMaker 的推理容器本身支持缓存机制。当实例收到请求并完成推理后,KV Cache 可以保留在容器内存中。后续请求如果命中相同前缀,模型可以直接复用这部分 Cache。
这里有个容易被忽略的细节:SageMaker 在扩容时是按实例粒度扩容的,每个新实例启动时会自动加载模型,但缓存状态是空的。这意味着扩容本身会暂时拉低整体 Cache 命中率。自建集群时经常出现“节点挂掉导致缓存全部丢失”的问题,在 SageMaker 上同样存在,但它的 Auto Scaling 策略可以配合请求量预估来做,避免因频繁扩缩容导致缓存被反复清空。
我自己的实践里,SageMaker 上单实例的 Cache 命中率可以从 0 爬升到 60% 以上,原因是它在同一个实例内部维护了比较完整的 Cache 池。跨实例的命中率要低一些,但如果已经把请求粘性做到位,整体命中率仍然比自建方案高不少。
2.3 自带 Telemetry 的价值
Kaggle 级别的项目可以靠肉眼观察吞吐量,但大规模推理集群上的 KV Cache 优化必须依赖指标监控。SageMaker 的一大优势是它自带一套完整的 Telemetry,你可以在 CloudWatch 里看到每实例的 Invocation 数量、延迟分布、GPU 利用率。
这些指标对 Cache 复用的意义在哪里?有了延迟分布,你可以反推 Cache Miss 的比例:如果某个实例的 p99 延迟远高于 p50,说明缓存命中率偏低;如果 GPU 利用率变化剧烈,说明请求波动导致 Cache 周期性失效。自建方案里这些指标要么需要自己打点,要么需要额外接入 Prometheus,开发量不小。
3. 实操过程与核心环节实现
3.1 确定复用级别:这决定了策略的复杂度
落地之前先想清楚一件事:你要复用 Cache 到哪一层。我归纳下来通常有三种级别。
第一级是请求级复用,也就是最简单的 Batch 内复用。同一 Batch 里如果有多个共享前缀的请求,把它们放在一起解码,共享同一份 KV Cache。这个级别不需要任何外部缓存系统,只需要在 Batch 采样策略上做匹配。实现上可以加一个简单的“请求队列 + 前缀哈希”,把相同前缀的请求排队进入同一个 Batch。
第二级是实例级复用,也就是跨请求、跨 Batch 复用。Batch 结束后,KV Cache 不清空,保留在实例内存中。后续新请求到达时,如果前缀在缓存池里能找到,就直接复用。这个级别需要一个可在内存里快速查询前缀的索引结构,以及一套缓存淘汰策略。
第三级是集群级复用,也就是跨实例复用。这需要把不同实例上的 KV Cache 状态同步到某个中心节点,并且允许请求路由到持有 Cache 的实例。这种级别的复杂度和收益成正比,但在大多数业务中不是必需项。
我在项目里最终做到了第二级,SageMaker 默认支持的也差不多是这一级别。三级全开的话,自建成本太高,而 SageMaker 本身的多模型路由能力最多只能做到二级的强化版,三级集群级的全局共享还得自己去搭。
3.2 关键步骤:如何在 SageMaker 上搭建可复用的推理服务
下面是一套可以照做的流程。在动手之前,需要假设你已经有一个训练好的模型,并且已经把模型权重保存到了某个存储服务里,SageMaker 的推理容器能加载它。
第一步,准备推理代码。SageMaker 的推理容器本质上是一个 HTTP 服务,需要实现一个transform_fn或者类似接口。在这个函数里,除了调用模型进行推理之外,还需要包含 KV Cache 的读取和更新逻辑。如果你的模型用的是原始 Transformer 接口,你得在加载模型时打开use_cache=True,并且把返回的past_key_values存到一个全局字典里。这个字典的 Key 可以设计成“前缀文本的哈希值”,Value 就是对应的 Cache 张量。
第二步,创建 SageMaker Model。把推理代码打包成 Docker 镜像,或者使用自带镜像加推理脚本,然后通过创建 Model 的方式注册。这一步的坑在于:默认的模型端口和环境变量对 KV Cache 的持久化没有特殊支持,所有缓存状态都在进程内维护。所以你必须保证单个实例的进程不会因为并发量过大被自动杀死,否则缓存就全丢了。建议把 MaxConcurrency 控制在model_max_batch * gpu_num以内,让每个 Worker 有足够余量去维护 Cache 状态。
第三步,创建 Endpoint 并配置自动缩放。Endpoint 创建后,流量会按照模型名分发到对应的实例组。这里建议直接关闭默认的按请求数扩容策略,改成按“平均 GPU 利用率”扩缩容。因为 KV Cache 的命中率是一个缓启动指标,频率过高的扩缩容会把刚开始积累的缓存打断。
第四步,压测并采集指标。压测时主要看两个指标:总吞吐量和端到端延迟。重点观察随着请求并发量上升,Cache 命中率是否稳定保持在高位。如果命中率持续走低,大概率是请求的前缀分布太分散,或者实例数量太多导致粘性不够。
3.3 自建方案的关键参数与实现选型
如果你不想用 SageMaker,想自己搓一套类似的东西,我基于自己的踩坑经验给一份参考建议。
请求路由的哈希键要按前缀长度来定,而不是整个 Prompt。很多现成的 Router 工具默认按完整 Prompt 做哈希,这在 Prompt 尾部有随机参数时会直接破坏前缀匹配。实际做法是:从 Prompt 里截取固定长度前缀,比如 512 个 token,计算哈希之后作为缓存索引。长度太短容易导致不同前缀冲突,太长会降低命中率,512 到 1024 是一个折中区间。
缓存淘汰策略建议用 LRU 的变体。普通 LRU 对 KV Cache 并不完全够用,因为缓存对象的体积差异很大:同一个前缀算出的 Cache 可能是几 MB,也可能是几百 MB。建议引入一个“按 Size 加权”的淘汰逻辑:总容量超过阈值时,优先淘汰体积大、最近未被访问的对象,而不是单纯按时间淘汰。
缓存加载与更新要放在 Worker 线程内,不要用单独进程。KV Cache 是深度学习框架中的张量对象,如果你用 PyTorch,这些对象在进程间传递时需要序列化和重新加载,代价极高,不如直接在进程内用一个全局字典。并发访问时加锁粒度要细,最好是按哈希桶粒度加锁,避免全部请求串行等待字典锁。
4. 常见问题与排查技巧实录
4.1 命中率上不去的十个常见原因
我自己和大规模集群打交道多了以后,总结出一份速查表。很多问题排查到最后,发现不是 Cache 本身的算法出了问题,而是周边条件的缺失。
第一个原因是前缀冻结失败。模型的 Tokenizer 在做预处理时,如果加入了一些动态增强(比如时间戳、随机数),那即使原始 Prompt 相同,Token ID 序列也不同,缓存自然无法命中。解决方法是在预处理阶段把动态内容放在非前缀位置,或者用模板预生成 Token ID 后再拼入动态内容。
第二个原因是 Batch 采样策略不合适。不少推理框架支持“动态 Batch”,但它是按到达时间合并的,不是按前缀合并的。要改成双队列模型:一个队列按请求到达时间排队,另一个队列按“前缀哈希”分组,只有分组长度达到 Batch Size 阈值时才触发 Batch 解码。
第三个原因是实例数量过多。实例多提升了并发能力,却打散了相同前缀请求的集中度。如果请求总量有限,建议把实例数量控制在让单实例的 Batch Size 能够填满的程度,而不是越多越好。
第四个原因是容器自动重启。监控里如果发现实例重启频繁,大概率是 OOM。KV Cache 占显存,OOM 后容器被强杀,缓存丢失,命中率自然暴跌。解决办法是把最大生成长度限制降低,或者使用更大显存的实例类型。
第五个原因是负载均衡的“公平策略”导致请求被轮询分发。如果流量在多个实例间均匀轮转,缓存永远无法积累。必须把路由策略改成哈希路由,或者至少改成最小连接数加会话粘性。
第六个原因是缓存过期策略太激进。如果设置的 TTL 只有几十秒,一个长对话稍等片刻再发下一条,前缀就过期了。TTL 至少要按小时级设置,哪怕 Cache 体积大,也值得用磁盘辅助存储做二级缓存。
第七个原因是动态形状影响缓存索引。部分框架在自回归解码时会动态扩张张量维度,导致缓存前后的张量形状不一致,索引匹配失败。需要在更新缓存时做形状对齐检查,必要时做 Padding。
第八个原因是模型层数太多导致 Cache 索引维度爆炸。索引结构设计的 Key 最好只包含“前缀 Token 哈希”,不包含模型层号,Value 里用一个字典按层存储。这能避免索引结构本身成为内存热点。
第九个原因是 Endpoint 并发过高导致排队严重。大量请求在网关排队时,缓存已经在等待中过期了。检查端到端延迟,如果 P50 很低但 P99 极高,大概率是排队导致缓存失效。
第十个原因是多模型共存时的干扰。如果同一个实例上部署了多个模型,但是显存是共享的,那算力和显存的互相挤占会导致每个模型的缓存上限被压缩。尽量把一个实例只分配给一类模型,避免业务间互相干扰。
4.2 实战中比较隐蔽的三个坑
有个坑是模型内部的 Beam Search 或采样器会改变 Cache 的使用方式。如果你是做贪婪解码,Cache 路径很规则;一旦开启温度采样或多路采样,模型可能对同一前缀生成不同的分支,每条分支都有独立 Cache,容量会快速膨胀。解决方法是限制采样宽度,或者在外部控制单次请求只使用一种采样策略。
另一个坑是推理容器初始化时的预热不足。新启动的实例如果没有任何 Cache,前几百个请求的延迟会明显更高。建议在 Auto Scaling 扩出来的新实例上,先主动发送一批预热请求,让缓存池先积累起来,然后再混入真实流量。预热请求可以专门用一个固定的模板拼接不同后缀。
还有一个坑是关于容器内存和 GPU 显存的比例。KV Cache 主要存在 GPU 显存里,但在做索引和哈希时,会消耗不少 CPU 内存。如果 CPU 内存被打满,进程会先 OOM,而不是等到 GPU 显存告急。所以在选型时,不要只看 GPU 显存,还要关注实例的 CPU 内存是不是够大。
4.3 排查 KV Cache 复用失效的两条核心命令思路
没有一键排查的工具,但有两条路径非常管用。第一条是在容器内记录每个请求的“Cache 命中/未命中”日志,把日志采集到集中式日志系统里,按时间段聚合计算命中率。第二条是利用性能剖析工具,在推理请求的时序里标记缓存查找耗时和模型解码耗时,如果缓存查找耗时超过推理耗时的 20%,说明索引结构已经成了瓶颈。
这两条思路在 SageMaker 上都不难实现。第一条可以通过在容器内打 stdout 日志,然后靠日志采集服务收走。第二条可以利用容器的自定义监控脚本,把自定义指标通过 SageMaker 的 Metric 接口上报到 CloudWatch。实际排查时,两者配合使用基本能覆盖 80% 的问题场景。
5. 写在最后的建议
KV Cache 复用这件事,本质上是一个“缓存命中的概率问题 + 系统工程的调度问题”。自建方案不是不行,但你要同时搞定路由、前缀对齐、内存淘汰、跨节点同步和指标观测,工程量一下子就上去了。SageMaker 帮我把后半段问题收敛掉,让我能集中精力解决缓存策略本身。
如果团队里已经有成熟的 K8s 运维能力,自建完全没问题;如果只是希望快速把推理服务跑起来并且拿到稳定的缓存命中率,SageMaker 这类托管推理平台是更划算的选项。选型没有绝对的好坏,只有成本曲线的差异。
再分享一条我的经验:不管用什么平台,KV Cache 的优化都建议从“命中率指标”反向驱动。先定义你要达到的命中率目标,再倒推实例数量、并发上限和请求路由策略。而不是先把机器跑起来再去看命中率,那样大概率会浪费大量时间去试错。