跳到主要内容

智能体无法察觉的死锁:生成计划中的循环工具依赖

· 阅读需 13 分钟
Tian Pan
Software Engineer

一个规划器智能体输出了七个步骤。每一个看起来都很合理。编排器分发了这些步骤,前三个返回了值,第四个在等待第五个,第五个在等待第七个,而第七个——埋藏在规划器散文般描述的第三行里——正静静地等待着第四个。没有任何东西被锁定。没有触发过任何 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 限制了 latitudelongitude 的有效范围。两个方向在逻辑上都成立,规划器也忠实地编码了这两者。

在智能体规划中,这种模式也是一样的。每当规划器输出一个图时,你应该预料到会有循环,而不是感到惊讶。

为什么经典的死锁检测不适用

如果你从事过数据库或内核开发,你的本能反应是寻找“等待图”(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 检查。

运行时监控:捕捉那些漏网的循环

静态分析捕获的是计划中编码的循环。它无法捕捉动态出现的循环——比如“规划-重新规划”循环(其中每次修复都会生成一个以几乎相同方式失败的新计划),或者“验证-修复”循环(不断重试一个验证器永远不会接受其输出的步骤)。

对于这些情况,你需要一个运行时监控器(Watchdog),它对“卡住”的定义比“N 秒内没有工具调用”更高级。因为请记住:一个陷入循环的 Agent 并不是在沉默,而是在忙着花钱。

一个真正起作用的监控器会结合以下几种信号:

  • 步骤数和 Token 预算硬上限。 每次 Agent 运行都有固定的最大 LLM 调用次数和固定的 Token 预算。一旦触及其中任何一个,运行即刻终止。这是不可商量的基础设施,相当于 Agent 的单次请求超时设置。关于 Agent 在循环中每小时烧掉 50 美元的报告屡见不鲜;如果没有硬上限,你的最坏情况成本将是无底洞。
  • 结构性重复检测。 通过 (tool_name, normalized_arguments) 对每个工具调用进行哈希处理。如果在某个窗口期内同一个哈希值出现超过三次,说明 Agent 在调用级别陷入了循环。终止它。哈希不需要完全精确——参数规范化(转换为小写、折叠空格、移除时间戳和请求 ID)可以捕获模型在每次迭代中改变琐碎字段的常见情况。
  • 语义重复检测。 对每个推理步骤或消息进行嵌入(Embedding),并与最近的窗口进行对比。当余弦相似度连续几轮超过阈值时,说明 Agent 只是在自我转述,而没有取得进展。这能捕捉到在尝试中改变表面形式但产生近乎相同计划的重新规划循环。
  • 计划偏离漂移。 如果 Agent 有一个当前计划,而其最新的工具调用既不在计划中,也不是公认的修复操作,那么肯定出问题了。要么是计划被默默放弃了(这是另一种失败模式),要么是 Agent 在计划图之外的循环中徘徊。

分层使用这些信号的意义在于,没有任何单一信号是完全可靠的。步骤数上限可以阻止失控的循环,但在生效前会容忍巨大的浪费。结构性哈希能捕获完全相同的重试,但会漏掉转述后的重试。语义相似度能捕捉转述循环,但会对合法的迭代产生误报。结合起来,它们勾勒出一幅画面:这个 Agent 很忙,但它没有产生任何进展。

将计划视为一等公民

这里更深层次的转变是,停止将计划仅仅视为 Agent 内部使用的支架,而是开始将其视为基础设施需要检查、验证和约束的一等公民(First-class artifact)。规划器并不因为它们最先运行就具有权威性。它们是 LLM 的输出,具有随之而来的所有不可靠性。

三个操作习惯会很有帮助:

  • 持久化完整的计划,而不仅仅是执行步骤的追踪(Trace)。 当出现问题时,你希望能够指向原始的 DAG、其中的循环,以及运行时尝试修复它们的时刻。目前大多数 Agent 可观测性工具只记录追踪而丢失了计划;在诊断规划失败时,这种不对称性完全搞反了。
  • 将计划验证作为一个独立的阶段,并拥有自己的指标。 “因循环而被拒绝的计划”、“因不可达步骤而被拒绝的计划”、“首次尝试即被接受的计划”应该出现在你经常查看的仪表盘图表中。如果这些数字为零,要么是你的验证器坏了,要么是你的规划器表现得好得令人怀疑。如果这些数字很高,说明你的规划器需要改进,但至少你的验证器正在产生价值。
  • 约束规划器的词汇表。 输出自由格式文本依赖关系的规划器比约束在具有显式 depends_on: [step_id] 列表的有类型 Schema 下的规划器更容易产生循环。Schema 并不能消除循环,但它使循环在机械上变得可提取,这是捕获循环的前提。当你看到团队发布的规划器输出不受约束的自然语言时,他们实际上是在接受一类他们无法检测到的 Bug。

核心观点是,LLM 生成的计划应当受到与任何其他 LLM 生成产物相同的质疑。我们已经痛苦地学会了验证面向用户的模型输出。而驱动接下来二十次工具调用的输出,至少理应受到同等程度的审视。

本季度该构建什么

如果你的 Agent 目前在分发计划时没有经过验证环节,那么你本季度能交付的杠杆率最高的东西,就是一个运行在规划器 (Planner) 与编排器 (Orchestrator) 之间、仅有 200 行代码的循环检测器。它在第一次捕捉到循环时就能值回票价,而且它产生的结构化指标将开始告诉你关于规划器的信息,而这些信息是仅凭 Trace 永远无法提供的。

将其与一个监控 Token 预算耗尽、重复调用哈希以及推理步骤相似度的看门狗 (Watchdog) 配合使用,你就能将这一整类故障从“我们在长尾效应中承担的隐性成本”转变为“带有指向循环的堆栈追踪的告警”。这就是一个你可以运维的 Agent 系统,与一个偶尔产生四位数账单却一事无成的系统之间的本质区别。

你的 Agent 无法察觉的死锁,正是你的基础设施尚未开始监测的死锁。开始监测吧。

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