跳转至

在 Agent 批量调用 LLM 时,Semaphore 满了任务会怎样?如何避免因退避重试导致槽位被长期占用?

面试官追问到这一层,通常是在验证你有没有真的在生产环境里处理过背压和资源泄漏。我一般会先画个简单的状态图,把“等槽位”和“占着槽位不拉”分清楚。


🔒 Semaphore 满了,任务到底在干嘛?

假设我们设了 asyncio.Semaphore(5),同时有 8 个协程试图进入:

sem = asyncio.Semaphore(5)

async def task():
    async with sem:          # ← 第6个及之后会挂在这里
        await call_llm()
  • 前 5 个协程立刻拿到槽位,开始执行 LLM 调用。

  • 第 6~8 个协程会被挂起,它们的状态变成“等待信号量”,事件循环知道它们正在等 sem.acquire()

  • 它们不占用 CPU,不会阻塞其他协程,只是排在一个 FIFO 的等待队列里。

用图表示就是:

image.png

任一槽位释放,等待队列最前面的协程立即被唤醒,拿到槽位继续执行。这套机制本身是健康的。


🐍 问题出在哪:重试退避把槽位“腌”住了

结合 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...finallyasync with 保证释放,否则那一个槽位就永远丢了。async with 会自动处理,所以这个坑反而不常出现在有经验的开发者身上,但值得一讲。