并行工具扇出的结构化并发:谁来负责部分失败?
当你的智能体(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),且取决于哪个异常最先触发以及重试中间件是如何配置的。
- 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
