Little's Law 与 vLLM 扩缩容:10 个踩坑与 +99% 吞吐

给推理服务做自动扩缩容,最容易犯的错是用 QPS 判断负载。由 Little's Law,请求越慢、满载系统的到达率反而越低——系统 100% 满载时它判定要缩容。换成饱和度判据后,并发 16 以上吞吐 +99.3%、p50 −50.1%。

Posted by Jessie Jia on 2026-08-07

给 vLLM 做多副本自动扩缩容,最容易犯的错误是用 QPS 判断负载。而要说清楚这个错误为什么致命,绕不开一条排队论里最朴素的定律:Little’s Law

这篇是把一套 SchedulerOrchestrator(多副本 vLLM 调度编排层)跑通会议摘要负载基准测试的记录:先讲清楚 Little’s Law,再是 10 个实际踩到的问题——6 个悄悄污染测量数据的陷阱,和 4 个真正的代码缺陷。

其中最关键的一条:AutoScaler 用到达率(QPS)当负载信号,而由 Little’s Law,请求越慢、满载系统的到达率反而越低——于是系统 100% 满载时它判定要缩容。换成饱和度判据后,并发 16 以上吞吐 +99.3%、p50 −50.1%。

结论先行

  • 目标环境:Qwen3-32B-FP8 · vLLM 0.26.0 · TP=2 · max-num-seqs 4 · 副本 1→2
  • 最终结果:并发 4–64,1,280 条请求,0 错误;c16 及以上扩容收益 +99% 吞吐 / −50% 延迟
  • 四个代码缺陷random.choice 负载均衡、启动预热泄漏槽位、AutoScaler 用 QPS 判断负载、阈值没有死区
  • 六个测量陷阱:孤儿进程、端口漂移、测量中途扩缩容、冷启动、结果覆盖、pkill -f 杀掉自己

如果你只想看一条:不要用到达率给推理服务做扩缩容,用在途请求数 ÷ 容量。


Little’s Law 是什么

Little’s Law(利特尔法则):在稳态系统中,平均在途请求数等于平均到达率乘以平均停留时间。

1
L = λ × W
符号 含义 在推理服务里对应什么
L 平均在途请求数(并发数 / WIP) active_requests,此刻正在系统里的请求
λ 平均到达率 qps,每秒进来多少请求
W 平均停留时间 端到端延迟,一条请求从进到出用多久

它的威力在于几乎不需要任何假设。不管到达是不是泊松分布、服务时间什么分布、调度是 FIFO 还是别的,只要系统处于稳态(长期看进出平衡、队列不无限增长),等式就恒成立。排队论里大多数结论都要先假设分布,唯独这条不用——所以它是做容量规划时最先该拿出来的工具。

为什么这让 QPS 不能当负载信号

把公式变形:

1
λ = L / W

关键在于:系统满载时,L 是被容量钉死的常数——副本数 × 每副本槽位,再多的请求也只能排队。于是

1
λ = 容量 / W

到达率变成了延迟的反比函数。同样是 100% 满载,模型越慢,QPS 读数越低:

场景 容量 L 单条延迟 W 满载时的 λ 阈值 0.5 判定
32B 模型,长摘要 8 33 s 0.24 /s 低于阈值 → 缩容
同一套系统,短请求 8 4 s 2.0 /s 高于阈值 → 扩容

同一个 100% 满载的系统,读数能差 8 倍,还恰好落在阈值两侧。原因是 λ 里混了两个变量——负载和速度——单看它无法区分「没人来」和「来了但每条都很慢」。而 L 本身就是负载,不需要解耦。

这就是把判据从 λ 换成 L ÷ 容量的全部理由。

注意适用条件:Little’s Law 只在稳态、长期平均下成立,不能拿瞬时快照去套;单位必须一致;系统边界要定义清楚——L 算不算「还在排队、尚未开始处理」的请求,会让结论完全不同。本文的 active_requests 包含排队。

同一条定律的其他应用:看板方法的 WIP 限制、TCP 的拥塞窗口,本质都是在控制 L 来保护 W。


一、测量方法问题(会直接污染数据)

这一组问题不会报错,只会让数字变得不可信。每一条都实际污染过至少一次运行。

1. 孤儿 vLLM 进程占着 GPU

表现:重启服务栈后,新副本启动失败,报 Engine core initialization failed

原因:按端口 kill 掉 orchestrator 后,它 fork 出的 vLLM 子进程不会跟着退出,仍持有 GPU 显存。新的 orchestrator 进程状态是干净的,认为 GPU 0,1 空闲,于是把新副本调度上去,与残留进程冲突。

修改:重启前执行深度清理,并核对显存归零。

1
2
3
pkill -9 -f "[v]llm.entrypoints"
pkill -9 -f "[V]LLM::Worker"
nvidia-smi --query-gpu=memory.used --format=csv,noheader # 必须全部为 0 MiB

2. 副本端口不固定

表现:对 8003 做健康检查一直超时,但副本其实是健康的。

原因JobManager::AllocatePort 从 8002 起单调递增,并跳过被占用的端口(含 TIME_WAIT)。每次扩缩容循环都会消耗端口号,第二副本实测漂移过 8003 → 8004 → 8005

修改:健康检查不得硬编码端口,从 /metricsendpoints 或进程列表动态获取。

1
PORTS=$(pgrep -af "[v]llm.entrypoints" | grep -oE "\-\-port [0-9]+" | grep -oE "[0-9]+")

3. AutoScaler 在测量过程中改变副本数

表现:连续三次名义上的「2 副本」运行,实际是 1 副本或 1/2 混合。replica_timeline 显示 replica counts during run: [1, 2]

原因:测量准备阶段有空闲期,AutoScaler 在空闲时缩容,压测开始时又得重新扩容。

修改:测量时不使用 AutoScaler,直接以目标副本数 actuate,并冻结副本数。

1
# api_layer/main.py 临时改为 initial_replica=2

同时停掉 autoscaler 进程,并用结果 JSON 里的 replica_timeline 核对全程只有一个副本数取值,才认为该组数据有效。


4. 冷启动污染首个场景

表现:某次基线 c8 的 p95 = 48.69 秒,而中位数只有 8.95 秒;同一场景 c16 的 p95 与中位数只差 0.6%。

原因:刚加载完的 vLLM,首批请求要付 Triton kernel 编译代价(配置里有 --linear-backend triton 等三个 triton 后端 + --enforce-eager)。

修改:所有测量在引擎预热后进行。冷启动那次结果保留为 *-COLD.* 作为对照证据。影响幅度:同一场景 0.56 → 0.89 RPS(+59%),纯粹来自预热。


5. 压测脚本会覆盖已有结果

表现benchmark_runner.py 固定写 benchmark_results.json / benchmark_summary.md,跑第二遍时第一遍的结果就没了。

修改:改用 benchmark_runner1.py--results-file / --summary-file 显式命名输出。另外在非项目目录执行,避免误覆盖仓库里已有的参照结果。


6. pkill -f 会杀掉自己的 SSH 会话

表现:执行清理命令时 SSH 直接断开,返回码 255。

原因pkill -f "uvicorn api_layer.main:app" 的匹配模式,恰好出现在承载该命令的 bash -c 进程自身的命令行里,于是把自己杀了。注意:bracket trick([u]vicorn)在脚本里同时还写了启动命令时同样会失效——因为启动命令里有未加括号的字面量。

修改:按监听端口取 PID,完全绕开模式匹配。

1
2
PID=$(ss -lntpH "sport = :8001" | grep -oE "pid=[0-9]+" | head -1 | cut -d= -f2)
[ -n "$PID" ] && kill "$PID"

更多命令行速查见《commands》


二、代码缺陷(已定位并修复)

7. 多副本负载均衡用 random.choice

位置api_layer/main.py:152

1
return random.choice(registry.endpoints[JOB_ID])

问题:每副本槽位有限(max-num-seqs 4)时,随机派发会把请求钉到已满的副本上,而另一副本仍有空槽。16 条并发在 2 副本间随机分配,恰好 8/8 均分的概率只有 C(16,8)/2¹⁶ ≈ 20%,其余情况必有一侧过载。

修改:改为最少在途请求(least-outstanding-requests)派发。

  • 新增 EndpointRegistry.endpoint_inflight(按副本的在途计数)
  • acquire_endpoint() 选在途最少的副本并占位,平局随机打破
  • release_endpoint()infer()finally 中释放,异常路径也不泄漏
  • set_endpoints() 统一处理端点列表刷新(sync_endpoints_loop 每 2 秒重建一次列表,朴素替换会丢失在途计数)
  • /metrics 增加 endpoint_inflight 字段(附加字段,endpoints 仍是列表,不影响压测脚本的 len(endpoints)

效果(并发 16,2 副本):

指标 random.choice 最少在途 变化
吞吐 1.62 RPS 1.60 RPS −1.2%(噪声)
p50 8.96 s 8.95 s −0.1%
p95 13.40 s 9.32 s −30.4%
p99 13.53 s 9.32 s −31.1%
离散度 p95/p50 1.50 1.04 收敛

中位数不动、尾部收敛,正是负载不均被修复的特征:典型请求本来就没问题,受害的是被随机分到满副本的那一小撮。


8. 启动预热泄漏一个槽位

位置api_layer/main.pystartup_event

问题:第 7 项改造后 get_or_start_job() 会占用一个槽位,但 startup_event 调它只是为了预热任务,从不释放 → 进程生命周期内永久少一个槽位。

发现方式:正是第 7 项新加的 /metrics 指标暴露的——空闲时 endpoint_inflight = 1active_requests = 0

修改:启动预热后立即归还槽位。

1
2
warmup_endpoint = await get_or_start_job()
await registry.release_endpoint(warmup_endpoint)

9. AutoScaler 用 QPS 判断负载(核心缺陷)

位置api_layer/autoscaler.py:128-133

1
2
if qps > SCALE_UP_QPS_THRESHOLD ...:   return SCALE_UP
if qps < SCALE_DOWN_QPS_THRESHOLD ...: return SCALE_DOWN

问题qpsapi_layer/main.py 中定义为最近 10 秒内「开始」的请求数 ÷ 10,即到达率,不是负载。

两个后果:

  1. 就是前面 Little’s Law 那一节的情形:λ = L / W,请求越慢,满载系统的到达率越低。8 条在途 × 33 秒/条 → λ ≈ 0.24/s,低于 0.5 的扩容阈值,尽管系统 100% 满载。
  2. 完成是成簇的(vLLM 一次批处理 4 条,同时完成 → 4 个 worker 同时发新请求 → 之后几十秒无新请求),10 秒窗口落在簇间空隙时读数直接归零。

实测证据(参照运行,405 个采样点):在途请求恒定为 8 的同时,QPS 在 0.00–0.80 之间摆动,其中 23% 的采样点满足缩容条件13% 满足扩容条件——同一个负载没变的系统。这就是参照运行出现 1→2→1→2→1 震荡的原因(48% 的时间跑在 1 副本)。

修改:改为按饱和度判断,也就是直接测 Little’s Law 里的 L。

1
2
3
4
5
6
7
8
def decide(active_requests, current_replicas):
capacity = SLOTS_PER_REPLICA * current_replicas
if active_requests > capacity and current_replicas < MAX_REPLICAS:
return SCALE_UP # 存在排队
if (current_replicas > MIN_REPLICAS
and active_requests <= SLOTS_PER_REPLICA * (current_replicas - 1)):
return SCALE_DOWN # 少一个副本仍装得下
return HOLD

配套改动:

  • 新增配置 slots_per_replica: 4必须与 config/vllm.yaml--max-num-seqs 一致
  • 新增 scale_down_sustained_observations: 3——连续 3 次(15 秒)低饱和度才缩容,用于扛过场景之间 10 秒的空隙,避免拆了马上又要重建
  • 日志增加 capacity / saturation / low_streak 字段
  • 旧的 qps 阈值键保留在 yaml 中(标注弃用),以免旧配置文件加载失败(_load_config 会对未知键抛异常)

效果(并发 4–64,每档 128 请求):

并发 关闭扩容 开启扩容 吞吐变化 p50 变化
4 0.88 0.89 +0.8% −0.0%
8 0.89 1.39 +55.2% −49.5%
16 0.89 1.78 +99.3% −50.1%
32 0.89 1.78 +99.4% −50.1%
64 0.89 1.78 +99.3% −50.1%

三个关键改善:

  • 并发 4 不再误扩:饱和度 = 4/4 = 1.00,无排队,判 HOLD。旧逻辑此时 qps ≈ 0.88 > 0.5 会扩容,白烧两张卡换 0 收益
  • 不再震荡:副本变化只有 0s:1 → 187s:2 一次,之后保持。
  • c16 以上完全兑现:+99.3%,等于固定 2 副本的稳态上限。

10. QPS 阈值默认值没有死区

位置api_layer/autoscaler.pyDEFAULT_CONFIG

1
2
"scale_up_qps_threshold": 0.5,
"scale_down_qps_threshold": 0.5, # 与扩容阈值相同

问题:两者相等意味着死区宽度为零,qps 在 0.5 上下抖动就会来回扩缩。只有 config/autoscaler.yaml 把缩容阈值覆盖为 0.05 才有死区——配置文件缺失时行为会明显劣化。

修改:随第 9 项改造已不再依赖这两个阈值;yaml 中保留并注明弃用原因。


结语

10 个问题里,真正的代码缺陷只有 4 个,其余 6 个是测量方法问题。这个比例本身就是结论:给推理服务做多副本自动扩缩容,难的不是写扩缩容逻辑,而是让你的数字可信。 孤儿进程、端口漂移、冷启动、测量中途的自动扩缩容,每一个都能悄悄把结果改掉几十个百分点,而且一行错误日志都不会给你。

而那个真正值钱的修复,说到底只是把 Little’s Law 用对了方向:别去测那个被延迟污染过的 λ,直接测 L。

如果要带走三条:

  1. 扩缩容判据用饱和度(在途请求 ÷ 容量),不要用 QPS——Little’s Law 决定了到达率会在系统最忙时给出最低读数。
  2. 槽位有限时用最少在途派发,不要 random.choice——它不影响中位数,只毁尾延迟。
  3. 每次测量前核对显存归零、副本数冻结、引擎已预热,否则产物无法自证。

如果你也在做多副本 vLLM 部署,欢迎把你踩到的坑发给我——尤其是扩缩容判据这块,我很想知道还有没有更好的信号。