不少团队在把大模型推理服务放到 Kubernetes 上之后,很快就会遇到一个尴尬场景:请求的上下文越长,显存越吃紧,GPU 利用率越低,响应耗时越不稳定。尤其是长上下文或者多轮对话场景,前一个请求的 KV Cache 还没释放,后一个请求又涌进来,导致 P99 延迟持续抬高,甚至触发 OOM。
为了解决这类问题,业界逐渐出现了两种关键手段:KV Cache 感知路由和Prefill / Decode 分离。前者让请求被调度到拥有合适缓存状态的实例上,后者把两阶段计算拆开,让不同算力特征的节点各司其职。本文将围绕这两个技术方向,说明它们的基本原理、Kubernetes 上的落地方式,以及可以运行的实战示例。
如果你正在做大模型推理服务化、Kubernetes 上部署 LLM 应用,或者负责推理平台的资源调度,这篇文章应该能帮你把思路理清楚。
1. KV Cache 与 LLM 推理的基本背景
1.1 LLM 推理为什么有“两阶段”
目前主流的大模型推理服务,例如 vLLM、TGI、SGLang 等,在生成回答时都会经历两个阶段:
- 预填充阶段(Prefill):用户输入 Prompt 后,模型需要并行处理这整段文本,产生中间状态,包括生成第一个输出 token。这个阶段是典型的计算密集型任务,需要做大量矩阵运算,GPU 利用率高。
- 解码阶段(Decode):模型逐个 token 生成后面的内容。每生成一个 token,都需要读取此前所有的 Key 和 Value 信息,所以这个阶段是典型的访存密集型任务,GPU 算力利用率反而不高,瓶颈更多在显存带宽和 KV Cache 读取。
从数学上看,两阶段的耗时特征差异是非常明显的:
- Prefill 阶段的耗时接近
T_prefill ≈ O(P),其中 P 是 Prompt 长度,GPU 算力越强、并行度越高,处理越快。 - Decode 阶段每生成一个 token 都需要访问全部已计算的 KV Cache,耗时接近
T_decode ≈ O(C),其中 C 是上下文长度,访问的数据量越大,生成越慢。
1.2 KV Cache 是什么
KV Cache 是 Transformer 架构推理时的一种缓存机制。模型在解码阶段计算注意力时,需要用到历史上所有 token 对应的 Key 和 Value 向量。为了不每生成一个 token 就重新计算前面所有 token 的 Key 和 Value,推理引擎会把它们缓存下来,这个缓存就是 KV Cache。
KV Cache 的大小可以按下面的近似公式估算:
KV Cache 内存 ≈ 2 × 层数 × Batch Size × 序列长度 × 注意力头数 × Head 维度 × 每个元素字节数举个例子,一个 7B 模型如果配置 32 层、32 个注意力头、Head 维度 128,用 FP16 存储,batch_size=16、seq_len=2048时,KV Cache 大约占用:
2 × 32 × 16 × 2048 × 32 × 128 × 2 ≈ 8.6 GB这只是一个 batch 状态下的占用。如果并发请求多、上下文长,KV Cache 很容易成为显存中的大头。
1.3 传统 Kubernetes 路由为什么不够用
Kubernetes Service 默认的负载均衡方式,要么是轮询,要么是随机连接,要么是基于连接数的简单均衡。它根本不知道 Pod 内部当前的 KV Cache 状态。
如果请求被随机分发到多个推理实例,就会遇到几个问题:
- 同一个用户的多轮对话,被分发到不同 Pod,前文的 KV Cache 无法复用。
- 某个 Pod 的显存已接近上限,但负载均衡器仍然把新请求打进去,OOM 风险升高。
- Pod 的缓存命中率低,推理服务被迫重复执行 Prefill,浪费 GPU 算力。
所以,单纯依赖 Kubernetes Service 做流量转发,在大模型推理场景下是不够的。我们需要在路由层面感知每个实例的 KV Cache 状态,从而做到缓存感知路由。
2. 核心概念拆解
2.1 KV Cache 感知路由
所谓 KV Cache 感知路由,就是请求进入网关或负载均衡层时,调度组件根据后端实例上报的缓存状态,决定该把请求转发给谁。
这里的关键信息包括:
- 每个实例当前缓存的 token 前缀(例如
prompt_hash或prefix_id)。 - 每个实例当前的 KV Cache 已用显存。
- 每个实例的剩余可用 KV Cache 空间。
- 每个实例当前排队的请求数量。
举例说明,如果有三个推理 Pod:
| Pod | 缓存前缀 ID | 空闲 KV Cache | 当前队列深度 |
|---|---|---|---|
| Pod A | prefix-007 | 8 GB | 2 |
| Pod B | prefix-007 | 2 GB | 6 |
| Pod C | prefix-009 | 10 GB | 1 |
当新请求的前缀是prefix-007时,路由层应该优先把请求转发给 Pod A,因为 Pod A 既有对应前缀的缓存,又有充足的 KV Cache 空间,队列深度也小。
这种路由策略带来的收益是:
- 提高 KV Cache 前缀命中率。
- 减少重复 Prefill 计算。
- 均衡实例间的显存压力。
- 降低响应延迟的抖动幅度。
2.2 Prefill / Decode 分离
把 Prefill 和 Decode 两个阶段拆分到不同的计算节点上执行,就是预填充/解码分离架构,国外一般称为 P/D Disaggregation。
传统做法是一个推理实例同时处理 Prefill 和 Decode。但两种阶段对资源的需求完全不同:
| 维度 | Prefill | Decode |
|---|---|---|
| 计算特征 | 高计算密度,适合大批量并行 | 访存瓶颈大,单 token 延迟敏感 |
| GPU 利用率 | 高 | 较低 |
| 显存压力 | 主要保存中间计算状态 | 主要存储 KV Cache |
| 目标 | 尽快完成 Prompt 处理 | 稳定低延迟逐 token 输出 |
如果混部在同一个 Pod 里,你会很难同时做好两件事:GPU 强算力的机器被 Decode 阶段拖慢,显存充足的机器又可能因为 Prefill 瞬时高负载而抖动。
P/D 分离之后,架构变成:
客户端请求 ↓ 网关/路由层(KV Cache 感知路由) ↓ Prefill 实例池(负责处理 Prompt,产生 KV Cache) ↓ KV Cache 传输 ↓ Decode 实例池(负责逐 token 生成) ↓ 流式返回响应Prefill 实例将生成的 KV Cache 通过高速网络传递给 Decode 实例,Decode 实例续接缓存并继续生成,最终把结果流式返回给客户端。
2.3 两者的关系
KV Cache 感知路由和 P/D 分离并不是互斥方案,而是可以协同工作:
- 在 P/D 分离架构下,Prefill 节点需要把新生成的 KV Cache 路由到合适的 Decode 节点,这里同样依赖 KV Cache 状态感知。
- 多轮对话命中缓存时,可以绕过 Prefill 节点,直接由 Decode 节点接管,这需要路由层识别请求前缀。
- 一个请求的 KV Cache 从 Prefill 节点迁移到 Decode 节点,实际上是一次有状态的调度,必须感知目标节点的缓存容量。
因此,KV Cache 感知路由可以视为 P/D 分离架构中的核心调度组件。
3. 系统架构设计
3.1 整体架构
在 Kubernetes 上实现 KV 缓存感知路由和 P/D 分离,整体架构可以按下面的层次拆分:
客户端 ↓ Ingress / 网关层(路由决策入口) ↓ 路由服务(KV Cache 感知调度器) ↓ Kubernetes API(读取 Pod 状态、元数据) ↓ Prefill Deployment → Prefill Pod 池 ↓ KV Cache 共享/传输 Decode Deployment → Decode Pod 池 ↓ 监控与指标采集路由服务需要感知两类信息:
- 静态信息:Pod 的标签、注解、调度分组。
- 动态信息:每个 Pod 当前缓存的前缀 ID、KV Cache 剩余容量、请求队列长度。
静态信息可以通过 Kubernetes API 直接获取,动态信息则需要推理引擎暴露出来。
3.2 路由决策流程
路由决策可以分为几个步骤:
- 请求进入网关。
- 网关解析 Prompt,计算前缀 Hash 或从请求头提取会话 ID。
- 路由服务查询实例状态表,筛选出符合前缀缓存的节点。
- 在候选节点中,结合 KV Cache 剩余空间、队列深度、GPU 负载进行打分。
- 得分最高的节点作为目标节点。
- 如果所有节点的前缀缓存均未命中,则选择 KV Cache 空间最充足的 Prefill 节点。
这个流程可以用伪代码表示:
def select_instance(request, instance_table): """ request: 包含 prompt 前缀 hash、上下文长度等信息 instance_table: 所有推理实例的动态状态 """ prefix_hash = request.prefix_hash # 第一步:过滤出命中前缀缓存的实例 hit_instances = [ ins for ins in instance_table if prefix_hash in ins.cached_prefixes ] if hit_instances: # 第二步:在命中缓存的实例中,按资源余量和负载打分 hit_instances.sort(key=lambda ins: ( ins.kv_cache_free_bytes, -ins.queue_depth ), reverse=True) return hit_instances[0] # 第三步:没有命中缓存,去 Prefill 池找空间最充足的实例 prefill_instances = [ ins for ins in instance_table if ins.role == "prefill" ] prefill_instances.sort(key=lambda ins: ins.kv_cache_free_bytes, reverse=True) return prefill_instances[0]这是简化版本,核心逻辑就是“先命中缓存,再考虑资源容量和负载”。
3.3 需要采集的指标
要让路由组件感知 KV Cache 状态,推理服务在 Pod 内需要暴露至少以下指标:
kv_cache_prefix_hits_total # 请求命中缓存的次数 kv_cache_prefix_miss_total # 请求未命中缓存的次数 kv_cache_used_bytes # 已使用的 KV Cache 显存 kv_cache_total_capacity_bytes # 可分配的 KV Cache 总容量 kv_cache_prefix_ids # 当前缓存的 token 前缀 ID 集合 gpu_queue_depth # 当前 Pod 队列中的请求数 gpu_running_requests # 正在执行推理的请求数这些指标可以通过 Prometheus 格式暴露,也可以以 JSON 接口的形式由路由组件主动拉取。
4. 基于 Kubernetes 的实现方案
4.1 环境准备与版本说明
下面给出一个可运行的示例,基于以下环境:
- Kubernetes 1.26 及以上版本。
- Python 3.9 以上,用于编写路由示例代码。
- FastAPI 用于实现指标采集接口。
- vLLM 或任意支持 KV Cache 前缀缓存的推理引擎,作为 Pod 内的推理服务。
版本需要根据你的实际环境调整,本文重点演示配置思想,代码在常见环境均可运行。
4.2 创建命名空间和实例池
首先创建命名空间和两组 Deployment,一组对应 Prefill 实例,一组对应 Decode 实例。
apiVersion: v1 kind: Namespace metadata: name: llm-inferencePrefill Deployment 示例:
apiVersion: apps/v1 kind: Deployment metadata: name: prefill-pool namespace: llm-inference spec: replicas: 2 selector: matchLabels: app: llm-worker role: prefill template: metadata: labels: app: llm-worker role: prefill spec: containers: - name: prefill-engine image: vllm/vllm-openai:latest args: - --model - /models/llama-7b - --gpu-memory-utilization - "0.90" - --enforce-eager ports: - containerPort: 8000 resources: limits: nvidia.com/gpu: "1"Decode Deployment 示例:
apiVersion: apps/v1 kind: Deployment metadata: name: decode-pool namespace: llm-inference spec: replicas: 3 selector: matchLabels: app: llm-worker role: decode template: metadata: labels: app: llm-worker role: decode spec: containers: - name: decode-engine image: vllm/vllm-openai:latest args: - --model - /models/llama-7b - --gpu-memory-utilization - "0.90" ports: - containerPort: 8000 resources: limits: nvidia.com/gpu: "1"这里的关键是给不同角色的 Pod 打上不同的role标签,路由组件可以利用 Kubernetes 标签选择器筛选对应 Pod。
4.3 Pod 内指标采集服务
为了让路由组件感知实例状态,需要在每个推理 Pod 中额外运行一个指标暴露服务。下面用 FastAPI 做一个简单的示例。
# 文件路径:worker_metrics/main.py """ 每个推理 Pod 内运行的指标服务。 实际项目中建议由推理引擎直接暴露这些指标,这里为了演示单独实现。 """ import os import random import time from fastapi import FastAPI app = FastAPI() @app.get("/metrics") def get_metrics(): """ 模拟返回 KV Cache 状态指标。 实际场景中,这里应该调用推理引擎的统计接口,例如 vLLM 的 /metrics。 """ prefix_ids = ["prefix-007", "prefix-009"] if random.random() > 0.5: prefix_ids.append("prefix-012") return { "pod_name": os.getenv("HOSTNAME", "unknown"), "role": os.getenv("ROLE", "unknown"), "kv_cache_used_bytes": random.randint(4_000_000_000, 10_000_000_000), "kv_cache_total_capacity_bytes": 20_000_000_000, "queue_depth": random.randint(0, 6), "cached_prefix_ids": prefix_ids, "timestamp": time.time(), }暴露cached_prefix_ids时需要注意,真实场景中前缀 ID 可以用 Prompt 的 Hash 值来标识。相同前缀的请求可以复用同一个 KV Cache 段。
4.4 路由服务实现
路由服务可以使用 Python 实现,它需要定期采集各 Pod 的指标,形成一个实例状态表。
# 文件路径:router_service/route.py """ KV Cache 感知路由服务的核心逻辑。 简化版,只展示路由决策部分,实际生产环境需要结合 gRPC 或 HTTP 转发。 """ class InferenceInstance: def __init__(self, pod_name, role, cached_prefix_ids, kv_cache_used_bytes, kv_cache_total_capacity_bytes, queue_depth): self.pod_name = pod_name self.role = role self.cached_prefix_ids = cached_prefix_ids self.kv_cache_free_bytes = kv_cache_total_capacity_bytes - kv_cache_used_bytes self.queue_depth = queue_depth class RouteDecision: def __init__(self, instances): self.instances = instances def select(self, prefix_hash): # 优先选择命中前缀缓存的实例 hit_instances = [ ins for ins in self.instances if prefix_hash in ins.cached_prefix_ids ] if hit_instances: hit_instances.sort( key=lambda ins: ins.kv_cache_free_bytes, reverse=True, ) return hit_instances[0] # 缓存未命中,选择 Prefill 池中剩余空间最大的实例 prefill_instances = [ ins for ins in self.instances if ins.role == "prefill" ] prefill_instances.sort( key=lambda ins: ins.kv_cache_free_bytes, reverse=True, ) return prefill_instances[0] def demo(): instances = [ InferenceInstance( pod_name="prefill-1", role="prefill", cached_prefix_ids=["prefix-007"], kv_cache_used_bytes=6_000_000_000, kv_cache_total_capacity_bytes=20_000_000_000, queue_depth=2, ), InferenceInstance( pod_name="decode-1", role="decode", cached_prefix_ids=["prefix-009", "prefix-007"], kv_cache_used_bytes=8_000_000_000, kv_cache_total_capacity_bytes=20_000_000_000, queue_depth=1, ), ] router = RouteDecision(instances) chosen = router.select("prefix-007") print(f"请求 prefix-007 被路由到: {chosen.pod_name}") if __name__ == "__main__": demo()输出预期:
请求 prefix-007 被路由到: decode-1因为decode-1命中了prefix-007,且其 KV Cache 剩余空间为 12GB,大于prefill-1,所以被选中。
这是路由策略的核心思想。实际生产环境中,还需要把这个决策结果和负载均衡器、网关联动,变成真正的流量转发动作。
4.5 定义 CRD 扩展调度信息
在实际项目中,Pod 之间的 KV Cache 分布、缓存前缀信息会不断变化,单纯依靠 Kubernetes 原生字段很难承载这类动态数据。常用的方式是定义 CRD(Custom Resource Definition)来描述推理实例状态。
下面是一个简化示例:
apiVersion: inference.example.com/v1 kind: WorkerStatus metadata: name: decode-1-status namespace: llm-inference spec: workerRef: name: decode-1 kind: Pod cachedPrefixes: - prefix-007 - prefix-009 kvCacheUsedBytes: 8000000000 kvCacheTotalBytes: 20000000000 queueDepth: 1通过 CRD,路由服务可以以 Kubernetes 原生的方式读取、监听推理实例状态变化,而不是额外维护一套外部存储。这个方案适合平台化、规模化的推理服务集群。
4.6 调度联动策略
KV Cache 感知路由要真正生效,还需要与 Kubernetes 调度联动。比较常见的做法有:
- 在网关层拦截请求,直接路由到指定 Pod,不经过 Service。
- 为每个 Pod 创建独立 Service Endpoint,路由服务控制转发目标。
- 使用自定义调度器扩展,在 Pod 创建时决定把 Prefill / Decode 实例调度到哪台节点。
- 结合
topology.kubernetes.io/zone等标签,尽量让 KV Cache 在物理网络上短距离传输。
如果是 P/D 分离架构,还需要考虑 KV Cache 的传输线路。Prefill 节点完成计算后,需要把 KV Cache 序列化并传给 Decode 节点。这个传输过程比较占用网络带宽,所以在 Kubernetes 节点分组时,最好让 Prefill 和 decode 节点所在的主机处于同一个高带宽网络区域内。
5. 实战案例:vLLM 前缀缓存 + 路由联动
5.1 场景设计
假设我们要部署一个在线问答服务,模型是 Llama-3-8B。用户会围绕同一个长文档进行多轮提问。
因为我们希望:
- 用户第一轮提问时,请求被路由到 Prefill 实例。
- 用户后续追问时,请求能命中 KV Cache,且被直接路由到持有缓存前缀的 Decode 实例。
所以必须让路由层看到prompt hash。常见做法是请求进入网关时,网关层解析 Prompt 的前缀,或者直接由客户端把prompt_hash写入 HTTP Header。
curl -X POST http://llm-gateway:8080/v1/completions \ -H "Content-Type: application/json" \ -H "X-Prompt-Hash: prefix-007" \ -d '{"prompt": "请总结文档第3章内容"}'网关根据X-Prompt-Hash查询路由表,转发到对应 Pod。
5.2 网关代码示例
下面是一个基于 FastAPI 的轻量级网关,它会在收到请求后调用路由决策模块,选择目标 Pod 并将请求转发过去。
# 文件路径:gateway/main.py """ KV Cache 感知网关示例。 简化版:实际项目中应使用异步 HTTP 客户端,并处理超时、重试、限流等问题。 """ import httpx from fastapi import FastAPI, Request from route import RouteDecision, InferenceInstance app = FastAPI() # 这里模拟实时拉取到的实例列表 # 实际项目中,应该从 Kubernetes API 或 CRD 中动态获取 instances = [ InferenceInstance( pod_name="prefill-0", role="prefill", cached_prefix_ids=["prefix-007"], kv_cache_used_bytes=5_000_000_000, kv_cache_total_capacity_bytes=20_000_000_000, queue_depth=1, ), InferenceInstance( pod_name="decode-0", role="decode", cached_prefix_ids=["prefix-007", "prefix-009"], kv_cache_used_bytes=9_000_000_000, kv_cache_total_capacity_bytes=20_000_000_000, queue_depth=2, ), ] router = RouteDecision(instances) @app.post("/v1/completions") async def completions(request: Request): body = await request.json() prompt_hash = request.headers.get("X-Prompt-Hash", "unknown") chosen = router.select(prompt_hash) target_url = f"http://{chosen.pod_name}.llm-inference.svc.cluster.local:8000/v1/completions" async with httpx.AsyncClient(timeout=300) as client: resp = await client.post(target_url, json=body) return resp.json()这个网关的存在价值,是把路由决策和流量转发聚合在一个入口,对客户端保持统一 API 形态。
5.3 验证完整流程
部署完成后,可以按以下步骤进行验证。
第一步,查看实例状态:
kubectl get pods -n llm-inference -o wide预期能看到prefill-pool和decode-pool下的 Pod 都在运行。
第二步,发送第一个请求,并携带新的前缀 Hash:
curl -X POST http://llm-gateway:8080/v1/completions \ -H "Content-Type: application/json" \ -H "X-Prompt-Hash: prefix-020" \ -d '{"prompt": "请解释 Kubernetes 调度器原理"}'这个请求的前缀prefix-020不在任何实例的缓存列表中,因此会被路由到 Prefill 实例。
第三步,再次发送相同前缀的请求:
curl -X POST http://llm-gateway:8080/v1/completions \ -H "Content-Type: application/json" \ -H "X-Prompt-Hash: prefix-020" \ -d '{"prompt": "请继续解释 Pod 生命周期"}'如果路由决策模块监测到prefix-020已经在某个实例上缓存,后续请求会被转发到该实例,从而复用 KV Cache,避免重复 Prefill。
5.4 结果说明
在实际负载下,命中缓存的请求 P99 延迟通常会有明显下降。因为解码阶段不需要重复计算前文,直接从已缓存的 KV Cache 开始生成即可。
量化地说,如果一个长文档有 4000 token,用户 10 轮追问都基于同一文档,那么在非缓存路由架构下,每一轮都需要重复处理 4000 token 的 Prompt,总计 40000 token 的 Prefill 计算。而在 KV Cache 感知路由架构下,第一轮 Prefill 处理 4000 token,后面 9 轮全部命中缓存,Preill 计算量减少约 90%。
6. P/D 分离中的 KV Cache 传输
6.1 为什么需要传输
P/D 分离架构中,KV Cache 在 Prefill 节点生成后需要传给 Decode 节点。这就涉及一个之前单体推理服务中不存在的问题:KV Cache 如何高效传输。
常见方式有:
- 分布式缓存共享:使用 Redis、Memcached 或专门的 KV Cache 存储层,把 KV Cache 持久化或暂存。
- RDMA / 高速网络直传:Prefill 节点和 Decode 节点之间通过 RDMA 或 InfiniBand 直接传输张量。
- Kubernetes 内网传输:在普通 TCP 网络环境下,把 KV Cache 序列化之后通过 Pod 网络传过去。
普通 TCP 传输在规模较小时可行,但在高并发场景下会成为瓶颈。一个 8B 模型在上下文 4096 token 时的 KV Cache 可能有几百 MB 到数 GB,如果是高 QPS 服务,网络负载会非常大。
6.2 在 Kubernetes 中降低传输损耗
从调度层面可以做一些优化:
- 把 Prefill Pod 和 Decode Pod 尽量调度到同一台节点,或者同一机架,减少跨交换机传输。
- 使用节点亲和性,把相同模型的 Prefill / Decode 实例绑定在同一拓扑域。
- 给调度器传入自定义评分逻辑,优先选择与 KV Cache 源节点网络距离短的节点。
示例调度配置:
apiVersion: apps/v1 kind: Deployment metadata: name: decode-pool namespace: llm-inference spec: template: spec: affinity: podAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: app operator: In values: - llm-worker - key: role operator: In values: - prefill topologyKey: topology.kubernetes.io/zone这段配置表示,在调度 Decode Pod 时,尽量让它和 Prefill Pod 处于同一个可用区,降低 KV Cache 传输延迟。
6.3 传输后的一致性保障
传输 KV Cache 时需要注意,不同批次、不同序列长度的请求生成的 Cache 结构不同。传输协议需要包含以下信息:
- 模型版本和配置,确保接收方模型结构一致。
- 原始 Prompt 的分词 ID 序列。
- 各层的 Key 张量和 Value 张量。
- Cache 对应的前缀长度。
在典型实现中,传输层会把这些信息封装成一个“状态包”。接收方拿到后,先校验模型 ID,再将张量加载进显存。
7. 常见问题与排查思路
7.1 路由不生效,请求仍打到了无缓存实例
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 请求总是命中同一个 Pod,且没有走缓存路由 | 网关按轮询策略转发,没有读取路由决策 | 检查网关代码是否真的使用了路由服务返回的目标地址 |
| 多个副本状态一致,但路由结果混乱 | 实例状态表长时间未刷新 | 检查指标采集器的同步周期,确保状态表更新频率高于请求频率 |
| 前缀 Hash 计算不一致 | 客户端和网关对 Prefix 的 Hash 算法不同 | 统一 Prefix Hash 计算逻辑,建议在网关内计算,不依赖客户端 |
排查顺序建议是:
- 先确认目标 Pod 上是否有对应前缀的 KV Cache。
- 确认路由服务读到的实例状态是否最新。
- 打印路由决策日志,确认决策过程。
- 确认流量转发链路用的是路由结果,而不是 Service VIP。
7.2 P/D 分离后延迟反而更高
P/D 分离并不是在所有场景下都有收益。如果你的 Prompt 很短,缓存命中率低,而且 KV Cache 传输耗时超过了重复 Prefill 的耗时,那么分离架构反而会带来额外开销。
这种情况下,需要评估收益边界:
| 场景 | Prefill 计算量 | Cache 传输量 | 是否适合 P/D 分离 |
|---|---|---|---|
| 短 Prompt,高并发 | 小 | 相对小 | 不一定 |
| 长文档多轮问答 | 大 | 大但命中率高 | 适合 |
| 低并发,单请求长输出 | 小 | 大 | 不适合 |
| 高并发,长上下文 | 大 | 大 | 适合 |
建议先通过监控数据评估当前系统的 Prefill 耗时占比和缓存命中率,再决定是否做架构拆分。
7.3 Pod 经常 OOM,显存不足
KV Cache 容量是推理服务显存管理的核心。出现 OOM 时,需要检查:
- 实例的最大并发数是多大,是否超出了 KV Cache 容量上限。
- 是否合理设置
gpu-memory-utilization。 - 路由层是否在 KV Cache 剩余空间不足时,仍然把请求发到了该实例。
在路由决策中,加入一个“最低剩余空间阈值”是一个常见的保护措施:
MIN_KV_CACHE_FREE_BYTES = 2_000_000_000 # 2GB 阈值 def select(self, prefix_hash): hit_instances = [ ins for ins in self.instances if prefix_hash in ins.cached_prefix_ids and ins.kv_cache_free_bytes >= MIN_KV_CACHE_FREE_BYTES ] if hit_instances: hit_instances.sort( key=lambda ins: ins.kv_cache_free_bytes, reverse=True, ) return hit_instances[0] prefill_instances = [ ins for ins in self.instances if ins.role == "prefill" and ins.kv_cache_free_bytes >= MIN_KV_CACHE_FREE_BYTES ] prefill_instances.sort( key=lambda ins: ins.kv_cache_free_bytes, reverse=True, ) if prefill_instances: return prefill_instances[0] # 所有实例空间都不足,应该触发限流或扩容 raise RuntimeError("no available instance, need scale up or reject")这样可以在 KV Cache 空间不足时,从“继续转发”变成“拒绝或扩容”,降低 OOM 概率。
7.4 缓存命中率低
缓存命中率低通常有以下原因:
- 请求前缀变化太大。如果每个用户的 Prompt 前缀都不相同,命中率自然低。
- 缓存淘汰策略激进,缓存被频繁清除。
- 路由策略没有基于前缀 Hash 做选择。
如果要提升命中率,可以考虑:
- 使用结构化 Prompt 模板,让相同意图的请求共享前缀。
- 在推理引擎中开启 Prefix Caching。
- 适当扩大每实例的 KV Cache 容量。
- 增加缓存预热机制,提前把高频文档的 Prefix 加载到实例中。
8. 最佳实践与工程建议
8.1 先把吞吐瓶颈量化清楚
在实际投入重构之前,先回答几个问题:
- 当前 Prefill 耗时占比是多少?
- 当前 KV Cache 平均占用率是多少?
- 缓存命中率是多少?
- P99 延迟主要贡献来自 Prefill 还是 Decode?
把这些指标监控起来之后,再决定是否需要做 P/D 分离,以及采用多大比例的 Prefill / Decode 实例池。
8.2 路由层要保留降级策略
KV Cache 感知路由依赖实例状态数据的实时上报。如果上报链路故障、实例状态缺失,路由层不能直接报错,应该降级为普通轮询路由,保证服务可用。这里推荐的做法是:
- 路由组件内存中维护一个长期状态表。
- 每次指标采集失败时,标记实例状态为“未知”,但保留历史 KV Cache 信息。
- 当实例状态完全不可用时,降级为随机路由。
8.3 安全与权限边界
路由服务需要读取 Kubernetes Pod 信息,如果使用 CRD 管理实例状态,还需要操作 CRD 的权限。应按最小权限原则配置 RBAC,不要给路由服务赋予整个集群的超级权限。
示例 RBAC:
apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: router-reader namespace: llm-inference rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] - apiGroups: ["inference.example.com"] resources: ["workerstatuses"] verbs: ["get", "list", "watch"]同时,实际生产环境中的变更操作,比如更新 CRD、更新 Deployment 副本数、调整缓存配置,都应该先在小范围实例上验证,再滚动发布。
8.4 监控与告警
需要重点监控的指标包括:
- 请求级别的缓存命中率。
- KV Cache 平均占用率。
- 每个实例的 KV Cache 剩余空间。
- Prefill 耗时 P99 和 Decode 耗时 P99。
- KV Cache 传输耗时和传输失败率。
- 路由实例状态表的数据新鲜度。
其中,KV Cache 剩余空间是最容易引发 P0 事故的指标。建议设定两级告警:
- 当实例 KV Cache 剩余空间低于 20% 时,触发告警。
- 当低于 10% 时,触发扩容流程或拒绝新请求。
8.5 版本管理注意事项
LLM 推理引擎的迭代速度很快,KV Cache 的存储格式、前缀缓存算法、显存管理方式都可能随版本变化。升级推理引擎时,要重点验证:
- KV Cache 的序列化格式是否兼容。
- 前缀缓存的 Hash 规则是否变化。
- 指标接口返回字段是否有调整。
如果上游版本有破坏性变更,建议在一个独立命名空间里先跑新版本,对照旧版本观察至少几天,再决定全量迁移。
9. 总结与学习路线
本文围绕 Kubernetes 上的大模型推理优化,讲了 KV Cache 感知路由和 Prefill / Decode 分离两条主线。核心结论如下:
- KV Cache 是 LLM 推理中显存消耗的大头,也是多轮对话优化的重要抓手。
- 传统 Kubernetes Service 负载均衡无法感知 KV Cache 状态,需要额外的路由组件参与流量调度。
- KV Cache 感知路由的核心是:优先命中缓存、按剩余空间排序、空间不足时拒绝或扩容。
- Prefill / Decode 分离可以让两种计算特征的阶段各得其所,但需要评估收益边界,不能盲目拆分。
- 在 Kubernetes 上落地时,可以通过标签区分实例池、CRD 描述实例状态、自定义网关完成端到端转发。
接下来如果你想继续深入,建议按这个顺序学习:
- 先把 Prefill 和 Decode 两个阶段用 Profiler 量化分析清楚。
- 掌握 vLLM / SGLang 的 Prefix Caching 配置方法。
- 实现一个最小可运行的 KV Cache 状态采集模块。
- 再扩展路由策略、调度联动和 P/D 分离。
大模型推理系统的优化空间很大,KV Cache 感知路由只是其中一个方向。对日常开发而言,先把缓存命中率和实例状态监控建设起来,往往就能解决大部分长上下文场景下的性能抖动问题。如果你正在推进类似的架构改造,建议从最小可行版本开始,逐步沉淀出一套适合自己业务的路由和调度体系。