多区域 LLM 服务:没人警告过你的缓存局部性问题
当你在多个区域运行无状态 HTTP API 时,路由问题基本上已经解决了。在前面放一个全球负载均衡器,按地理位置分配请求,最糟糕的情况也不过是缓存项稍微过时。任何副本都可以处理任何请求,并获得相同的结果。
LLM 推理打破了每一个假设。一旦你添加了提示词缓存(Prompt Caching)——你肯定会加,因为缓存命中和未命中的成本差异大约是 10 倍——你的服务就会以大多数基础设施团队预料不到的方式变得有状态,直到他们在第二个区域看到延迟数据退化。
根本原因在于 KV 缓存局部性(KV cache locality)。当语言模型处理提示词时,它会为该提示词中的每个 token 计算键值张量(key-value tensors)并将其存储在 GPU 显存中。下一个与缓存提示词共享前缀的请求可以跳过重新计算这些张量——但前提是该请求落在了持有该缓存的同一个 GPU 节点上。跨区域路由不仅仅是导致缓存未命中,它会让缓存变得像从未存在过一样。
为什么多区域 LLM 服务不同于多区域 API
考虑一下当你给一个调优良好的单区域 LLM 部署添加第二个区域时会发生什么。在区域 A,你通过重复的系统提示词、共享的对话前缀以及附加在大多数请求前的 RAG 上下文分块,建立起了温缓存(warm caches)。你的缓存命中率维持在 60–70%。区域 A 的用户看到的延迟始终保持在低水平。
区域 B 启动时是冷启动。每个请求都是全上下文重新计算。延迟飙升。你增加了更多的 GPU。成本上升。你告诉自己过几天就会变暖,确实如此——但现在你遇到了新问题。你的全球负载均衡器在执行感性操作时,偶尔会因为区域 A 负载过重而将区域 A 用户的请求路由到区域 B。该请求命中了冷缓存,你会在 P95 指标中看到延迟飙升。你根据用户 ID 添加了粘性会话(sticky sessions)。延迟恢复正常。一周后,你意识到按用户 ID 建立粘性会话意味着你的负载分布不均,因为某些用户产生的流量是其他人的 10 倍。
这就是模式。每一次修复都会揭示下一个问题,而且它们都不是代码中的 bug——它们是无状态基础设施假设与有状态推理需求之间的架构不匹配。
KV 缓存是你真正的状态单元
在传统的分布式系统中,状态存在于数据库中。你在无状态计算和有状态存储之间有明确的分离。扩缩容是可预测的。
在 LLM 服务中,KV 缓存驻留在运行预填充(prefill)阶段的 GPU 上。它与产生它的计算任务位于同一位置,在内存压力下会被逐出且没有持久记录,并且没有用于外部检查的 API。发送到两个不同节点的两个相同请求会导致两次缓存未命中,尽管从系统设计的角度来看,“正确”的行为应该是共享该计算结果。
这与在其他地方都行之有效的扩缩容策略产生了根本性的冲突。轮询(Round-robin)负载均衡对无状态服务是正确的,但对带缓存的 LLM 推理却是有害的。每一个没有落在具有匹配前缀节点上的请求都要支付完整的预填充成本。在大规模场景下,长系统提示词的预填充可能需要数百毫秒,这不仅仅是一个小小的效率问题——它是 P50 延迟 80ms 与 800ms 之间的区别。
实际后果是:你的负载均衡器需要了解 KV 缓存状态,而不仅仅是节点健康状况和队列深度。通用的 HTTP 负载均衡器没有这些信息。这就是为什么会出现像 vLLM Router 这种专用路由器的原因——它用 Rust 编写以最小化开销,并专门设计用于消费来自推理引擎的 KV 缓存事件。它们基于前缀哈希匹配进行路由,而不仅仅是轮询或最小连接数。
基于前缀的一致性哈希是单区域部署的正确默认选择。你对提示词的前 N 个 token 进行哈希处理,将该哈希映射到一个节点,并进行相应路由。添加有界负载约束,使单个节点不会过载,这样你就拥有了一个合理的稳定状态。经过研究支持的实现方案是有界负载一致性哈希(CHWBL),它是专门为这个问题设计的。
