我给一个并发控制器写了一行 per_backend = 8。写的时候没多想,这个 8 是我顺手敲出来的。
接下来一整天,我在这个 8 上面修了三个 bug,出了四套优化方案,还做了一次「修正」:发现健康检查把 2 台机器数成了 4 台,于是把并发上限从 32 改成 16。
改得越对,离真相越远。那个数错了的 32,反而最接近真值。
后来我把 8 换成实测出来的数,上限变成 28,端到端延迟从 20.47 秒降到 17.50 秒。效果挺好。然后我把整个上限删掉了。
想拿公式的看第 3、4 章,各是一个除法;想学怎么识破「利用率才 41%,并发不是瓶颈」这种说法,看第 2 章。
1. 这个上限是干嘛的
我在跑一个模拟世界:三十来个 AI 角色住在里面,各自做决定。世界每推进一步,所有角色同时行动,等全员都跑完了才进下一步。这种「全员到齐才放行」的点叫栅栏,你可以想成一个班级交作业,最后一个人交了老师才讲下一课。
后端是两台推理机,各跑一个 vLLM。每个角色行动一次就是一次模型调用。
所以每个栅栏一打开,就会有一大群请求同时冲向这两台机器。我写了一个自适应并发控制器:延迟变高就收紧,平稳就放宽,但不能超过一个硬上限:
上限 = 健康的机器数 × per_backend
问题全出在 per_backend = 8 这个数上。它从来没被测过。
2. 均值说没打满,p90 说早撞顶了
我第一次怀疑并发,看的是平均在途请求数:6.59,上限是 16。
6.59 ÷ 16 ≈ 41%。一看就没打满嘛,并发肯定不是瓶颈。我就去查别的了。
后来我请另一个模型复核方案,它一句话就把这个结论拆了:我数错了对象。我数的是「每一步有多少个活动」,中位数 10;可每个角色各自发调用,一个活动里可能有好几个角色。重新数:每一个栅栏同时开跑的角色都超过 16 个,典型是 29 个。
那 6.59 是怎么来的?栅栏开头 29 个请求一起砸进来,被上限卡成两波;后面大段时间在等最慢的那几个角色写完,在途数掉到个位。均值把开头那个尖峰和后面的空档平均到了一起。
真正的指纹在排队时间的分布里:
排队时间 p50 = 0.3 秒
排队时间 p90 = 30 秒
中位数几乎不排队,p90 排半分钟。这么分裂的分布,意思是:大部分请求赶上了第一波,少部分被挤进第二波,要等第一波里有人跑完才轮得到。
波数本身就是个除法:
波数 = ⌈ 同时发起的请求数 ÷ 并发上限 ⌉
⌈ 29 ÷ 16 ⌉ = 2
识破判据:谁跟你说「平均利用率才 40%,并发不是问题」,先问他负载是不是突发的。突发负载下,均值利用率几乎说明不了任何事。要看的是排队时间的 p50 和 p90 差多少倍。差两个数量级,就是请求被上限切成了好几波。
3. 先拆再优化:我的四套方案全打在 13.7% 上
在发现这个之前,我已经量了一整天:并发、前缀缓存命中率、prompt 结构、栅栏结构。量完出了四套方案,全是给 prompt 瘦身的。
复核时被问了一句我答不上来的话:最慢的那些调用里,排队、预填充、解码各占几秒?
预填充就是模型把整段 prompt 先读一遍;解码是它一个字一个字往外吐。vLLM 自带这三段的直方图,不用自己插桩:
vllm:request_queue_time_seconds 排队
vllm:request_prefill_time_seconds 预填充
vllm:request_decode_time_seconds 解码
vllm:e2e_request_latency_seconds 端到端
15 分钟窗口,528 个请求:
| 阶段 | 每请求 | 占比 |
|---|---|---|
| 排队 | 3.75 秒 | 18.5% |
| 预填充 | 2.78 秒 | 13.7% |
| 解码 | 13.72 秒 | 67.8% |
| 端到端 | 20.47 秒 |
prompt 瘦身能动的只有预填充那一段。我的四套方案,全部瞄准的是 13.7%。
某项优化的端到端收益上限 = 它能动的那一段的占比 × 它能砍掉的比例
13.7% × 50% ≈ 6.9%
就算把 prompt 砍掉一半、预填充时间跟着线性减半(这已经是很乐观的假设了),端到端也只快 7% 左右。这个除法一做,四套方案当场就该降级。
拆解表里排队那 18.5% 才指向真问题,也就是那个 8。
4. 把 8 换成实测值
服务端能同时吃下多少请求,有两个硬限制:KV 缓存(模型给每个正在处理的对话留的「草稿纸」)装得下几份,以及 vLLM 启动参数里最多允许几条并行序列。
每台容量 = min( KV 缓存总容量 ÷ p90 prompt 长度 , max_num_seqs )
= min( 1,490,818 ÷ 100,000 , 160 )
= 14
两台 = 28
KV 缓存容量是 vLLM 启动时打印在日志里的那一行,它不作为 metric 导出,要去日志里抠。p90 prompt 用的是我实测的 10 万 token。
我还算了一个最悲观的版本:假设每个请求都把模型的上下文窗口塞满,一台只能放约 11.37 个,两台约 22。就算这么悲观,22 也大于 16。 我手写的 8 连悲观下限都没摸到;跟实测的 14 比,少估了 43%。
同样长度的窗口、同样的请求数,前后对比:
| 上限 16 | 上限 28 | |
|---|---|---|
| 端到端 | 20.47 秒 | 17.50 秒(−14.5%) |
| 两台机器的端到端 | 32.91 / 12.18 秒(2.7 倍) | 17.90 / 17.05 秒(1.05 倍) |
| 前缀缓存命中 | 58.3% / 54.1% | 71.6% / 63.2% |
| 抢占次数 | 0 | 0 |
第二行是个意外收获。我本来把「两台机器忙闲不均」当成一个独立问题,准备回头去改路由。结果上限一放开它自己没了。
原因说穿了很简单:路由是粘性的,同一个角色的请求总往同一台机器发,好吃到它自己的缓存。上限 16 时每台只有 8 个槽,长 prompt 的角色全堵在一台上排队,另一台闲着。放到 28,两台都吃得下,队伍自然摊平了。
这里要标一句:前缀缓存命中率也一起涨了,所以那 14.5% 不能全记在上限头上。两个窗口是前后两段时间,不是同一时刻的 A/B。
5. 那为什么还要删
28 比 16 好很多。但再把第 2 章那个除法代进去:
⌈ 29 ÷ 28 ⌉ = 2
还是两波。只是第二波从 13 个请求变成了 1 个。
然后我回头盯着这个函数看,冒出一个问题:这个上限凭什么存在?
我把 681 个栅栏的同时开跑人数全拉出来:中位数 29,最大 32。 这个世界里就三十来个角色,再怎么并发也不可能超过花名册。
于是任何上限只有两种状态:
- 低于 32:至少会卡住一部分栅栏,我的 16 和 28 都在这里
- 大于等于 32:永远碰不到,纯死代码
没有一个取值是「有用」的。
另一半理由在服务端。上限 28 那轮,KV 缓存占用峰值只有 54.9%,抢占次数 0。真发生排队的那些时刻,KV 才用了 22%;请求在等的是 vLLM 每一步的预填充 token 预算(8192 个),跟内存满不满没关系。
也就是说,我在调用方辛辛苦苦估算「服务端能吃多少」,而服务端自己就知道:它会排队,会在 metrics 里报「因容量不足在等的请求数」,真扛不住了还会抢占。调用方那个估计,顶多是这个信号的一份劣化拷贝。
最后的版本就两行:
def fleet_ceiling(self, healthy: int) -> int:
if healthy <= 0:
return self.cfg.min_limit # 机器全挂了,别对着空气狂发请求
return self.cfg.max_limit # 活着就别拦,让服务端自己说话
max_limit 的意思也变了:它现在是防失控的保险丝,设成 48,远高于花名册的 32,保证它永远不是卡人的那个。上一版刚写的那套从日志里抠 KV 容量的探测,大约 100 行,连同 6 个测试一起删了。
改完再跑,控制器日志从以前每次都打印的 32 -> 28, clamp: fleet ceiling,变成了 32 -> 32。
6. 测试把 bug 也钉住了
这个 8 能活一整天,测试有一份功劳。老测试里写着:
check("never exceeds the fleet ceiling", max(seq) <= 16) # 2 × 8 写死在断言里
把容量常数写进断言,就是把 bug 写成了规格。谁想把 8 改大,测试先红。
删上限之后,我加了一个反向的变异测试:故意把 healthy × 8 那个上限装回去,看测试能不能发现。结果有 6 个用例抓到了它。以后谁想把这个设计悄悄装回来,测试会直接变红。
中间还有个小坑。第二版(实测容量那版)首轮变异测试 9 个变异有 2 个活下来了,因为测试数据里两台机器的参数一模一样,p90 恰好落在同一个桶里,「取最窄的那台」和「取最宽的那台」算出来没区别。测试数据里某个维度所有样本都相同,等于这个维度没被测过。
7. 这次实验哪里不够硬
诚实起见,下面几处我没做到位:
- 拆解和前后对比都只用了一个 15 分钟、528 个请求的窗口。我没有重复跑几次看波动。
- 16 和 28 的对比是前后两段时间,前缀缓存命中率同时变了,14.5% 里分不清各占多少。
- 删掉上限之后,我没有单独测一组端到端延迟跟 28 比。能证明它没变坏的只有三样:控制器日志里
32 -> 32、KV 峰值 54.9%、抢占为 0。删除这一步的收益,严格说还没量过。 - 「服务端会自己报饱和」这条依赖 vLLM 的排队和抢占机制。换一个没有这套信号的后端,这个结论不能直接搬。
- 14 这个数没算前缀缓存的红利。实测命中率 55% 到 66%,但各阶段波动大,算进去容易过度承诺、引发抢占,我故意没加。
8. 下次想给调用方加并发上限之前
按顺序问:
- 先拆延迟:排队、预填充、解码各占多少。优化方案的收益上限 = 它能动的那段占比 × 能砍掉的比例,先算完再动手。
- 看排队时间的 p50 和 p90,别看均值利用率。p90 是 p50 的几十倍,就是请求被切成了好几波。
- 代码里每个容量常数,问一句「它是测出来的吗」。答不上来就去测:
min(KV 容量 ÷ p90 prompt, max_num_seqs)。 - 工作负载有没有天然上界(花名册、批大小、连接数)。有的话,低于它的上限都在卡人,高于它的都是死代码。
- 服务端会不会自己报饱和。会的话,调用方只留一个「服务端全挂了」的兜底。
- 删掉一个设计时,写一个「把它装回去」的变异测试,别让它悄悄回来。
现在控制器源码里,per_backend 只在一段注释里出现,那段注释讲的就是它当年怎么错的。