在 Agent 批量调用 LLM 时,Semaphore 满了任务会怎样?如何避免因退避重试导致槽位被长期占用?
面试官追问到这一层,通常是在验证你有没有真的在生产环境里处理过背压和资源泄漏。我一般会先画个简单的状态图,把“等槽位”和“占着槽位不拉”分清楚。
🔒 Semaphore 满了,任务到底在干嘛?¶
假设我们设了 asyncio.Semaphore(5),同时有 8 个协程试图进入:
-
前 5 个协程立刻拿到槽位,开始执行 LLM 调用。
-
第 6~8 个协程会被挂起,它们的状态变成“等待信号量”,事件循环知道它们正在等
sem.acquire()。 -
它们不占用 CPU,不会阻塞其他协程,只是排在一个 FIFO 的等待队列里。
用图表示就是:

任一槽位释放,等待队列最前面的协程立即被唤醒,拿到槽位继续执行。这套机制本身是健康的。
🐍 问题出在哪:重试退避把槽位“腌”住了¶
结合 tenacity 做重试时,最容易踩的坑是退避等待发生在信号量内部
# ❌ 有问题的写法
@retry(wait=wait_exponential(min=1, max=30))
async def call_with_retry(payload):
async with sem: # 拿到槽位
response = await client.post(url, json=payload)
if response.status_code == 429:
raise RateLimitError # 触发 tenacity 等待
return response
tenacity 的 wait 会在协程内做 await asyncio.sleep(...)。此时信号量并没有释放——协程只是睡在那里,槽位却被空占着。如果几个协程同时被限流,它们会各占一个槽位集体睡觉,外面的任务全部饥饿。
实际效果:并发数从 5 跌成 2 甚至 0,明明还有大量请求排队,但槽位都在“静坐”。
🔧 解法一:重试期间释放槽位(推荐)¶
思路很简单:退避等待时不持有信号量,重试时再重新获取。
async def call_with_smart_retry(payload):
for attempt in range(MAX_RETRIES):
async with sem: # 每次尝试都排队取号
response = await client.post(url, json=payload)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After", 1)
else:
return response
# 离开 with 块,信号量已释放,在外面等待
await asyncio.sleep(float(retry_after) * (2 ** attempt))
raise RuntimeError("Max retries exceeded")
关键:async with sem 管的是“这一次 HTTP 请求”,不是“这个任务的整个生命周期”。等退避时槽位已经还给公共池,别的协程马上能进来。
🔧 解法二:把重试封装成一个内部任务,但用外部队列调度¶
如果你已经在用 asyncio.Queue 或任务队列,可以把需要重试的任务重新塞回队尾,让调度器统一分配槽位,而不是在原地死等。
async def worker(queue):
while True:
payload = await queue.get()
async with sem:
response = await client.post(url, json=payload)
if response.status_code == 429:
await queue.put(payload) # 重新排队,不占槽位
else:
# 处理成功
...
queue.task_done()
这相当于把“重试决策”和“并发控制”完全解耦,重试任务重新走一遍排队流程,天然避免槽位独占。
🧩 两种方案对比,选哪个?¶
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 退避期间释放槽位 | 实现简单,不改变整体架构 | 重试次数多时频繁申请/释放 | 每个任务独立,无全局顺序要求 |
| 重新入队 | 更公平,任务统一调度 | 需要维护队列,复杂度稍高 | 有中心化调度器的 Agent 架构 |
我个人的经验:80% 的情况用第一种就够,只要记住一个原则——信号量应该只保护“实际占用下游资源”的那段代码,退避等待不属于这个范畴。
🧠 另一个隐蔽坑:Semaphore 与 CancelledError¶
如果任务在等待槽位时被 cancel(),asyncio.Semaphore 会正确处理 CancelledError 并保持内部计数器不变。但如果你在持有槽位期间被取消,一定要用 try...finally 或 async with 保证释放,否则那一个槽位就永远丢了。async with 会自动处理,所以这个坑反而不常出现在有经验的开发者身上,但值得一讲。