路由、自动扩缩与 KV 感知路由(多实例)¶
基线:vLLM 0.26.0 · 模型 Qwen2.5-7B-Instruct · 多实例(单卡或多卡)
已按 ADR-0004 用 Context7 对照 vLLM 0.26.0 核实:要扩到一个实例之外,vLLM 指向 production stack(helm repo add vllm https://vllm-project.github.io/production-stack、helm install vllm vllm/vllm-stack -f values.yaml),它在 engine pod 前部署一个 router pod,提供模型感知与前缀感知路由 (model-aware and prefix-aware routing),外加经 LMCache 的 KV-cache offload。对数据并行部署,文档说得很明确:每个引擎各自维护独立 KV cache,所以智能路由能最大化 prefix-caching 收益。自动扩缩读负载信号——SkyPilot 的 replica_policy 用 target_qps_per_replica;Kubernetes HPA 读引擎自身 /metrics 的 vllm:num_requests_waiting gauge。本节所有数字均为示例 / 量级参考。
1 · 直觉 & 为什么重要¶
上一节给了你一个数:一个实例的 knee——过了它延迟就失控的到达率。真实流量会超过一个实例的 knee。于是你做那件显然的事:跑若干实例,在前面放一个 router 把请求铺开。那就是横向扩展,也是「一个引擎」变成「一个服务」的地方。
但有两个决定,把一堆实例变成一个好服务,两者都是面试宠儿:
- 怎么路由和开几个一样重要。 朴素答案——round-robin——常常是错的,因为它无视你在 Part 5 造好的最大免费收益:prefix cache。若两个请求共享一段长 system prompt,把它们发到同一个实例,第二个就能复用第一个缓存的 KV;round-robin 把它们打散、每个实例都重做一遍 prefill。KV 感知(前缀感知)路由把请求落点变成一个缓存命中优化。
- 怎么扩——以及按什么信号。 更多流量 → 更多实例,自动地。但你按之扩缩的信号,决定它成不成。GPU 利用率是个陷阱(memory-bound 的 decode workload 可以「100% 忙」却仍有队列余量,或「低利用」却 KV cache 已满)。真正跟踪「我过 knee 了吗」的信号是队列深度——
vllm:num_requests_waiting——这就是 vLLM 导出它、自动扩缩器挂它的原因。
所以:先是跨副本路由(以及前缀感知为何胜过 round-robin),再是自动扩缩(以及为何队列、而非 GPU 利用率,是触发信号)。→ 术语见 术语表 的 KV 感知路由、SLO、Knee。
2 · 心智模型¶
一个 router 坐在 N 个独立引擎副本前——每个各有自己的 KV cache——一个自动扩缩器看负载信号、改 N。
┌─────────── 自动扩缩器 ───────────┐
│ 读 vllm:num_requests_waiting │
│ (队列深度) → 增/减 N │
└───────────────┬──────────────────┘
│ 设 N
请求 ▼
───────────────▶ ┌───────────────┐ ┌────────────────────────────────┐
│ ROUTER │────▶ │ 副本 0 [KV cache A] │
│ round-robin? │────▶ │ 副本 1 [KV cache B] ← 各自独立 │
│ 前缀感知? │────▶ │ 副本 2 [KV cache C] 的缓存! │
└───────────────┘ └────────────────────────────────┘
│
ROUND-ROBIN: 同前缀请求打散 → 每个副本都重 prefill 共享 prompt(缓存未命中)
前缀感知: 按缓存前缀路由 → 已有它的副本来服务(缓存命中,跳过 prefill)
上面的 router/副本拓扑是一张空间布局(ASCII,按 ADR-0005)。而队列信号驱动的自动扩缩控制回路是一个流程,故用 Mermaid flowchart(图内标签按 ADR-0005 保持英文):
flowchart TB
M["read vllm:num_requests_waiting (queue depth)"] --> Q{"queue above threshold?"}
Q -->|"yes"| UP["scale up: add replica<br/>(cold-start lag — provision headroom / pre-warm)"]
Q -->|"no"| Q2{"queue near 0 and running low?"}
Q2 -->|"yes"| DOWN["scale down: drain first<br/>(running AND waiting reach 0), then remove"]
Q2 -->|"no"| HOLD["hold N replicas"]
UP --> M
DOWN --> M
HOLD --> M
三个要记住的形状:
- 缓存是每实例的,不共享。 副本 0 的 prefix cache 与 KV block 对副本 1 不可见。所以你把请求发去哪决定它命不命中热缓存。路由因而是个缓存落点问题,不只是铺负载。
- 前缀感知路由 =「发到它前缀已在的那台」。 若一个请求的前导 token(system prompt、few-shot 前言、对话历史)已缓存在某副本上,就路由过去、跳过 prefill。round-robin 把这个丢掉;前缀感知路由正是 production stack 标榜它的全部理由。
- 按队列扩缩,不按利用率。
num_requests_waiting > 0且在涨,意味请求此刻过了 knee——直接、与模型无关的过载信号。GPU 利用率混淆 compute-bound 与 memory-bound 两种状态、对 decode 的余量撒谎。按队列(或按校准到 knee 的 QPS 代理)扩 N。
3 · 原理¶
3.1 多实例,独立缓存¶
扩展单元是一个完整引擎实例(一个 vllm serve 进程,它自己可能跨 GPU 做 Part 7 的 TP/PP)。跑若干个——在若干 GPU、若干节点或若干 pod 上——挂在一个地址后面。每个实例都独立:自己的权重(或一份只读共享副本)、自己的 KV-cache block pool、自己的 prefix cache。除非你加一个外部 KV 存储(LMCache,见下),实例间什么都不共享。这个独立性正是路由不中立的原因:两个相同请求,纯凭 router 挑了哪个副本,就命中热缓存或冷缓存。
3.2 负载均衡:round-robin vs KV 感知¶
- round-robin / 最少负载。 按数量或当前负载均匀铺请求。简单,且在请求独立且短时是对的。但它无视前缀复用:共享长 system prompt 的请求被打散,于是每个副本再付一遍共享 prefill。你恰在它最要紧时(长共享前缀)丢掉 prefix-cache 收益。
- KV 感知 / 前缀感知路由。 按内容路由:哈希请求前缀、在负载上限内发给已有该前缀缓存的副本。现在共享 system prompt 每副本只 prefill 一次并复用;DP 部署文档直说了理由——因为每个引擎维护独立 KV cache,智能请求路由能最大化 prefix-caching 收益。代价是热点风险(一个流行前缀堆到一个副本上),所以真实 router 会把前缀亲和与负载均衡揉在一起。
production stack 的 router 把这个内建:它提供模型感知路由(挑服务对应模型的副本)与前缀感知路由(挑有热前缀的副本),并能把 KV offload 到 LMCache,让一个副本 GPU 上被逐出的前缀被重取、而非重算。
3.3 自动扩缩——以及那个信号¶
自动扩缩随负载改副本数 N。整个游戏是按哪个信号扩缩:
- 队列深度——
vllm:num_requests_waiting。 「请求过了 knee」的直接读数。vLLM 在/metrics导出它,正是为了让 Kubernetes HPA(或 KEDA Prometheus scaler)挂它:waiting 涨 → 加副本;waiting 停在 0 且 running 低 → 撤掉。这是推荐信号,因为它对 prefill-heavy 与 decode-heavy workload 意思一样。 - 每副本 QPS。 SkyPilot serve 的
replica_policy在min_replicas与max_replicas间按target_qps_per_replica扩缩——一个更粗但有效的代理,前提是你把目标校准到上一节测出的 knee。把target_qps_per_replica设在(或略低于)knee,扩缩器就把每副本保持在线性区。 - GPU 利用率——陷阱。 诱人且错。decode 是 memory-bound 的:一个副本可以报高「利用率」却仍有 batch 余量,或报适中利用率却 KV cache 已满、正在排队。利用率不跟踪 knee;队列跟踪。
两个运维现实:冷启动——新副本要加载权重、热 CUDA graphs(数十秒),所以扩容滞后;备好余量或预热。以及缩容安全——杀一个副本前,让它排空 (drain)(vllm:num_requests_running 与 vllm:num_requests_waiting 都到 0),别丢在途请求。
3.4 拼起来¶
一个生产部署的形状:一个 router(前缀感知、模型感知)在一个引擎 pod Deployment 前,一个自动扩缩器盯 num_requests_waiting,/health 做存活性、/metrics 做负载,以及——可选——LMCache 作为共享 KV 层,让跨副本未命中花一次取、而非一次完整 prefill。production stack 把这一切作为一个 Helm chart 发布;你也可以用一个普通负载均衡器 + HPA + 你自己的路由规则拼出来。
3.5 在 vLLM 源码里读它(v0.26.0)¶
本节大部分机制——router、HPA、SkyPilot policy——都住在引擎之外(production stack 是独立仓库,K8s/Helm 是基础设施)。vLLM 自己提供的、以及在哪,是两样东西(ADR-0002:读懂 + 会推,不重写):
- 自动扩缩的信号是一个导出的 gauge。
vllm/v1/metrics/loggers.py里的PrometheusStatLogger在/metrics上发布vllm:num_requests_waitingGauge(与num_requests_running)。HPA/KEDA 不会窥探 vLLM 内部——它抓取那个 gauge。所以「按队列扩缩」其实是「按这一个导出的数扩缩」;引擎唯一的活是诚实上报它。 - 每实例独立的缓存就是引擎核心。 每个
vllm serve进程拥有自己的 KV-cache block 池与 prefix cache(Part 5 的机制)——vLLM 本身没有跨进程共享,这正是前缀感知路由(一个外部 router 的决策)为何重要。跨副本复用需要外部的 LMCache 层。
读源码时的要点:vLLM 暴露信号与独立缓存;路由与扩缩的策略是刻意放在引擎之外的。从 loggers.py 开始,看清你到底能按哪些 gauge 扩缩。
4 · 完整可跑代码 + 逐行讲解¶
部署 production stack(router + 副本),再是两种自动扩缩信号:一个挂队列 gauge 的 Kubernetes HPA,和 SkyPilot 的 QPS 策略。
# helm repo add vllm https://vllm-project.github.io/production-stack
# helm install vllm vllm/vllm-stack -f values.yaml
# 注:Helm 安装、router pod + 引擎 pod、模型/前缀感知路由均已核实;下面 values.yaml 的确切
# 字段名为示意——请对照 production-stack chart 自带的 values.yaml(按你的 chart 版本)确认 schema。
servingEngineSpec:
modelSpec:
- name: "qwen"
repository: "vllm/vllm-openai"
modelURL: "Qwen/Qwen2.5-7B-Instruct" # 所服务的模型
replicaCount: 2 # 起步 2 个引擎 pod,每个各是独立缓存
requestGPU: 1 # 每副本 1 张 GPU(TP>1 会请求更多)
routerSpec:
routingLogic: "prefixaware" # KV 感知:把请求发给有其热前缀的副本
# (round-robin 会打散共享前缀 → 缓存未命中)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-engine
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: vllm-engine }
minReplicas: 2
maxReplicas: 8
metrics:
- type: Pods
pods:
metric:
name: vllm_num_requests_waiting # /metrics 的 gauge = 队列深度 =「过了 knee」
target:
type: AverageValue
averageValue: "5" # 平均队列 > 5 就加副本(按你的 SLO/knee 调)
service:
replica_policy:
min_replicas: 2
max_replicas: 4
target_qps_per_replica: 8 # = 压测那节测出的每实例 knee
readiness_probe:
path: /v1/chat/completions # 一个真实请求,不只是 /health——证明它能服务
post_data: { model: qwen2.5-7b, messages: [{role: user, content: "ping"}], max_completion_tokens: 1 }
逐行讲解:
replicaCount: 2+ 独立缓存 —— 两个引擎 pod,每个有自己的 KV/prefix cache。那份独立性正是 router 策略要紧的原因:同一请求在一个 pod 命中、在另一个未命中。routingLogic: "prefixaware"—— router 哈希每个请求的前缀、发给已持该前缀 KV 的副本,把一段共享 system prompt 从每请求一次 prefill 变成每副本一次 prefill。换成 round-robin,你就为共享前缀流量放弃了 prefix-caching 收益。- HPA
metric: vllm_num_requests_waiting—— 扩缩器盯 vLLM 在/metrics导出的队列深度 gauge。平均队列 > 5 → 扩容;排空趋 0 → 缩容。这是与模型无关的过载信号;无论负载 prefill-heavy 还是 decode-heavy 都一个意思——不像 GPU 利用率。 averageValue: "5"—— 阈值是你的策略旋钮:按 SLO 与 knee 设(加容量前你能容忍的一小段常驻队列)。太低 → 抖动;太高 → 扩容滞后期错过 SLO。target_qps_per_replica: 8—— SkyPilot 更粗的旋钮:把每副本保持在约 8 请求/秒,那应是你上一节测出的 knee。knee 之下每副本停在线性区;总 QPS 涨时扩缩器加副本。readiness_probe打一个真实 chat 请求 —— 就绪必须证明 pod 真能服务(权重加载完、引擎热),而/health(只存活性)做不到。一个极小max_completion_tokens: 1的请求是诚实的就绪检查。
5 · Lab —— 看前缀感知路由打赢 round-robin¶
GPU Lab(多实例——2 张 GPU,或单卡上 2 个小实例)
- 最低显存 / 卡数: 两个引擎实例。2 张 GPU(各一副本)最干净。单张 24 GB 4090 上可用两个小模型实例(如
Qwen2.5-0.5B-Instruct)在不同端口、或两个 MPS 分片来近似——够看路由行为,不够压 7B 吞吐。 - 建议 AutoDL 卡型: 真实 7B 多实例测试用 2× 4090 或 2× A100、「开机即关」(ADR-0001);或单张 4090 配两个小实例做路由演示。做完把多卡拆掉。
- 预估耗时 / 花费: 约 30–45 分钟 · 视卡 约 ¥3–15(示例)。产出是缓存命中差异,不是吞吐纪录。
- 平台: NVIDIA CUDA(默认)。非 NVIDIA: router 与自动扩缩器是基础设施(Helm/K8s/HTTP)、与硬件无关;只有引擎 pod 按后端不同。
步骤:
- 两副本,一个共享前缀。 起两个实例。造一批共享长 system prompt(如 1k-token 前言)、user 问题各异的请求。
- round-robin 基线。 把共享前缀请求 round-robin 路由。在每个副本上
curl /metrics | grep -i prefix(prefix-cache 命中/总数计数器)——命中很低,因为前缀被打散、每个副本都重 prefill。 - 前缀感知。 把 router 切到前缀感知。发同样的请求。现在共享前缀反复落在一个副本上:它的 prefix-cache 命中计数器爬升、它的 TTFT 下降(prefill 被跳过)。那个差距就是全部要点。
- 按队列扩缩。 把负载推过一个副本的 knee,看
vllm:num_requests_waiting上升;确认你的 HPA/策略加了一个副本、队列排空。然后把任何多卡实例关机。
6 · 常见坑 / 反直觉点¶
- round-robin 路由悄悄杀死 prefix cache。 最常见的浪费:共享前缀流量(同 system prompt、RAG 前言或对话)被均匀打散,意味每个副本都重 prefill 共享部分。对共享前缀 workload,前缀感知路由能大幅削减 TTFT 与 prefill 成本——round-robin 把它丢了。
- 按 GPU 利用率自动扩缩。 decode 是 memory-bound 的:一个副本可以坐在「100% 利用」却有 batch 余量,或「40% 利用」却 KV cache 满、在排队。利用率不跟踪 knee。按
vllm:num_requests_waiting(队列深度)或校准到 knee 的 QPS 目标扩缩。 - 无视冷启动滞后。 新副本要数十秒加载权重、热 CUDA graphs。若你只在队列已深之后才扩容,等 pod 就绪时 SLO 已被打穿。备余量、预热、或按前瞻指标扩缩。
- 不排空就缩容。 杀一个有在途请求的 pod 会丢掉它们。终止前,停止把新活路由给它、等到
num_requests_running与num_requests_waiting都到 0——再撤。 - 以为缓存跨副本共享。 不共享。副本 0 上热的前缀在副本 1 上是冷的。跨副本复用需一个显式共享 KV 层(production stack 里的 LMCache);否则路由是你对缓存命中的唯一杠杆。
- 前缀亲和热点。 纯前缀感知路由会把一个流行前缀堆到一个副本上、其他闲着。真实 router 揉合前缀亲和与负载均衡;调这个平衡,别只按前缀路由。
- 把就绪当
/health。/health是存活性(引擎活着),不是就绪性(现在能服务)。在/health上就把 pod 加入轮转的负载均衡器,会把流量发给还在加载的引擎。用真实就绪探针(一个极小生成请求),如 §4。 - 流式穿过一个缓冲的负载均衡器。 如server 那节,一个缓冲响应的 LB 会把 SSE 流式塌成一个迟到的 chunk。配置 router/LB 不缓冲地透传流式响应。
- 以为 vLLM 会自己扩缩。 引擎只导出
vllm:num_requests_waiting(经PrometheusStatLogger,vllm/v1/metrics/loggers.py);它不路由、不扩缩、不拉副本。在你把外部抓取器(Prometheus)→ 外部控制器(HPA/KEDA/SkyPilot)→ 副本数这条链接好之前,什么都不会自动扩缩。忘了这一半的回路是你的基础设施——以及这个 gauge 是每实例的、所以 HPA 必须跨 pod 聚合(AverageValue)——正是「我把num_requests_waiting设成 metric 了但没扩缩」的由来。
7 · 面试连线¶
- 路由、自动扩缩与 KV 感知路由 —— 本节为你准备的高频题:前缀感知路由为何胜过 round-robin(每副本独立缓存)、为何按
num_requests_waiting而非 GPU 利用率自动扩缩、以及冷启动与「缩容前排空」怎么塑造一个安全策略。
8 · 小结 & 延伸阅读¶
一句话: 过了一个实例的 knee,你横向扩到 N 个独立引擎副本、挂在一个 router 后;让它变好的两个决定是 KV 感知(前缀感知)路由(缓存是每实例的,所以 round-robin 会重 prefill 共享 prompt)与按队列自动扩缩(vllm:num_requests_waiting,与模型无关的过载信号,不是 GPU 利用率),并处理好冷启动滞后与「缩容前排空」;vLLM 把它作为 production stack 发布(Helm:前缀感知 + 模型感知 router、引擎 pod、LMCache KV offload),SkyPilot 按 target_qps_per_replica 自动扩缩。
延伸阅读:
- vLLM
docs/deployment/integrations/production-stack.md—— Helm chart、router(模型感知 + 前缀感知)、LMCache KV offload。 - vLLM
docs/serving/data_parallel_deployment.md—— 为何每引擎独立 KV cache 让智能路由划算。 - vLLM
docs/deployment/frameworks/skypilot.md——replica_policy/target_qps_per_replica自动扩缩与就绪探针。 - vLLM
docs/design/metrics.md—— 你用来路由与扩缩的vllm:num_requests_waiting与 prefix-cache 命中/总数计数器。 - vLLM 源码(v0.26.0):
vllm/v1/metrics/loggers.py(PrometheusStatLogger→ autoscaler 抓取的vllm:num_requests_waiting/num_requests_runninggauge)——§3.5 的导出信号。(router、HPA、SkyPilot policy 是外部 production stack,不在引擎里。) - prefix-caching 那节 —— 前缀感知路由跨实例保住的那份每实例收益。
9 · 自测小问¶
你在一个 round-robin 负载均衡器后跑 4 个副本。所有请求共享一段 2000-token 的 system prompt。TTFT 很高、GPU 成本比预期差。发生了什么?怎么修?
round-robin 把共享前缀请求打散到全部 4 个副本,而每个副本有自己独立的 prefix cache——于是四个都为共享 system prompt 重跑那 2000-token 的 prefill、而不复用。你打败了 prefix cache:共享前言被每副本每请求重 prefill、而非缓存。修法是 KV 感知(前缀感知)路由——哈希前缀、把匹配请求发给已持该前缀 KV 的副本,于是前言每副本 prefill 一次再复用(prefill 跳过 → TTFT 降、GPU 活减少)。把它与负载均衡揉在一起,别让热前缀压垮一个副本;可选加共享 KV 层(LMCache),让跨副本未命中是一次取、而非重算。
一个 SRE 提议按 GPU 利用率 > 80% 自动扩缩 vLLM。为什么这对 LLM 服务是个差信号?你会改用什么?
GPU 利用率对 LLM 推理是陷阱,因为 decode 是 memory-bound 而非 compute-bound:跑 decode-heavy batch 的副本可以报高「利用率」却仍有 batch 宽度余量(它在等 HBM、不是打满 FLOPs),或报适中利用率却 KV cache 已满、请求已在排队。利用率不与「我过 knee 了吗」相关。直接信号是队列深度——/metrics 的 vllm:num_requests_waiting——它无论 prefill/decode 配比都一个意思:>0 且涨 = 过载、加副本;≈0 且 running 低 = 空闲、缩容。一个校准过的 target_qps_per_replica(设到测出的 knee)是可接受的更粗代理;GPU 利用率不是。
你的扩缩器在队列一飙就加副本,SLO 却仍在飙升期被违反。另外,缩容偶尔丢用户请求。分别诊断。
扩容期 SLO 违反 = 冷启动滞后。 新副本要加载权重、热 CUDA graphs——数十秒——所以队列飙升时它还没在服务;等它就绪,SLO 已被打穿。修法:留余量(按前瞻指标/更低阈值扩缩,好在饱和之前加容量)、预热副本、或接受扩容非即时并把 min-replicas 按基线峰值定。缩容丢请求 = 没排空。 终止一个有在途活的 pod 会杀掉那些请求。撤一个副本前,停止把新请求路由给它、等到 vllm:num_requests_running 与 vllm:num_requests_waiting 都到 0(优雅排空),再终止。合起来:扩容趁早(滞后),缩容从容(排空)。