LLM Serving 自动伸缩生产实战:用队列水位、P95 延迟与 GPU 利用率安全扩缩容
背景问题:LLM Serving 不能只用普通 Web 服务的扩缩容思路
传统 Web 服务扩缩容习惯看 CPU、内存、QPS 或请求并发。到了 LLM Serving 场景,这些指标开始失灵:同样是 1 个请求,短 prompt 和长 prompt 的计算量可能完全不同;同样是 10 QPS,上下文长度、输出 token 数、batch 状态和模型规模的差异,对 GPU 的压力也完全不同。
生产环境里最常见的错误,是把 GPU 利用率或 CPU 利用率当成唯一扩缩容信号,这通常会引发三类问题:
- GPU 利用率不高,但用户已经感觉到慢。 请求在入口队列或副本内部队列中堆积,P95 延迟飙升,而 GPU 利用率仍然显示”还有余量”。
- 扩容动作生效存在延迟。 大模型权重加载、GPU 显存分配、CUDA graph 构建、kernel warmup、健康检查和路由注册都需要时间——这段时间内,新副本无法承接真实流量。
- 降容只看瞬时低负载,误伤预热副本。 流量低谷时回收刚预热好的副本,下一波流量到来时又重新冷启动,形成”扩了降、降了扩”的震荡。
因此,LLM Serving 自动伸缩的目标不是”让 GPU 始终跑满”,而是:在给定成本约束下,尽量稳定满足 SLO,并避免因扩缩容动作本身制造新的抖动。
核心原理:把自动伸缩看成控制闭环
一个可投产的 LLM Serving 自动伸缩系统,至少需要四类信号。
1. 队列水位
队列水位是最早暴露问题的信号。用户体验恶化通常从排队开始,而不是等到 GPU 利用率达到 100%。
核心指标包括:
- 入口队列长度和等待时间分布
- 每个副本的
pending_requests与ongoing_requests - 副本内部调度队列深度
Ray Serve 的 autoscaling 文档明确建议用 target_ongoing_requests 和 max_ongoing_requests 定义 steady state,并基于每个副本的 ongoing requests 做扩缩容判断。在 LLM 场景中,队列水位比 CPU 更直接反映用户体感。
2. P95 / P99 延迟
LLM 服务的延迟观测必须拆分为三个维度:
| 指标 | 含义 | 说明 |
|---|---|---|
| TTFT | Time To First Token | 用户看到第一个 token 前的等待时间,直接影响交互体感 |
| TPOT | Time Per Output Token | 生成阶段每个 token 的间隔,反映 decode 效率 |
| E2E Latency | 端到端延迟 | 涵盖网关、排队、prefill、decode、流式输出和后处理 |
只看平均延迟会掩盖长尾问题。 扩缩容策略应至少把 P95 作为保护信号,必要时单独对高优先级租户设置更严格的 P95 目标。
3. Token 吞吐
LLM 服务的真实负载不是 QPS,而是 token 流量。核心指标包括:
input_tokens/s与output_tokens/sprefill_tokens/s与decode_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
这个配置表达了三个核心原则:
- 扩容要快于降容,但不能无限快。 每次最多新增 4 个副本,扩容冷却 30 秒。
- 降容必须更保守。 LLM 副本有较高冷启动成本,降容冷却 300 秒,连续 10 个窗口满足条件才动作。
- 扩容失败时必须转入保护模式。 拒绝低优先级请求、降低最大输出 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/s或output_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 不足、节点不可调度、镜像拉取失败?
- 是否验证过连续扩容和连续降容的稳定性?
- 是否能解释每一次扩缩容动作?