AI / LLM

Python 并发调大模型上不去,原因是默认线程池限制了并发

1497 字约 5 分钟

摘要把 asyncio 信号量调到 48 后,吞吐仍停在每分钟 70 条左右,单次请求延迟升到 32 秒。排查发现是 run_in_executor 的默认线程池在 2 核容器里只有 6 个线程。

在给招聘监控项目跑数万条岗位的初次清洗时,速度是最大的瓶颈。

单次请求带有较长的 Prompt 和 Schema 校验,本身就要几秒钟。为了尽快跑完历史存量,我开始调整并发。

当时服务商给的 TPM(Tokens Per Minute,每分钟 token 数)配额很充足,网络带宽也远够用。我先用并发 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 秒,但排在后面的人要等前面几轮办完,总耗时就被拉长到了 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?

run_in_executor(None, ...) 会使用默认的 ThreadPoolExecutor。在 Python 3.8+ 中,它的最大线程数是: min(32, (os.cpu_count() or 1) + 4) 我的 Worker 跑在一个 2 核 的 Docker 容器里,所以是 2 + 4 = 6。

原因找到了。48 个协程都通过了信号量,但其中 42 个在拿到线程之前,一直停在默认线程池的队列里。

外层统计的 32.8 秒并不是大模型推理慢。其中 20 多秒是这 42 个请求在容器里排队等线程,请求还没有离开本机。

让 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实际达到 48约 8 倍
整体吞吐量约 75 条/分钟170 条/分钟提升 126%
请求中位延迟32.8 秒5.4 秒下降 83%

同样是并发 48,吞吐提高到每分钟 170 条,中位延迟也低于之前并发 15 时的数值。之前怀疑的“云厂商只给 15 个并发槽”并不成立。

真正的外部瓶颈

本地线程的限制解除后,瓶颈才转移到外部。

小规模测试通过后,批量运行到第三分钟时,控制台出现了 HTTP 429: Rate Limit Exceeded (TPM limit)。这是真正的外部限制。

这次的 429 是服务商规定的 500 万 TPM 配额被真正打满了。之后把并发调到 32,并在发起请求时加入 200ms 的随机抖动(jitter)错开时间,系统就稳定运行在配额上限附近。

总结

在 Python 异步开发中,协程和线程混用很常见,也最容易出问题:

  1. asyncio.Semaphore 不等于实际并发连接数: 它只限制协程数量。一旦下游用了 run_in_executor,瓶颈就会转到线程池上;
  2. 注意默认的全局线程池: run_in_executor(None) 在容器环境里容易成为隐患。容器配额通常只有 1 到 2 核,默认线程数会很少;
  3. 遇到延迟异常,先查在途时间: 不要把发送耗时和排队耗时混为一谈。在关键调用前后打上时间戳,能看出问题出在本地还是远端。

评论