增强 C · 并发背压(Backpressure)—— 开发文档(面试向)#
这篇讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话复述。 对应
docs/BACKEND_ENHANCEMENT.md的 C 块(后端增强第一波 P0)。
一句话概括#
给这个 Agent 服务装两道「同时最多能跑几个」的闸:一道在最外层(全局同时最多几个用户任务),一道在单个任务内部(一次跨平台搜索同时最多 fork 几个子 Agent)。在此之前系统只拦了「总共能 fork 几次」,却没人管「同一时刻一起跑几个」——并发一高就会把下游(向量库、精排、大模型外呼)一起打爆。
打个比方:原来的护栏管的是「这趟旅程总共能加几次油」,C 块补的是「停车场同时只能停几辆车」。两件事不一样,都得管。
1. 背景:原来缺了哪一类闸#
这个项目本来已经有挺狠的护栏了——fork 安全四层(深度上限、超时、结果截断、循环检测)、还有按「检索总次数」算的预算(retrieval_budget)和按「fork 轮数/次数」算的预算(ForkBudget)。
但这些拦的都是累计量:总共能 fork 几次、总共能搜几次。它们回答不了另一个问题——同一时刻,到底有多少活儿在并行跑?
会出什么乱子:
- 外层:
/api/task来一个请求就开一个后台任务,没有上限。十几个用户同时发起,就是十几个主 Agent 一起跑,每个又各自 fork 五个平台的子 Agent,瞬间几十路并发去打同一个向量库 / 精排服务 / 大模型接口,下游被打挂,大家一起慢甚至全错。 - 内层:一次跨平台搜索会「机制补齐到 5 个平台」,于是 5 个子 Agent 同时起跑、同时打下游。平台再多一点(将来扩展)就更夸张。
所以 C 块补的就是这一类「同时几个」的闸(专业叫「背压」——下游扛不住时,往上游施加反向压力,让上游慢下来或挡回去)。
2. 两道闸,为什么用了两种完全不同的做法#
这是整个 C 块最值得讲的取舍:同样是「限并发」,外层和内层我故意用了两种相反语义的原语。
外层(任务级):满了就拒,不排队#
- 做法:一个进程级的计数器,满了直接给前端回
429 Too Many Requests+Retry-After(告诉它过几秒再来)。 - 为什么不用标准的
asyncio.Semaphore:Semaphore 的语义是「满了就排队等」。但一个购物 Agent 任务可能要跑几十秒到几分钟,如果满了还往队列里塞,就会堆出一批「看起来没满、其实全在干等」的任务——用户那头是长时间转圈,体验比直接告诉他「现在忙、待会再来」更差。所以外层要的是明确拒绝,不是排队。 - 为什么敢用一个普通计数器、不怕并发出错:这个服务是单线程的事件循环(asyncio)。我把「检查满没满」和「占一个坑」放在 FastAPI 接口的同步代码段里、中间不留任何
await。单线程下,没有await就不会切到别的请求去,所以「检查 + 占坑」这两步之间绝不会被插队,天然不需要加锁、也不会数错。
面试追问点:「你这不是手搓了个信号量吗?」——是,但故意的。标准 Semaphore 给的是「排队」语义,我要的是「拒绝」语义;与其用 Semaphore 再绕一圈实现非阻塞拒绝,不如直接写个非阻塞计数器,意图更清楚。知道工具的默认语义、并知道它不匹配需求,比硬套工具更重要。
内层(fork 级):满了就排队,不拒绝#
- 做法:用真正的
asyncio.Semaphore,限单个任务内同时在跑的子 Agent 数,超出的子 Agent 排队等空位。 - 为什么这里反而要排队:子 Agent 是任务自己的一部分(5 个平台都是这次搜索要的结果)。如果像外层那样「满了就拒」,等于把某个平台直接丢掉、搜索结果残缺。所以内层超额必须排队、一个都不能少,只是分批跑、给下游喘口气。
一句话总结这个取舍:外层拒的是「别人的新任务」(拒掉没损失,他重试就好);内层排的是「自己任务的子活儿」(拒掉就丢结果,只能等)。语义不同,原语就不同。
3. 和原有预算的关系:正交,不是替代#
容易混的一点:项目里已经有 ForkBudget 了,C 块又来限 fork,是不是重复?
不是,两者正交(互不替代、各管一维):
ForkBudget管的是总量/动机:这棵搜索树「总共」能 fork 几轮几次——防的是模型「再 fork 一个去找找更好的」这种没完没了的扩张。- C 块的 fork 信号量管的是瞬时/资源:同一时刻「最多几个」子 Agent 在跑——防的是下游被瞬间峰值打爆。
举例:ForkBudget 允许这次总共 fork 5 个平台(总量 OK),但 C 块可以让这 5 个分两批跑(峰值 OK)。一个管「总共几次」,一个管「同时几个」,缺一不可。
4. 几个把细节做对的地方(踩坑 / 边界)#
- 超时不该算上排队时间:子 Agent 有 90 秒超时。如果它在信号量门口排了 30 秒队才轮到,这 30 秒不该算进它的 90 秒——否则排队久的子 Agent 会「还没真干活就被判超时」。所以我把「占信号量」放在「开始计时」的外面:先拿到坑、再开始算 90 秒。
- 占坑和释放必须严格配对、绝不漏放:每个成功启动的后台任务占 1 个坑,它收尾时(不管是正常结束、被取消、还是报错)都必须在
finally里释放 1 个坑。漏放一次,那个坑就永久泄漏,时间一长「看起来没满其实全占着」,整个服务卡死。这条是这类闸最常见的 bug,所以释放放在finally、和原有的任务摘除逻辑并排,任何收尾路径都跑得到。 - 同一会话里改问,不能被自己的旧任务挡在门外:用户在同一个对话里重新提问(同
thread_id),系统会先取消旧任务、再起新任务。这时如果新任务也走「满了就拒」,就可能被自己那个正要退出的旧任务占着的坑挡住、误报 429。所以覆盖重发走「强占」(无视上限先占),反正旧任务马上释放,真实并发并没增加。 - 机制兜底,不靠提示词:这和项目一贯的哲学一致——并发上限是用并发原语机械执行的,不靠在 prompt 里求模型「悠着点别开太多」。模型不一定听,机制一定算数。
5. 顺手做的可观测性#
/api/health 现在除了活跃任务数,还吐出任务闸的用量(占了几个 / 上限多少 / 满没满)。这是后端增强 A 块(Prometheus metrics)的雏形——背压闸的占用率本来就是最该上看板的指标之一,先在 health 里露出来,将来直接接进 metrics。
6. 面试可讲点小结#
- 「同一套需求,两种相反语义的原语」:外层满则拒(非阻塞计数器 + 429),内层满则排队(Semaphore)——理由是「拒别人的新请求没损失,拒自己的子任务会丢结果」。这是全篇最能体现工程判断的点。
- 「知道工具默认语义不匹配,就别硬套」:没有因为「并发就该用 Semaphore」而硬上,反而手写计数器拿到「拒绝」语义;并讲清单线程 asyncio 下为什么它无锁也安全。
- 「正交的两层预算」:能说清
ForkBudget(总量闸)和 fork 信号量(瞬时闸)管的是不同维度,不是重复造轮子。 - 「占用/释放配对 + finally 不漏放」:能主动指出这类闸最容易泄漏槽位,以及自己怎么防的。
- 诚实边界:这是单机方案。多副本部署后,进程内计数器各算各的、拦不住跨实例的总并发——那是「毕业线」才上 Redis 协调的事,当前单机进程内严格更优(少一次网络往返、少一个单点)。