并行工具扇出的结构化并发:谁来负责部分失败?
当你的智能体(Agent)扇出五个并行工具调用——跨三个索引进行搜索、查询两个数据库、调用一个外部 API——的那一刻,你已经跨越了一道无形的界限。你不再是在编写“提示-响应”(prompt-and-response)代码,而是在编写一个并发程序。大多数智能体框架都假装你没有在这样做,而账单会在凌晨 2 点准时送达。
这种假象是令人愉悦的。规划器(Planner)发出一个工具调用列表,运行时环境(Runtime)启动它们,收集返回的任何结果,最后由规划器消费这些汇总数据。从万英尺的高空俯瞰,这就像一个扇出 / 扇入(fan-out / fan-in)流水线,大多数团队在生产环境给他们上课之前,也确实是这样对待它的。问题在于,二十年的并发编程研究——部分失败语义(partial-failure semantics)、结构化取消(structured cancellation)、背压(backpressure)、确定性错误归因(deterministic error attribution)——已经解决了你即将重新发现的那些失败模式。而你的智能体框架在默认情况下,没有引入其中的任何一项。
默认的“goroutine 汤”模式
打开你的智能体框架,找到运行并行工具调用的函数。很大概率上,它执行的是 asyncio.gather(*calls)、Promise.all(calls) 或 errgroup.Wait() 的某种变体,辅以简单的重试逻辑和顶层超时设置。每个子调用都在某种关键意义上是脱离的(detached):没有父作用域(parent scope)拥有它的生命周期,没有路径能让一个子调用的失败中断另一个子调用的工作,也没有保证在规划器消费结果之前所有子调用都已退出。
这就是“goroutine 汤”(goroutine-soup)模型——这个名字借用自 Nathaniel Smith 的观点,他认为非结构化的 go 语句,就像它所继承的非结构化 goto 一样,是一个对于我们实际编写的程序来说过于原始的控制流原语。它在失效之前一直都能维持扩展。
一旦你列出这些失败模式,它们在智能体系统中的产生就是可以预见的。一个调用永远挂起,而其他调用已经完成;规划器等待超时触发,而超时时间被设置为最慢预期调用的最坏情况延迟,这远比实际的停机时间要长。两个调用返回了格式错误的结果,规划器决定重新规划,但另外三个调用仍在运行中,仍在对那些对新计划已不再重要的系统产生副作用。新的规划在旧的扇出耗尽之前又催生了新的扇出,现在你有两组工具调用在重叠运行,互相竞态以写入相同的下游状态。供应商返回了 500 错误,重试层启动,而原始调用的响应在一秒后到达,智能体同时拥有了两者——却无法知道该信任哪一个。这些都不是罕见的情况。它们是一个 不拥有其子项生命周期的工具层产生的常规产物。
成本维度则更加糟糕。到 2026 年的行业跟踪显示,失控的智能体成本是头号运维痛点——一个由于三分之二的重试失败而导致循环的无限制扇出,在一下午花费的资金可能超过一个团队整个季度的预算。工具调用本身不需要很昂贵;它们只需要是“无主的”,这样当它们所做的工作变得无关紧要时,没有什么能阻止它们。
结构化并发到底引入了什么
结构化并发(Structured Concurrency)是一条规则:每个并发任务都生活在一个父作用域内,且在该作用域的所有子项终止之前,该作用域不会退出。Trio 的 nurseries、Python 3.11 的 asyncio.TaskGroup、Java 的 StructuredTaskScope(截至 2025 年底在 JEP 505 中仍处于预览阶段)、Kotlin 的协程作用域——它们是在同一个不变性(invariant)之上的不同人体工程学实现。如果你开启了作用域,你就拥有了其中的一切。没有东西能逃逸。没有东西能比你活得更久。作用域内发生的错误不会被默默记录并丢弃;它们会像单线程代码中的异常一样传播给父级。
基于这一不变性,产生了三个特性,而这三个特性正是你的工具层所需要的。
第一是确定性清理。当任何一个子项失败时,作用域会取消所有其他子项,并等待它们完成清理工作,然后再传播异常。当你看到错误时,不存在哪些调用仍在运行的问题;它们都已经停止了。
第二是异常聚合。如果两个子项同时失败,你不会在竞态中丢失其中一个。作用域会收集它们——现代运行时会将它们包装在一个 ExceptionGroup 中——这样父级就能看到完整的失败集并决定如何应对。在“goroutine 汤”模型中,第二个失败通常会竞态进入静默日志并消失。
第三是被尊重而非“被礼貌请求”的取消。当规划器因为五个调用中有两个返回致命错误而重新规划时,其他三个调用需要真正停止。大多数智能体框架发送一个取消信号,底层的 HTTP 客户端可能会也可能不会遵守,而一个已经发出产生副作用的 POST 请求的工具,无论如何都会兴高采烈地运行到完成。结构化并发要求取消原语在作用域之下的每一层都得到尊重,这意味着它强迫你回答一个你的工具层可能尚未回答的问题:我的哪些工具是可中断的,以及在哪个边界中断?
部分失败必须是一项策略,而不是一种涌现行为
并行扇出(parallel fanout)中最难的部分不是“当一切都成功时我该做什么”,而是“当五个调用中有两个成功而三个失败时,我该做什么”。大多数智能体(agent)框架对此没有明确立场,这意味着它们给出的答案是涌现的(emergent),且取决于哪个异常最先触发以及重试中间件是如何配置的。
你真正需要的策略应该是针对单个调用,而非整个扇出。考虑一个规划器(planner),它跨不同索引发出三个搜索请求,并进行两次元数据查询。那两次元数据调用是建议性的(advisory)——它们能丰富响应内容,但即便没有它们,智能体也能给出一个连贯的回答。而在三个搜索请求中,一个是主路径,另外两个是佐证。你的扇出机制必须了解这一点。如果运行时(runtime)在收到所有五个调用后只告诉规划器“其中两个失败了”,那就是把失败恢复的决策权交给了错误的层级。
一种可行的分类法是将调用区分为“必要的”(essential)和“建议性的”。在必要调用中,进一步区分“基于法定人数的扇出”(quorum-based fanouts)和“首个成功即返回的扇出”(first-success fanouts)。必要调用的失败会导致该步骤中止;建议性调用的失败则被记录并忽略。法定人数扇出在 M 个调用中有 N 个返回时即宣告成功,并取消剩余的 M-N 个调用。首个成功扇出则返回第一个非错误的响应并取消其余调用。Java 25 的 StructuredTaskScope 提供了 ShutdownOnSuccess 和 ShutdownOnFailure 作为命名原语,正是因为这些模式反复出现。大多数智能体框架两者都不提供;你只能在 gather() 中内联编写它们,结果导致每个调用点的实现都存在微妙差异。
策略决策应属于“规划器-工具层”(planner-tool-layer)接口,而非工具的具体实现。执行搜索的工具并不知道规划器是否认为它是必要的,但规划器知道。因此,扇出 API 必须允许规划器在发起调用时声明部分失败策略,而运行时必须在不进一步协商的情况下执行该策略。
取消准则:无人认领的半成品副作用
在工具层中,“取消”(cancellation)的含义必须超越“不再等待这个 future”。它必须意味着“该工具本应产生的副作用将不会发生,或者已经得到了补偿”。否则,你构建的系统虽然允许智能体的计划重新规划,但智能体所作用的现实世界却无法同步。
考虑 2025 年暴露出的 LangGraph 问题:当 ToolNode 使用 asyncio.gather 并行运行工具时,第一个传播的 GraphInterrupt 会取消兄弟协程,但它们的终端信号——以及它们正在进行的任何在途工作——都丢失了。该框架在 asyncio 层具有结构化并发(structured-concurrency)的取消语义,但在智能体层却是“Goroutine 大杂烩”式的语义,两者的接缝处正是故障所在。由于中断 ID 仅根据检查点命名空间生成,来自并行工具的多个“人在回路”(human-in-the-loop)中断被合并成了一个。这并非 LangGraph 所特有;每一个将并行执行硬塞进本质上是顺序规划器的智能体框架,最终都会发现这个问题。
有效的取消准则分为三层。在运行时层,取消必须向下传播穿过调用树,并在父级退出前等待清理完成。在工具层,每个产生副作用的工具都必须声明其取消契约:它在执行中途是否可中断?它是否是幂等的(idempotent)以确保重试安全?它是否有撤销部分影响的补偿动作?在规划器层,重新规划必须等待先前的扇出排空(drain)后才能发布新的计划——否则取消就毫无意义,因为两批工具调用正在发生竞态。
第三点是团队最常跳过的。他们取消了先前的扇出并立即发布新扇出,而取消令牌(cancellation token)只是一个礼貌性的建议,底层的 HTTP 客户端可能会也可能不会遵守。于是,旧的 POST 请求在新的 POST 之后一秒钟到达并覆盖了它 。取消是一项契约,而非一个事件。
构建对抗性扇出的评估
大多数智能体评估(evals)只针对正常路径(happy path)打分。而对抗性扇出评估(adversarial fanout eval)才能捕捉到本文所讨论的失效模式,但大多数团队的测试套件中并不存在这种评估,因为这些失效模式看起来更像随机不稳定性(flakiness)而非 Bug。
你需要的测试固件(fixture)很小但很具体。运行一个扇出,其中一个工具返回格式错误的响应,一个永久挂起(或直到硬超时),一个返回缓慢但有效的响应,一个返回快速有效的响应,还有一个返回瞬时错误但在重试时成功。测试要求:智能体是否在有限时间内达到连贯状态?它是否正确识别了哪些调用成功、哪些失败、哪些被取消?它产生的是最终答案还是干净的错误,且绝不会产生不连贯的部分结果?
第二个固件用于诚实地检查取消。发起一个扇出,让规划器根据部分结果进行重新规划,观察仍在运行的调用。断言它们在取消后没有产生副作用。大多数团队在此时会发现,他们根本没有任何手段能证明这一点。
第三个固件检查成本。在故意破坏的环境中运行扇出,使每个工具都失败或挂起。断言智能体不会进入重试-重新规划循环,且消耗的资源不会超过测试预设的预算。很多团队在没有这项测试的情况下就发布了智能体,然后在生产环境中遭遇了这种循环,这种情况多得令人沮丧。
你的工具层就是一个并发编程框架
这种完成闭环的架构认知可能让人感到不适:无论开发团队是否意识到这一点,智能体(agent)的工具层本质上就是一个并发编程框架。每一个并行扇出(fanout)、每一次重新规划、每一次重试、每一次取消,都是一个背后有着大量学术文献支撑的问题。如果不去引入并应用这些既有理论,代价就是通过一次又一次的线上事故来重新发现它们。
对于本周正在审视自家工具层的团队,这里有三点建议。首先,审计当前的并行工具调用是如何运行的;如果实现方式涉及没有父作用域的无界 gather(),那么你的代码中就存在 goroutine 大杂烩(goroutine soup),而本文提到的那些故障模式正潜伏在你的系统中。其次,将局部失败策略作为扇出 API 的一等公民参数;不要让策略仅仅取决于哪个异常最先被触发。最后,为每个具有副作用的工具明确“取消契约”(cancellation contract),并在对抗性扇出评估中对其进行断言;未经测试的取消逻辑,就是无法正常工作的逻辑。
能够超越原型阶段并成功规模化的智能体,其工具层设计者往往研读过结构化并发(structured-concurrency)的相关文献。而那些在生产环境中惨败的智能体,其工具层设计者通常认为“扇出五个工具”只是一个可以用一行代码解决的问题。代码确实只有一行,但背后的语义却远非如此。
- https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
- https://en.wikipedia.org/wiki/Structured_concurrency
- https://trio.readthedocs.io/en/stable/reference-core.html
- https://openjdk.org/jeps/505
- https://google.github.io/adk-docs/agents/workflow-agents/parallel-agents/
- https://www.codeant.ai/blogs/parallel-tool-calling
- https://github.com/langchain-ai/langgraph/issues/6624
- https://changelog.langchain.com/announcements/langgraph-v0-4-working-with-interrupts
- https://www.gocodeo.com/post/error-recovery-and-fallback-strategies-in-ai-agent-development
- https://relayplane.com/blog/agent-runaway-costs-2026
- https://galileo.ai/blog/multi-agent-llm-systems-fail
- https://medium.com/@komalbaparmar007/llm-tool-calling-in-production-rate-limits-retries-and-the-infinite-loop-failure-mode-you-must-2a1e2a1e84c8
