跳到主要内容

智能体集群并发:在没有死锁或惊群效应的情况下协调数十个智能体

· 阅读需 13 分钟
Tian Pan
Software Engineer

十一个智能体在同一秒内启动。在第一个工具调用返回之前,就有三个阵亡了。那 27% 的失败率不是模型问题、提示词问题或工具问题。这是一个调度问题 —— 就像操作系统在五十个进程同时唤醒并争抢单个 CPU 时所解决的问题一样。区别在于,操作系统拥有四十年的智慧积累,而智能体运行时只有大约两年。

任何连接过超过几个并发 LLM 工作节点的人都见过类似的情况。你在 02:00 启动一个定时任务,三十个智能体同时启动,它们在 200 毫秒内同时请求同一个提供商,结果大多数都以 429、502 和连接重置告终。幸存者只能获得承诺的一半速率配额,因为提供商的公平共享逻辑已经开始对你的 API 密钥进行节流了。到 02:05 时,幸存的智能体运行结束,你的仪表盘显示的完成率足以让一个刚写出第一个生产者-消费者的计算机专业大一学生感到汗颜。你的值班人员会争论是该增加重试、增加队列,还是干脆减少运行数量。

这些方法本身都不是正确答案。正确答案是:一个智能体集群是一个小型分布式系统,需要按照分布式系统的方式进行设计。

集群不仅仅是智能体

单个智能体是循环中的语言模型。它接收提示词,调用工具,再次调用工具,然后停止。你可以将其延迟视为 Token 生成时间加上工具调用时间的总和。你可以将其故障模式归类为“模型困惑”或“工具超时”。你可以通过对话记录来调试它。

但智能体集群并非如此。它是三十个智能体在争夺共享的速率配额、共享的数据库连接池、共享的文件系统以及一个根本不在乎你的提示词有多聪明的共享下游服务。集群会产生单个智能体自身无法产生的涌现行为:车队效应(convoy effects)、优先级反转(priority inversion)、活锁(livelock)、惊群同步(herd synchronization)以及公平共享饥饿(fair-share starvation)。智能体本身并不是有趣的部分,竞争才是。

这种视角的转变至关重要,因为它告诉你应该把工程精力放在哪里。如果你把集群视为“一个智能体的三十个副本”,你就会把时间花在调整提示词上,并纳闷为什么可靠性在并发超过十个工作节点后就无法扩展。如果你把集群视为调度问题,你就会把时间花在准入控制、背压(backpressure)和协调原语上 —— 而这些才是真正能解决问题的手段。

惊群效应的重新发现

惊群效应(thundering herd)不是流量问题,而是同步问题。它是指大量工作节点在同一瞬间被唤醒并尝试使用共享资源时发生的情况。经典的例子 —— 缓存项过期、故障转移导致新主节点选出、定时任务触发 —— 正是智能体集群正在重复的模式。

02:00 的 Cron 触发器是最明显的例子。不那么明显的是在执行通用步骤结束时出现的隐式“惊群”。集群中的每个智能体往往会在大致相同的时间完成步骤 N 并开始步骤 N+1,因为它们的运行时间主要受同一个模型支配,具有大致相同的延迟分布。因此,即使你交错启动它们,它们也会在每个工具边界重新同步。这与并行 HTTP 获取在 TCP 慢启动时堆积的原因相同:它们都同时挤在同一个瓶颈处。

缓解措施并不新鲜。为每个定时器添加抖动(jitter)。刻意交错启动时间,而不是任其随机。使用带随机性的指数退避,而不是固定步长的退避。限制在任何给定时间内处于“模型调用”阶段的智能体数量,将其与集群中存在的智能体总数分开管理。一个拥有 100 个智能体但只有 20 个可以同时向提供商发起请求的集群,其表现将优于一个 50 个智能体同时发起请求的集群。因为第一种设计在时间上摊销了速率配额,而第二种设计则会产生 429 错误和重试带来的锯齿状负载。

共享工具的速率限制竞争

最棘手的竞争不在于 CPU 或内存 —— 那些是廉价且本地的。真正的竞争在于共享下游的速率配额,这是一个全局资源,单个智能体无法观测到。提供商的速率限制通常同时从三个维度进行约束:每分钟请求数(RPM)、每分钟 Token 数(TPM)以及并发进行的请求数。每个维度都是独立节流的。你可能远低于 RPM 限制,但仍会因为耗尽了并发请求上限而收到 429 错误。

这就是操作系统类比发挥作用的地方。操作系统通过准入控制(admission control)解决这个问题:调度程序在进程拥有完成一个时间片所需的资源之前,不会允许其运行。对于智能体集群,类似的方案是一个集中的限制器,每个智能体在进行模型调用前都会咨询它。限制器持有共享配额,并返回“执行”或“等待若干毫秒”。通过实现为令牌桶算法,并将补充速度调整为提供商声明的 RPM,同时配合一个用于并发请求的信号量,它可以消除两种最常见的故障模式 —— 唤醒时的惊群和并发请求的瓶颈。

有几点会让这比听起来更难。首先,提供商的速率限制是近似值;它们在执行时有一定的松紧度和爆发空间,你在文档中看到的限制通常是保障的下限,而非绝对上限。其次,速率配额并非你独享;同一 API 密钥下的其他应用程序也在共享它,你的限制器需要留出安全边际。第三,限制器本身不能成为新的单点故障 —— 一个崩溃并阻塞整个集群的限制器比它要防范的惊群效应更糟糕。

可行的答案是自适应限制器:从保守开始,当调用成功时线性增加容量,当调用失败时成倍减少容量。这就是 AIMD(加性增/乘性减)—— 与 TCP 用于拥塞控制的算法相同 —— 它会收敛到提供商当前的实际容量,而无需预先知道具体数值。当提供商出现“吵闹的邻居”或区域性故障时,AIMD 会在几秒钟内退避。当情况改善时,它会在接下来的几分钟内逐渐恢复。你不需要为每个提供商单独调优;你只需根据可观测的信号(错误率、延迟 p99、明确的 429 错误)进行调优。

工作窃取、专用队列与调度衔接

一旦建立了准入控制,接下来的问题就是如何将工作分配给 Agent。目前主要有两种模式,它们各有优劣。

专用队列 (Dedicated queues) 为每个 Agent 提供各自的收件箱。调度器在入队时预先将工作项分配给特定的 Agent。这种模式易于推理,方便调试(每个项都有明确的归属),并且能很好地利用粘性状态——如果 Agent 3 已经为客户 X 加载了大量的上下文,那么就将更多客户 X 的工作发送给 Agent 3。其代价是系统无法察觉负载不均衡:如果 Agent 7 连续收到五个艰巨的任务,而 Agent 2 收到五个简单的任务,那么 Agent 2 会处于闲置状态,而 Agent 7 则在超负荷运转。

工作窃取 (Work stealing) 为集群提供一个就绪任务的共享池。每个 Agent 首先从自己的本地队列中获取任务;当本地队列为空时,它会从随机同伴的队列中“窃取”任务。这是现代语言运行时所采用的模式,因为它能在无需中央协调器的情况下实现负载自平衡。其代价是破坏了局部性 (Locality):任何 Agent 都可能处理任何任务,因此在一个 Agent 中已经预热的状态在另一个 Agent 中却是冷的。

具体到 Agent 集群,当任务具有异构性但可预测时,专用队列效果很好——例如“处理客户 X 的数据”应交给拥有客户 X 上下文的 Agent。当任务具有同构性且耗时较短时,工作窃取效果更好——例如一大批类似的摘要生成作业,这种情况下没有任何 Agent 具有局部性优势。一个常见的错误是在第一天选定一种模式后就再也不去审视它;正确的模式取决于任务的具体形态,而任务分布通常并非你当初预测的那样。

还有第三种从业者经常忽略的选择:带有窃取阈值的专用队列。Agent 默认处理自己的队列,但如果某个 Agent 的队列深度超过了某个阈值,而其同伴的队列为空,那么同伴就可以进行窃取。这在不损失局部性的情况下获得了大部分负载平衡的好处,也更接近生产级工作流系统(如 Temporal 和 Zeebe)的实际运行方式。

通过外部状态进行协作

多 Agent 系统中最大的死锁来源,事后看来也是最显而易见的:Agent 试图通过相互对话来进行协作。最近的一项基准测试将“哲学家就餐问题”应用于 LLM Agent,发现三个针对共享资源同时做决策的 Agent 死锁率高达 95%–100%。根本原因是趋同推理 (Convergent reasoning) —— 独立且智能的 Agent 选择了相同的“合理”动作,而这恰恰是导致冲突的动作。

解决方法是停止尝试通过 Agent 进行协作。转而通过外部状态进行协作。带有版本号的共享数据库行、带有租约 (Lease) 的分布式锁、由真实共识系统支撑的主节点选举——这些方案之所以有效,是因为它们将协作从系统的非确定性部分移到了确定性部分。Agent 随后根据权威的外部状态做出局部决策,协作问题由此变成了一个成熟的分布式系统问题,而不是多 Agent AI 领域的开放性研究课题。

具体来说,有几种模式值得内化。如果两个 Agent 可能会触及同一条记录,请对其加一个建议锁 (Advisory lock) 并让第二个 Agent 等待。如果你需要单个 Agent 负责一项长期运行的任务,请选举一个领导者并给它一个租约,如果领导者宕机,租约就会过期。如果你需要防止启动时的惊群效应 (Thundering herd),请让 Agent 在共享表中注册其意图,并仅允许 N 个 Agent 同时进行。这些方法枯燥、古老且可靠。它们优于任何需要 Agent 相互协商的方案。

你应该已经拥有的运维原语

任何运行超过少数几个 Agent 的集群都应该具备三件基础设施。

为每个依赖项设置熔断器 (Circuit breaker)。 当下游供应商开始出现故障时,整个集群需要立即做出一致的反应。每个 Agent 独立的重试循环完全是错误的应对方式:它会成倍增加本已不健康的依赖项的负载。共享熔断器能将负载降至零,给依赖项恢复的空间,并定期允许一个探测请求通过以检测是否恢复。对于针对 LLM 的熔断器,你还需要处理一种奇怪的情况:请求“成功”但输出是垃圾——这信号表明供应商在未降低可用性的情况下降低了质量。这很难检测,值得单独构建检测机制。

在拥有共享资源的层级实施有界并发 (Bounded concurrency)。 调用供应商时的信号量 (Semaphore) 是必不可少的。数据库写入时的信号量通常也是必不可少的。昂贵工具(搜索 API、代码执行沙箱)周围的信号量经常被忽视,但却总是必要的。实施这些原语的正确位置不在 Agent 循环内部——Agent 是为推理而非资源管理而优化的——而是在包装工具的层级中。

端到端的幂等任务 ID (Idempotent task IDs)。 进入集群的每个任务都应该携带一个 ID,该 ID 在每次重试、每次补偿和每个检查点中都保持不变。这是你可以添加的最廉价的基础设施,而且在第一次需要调试集群范围的事故时,它就会体现出价值。如果没有它,你无法判断一个任务运行了一次、两次还是零次,也无法判断数据库中的重复结果是 Bug 还是重试产生的副作用。

底层模式

令人不安的事实是,运行智能体集群并不是一个 AI 问题。它是一个披着 AI 外衣的操作系统问题。那些至关重要的原语——准入控制、有界并发、背压、熔断器、领导者选举、幂等标识符——在 Transformer 出现之前的几十年里就已经被解决了。令人惊讶的是,AI 社区正在逐一重新发现它们,而且通常是在发生生产事故之后。

好的一面是,一旦你洞察了这种模式,缓解措施就是具体且枯燥的。它们不需要创新的研究,只需要应用任何胜任的分布式系统所要求的纪律,将其应用于语言模型所在的架构层级。先构建调度器层,其上的智能体层就会变得更容易推理——因为智能体正在做它最擅长的事情(对任务进行推理),而调度器则在做它最擅长的事情(确保集群不会自我吞噬)。

智能体会变得越来越聪明。调度问题不会消失。去投资那个即使模型名称发生变化也依然存在的层吧。

References:Let's stay in touch and Follow me for more thoughts and updates