跳到主要内容

工具重入:你的函数调用层尚未察觉的 Bug 类别

· 阅读需 13 分钟
Tian Pan
Software Engineer

智能体用 400 毫秒回答了一个简单的问题,然后因递归限制错误(recursion-limit error)崩溃。Trace 显示了 25 次工具调用。从上到下阅读 Trace,工程师会得出结论:智能体糊涂了 —— 以略有不同的顺序反复调用那几个工具,始终无法收敛。这个结论是错误的。智能体并没有糊涂。它陷入了一个死循环:工具 A 调用了模型,模型选择了工具 B,工具 B 的实现再次调用模型来格式化其输出,而格式化程序又选择了工具 A。Trace UI 将四个嵌套调用渲染为扁平列表中的四个兄弟调用,导致唯一能发现问题的开发者也无法察觉这个循环。

这就是工具重入(tool reentrancy),这是一种你的函数调用层几乎肯定没有建模的 Bug 类别。并发安全的代码对此已有数十年的原语支持:记录同一线程嵌套获取次数的重入互斥锁(reentrant mutexes)、语言层面的递归限制、堆栈检查 API,以及一种文化共识:任何回调运行时的函数都需要一个明确的契约,规定允许何种重入。工具调用层默认采用“发后即忘”(fire-and-forget)模式。运行时没有可供检查的调用栈,调度前没有循环检测器,工具定义上没有重入属性,Trace UI 的形式像日志而非图。结果就是,任何超过十几个条目的工具目录都会悄悄变成框架无法察觉的递归。

隐藏在“成功”工具调用中的故障模式

团队在生产环境中发现工具重入的方式很少是“智能体循环了”。那是明显到足以进入事后分析的故障。更常见的情况是:请求成功完成,但成本是成本模型预测的三到四倍,由于最终输出正确,评估套件得分正常。循环发生了,模型消耗了 Token 来发现它,当外部轮次预算耗尽时循环中断,由于模型擅长从自身的困惑中恢复,答案最终还是输出了。账单是该 Bug 显现的唯一地方。

LangGraph 默认的 25 次递归限制正是为了捕捉这种情况而设置的 —— 并非因为 25 对于复杂工作流是一个合理的超级步(supersteps)数值,而是因为它是一道防止智能体停不下来的防线。错误消息告诉你循环撞墙了,但它没告诉你原因。生产团队看到 GraphRecursionError 后的第一反应通常是提高限制。这把一个喧闹的 Bug 变成了一个安静的 Bug。循环依然在发生,只是在放弃之前运行得更久了。DeepAgents 用户报告称,子代理会静默继承默认限制,而无视父级的配置。因此,一个配置了 50 个超级步的父图生成的子图会在达到 25 次时崩溃,且不向调用者显现正确的原因。循环和限制发生在不同的地方,诊断也是如此。

最近的文献开始指出一种更丑陋的变体:隐藏循环,在这种情况下,轨迹在结构上看是非循环的 —— 不同的工具、不同的参数、不同的检索文档 —— 但智能体实际上正在重新访问相同的逻辑状态。对调用栈的结构化分析完全无法发现这些。只有当你能比较连续状态的语义内容时,它们才会显现,而大多数可观测性工具并不具备这种能力。

应该叫“重入”,而非“递归”

将此称为“递归”会将其与智能体合法地将任务分解为子任务并向自身寻求帮助的情况混为一谈。那种递归是没问题的。ReDel 和更广泛的递归语言模型文献展示了一些有用的模式:父代理生成子代理来处理复杂的子问题,且调用树是格式良好且有界的。而那些病态的情况并不是格式良好意义上的递归。它们是重入。

重入(Reentrancy)是系统编程中的一个属性:问题在于一个函数是否可以在其前一次调用完成之前安全地再次进入。可重入函数是指可以这样做的函数。一个不可重入函数被以重入方式调用就是 Bug。系统编程领域通过给每个函数一个明确的答案来解决这个问题:“如果我在前一次调用仍在栈中时再次被调用,会发生什么?”有些函数是安全的(纯函数、设计良好的库代码)。有些函数需要重入互斥锁(reentrant mutex)来计算同一调用者的嵌套获取次数。有些函数明确是不可重入的,运行时应该捕捉到这种违规。

智能体框架中的工具没有在任何地方声明这一属性。框架不知道在工具 A 执行期间模型再次选择工具 A 是否安全。这里没有注解,没有强制执行,最常见的运行时行为是像第一次调用一样调度该调用。如果工具 A 持有外部状态(一个开启的事务、一个分页游标、一个部分写入的文件),以重入方式调用它所产生的 Bug,其堆栈轨迹可能位于三跳之外的另一台机器上。

Trace UI 正在通过遗漏隐瞒事实

大多数智能体(Agent)观测平台将工具调用渲染为 Span 的层级树,在智能体只是简单的“模型-工具-模型”扁平循环的情况下,这种方式效果很好。层级结构会退化为一个扁平列表,时间轴视图能提供你所需的一切信息。

它无法处理的情况是:一个工具的实现再次调用了模型,而模型又选择了另一个工具。那个嵌套的模型调用在逻辑上是该工具的子节点,而第二个工具则是第一个工具的孙节点。大多数追踪库要么将整个过程扁平化为外部智能体循环的兄弟节点(因为内部模型调用使用了相同的客户端,且追踪上下文没有传播),要么在技术上正确地渲染了父子关系,但其 UI 是为浅层树优化的,深层嵌套会超出屏幕右侧并在视觉上丢失。

在这两种情况下,循环都是不可见的。一个包含 [outer_call_to_A, inner_call_to_B, inner_call_to_A, outer_call_to_B] 的扁平列表读起来就像是智能体按顺序选择了四个工具。但 Bug 在于,第二个 inner_call_to_A 与第一个 outer_call_to_A 在逻辑上是同一次调用——模型重新进入了 A。图视图(Graph view)能显示出一个循环,而列表视图只显示了四次顺序选择。

规范的实践应该是怎样的

修复方法不是单一的功能,而是一系列细微改进的堆叠,这些改进共同赋予了运行时(Runtime)如同编程语言给调试器(Debugger)那样的检查能力。

运行时维护并在调度前检查的“每个请求的工具调用图”。 每次工具调用都带有指向调用图中父节点的边。在调度新调用之前,运行时会遍历祖先节点,检查是否已存在相同的 (tool_name, normalized_arguments) 对。如果存在,运行时就有数据来决定是允许重入、发出警告,还是通过结构化错误来打破循环。模型处理结构化错误的能力远强于处理轮次耗尽的情况。

一个循环检测规则,通过结构化错误中断循环,而不是让模型在重试中盲目消耗。 当运行时看到一个无法证明其合理性的重入时,不应默默调度。它应该返回一个工具结果,说明:“这将创建一个循环:工具 A 已经以参数 X 出现在你的调用栈中。”模型非常擅长理解这一点并制定不同的计划。隐藏循环——参数不同但语义状态相同的情况——需要更丰富的检测器,但仅结构化检测就能捕捉到大多数生产环境中的情况,且实现成本极低。

为嵌套的“模型-工具-模型”模式设置独立的栈深度预算(Stack-depth budget),与外部轮次限制分开。 外部轮次限制(“智能体可以循环多少次?”)与内部栈深度(“工具可以递归进入模型多深?”)并非同一种约束。将两者混为一谈,会导致深层智能体和子智能体都继承相同的 25 次 recursion_limit,并同时耗尽空间。应将两者视为正交关系:根据工作负载,应允许智能体在栈深度为 3 的情况下进行 30 轮进展,或在栈深度为 10 的情况下进行 3 轮进展。

在工具定义上显式标注重入属性(Reentrancy attribute)。 工具作者知道他们的工具是否可以安全地被重入调用。database.transaction.begin 工具是不安全的,而 text.summarize 工具是安全的。filesystem.read 工具可以使用不同路径安全重入,但在进行中的写入操作内使用相同路径则不行。对这些信息进行编码,运行时无法猜测。默认设置为“不可重入”,这样即使注解错误,代价也只是产生一个嘈杂的错误,而不是隐性的数据损坏。

将追踪界面(Trace UI)渲染为图而不是扁平列表。 一旦可视化形式与底层结构一致,循环就会变得显而易见。Datadog 和 Langfuse 已经开始在时间轴视图旁边提供流程图视图(Flow-graph views);流程图视图是重入 Bug 变得显而易见的地方。请使用它。如果你的观测平台供应商只提供时间轴,请将追踪数据后处理为图布局以供生产调试使用;对于这类 Bug,即使是每晚生成的静态 SVG 也比扁平的 Span 列表要好。

使用工具的智能体是一个递归评估器

让上述所有规范变得显而易见的架构认知是:使用工具的智能体是一个递归评估器(Recursive evaluator)。它不是状态机,不是工作流,也不是流水线。从结构上看,它是一个语言解释器,其程序由自然语言编写,其值是工具的结果。模型是 eval 函数,工具库是标准库,对话历史是堆(Heap)。

编程语言研究五十年来命名的每一个并发 Bug,在这个系统中都是可能触发的。重入(Reentrancy)、栈溢出(Stack overflow)、死锁(Deadlock,当两个工具各自等待对方持有的资源时)、活锁(Livelock,生产中最常见的情况——两个工具不断互相移交控制权却没有任何进展)、竞态条件(Race conditions,读取和写入共享状态的并行工具调用)。编程语言为这些问题构建的工具——递归限制、栈检查、管程对象、锁排序、死锁检测器——在智能体运行时中都有直接的对应物。而这些对应物目前大多缺失。

构建智能体系统的团队往往表现得好像这个领域是前所未有的,每个 Bug 都需要一个新名字。但这些 Bug 并不新鲜,名字早已存在。阅读 1980 年关于重入互斥锁(Reentrant mutexes)的文献,会比阅读另一篇关于提示词模式(Prompt patterns)的博客文章给智能体工程师带来更多启发。必须在智能体运行时中落地的模式是编程语言已经采用的:运行时负责了解栈上的内容,开发者负责声明什么是安全的,类型系统强制执行契约。目前工具领域还没有这些。构建这些设施,才能将“智能体偶尔陷入循环”从一种玄学问题转变为一种在调度时就能被捕捉到的 Bug 类型,而不是直到撞上递归限制的墙才被发现。

诚实的成本线

这种问题之所以比其他的正确性问题更重要,原因在于账单。重入漏洞(Reentrancy bugs)并不会表现为评估测试集(eval suite)中的失败(答案是正确的,只是循环耗时更长)。它们也不会表现为错误率的升高(递归限制捕捉到了最坏的情况,而模型则能从其余部分中恢复)。它们表现为单次请求的 Token 计数随着工具目录的增长在数月内逐渐漂移上升,且没有明确的原因,也没有显而易见的修复方法。成本分摊仪表盘显示智能体“比以前更贵了”,而恢复流失利润的唯一方法是在分发(dispatch)阶段捕获这些循环,而不是在它们消耗完 Token 之后。

一个没有循环检测的工具调用智能体,正在为一个从未定价过的随机循环(stochastic loop)买单。修复方法并不奇特。它是每个解释器最终都会采用的相同修复方案:显式地建模调用栈(call stack),赋予运行时拒绝分发的权限,声明被分发对象的契约(contracts),并将结构可视化为图表,以便人类能看到运行时所看到的内容。在函数调用层将其自身视为其本质上的递归评估器(recursive evaluator)之前,每个在其上构建的团队都将重新发现系统编程界几十年前就已命名的相同漏洞——而且每次发生的成本要高出百倍。

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