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 不一定减计数,它也可能完成所有权转移。 如果只观察“队列已经空了”,就无法判断这个槽位有没有真正回收。

出错需要四个步骤同时对上

在本次确定性复现中:

  1. A 占据唯一槽位,B 已进入等待队列。
  2. A 释放,把槽位交给 B;B 的 Future 已完成,但 B 还没开始执行任务正文。
  3. B 收到取消,进入放弃等待的清理;此时容量锁被测试持有,清理必须等待。
  4. 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
调用方收到取消是是
最终 running10
最终 queued00
立即重新获得槽位否是
资源不变量断言失败通过

上游 PR 还保留了提交时的证据:未改动基线加回归为 1 失败、3 通过;修复后的容量、配置与指导文件相关测试为 27 通过。这是当时的测试范围,本文没有把它说成当前所有测试通过,也没有运行生产压力测试。

这个修复付出的代价

取消响应要等容量清理完成。当前实现没有为这段锁等待另设截止时间,因此它依赖容量锁最终能够释放。这里清理的是短小的进程内账目,不应该在锁中增加远程网络请求或无界工作。

不能把这个模式不加区分地套在所有后台清理上。需要区分:什么状态必须在返回取消前恢复,什么工作可以安全地延期。对执行槽位而言,计数失真会直接影响后续任务,所以先把所有权结算清楚。

本次修改没有改变并发上限、排队配置、前端或 HTTP API,也没有引入分布式容量协调。单进程的断言通过,不代表跨进程资源和任务持久化都已经解决。

面试里如何讲清这类问题

可以用一条因果链回答:取消请求遇到了资源交接,清理又被第二次取消打断;修复让清理任务在屏蔽后续取消的情况下完成,再传播取消;测试同时断言异常、队列、计数和重新获取能力。

最有区分度的证据不是“加了 shield”,而是业务已经退出以后,下一次任务是否还能获得资源。这也是检查连接池、worker 配额和后台任务生命周期时值得保留的思路。

参考与来源:

DeerFlow 源码及测试引用遵循项目 MIT 许可;文中的示意代码不替代完整实现。

Logo

更多推荐