Agent 已经取消,为什么并发槽位还被占着?一次 DeerFlow 容量泄漏修复
Agent Runtime 工程 · 开源贡献实战
任务收到取消异常,不代表运行时已经归还它占用的资源。 如果取消发生在并发槽位交接期间,又一次取消打断了清理,业务任务虽然退出,容量计数仍可能停在“正在运行”。当上限只有 1,下一个任务就再也进不来。
这不是模型推理的问题,而是异步运行时的资源所有权问题。我提交的 DeerFlow PR #7900 已于北京时间 2026 年 11 月 7 日合并。修复延续了 @xiaoyuCR7 在已关闭、未合并的 PR #7571 中的清理方案;本次补充确定性回归和取消、超时控制场景。

先看正常交接:计数没变,所有者变了
DeerFlow 的 SubagentExecutionCapacity 管理进程内子 Agent 的执行容量。running 表示被占用的执行槽位数,等待任务放在 FIFO 队列里。排队本身不占用执行线程。
假设只有一个槽位:A 正在执行,B 排队。A 释放时,如果 B 仍在等待,运行时直接唤醒 B,把原来的槽位交给它,running 保持 1;队列没人时才把计数减到 0。
这里的关键是:一次 release 不一定减计数,它也可能完成所有权转移。 如果只观察“队列已经空了”,就无法判断这个槽位有没有真正回收。
出错需要四个步骤同时对上
在本次确定性复现中:
- A 占据唯一槽位,B 已进入等待队列。
- A 释放,把槽位交给 B;B 的 Future 已完成,但 B 还没开始执行任务正文。
- B 收到取消,进入放弃等待的清理;此时容量锁被测试持有,清理必须等待。
- B 又收到取消,旧实现的锁等待被打断,清理退出。测试随后还发送一次取消,验证修复后的清理也能承受后续取消请求。
此时 B 已从队列移除,也已经退出,但 running 仍是 1。一个没有所有者的槽位留在账上。新任务在 wait=False 时被拒绝;选择排队也不能凭空释放这个槽位。
不要把第四步理解成每次普通取消都会触发的问题。它依赖槽位已经交接、清理发生锁竞争、并且清理期间再次收到取消这一组条件。
为什么只写 finally,或者只用 shield,还不够
finally 能保证控制流进入清理代码,但不能保证其中的 await 一定完成。如果清理需要等待锁,它仍然可能被后续取消打断。
asyncio.shield() 保护被等待的内部任务,却不让外层任务自动等到它结束。外层仍然会收到 CancelledError。如果捕获后直接返回,资源账目可能还在后台收尾,调用方已经继续观察状态。
本次修复把容量清理交给一个独立任务,并在传播取消之前把它等待完。下面摘出核心结构,保留清理失败时取消异常的传播关系:
cleanup_task = asyncio.create_task(cleanup())
cancellation = None
while not cleanup_task.done():
try:
await asyncio.shield(cleanup_task)
except asyncio.CancelledError as exc:
if cancellation is None:
cancellation = exc
# 先确认清理结果,再把取消交回调用方。
if cancellation is not None:
try:
cleanup_task.result()
except Exception as exc:
raise cancellation from exc
raise cancellation
cleanup_task.result()
这个顺序没有把取消变成成功:容量账目先归位,取消仍然向上传播。 同一套等待逻辑用于“已经拿到槽位后的释放”和“排队调用放弃时的清理”,避免两条路径对重复取消有不同保证。

怎么复现,而不是靠 sleep 碰运气
并发错误的测试要控制事件顺序。上游回归通过 asyncio.Event 确认等待者已入队,再持有容量锁触发交接与取消;观察到清理开始等待锁后,再发送后续取消。代码里的 sleep(0) 只是让事件循环交付一次取消,不是用固定毫秒延迟猜测机器调度。
在干净的 DeerFlow 工作副本中切到合并提交 c953ab65f5eb1c43c4231ccc3a8a515021681e19,按该版本贡献规范准备 backend 环境后,可以运行原始回归:
cd backend
uv run --no-sync pytest tests/test_subagent_capacity.py \
-k abandoned_waiter_drains_cleanup -q
该测试覆盖取消/超时,以及旧所有者是否已经释放容量,共四种组合。超时组合验证“超时清理期间再次取消”,不等于证明“已经超时的 Future 收到了转移槽位”;后者在该路径中会被 release 跳过。
本文另用 Python 3.12 隔离加载两份真实容量模块,重放“已交接后重复取消”这一条时序。只用简单配置对象代替配置依赖,未替换容量逻辑。2026 年 11 月 10 日结果如下:
| 检查项 | 修复前 c373f8491 | 合并版本 c953ab65 |
|---|---|---|
| 调用方收到取消 | 是 | 是 |
| 最终 running | 1 | 0 |
| 最终 queued | 0 | 0 |
| 立即重新获得槽位 | 否 | 是 |
| 资源不变量断言 | 失败 | 通过 |
上游 PR 还保留了提交时的证据:未改动基线加回归为 1 失败、3 通过;修复后的容量、配置与指导文件相关测试为 27 通过。这是当时的测试范围,本文没有把它说成当前所有测试通过,也没有运行生产压力测试。
这个修复付出的代价
取消响应要等容量清理完成。当前实现没有为这段锁等待另设截止时间,因此它依赖容量锁最终能够释放。这里清理的是短小的进程内账目,不应该在锁中增加远程网络请求或无界工作。
不能把这个模式不加区分地套在所有后台清理上。需要区分:什么状态必须在返回取消前恢复,什么工作可以安全地延期。对执行槽位而言,计数失真会直接影响后续任务,所以先把所有权结算清楚。
本次修改没有改变并发上限、排队配置、前端或 HTTP API,也没有引入分布式容量协调。单进程的断言通过,不代表跨进程资源和任务持久化都已经解决。
面试里如何讲清这类问题
可以用一条因果链回答:取消请求遇到了资源交接,清理又被第二次取消打断;修复让清理任务在屏蔽后续取消的情况下完成,再传播取消;测试同时断言异常、队列、计数和重新获取能力。
最有区分度的证据不是“加了 shield”,而是业务已经退出以后,下一次任务是否还能获得资源。这也是检查连接池、worker 配额和后台任务生命周期时值得保留的思路。
参考与来源:
DeerFlow 源码及测试引用遵循项目 MIT 许可;文中的示意代码不替代完整实现。
更多推荐
所有评论(0)