Junie's Blog

Python 并发调大模型卡死,原来是默认线程池悄悄限了流

全文共 1842预计阅读 7 分钟

在给招聘监控项目跑数万条岗位的初次清洗时,速度成了最大的卡点。

单次请求由于带了长 Prompt 和 Schema 校验,耗时本来就要几秒钟。为了尽快把历史存量跑完,我开始做并发调优。

当时服务商给的 TPM(Tokens Per Minute)配额非常大方,网络千兆带宽也绰绰有余。我先开着并发 15 测了一组,每分钟大概能洗 70 条,平均单个请求延迟在 10 秒左右。

按照线性推算,既然外部配额还有大把空余,那直接把并发拉大到 3 倍以上不就起飞了?

我信心满满地把并发参数改成了 48

再次点下启动,盯着终端输出,屏幕上的数字却让人目瞪口呆:

  • 吞吐量:从 70 条/分变成 75 条/分(几乎毫无变化);
  • 请求延迟:不仅没快,中位耗时反而从 10 秒一路狂飙到了 32.8 秒

怀疑云厂商在后台偷偷排队

很多后端同学在遇到这种“加大并发、延迟剧增、吞吐不动”的现象时,脑海里蹦出的第一个猜想往往是:云厂商在搞鬼。

“肯定是因为我虽然客户端发了 48 个并发,但服务商在网关层给我的 API Key 限制了实际工作槽位(Worker Slot),只给我分了十几个槽,多出来的 30 多个请求全在服务商的服务端排队呢!”

这个推论看起来严丝合缝:

实验配置吞吐表现单请求平均耗时429 限流报错
并发 15约 70 条/分钟约 10 秒0
并发 48约 75 条/分钟32.8 秒0

没有报 429,也没有任何连接被拒绝,请求最终全都成功返回了,唯一的区别就是排在后面的请求延迟奇高无比。

这不活脱脱就是“服务端在暗中排队”的铁证吗?我甚至一度想妥协:“算了,服务商限制就是这样,老老实实用 15 并发跑吧。”

通俗比喻:48 个人排队,前台却只有 6 个窗口

为什么信号量设了 48,并发却卡死在 6? 想象你去办事大厅:

  • 你在外门(asyncio.Semaphore(48))放了 48 个人进来;
  • 但进门之后,大家要去办业务(同步阻塞 SDK),Python 默认只给他们开了 6 个柜台(run_in_executor 默认根据 CPU 核数只分配了 6 个线程);
  • 剩下的 42 个人只能在走廊里干瞪眼排队。

虽然每个人在柜台上办业务只要 5 秒,但排在第 48 号的人得等前面好几轮办完,总耗时自然被生生拖长到了 30 多秒!瓶颈根本不在门外的大马路上,而是大厅里的柜台开太少了。

逻辑自洽不等于真理

当排查遇到瓶颈时,人很容易脑补出一个“无法证伪的外部假想敌”(比如云厂商黑盒限流)。但在没有抓包和拿到物理指标之前,任何假设都只是偷懒的托词。

撕开假象背后的致命限制

真正让我推翻推测的,是一次对本地运行环境的常规探针注入。

整套任务采用的是 Python 的 asyncio 协程架构,但由于当时使用的底层 LLM SDK 是同步阻塞的,在协程里调用它时,用了常见的异步包裹技巧:

# 协程里调用同步阻塞 SDK
await loop.run_in_executor(None, sync_llm_client.predict, payload)

外层我们用了一个 asyncio.Semaphore(48) 来作为大门,管控最大并发数。

我原以为只要信号量放行了 48 个协程,操作系统就会向网络发起 48 路 TCP 连接。但当我在 sync_llm_client.predict 函数入口处加了一个原子计数器去监控**“同一瞬间真正正在执行的线程数”**时,一个刺眼的数字浮出水面:

峰值永远只有 6。

不管外层把信号量设成 48、100 还是 1000,正在执行的函数数量死活超不过 6 个!

为什么偏偏是 6?

回看 Python 官方文档关于 loop.run_in_executor(None, ...) 的实现细节: 当第一个参数传 None 时,Python 会调用默认的 ThreadPoolExecutor。 在 Python 3.8+ 中,默认线程池的最大线程数计算公式是: min(32, (os.cpu_count() or 1) + 4) 而我们的 Worker 跑在一个轻量级的 2 核 Docker 容器里! 2 (CPU核数) + 4 = 6

真相瞬间水落石出!

48 个协程确实通过了信号量的大门,但其中 42 个在拿到线程前,被活生生卡死在 Python 全局默认线程池的阻塞队列里!

我们在外层统计的“32.8 秒请求耗时”,根本不是大模型推理慢,而是那 42 个倒霉的请求在容器内存里排队等线程就空耗了 20 多秒!请求连本地网卡都没出,云厂商的大模型何其无辜!

让 48 路并发真正冲出网卡

根因既然是线程池被压死在 6 个,修复起来自然快如闪电:

# 不再传 None 使用全局鸡肋线程池,而是显式注入专用的大线程池
executor = ThreadPoolExecutor(max_workers=64)
await loop.run_in_executor(executor, sync_llm_client.predict, payload)

改完后,为了防止再次被假象欺骗,我先写了一个本地的 threading.Barrier(40) 屏障测试,物理验证确实有 40 个工作线程能够齐头并进,然后再次连接真实的模型 API 发起 48 并发实测:

性能指标修复前(受困于 6 线程)修复后(独立 64 线程池)变化幅度
配置并发数4848-
底层活跃线程数锁死在 6真实达到 488 倍物理爆发
整体吞吐量约 75 条/分钟170 条/分钟提升 126%
请求中位延迟32.8 秒5.4 秒暴降 83%

同样是并发 48,吞吐直接翻倍拉升到每分钟 170 条,单次请求延迟甚至比原来的 15 并发还要低得多!所谓“云厂商只有 15 个并发槽”的谣言被粉碎得彻彻底底。

真正的外部瓶颈终于浮现

当本地线程的枷锁被解开后,系统的短板才终于转移到了外部真实的物理世界。

在小规模测试跑顺后,当批量处理持续运行到第三分钟时,控制台终于弹出了真正的 HTTP 429: Rate Limit Exceeded (TPM limit)

这次我们不再慌张,因为这是服务商明文规定的 500 万 TPM 配额被我们真正打满了。我们随后把并发微调到 32,并在协程发起时加入 200ms 的平滑 jitter 错峰,系统便如同精密的瑞士钟表一般,贴着云厂商的最高速率上限稳定飞驰。

总结

在 Python 异步开发中,“协程”与“线程”的混搭极其常见,却也最容易埋藏暗礁:

  1. asyncio.Semaphore 绝不等于并发物理连接数: 它只是协程层面的令牌环,一旦下游涉及 run_in_executor,瓶颈就会神不知鬼不觉地转交到线程池手里;
  2. 警惕默认的全局线程池: run_in_executor(None) 在云原生和容器化时代是个危险的隐患,因为容器的配额往往只有 1~2 核,默认计算出的线程数小得可怜;
  3. 遇到离奇延迟,先查“在途时间”: 不要把请求发送耗时和排队耗时混为一谈,在关键调用处打上网络出站前后时间戳,能帮你一眼看出问题到底在本地还是在远端。

评论