给 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 | pkill -9 -f "[v]llm.entrypoints" |
2. 副本端口不固定
表现:对 8003 做健康检查一直超时,但副本其实是健康的。
原因:JobManager::AllocatePort 从 8002 起单调递增,并跳过被占用的端口(含 TIME_WAIT)。每次扩缩容循环都会消耗端口号,第二副本实测漂移过 8003 → 8004 → 8005。
修改:健康检查不得硬编码端口,从 /metrics 的 endpoints 或进程列表动态获取。
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 | PID=$(ss -lntpH "sport = :8001" | grep -oE "pid=[0-9]+" | head -1 | cut -d= -f2) |
更多命令行速查见《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.py 的 startup_event
问题:第 7 项改造后 get_or_start_job() 会占用一个槽位,但 startup_event 调它只是为了预热任务,从不释放 → 进程生命周期内永久少一个槽位。
发现方式:正是第 7 项新加的 /metrics 指标暴露的——空闲时 endpoint_inflight = 1 而 active_requests = 0。
修改:启动预热后立即归还槽位。
1 | warmup_endpoint = await get_or_start_job() |
9. AutoScaler 用 QPS 判断负载(核心缺陷)
位置:api_layer/autoscaler.py:128-133
1 | if qps > SCALE_UP_QPS_THRESHOLD ...: return SCALE_UP |
问题:qps 在 api_layer/main.py 中定义为最近 10 秒内「开始」的请求数 ÷ 10,即到达率,不是负载。
两个后果:
- 就是前面 Little’s Law 那一节的情形:λ = L / W,请求越慢,满载系统的到达率越低。8 条在途 × 33 秒/条 → λ ≈ 0.24/s,低于 0.5 的扩容阈值,尽管系统 100% 满载。
- 完成是成簇的(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 | def decide(active_requests, current_replicas): |
配套改动:
- 新增配置
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.py 的 DEFAULT_CONFIG
1 | "scale_up_qps_threshold": 0.5, |
问题:两者相等意味着死区宽度为零,qps 在 0.5 上下抖动就会来回扩缩。只有 config/autoscaler.yaml 把缩容阈值覆盖为 0.05 才有死区——配置文件缺失时行为会明显劣化。
修改:随第 9 项改造已不再依赖这两个阈值;yaml 中保留并注明弃用原因。
结语
10 个问题里,真正的代码缺陷只有 4 个,其余 6 个是测量方法问题。这个比例本身就是结论:给推理服务做多副本自动扩缩容,难的不是写扩缩容逻辑,而是让你的数字可信。 孤儿进程、端口漂移、冷启动、测量中途的自动扩缩容,每一个都能悄悄把结果改掉几十个百分点,而且一行错误日志都不会给你。
而那个真正值钱的修复,说到底只是把 Little’s Law 用对了方向:别去测那个被延迟污染过的 λ,直接测 L。
如果要带走三条:
- 扩缩容判据用饱和度(在途请求 ÷ 容量),不要用 QPS——Little’s Law 决定了到达率会在系统最忙时给出最低读数。
- 槽位有限时用最少在途派发,不要
random.choice——它不影响中位数,只毁尾延迟。 - 每次测量前核对显存归零、副本数冻结、引擎已预热,否则产物无法自证。
如果你也在做多副本 vLLM 部署,欢迎把你踩到的坑发给我——尤其是扩缩容判据这块,我很想知道还有没有更好的信号。