文章

LLM Serving 自动伸缩生产实战:用队列水位、P95 延迟与 GPU 利用率安全扩缩容

本文深入探讨 LLM Serving 生产环境的自动伸缩方案,以队列水位、P95 延迟、Token 吞吐和 GPU 利用率构建多层扩缩容控制闭环,融入 warm pool、admission control、成本边界与 cooldown 策略,帮助团队在成本约束下稳定满足推理 SLO。

LLM Serving 自动伸缩生产实战:用队列水位、P95 延迟与 GPU 利用率安全扩缩容

背景问题:LLM Serving 不能只用普通 Web 服务的扩缩容思路

传统 Web 服务扩缩容习惯看 CPU、内存、QPS 或请求并发。到了 LLM Serving 场景,这些指标开始失灵:同样是 1 个请求,短 prompt 和长 prompt 的计算量可能完全不同;同样是 10 QPS,上下文长度、输出 token 数、batch 状态和模型规模的差异,对 GPU 的压力也完全不同。

生产环境里最常见的错误,是把 GPU 利用率或 CPU 利用率当成唯一扩缩容信号,这通常会引发三类问题:

  1. GPU 利用率不高,但用户已经感觉到慢。 请求在入口队列或副本内部队列中堆积,P95 延迟飙升,而 GPU 利用率仍然显示”还有余量”。
  2. 扩容动作生效存在延迟。 大模型权重加载、GPU 显存分配、CUDA graph 构建、kernel warmup、健康检查和路由注册都需要时间——这段时间内,新副本无法承接真实流量。
  3. 降容只看瞬时低负载,误伤预热副本。 流量低谷时回收刚预热好的副本,下一波流量到来时又重新冷启动,形成”扩了降、降了扩”的震荡。

因此,LLM Serving 自动伸缩的目标不是”让 GPU 始终跑满”,而是:在给定成本约束下,尽量稳定满足 SLO,并避免因扩缩容动作本身制造新的抖动


核心原理:把自动伸缩看成控制闭环

一个可投产的 LLM Serving 自动伸缩系统,至少需要四类信号。

1. 队列水位

队列水位是最早暴露问题的信号。用户体验恶化通常从排队开始,而不是等到 GPU 利用率达到 100%。

核心指标包括:

  • 入口队列长度和等待时间分布
  • 每个副本的 pending_requestsongoing_requests
  • 副本内部调度队列深度

Ray Serve 的 autoscaling 文档明确建议用 target_ongoing_requestsmax_ongoing_requests 定义 steady state,并基于每个副本的 ongoing requests 做扩缩容判断。在 LLM 场景中,队列水位比 CPU 更直接反映用户体感。

2. P95 / P99 延迟

LLM 服务的延迟观测必须拆分为三个维度:

指标含义说明
TTFTTime To First Token用户看到第一个 token 前的等待时间,直接影响交互体感
TPOTTime Per Output Token生成阶段每个 token 的间隔,反映 decode 效率
E2E Latency端到端延迟涵盖网关、排队、prefill、decode、流式输出和后处理

只看平均延迟会掩盖长尾问题。 扩缩容策略应至少把 P95 作为保护信号,必要时单独对高优先级租户设置更严格的 P95 目标。

3. Token 吞吐

LLM 服务的真实负载不是 QPS,而是 token 流量。核心指标包括:

  • input_tokens/soutput_tokens/s
  • prefill_tokens/sdecode_tokens/s
  • 每个请求的 max_tokens 上限
  • 每个租户的 token 预算消耗速度

当请求数量不变、但平均 prompt 长度变长时,QPS 不会发出任何告警,而 prefill 压力已经明显上升。Token 吞吐是比 QPS 更真实的负载度量。

4. GPU 利用率与显存水位

GPU 利用率仍然重要,但应作为容量和安全边界信号,而非唯一控制目标。需要同时观测:

  • GPU utilization(SM 占用率)
  • GPU memory used(显存使用量)
  • KV cache 或显存碎片水位
  • OOM / retry / preemption 次数
  • 每个副本可接收新请求的剩余容量

NVIDIA Triton 的 rate limiter 强调:在多模型或资源受限场景下,“能接收请求”和”应该立即执行请求”不是同一个问题。rate limiter 控制请求调度速率,避免同时运行过多实例导致服务器过载。


推荐架构:入口限流、队列观测、扩缩容控制器三层分离

不要把自动伸缩逻辑塞进模型服务进程本身。 更稳妥的做法是拆成三层。

第一层:Gateway / Ingress

入口层负责租户识别、认证、限流、请求大小检查和路由。关键在于为每个请求打上成本画像

{
  "tenant_id": "tenant-a",
  "model": "llama-70b-chat",
  "input_tokens_estimated": 4200,
  "max_output_tokens": 800,
  "priority": "interactive",
  "request_class": "chat_long_context"
}

这个画像不是为了替代模型服务,而是为排队、限流、成本归因和扩缩容提供统一语义

第二层:Serving Pool

Serving Pool 可以是 vLLM、Triton、Ray Serve、自研推理服务或托管 endpoint。每个副本必须暴露足够丰富的指标:

metrics:
  request_queue_depth: gauge
  ongoing_requests: gauge
  ttft_p95_ms: histogram
  tpot_p95_ms: histogram
  input_tokens_per_second: counter
  output_tokens_per_second: counter
  gpu_utilization: gauge
  gpu_memory_used_ratio: gauge
  rejected_requests_total: counter
  oom_total: counter

如果指标只停留在 GPU 利用率和 HTTP latency,扩缩容控制器很难判断是该扩容、该限流、该拆分长请求,还是该修复某个慢副本。

第三层:Autoscaler Controller

控制器不应直接根据单个指标做动作,而应计算一个综合状态

autoscalingPolicy:
  minReplicas: 2
  maxReplicas: 24
  warmPoolReplicas: 2
  scaleUp:
    queueDepthPerReplica: 8
    p95LatencyMs: 2500
    gpuUtilization: 0.78
    consecutiveWindows: 2
  scaleDown:
    queueDepthPerReplica: 1
    p95LatencyMs: 1200
    gpuUtilization: 0.35
    consecutiveWindows: 10
  cooldown:
    scaleUpSeconds: 30
    scaleDownSeconds: 300
  safety:
    maxNewReplicasPerStep: 4
    maxHourlyGpuCostUsd: 120
    rejectWhenQueueWaitMsAbove: 8000

这个配置表达了三个核心原则:

  1. 扩容要快于降容,但不能无限快。 每次最多新增 4 个副本,扩容冷却 30 秒。
  2. 降容必须更保守。 LLM 副本有较高冷启动成本,降容冷却 300 秒,连续 10 个窗口满足条件才动作。
  3. 扩容失败时必须转入保护模式。 拒绝低优先级请求、降低最大输出 token 或切换至较小模型。

工程落地:一套可上线的扩缩容决策流程

第一步:定义容量单位

不要说”一个副本能处理多少 QPS”,应定义一个更贴近 LLM 的容量单位:

capacity_unit = weighted_input_tokens + weighted_output_tokens + queue_penalty

一个可落地的实现:

def estimate_request_cost(
    input_tokens: int,
    max_output_tokens: int,
    request_class: str,
) -> float:
    prefill_weight = 1.0
    decode_weight = 2.0
    class_factor = {
        "chat_short": 1.0,
        "chat_long_context": 1.4,
        "agent_tool_loop": 1.6,
        "batch_summary": 0.8,
    }.get(request_class, 1.0)
    return class_factor * (
        input_tokens * prefill_weight + max_output_tokens * decode_weight
    )

这不是精确建模,而是让网关、队列和控制器都能基于同一套负载语义对话。

第二步:扩容信号使用”或”,降容信号使用”且”

扩容是为了保护用户体验,所以只要多个风险信号中有一类持续触发,就可以扩容:

  • 队列水位连续两个窗口超过阈值
  • P95 延迟连续两个窗口超过 SLO
  • GPU 利用率和显存水位同时接近上限
  • input_tokens/soutput_tokens/s 突然攀升

降容则必须更保守,要求多个信号同时满足:队列低、延迟稳定、GPU 低、没有待处理长请求、没有新上线版本的流量观察期。

Kubernetes HPA 的基本算法基于当前指标与目标指标的比例计算目标副本数,LLM Serving 可复用这种思想,但指标选择必须从 CPU 扩展到队列、延迟和 token 吞吐

第三步:引入 Warm Pool

大模型冷启动的代价远高于普通 Web 服务。即使容器启动很快,以下步骤仍可能让新副本在短时间内无法承接真实流量:

  • 模型权重加载(几十 GB)
  • GPU 显存分配与碎片整理
  • CUDA graph 构建与 kernel warmup
  • 路由注册与健康检查

因此,生产系统应保留 warm pool——这些副本已完成模型加载和健康检查,但暂不接收主流量。当入口队列开始抬升时,先切入 warm pool,再触发新副本补充 warm pool 水位。

第四步:把 Admission Control 放在扩容前面

自动伸缩不是无限容量。流量突增时,扩容可能来不及。此时先保护系统不崩比尝试扩容更重要:

保护动作适用场景
对低优先级请求返回 429 或排队提示瞬时流量尖刺
限制单请求最大 input/output tokens异常长请求
暂停非实时 batch 请求资源争抢
路由至小模型或降级模型主模型过载
对相同 prompt 启用语义缓存重复请求
超长上下文请求拆分到独立池长尾请求影响短请求

核心思想:拒绝一部分请求,比让所有请求一起超时更可控。

第五步:让扩缩容动作可解释

每一次扩容或降容都应留下决策记录:

{
  "time": "2026-07-08T23:02:17Z",
  "model_pool": "chat-70b-prod",
  "action": "scale_up",
  "from_replicas": 8,
  "to_replicas": 12,
  "reason": [
    "queue_depth_per_replica=11 > 8",
    "p95_latency_ms=3100 > 2500",
    "gpu_utilization=0.81 > 0.78"
  ],
  "cooldown_seconds": 30,
  "cost_guardrail": "within_budget"
}

没有决策记录,后续排查只能靠猜。尤其当业务方质疑成本上涨时,必须能回答:扩容是哪个 SLO 风险触发的、持续了多久、是否有替代动作、是否越过成本边界。


适用场景

适合的场景:

  • 在线 Chat、Copilot、Agent 或客服助手——对 P95 和 TTFT 敏感
  • 多租户模型服务——需要防止某个大租户把共享 GPU 池打满
  • 同一集群托管多个模型或多版本——避免互相争抢显存和执行槽位
  • 有明显流量波峰波谷——在稳定体验和 GPU 成本之间取得平衡

不适合的场景:

  • 纯离线 batch——优先考虑任务队列、批量窗口和成本优化,不一定需要秒级扩缩容
  • 强实时语音或视频对话——扩缩容要依赖预热容量和流量预测,不能只等队列升高再反应

常见误区

误区一:GPU 利用率越高越好

GPU 利用率高通常意味着资源被充分使用,但不等于用户体验好。对于交互式 LLM 服务,过度追求 GPU 利用率可能牺牲 TTFT 和 P95。应按业务分层:交互请求重延迟,离线请求重吞吐。

误区二:扩容能解决所有慢请求

扩容解决的是容量不足,不解决单请求过长、模型本身慢、网络慢、下游工具慢、prompt 过大或流式输出阻塞。扩容前要先看慢请求画像,避免用更多 GPU 掩盖架构问题。

误区三:降容只看低负载窗口

LLM 服务降容必须考虑冷启动成本和下一波流量风险。建议使用更长观察窗口、充足的 downscale cooldown 和最小 warm pool 保护。

误区四:所有请求共享一个池

短 prompt、长上下文、Agent、多模态、batch 任务混在一个池里,最容易互相影响。生产上应按模型、请求类型、租户等级、延迟等级拆分多个 pool,分别配置扩缩容策略。


上线检查清单

指标完整性

  • 是否有入口队列、每副本队列、ongoing requests?
  • 是否区分 TTFT、TPOT 和端到端延迟?
  • 是否记录 input tokens/s 与 output tokens/s?
  • 是否有 GPU 利用率、显存水位和 OOM 计数?
  • 是否按租户、模型、请求类型打标签?

扩缩容策略

  • 是否设置 minReplicas、maxReplicas 和 warm pool?
  • 是否配置 scale-up 与 scale-down 的不同观察窗口?
  • 是否有 cooldown 和 hysteresis,避免抖动?
  • 是否限制每次最大新增副本数?
  • 是否有成本上限和异常报警?

保护机制

  • 入口是否能按租户限流?
  • 是否能拒绝或降级低优先级请求?
  • 是否能限制单请求最大 token?
  • 是否能把 batch 和 interactive 分池?
  • 扩容失败时是否有 fallback?

回放验证

  • 是否用历史峰值流量做过 replay?
  • 是否模拟过新副本冷启动延迟?
  • 是否验证过 GPU 不足、节点不可调度、镜像拉取失败?
  • 是否验证过连续扩容和连续降容的稳定性?
  • 是否能解释每一次扩缩容动作?

参考资料

常见问题

LLM Serving 自动伸缩应该优先看 CPU 还是 GPU?
都不能单独看。CPU 往往不是瓶颈,GPU 利用率也不一定提前暴露排队问题。生产上更稳妥的主信号是队列水位、ongoing requests、TTFT/P95、token 吞吐和 GPU/显存水位的组合。
为什么 GPU 利用率很低,用户仍然感觉慢?
LLM 推理慢不一定来自 GPU 空闲不足,也可能来自排队、prefill 堵塞、长上下文请求挤占、冷启动或副本内部并发过高。需要同时观察队列等待时间、TTFT 和请求 token 分布。
扩容越快越好吗?
不是。扩容过激会造成副本震荡、冷启动放大和成本浪费。生产中需要 cooldown、hysteresis、warm pool 和最大成本边界,降容应比扩容更保守。