返回博客

2026.10.14

并发上限从拍脑袋的 16 改成实测的 28,延迟降了 14.5%:为什么我最后把这个上限整个删了?

我给并发控制器顺手写了个 per_backend = 8,在它上面盖了一整天楼,四套优化方案全瞄准只占 13.7% 的那一段。换成实测值后端到端从 20.47 秒降到 17.50 秒,接着我发现任何取值的上限都只有两种状态:卡人,或者永远碰不到。这篇给三个除法,外加一个识破「利用率才 41%」的判据。

LLM推理并发方法论

我给一个并发控制器写了一行 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. 下次想给调用方加并发上限之前

按顺序问:

  1. 先拆延迟:排队、预填充、解码各占多少。优化方案的收益上限 = 它能动的那段占比 × 能砍掉的比例,先算完再动手。
  2. 看排队时间的 p50 和 p90,别看均值利用率。p90 是 p50 的几十倍,就是请求被切成了好几波。
  3. 代码里每个容量常数,问一句「它是测出来的吗」。答不上来就去测:min(KV 容量 ÷ p90 prompt, max_num_seqs)。
  4. 工作负载有没有天然上界(花名册、批大小、连接数)。有的话,低于它的上限都在卡人,高于它的都是死代码。
  5. 服务端会不会自己报饱和。会的话,调用方只留一个「服务端全挂了」的兜底。
  6. 删掉一个设计时,写一个「把它装回去」的变异测试,别让它悄悄回来。

现在控制器源码里,per_backend 只在一段注释里出现,那段注释讲的就是它当年怎么错的。