
并发上限设为 1,为什么两个任务仍能同时运行?
假设一个 Agent 的配置写着 max_concurrency=1。两个线程同时使用这个 Agent,最终有两个任务在运行。它违反配置了吗?
如果“1”属于整个 Agent,答案是违反。如果每个线程中的事件循环各有一份额度,两项任务可以同时运行,而且都遵守了各自的限制。
这不是文字游戏。同一个数字,可以保护一次 API 调用的成本,也可以只是安排单个事件循环里的工作。工程审查若不先问“这个上限属于谁”,很容易把局部修复误读成全局保证。
先把真正的错误复现出来
AG2 是一个构建和编排 Agent 的开源框架。PR #3243修复子任务准入时的信号量缓存。信号量可以理解为可借出的有限名额:名额用完,后来的协程等待;任务结束,名额归还。
旧方法把信号量和它所属的事件循环保存在同一个 Agent 的两个字段里。遇到另一个循环时,就重新创建一份。多个 OS 线程交替使用同一个 Agent,可能不断替换这份缓存。先前已有任务占用的名额没有消失,但新请求开始拿另一份全新的名额。
我们固定修复前后源码,抽取原始 _spawn_subtask 方法运行。任务正文被替换成等待释放的计数协程,不调用模型。为了避免把能否复现交给运气,实验明确安排了一个交错:
- 循环 A 的第一个任务 A1 进入,占住一个名额。
- 另一个真实线程中的循环 B 启动 B1。
- A1 和 B1 都未结束,循环 A 再申请 A2。
- 记录各循环和整体在途峰值,最后统一释放。
旧代码下,A2 又获得一份新名额。A 自己同时运行两个任务,整个实验同时运行三个。这已经超过“每循环 1”的较窄解释,是明确的局部额度失效。
修复后,哪个数字变成了 1?
候选修复为每个运行中的事件循环保存自己的信号量,并用线程锁保护查询和创建。循环 B 不再覆盖循环 A 的那份额度。相同交错中,A2 等待 A1 释放,局部错误消失。
| 代码版本与输入 | A 循环峰值 | B 循环峰值 | 全局峰值 |
|---|---|---|---|
| 修复前,单循环 | 1 | 0 | 1 |
| 修复前,双循环 | 2 | 1 | 3 |
| 候选修复,单循环 | 1 | 0 | 1 |
| 候选修复,双循环 | 1 | 1 | 2 |
每种配置每轮运行五次,两轮共四十次受控试验,逐次结果一致,没有异常。我们同时统计局部和全局,是为了让“修好了”对应到具体承诺:所测交错下,新方法维护了每循环上限,双循环合计仍为二。

图 1:图中是等待型合成子任务的在途数,不是模型请求数、吞吐量或费用测量。 来源:本轮固定源码实验的第 1、2 轮保存观测。
上游新增测试也明确检查每个循环的上限。因此,合计为二不应被命名为“修复仍然失效”。它是另一份合同尚未被这项修复提供的证据。善意、准确地指出这个区别,比把正常的分域行为包装成安全漏洞更有价值。
从一个数字,补成一个可验收的约束
当这个配置用于限制真实资源时,评审至少要说清四件事。
第一是额度所属范围:单循环、单 Agent、单进程、单宿主,还是整个租户?第二是谁有权发放额度:一份内存对象,还是所有执行者共享的计数服务?第三是额度持续多久:任务返回、进程退出、租约到期或人工清理,分别意味着什么?第四是发生不确定性时怎么处理:旧任务失联,不应仅因观察不到就默认归还它可能仍在占用的名额。
例如,一个用户把上限设为一,是为了避免两个昂贵任务同时消耗同一个账户。实现若按循环分别发放名额,即使每个循环的测试全绿,也可能没有满足用户的预算意图。反过来,产品若明确承诺每循环一项,就不该为了追求“更安全”的宣传,未经设计把它改成跨域串行,损失本来允许的并行能力。
这里的改进起点是合同:先决定上限保护什么,再选择锁、共享计数或租约。增加一把线程锁,只能解决它实际保护的那次读写;不会自动把独立计数器变成同一份资源事实。
这次实验没有证明什么
我们没有运行完整 AG2 Agent、工具链或模型服务,也没有测试多进程、多宿主、跨租户预算。初始化状态是按版本设置的测试替身,实际执行的是固定源码里的原准入方法。该 PR 在核查时仍为 Open,不能写成所有用户已经获得修复。
它给出的可复用检查却很简单:除了“峰值是否超过上限”,还要断言“在哪个范围统计这个峰值”。 一个没有对象范围的上限,既难被实现,也难被公平地验收。