智能体无法察觉的死锁:生成计划中的循环工具依赖
一个规划器智能体输出了七个步骤。每一个看起来都很合理。编排器分发了这些步骤,前三个返回了值,第四个在等待第五个,第五个在等待第七个,而第七个——埋藏在规划器散文般描述的第三行里——正静静地等待着第四个。没有任何东西被锁定。没有触发过任何 EDEADLK。智能体消耗了 40,000 个 token 来推理为什么第四步“花费的时间比预期长”,最终以一个温和、合理的道歉向用户宣告放弃。
这就是你的智能体无法察觉的死锁。它不是操作系统课程中的那种经典死锁——这里没有互斥锁(mutex),没有内核可以内省的资源图,也没有你的技术栈中任何人能识别的持有者或等待者。依赖关系存在于规划器生成的英语句子中,循环形成于潜在语义而非任何数据结构中,而故障模式看起来与“模型正在努力思考”无异。经典的死锁检测在这里毫无用处,但代价是相同的:工作流停滞,token 蒸发,而你的 trace 什么也不会告诉你。
好消息是,这类失败在机械上是可检测的——只要你不再指望操作系统帮你发现它 ,并开始将计划本身视为一个在任何工具运行前都需要验证的图。
为什么 LLM 生成的计划会产生循环
规划-执行(Plan-and-execute)架构,包括流式传输带有显式 dependencies 字段的任务 DAG 的 LLMCompiler 式规划器,都有一个共同的默认假设:规划器可以预先正确地列举出每一步究竟需要从其他步骤中获取什么。在实践中,规划器幻觉出依赖关系的方式与它们幻觉出引用文献的方式如出一辙。
以下是产生循环的几种常见模式:
- 双向事实依赖。 “将地址地理编码为国家,然后查找该国家的地址格式,最后验证地址。”规划器注意到第 3 步依赖于第 2 步,第 2 步依赖于第 1 步——但在给第 1 步的提示词中却说“使用该国家的规范格式”,这使得第 1 步依赖于第 2 步,与依赖字段所说的完全矛盾。每一步在局部都是连贯的。但图不是。
- 自引用聚合。 “总结第 4-9 步的发现,然后利用该总结来选择第 5 步中需要调查的子问题。”人类读者能一眼看穿。规划器则不然,因为它按线性顺序生成计划,且从未重新检查过回边(back-edges)。
- 隐式共享状态。 两个步骤都向一个“暂存区”(scratchpad)内存写入,并都从中读取。步骤 A 的工具描述说它先读取暂存区;步骤 B 的描述说它必须在 A 之后运行,但在 A 运行前将前提值写入暂存区。依赖字段声明为 A → B,但实际的数据流是 B → A → B。
- 修复循环。 增加了一个验证器步骤“以便在前一步失败时重试”。当前一步的失败模式是生成了验证器拒绝的输入时,你就得到了一个披着防御性工程外壳的无限循环。
最近关于表格数据生成的研究使这一点变得具体:当要求 LLM 提取特征依赖并输出一个用于下游拓扑遍历的图时,生成的图中经常包含循环。给出的例子很寻常——latitude(纬度)和 longitude(经度)决定 country(国家),但 country 限制了 latitude 和 longitude 的有效范围。两个方向在逻辑上都成立,规划器也忠实地编码了这两者。
在智能体规划中,这种模式也是一样的。每当规划器输出一个图时,你应该预料到会有循环,而不是感到惊讶。
为什么经典的死锁检测不适用
如果你从事过数据库或内核开发,你的本能反应是寻找“等待图”(wait-for graph)中的循环检测。但这种思维模型在这里行不通,原因有三。
首先,没有任何资源被持有。真正的死锁需要互斥(mutual exclusion)和保持并等待(hold-and-wait)。第四个工具调用没有获取任何资源;它只是在编排器的队列中等待一个永远不会到来的上游值。你的运行时看到的是一个挂起的 future,而不是一个被持有的锁。
其次,依赖边并不在运行时看到的图中。编排器的 DAG 显示第 4 步依赖于第 5 步。它没有说第 7 步也依赖于第 4 步,因为那条边仅存 在于第 7 步内部的自然语言参数模板中。在第 7 步实际运行且 LLM 解析其模板之前,运行时无法看到该回边。而当回边可见时,智能体已经空转了数分钟。
第三,症状看起来像进展缓慢,而不是停滞。一个真正发生死锁的进程是被挂起的;你可以通过 ps 命令看到它被阻塞在一个 futex 上。而一个“死锁”的智能体却是忙碌的。它在推理,再次调用它的规划器,输出尝试性的工具调用,生成关于如何继续的深思熟虑的文字。token 计数器在飞转。trace 记录在不断填充。从监控的角度来看,这是最糟糕的失败形式:一种高成本、高活跃度、零进展的状态,它避开了所有针对“卡住”或“错误”设置的警报。
这就是为什么《多智能体系统故障分类学》(Multi-Agent System Failure Taxonomy)将“步骤重复”列为一个独特的故障类别。它不是错误。它是披着工作外壳的零进展。
静态计划图分析:在第一次工具调用前拦截循环
最有效的防御往往也是成本最低的:在分派任何任务之前,将计划提取为图并检查其中是否存在循环。规划器已经完成了写下步骤的艰巨工作。你只需要比规划器更严谨地阅读它们。
一个实用的分析过程如下:
- 将计划标准化为节点和边。 每个步骤成为一个节点。针对每个步骤,解析出两种边:声明的依赖关系(如果你的规划器输出了显式的
depends_on字段)和引用的依赖关系(步骤参数或 prompt 中提到的任何${step_3.output}、<step3>或命名占位符)。引用边正是隐藏“回边”(back-edges)的地方。 - 解析别名。 规划器会用五种不同的方式引用同一个步骤:通过索引、通过工具名称、通过它们发明的变量名、通过转述。只需在每个计划生成时构建一张简单的别名表,并加上一个快速的 LLM 提取环节来规范化引用,就能消除 90% 的噪声。
- 运行深度优先循环检测。 Tarjan 算法或带有递归栈的普通 DFS 可以在几毫秒内找到强连通分量。任何大于一个节点的强连通分量都是循环。报告该循环及其路径。
- 拒绝或修复。 如果发现循环,将计划打回给规划器,并引用出错的路径:“步骤 4 依赖于步骤 5;步骤 5 依赖于步骤 7;步骤 7 引用了步骤 4 的输出。请修改。”大多数规划器在第一次重试时就能修复它,因为循环通常真的是一个错误,而不是刻意的选择。
这种做法的经济效益极高。对一个包含 20 个步骤的计划进行循环检测几乎不需要任何成本。而一个陷入死锁的计划,在编排器的挂钟预算(wall-clock budget)强制终止它之前,单次执行可能就会产生数十美元的 token 费用。一次静态检查带来的收益是其成本的数千倍。
同样的想法也可以推广到分派前值得检查的其他计划层级不变性:不可达的步骤、输入从未生成的步骤、输出类型与输入类型不匹配、调用工具时缺少必需参数。像对待代码一样对待计划。对其进行 Lint 检查。
