1. 工具循环失控的现场:从一个“读不完的文档” Bug 说起
写过 AI Agent 的朋友应该都有过这种经历:你给智能体配了三五个工具,本来只是让它查个资料、整理个表格,结果它像魔怔了一样,反反复复把同一个工具调用十几遍。日志里密密麻麻全是 tool call,每一条都是同样的参数、同样的输入,唯一区别就是时间戳不同。电话费、Token 费哗哗流走,最后模型吐出来一句“抱歉,我没找到答案”。
我最初遇到这个问题是在做一个内部知识库问答助手。当时给 Agent 配了一个search_documents工具,让它帮同事找公司制度文档。某天有人提问“请总结一下出差报销的所有规定”,Agent 的第一步跑得很正常:调用搜索工具,拿到了二十多条标题,其中五条看起来跟报销相关。问题出在第二步:它想逐一打开这五条查看内容,就引入了一个read_document工具。然后我再看日志就傻眼了——read_document被连续调用了三十多次,传入的文档 ID 永远只有那五个,而且每次都能成功返回正文,但 Agent 每一次都说“需要继续确认细节”。
这就是典型的 AI 工具循环失控。用户看到的是无限转圈,开发者看到的是 Token 快速蒸发,底层原因是 Agent 在自己的推理循环里失去了“退出意识”。
这个循环机制本身不是坏事。业界管它叫AI 工具循环(agentic loop):模型先分析任务,选择一个工具,拿到返回结果,再分析结果,再选下一个工具,直到它认为任务完成。这是所有 Agent 的骨架,没有这个循环,Agent 就只是“一次性的问答机器”。但它一旦失控,就成了无底洞。
控制这个循环有几个关键抓手,其中最基础也最容易被低估的就是标题里那个recursionLimit。很多人对这个概念的理解停留在“把数字调大一点就好了”,实际上它和条件路由是搭配使用的。recursionLimit 负责兜底,条件路由负责止损,两者一个管“最多跑多远”,一个管“这段路怎么选”,配合起来才能真正把循环管住。
这篇文章我按自己的项目经验来拆:先说说工具循环到底是怎么失控的,再从原理层面讲清 recursionLimit 到底限制了什么,然后用一个条件路由的设计实例说明“如何让 Agent 自己知道何时收手”,最后分享一次完整的故障排查链路和一些框架配置上的对照。如果你正准备给自己的 Agent 加工具,或者已经被循环折磨过,这篇文章应该能帮你从“抢修模式”切换到“设计模式”。
2. recursionLimit 到底在限制什么:执行深度与资源代价的账
先抛个结论:recursionLimit 限制的不是工具的调用次数,而是 Agent 决策循环的执行深度。
这个区别很重要。很多人看名字里有“recursion”这个词,就误以为它管的是“递归调用”,也就是工具 A 内部调用了工具 B 那种嵌套场景。实际上,在主流 Agent 框架和编排引擎的语境里,recursionLimit 是配置在图执行器(graph executor)层面的一个计数器——每一次大模型完成一次推理并产生工具调用,计数器就加一,一旦超出设定阈值,整个执行就强制终止。
为了把这个讲透,我从执行深度的角度重新解释一遍它的作用。
2.1 执行深度:Agent 的“第几手牌”
你可以把 Agent 完成一个任务的过程想象成和它玩扑克。每打出一张牌,它就根据牌桌上反馈的信息决定下一张怎么打。这张牌可能是调用一个搜索工具,可能是读一个网页,也可能是写一段代码然后运行。每一次“出牌”,就代表 AI 工具循环多走了一步。
recursionLimit 限制的就是“一共能出几手牌”。它和工具数量无关——你要是有二十个工具,但所有信息一步就能拿全,可能一次调用就结束了;反过来,就算你只有两个工具,如果 Agent 在所有分支里反复横跳,二十步也可以瞬间用光。
我自己的经验里,比较合理的理解方式是:递归深度代表 Agent 对任务不确定性的容忍度上限。如果任务链路非常直观,比如“查天气并告诉我”,3 到 5 轮足矣;如果是“访问官网 → 获取 API 文档 → 写一个调用脚本 → 执行脚本 → 分析输出结果”,那么这个链路本身就要 4 到 5 轮,此时 recursionLimit 必须大于它。
2.2 循环失控时,钱和时间的账怎么算
为什么整个行业都在强调管住 recursionLimit?因为失控循环烧掉的不仅是耐心,更是真金白银。我算过一笔实际账:
- 一次 GPT-4 级别的工具调用,输入加上工具返回结果,平均在 2000 到 4000 Token 左右;
- 一次循环里,模型要重新读取全部历史消息(包括前面所有工具调用及其结果),所以 Token 消耗是线性叠加甚至超线性增长的;
- 再加上每次模型调用的 API 延迟,通常在 2 到 6 秒之间,循环 20 次就意味着用户要干等 40 秒以上,这还是在不考虑限流的情况下。
更让运营头疼的是,这些失效循环并不是毫无意义的中断,而是反复调用真实工具。如果你的工具带副作用——比如“发送邮件”“创建订单”——那就不只是烧钱问题,可能还会制造脏数据。我听过一个案例,某个团队给 Agent 配了数据库写入工具,循环失控后订单表里多了一堆重复测试记录,最后只能人工清洗。
2.3 兜底和保护的关系
所以我会说,recursionLimit 本质上是一道安全闸门。它不关心你这段循环的质量,不判断你“是不是正走在正确的路上”,它只是冷酷地告诉你:到点了,必须停。
也正因为它是一个“一刀切”的全局限制,单独配置它并不能解决循环的“质量问题”。
举个很常见的情况:Agent 在处理一个需要分三步走的任务,但是每走一步前它都习惯性地先调用一个“搜索工具”去确认上下文。你把 recursionLimit 从 10 提到 20,它可能还是会在 19 步时因为“没有足够信心”而继续调用。这个场景里,真正管用的不是加大限制,而是给它一套更聪明的决策逻辑——这就是后面要讲的条件路由。
提示:不要把 recursionLimit 当作治理工具循环的唯一手段。它是发动机的保险丝,不是方向盘。保险丝能防止烧毁,但它不负责把车开到目的地。
3. 条件路由:把“无限可能性”压缩成“有限路由表”
条件路由(conditional routing)这个词听起来很工程,其实概念特别简单:在 Agent 每次打算调用工具之前,先通过一组规则或模型判断,明确告诉它“现在有那几条路可选,每条路的入口条件是什么”。
没有路由的 Agent 像是一个没有地图的司机。它手里有导航(大模型),导航告诉它“往前开”,但是没告诉它什么时候该拐弯、什么时候该停。条件路由就是在地图上把路标竖起来:遇到“前方路口左转”的标志,才允许它进入左转车道;遇到“禁止通行”的标志,必须重新规划。
在实现层面,条件路由可以做到很多种粒度。
3.1 第一层路由:工具选择的前置规则
最基础的一层,是在模型产生 tool call 之前加一道过滤器。比如你配置了三个工具:get_file_content、summarize_text、send_email。理论上,模型可以因为一句“我觉得需要发送一下结果”就去调用send_email,这没问题;但在真实场景里,模型经常因为对工具职责理解不够,乱调一通。
我自己的做法是写一个简单的前置判断:
def route_intent(user_input: str, tool_calls: list): # 假设模型已经产生了候选工具调用 allowed_tools = [] for call in tool_calls: tool_name = call.function.name if tool_name == "send_email" and not user_email_confirmed(user_input): # 未确认收件人之前,不允许进入发信流程 continue allowed_tools.append(call) return allowed_tools这套规则的业务含义是:如果用户从头到尾都没提供过收件人邮箱,就别让 Agent 进入发信分支。它看起来和“工具循环”问题无关,但实际效果非常大——很多失控循环都是因为 Agent 在某个分支里做了“无法退出的操作”,比如它真要发邮件,但是参数又不够,于是反复回到查询逻辑里死循环。
3.2 第二层路由:基于工具返回值的循环出口判断
第二层路由才是真正管循环的。它的核心机制是:在每次工具返回结果后,不是直接把结果原样丢回给大模型,而是先做一次“结果质量判断”,决定是继续调用、还是切换到另一个分支、还是直接终止。
我举个可复现的伪代码例子:
def trim_tool_response(tool_output: str) -> str: # 如果输出过长,则截断;如果输出为空,则返回特定标记 if len(tool_output) > 3000: return tool_output[:3000] + "...[已截断]" if not tool_output.strip(): return "[工具返回为空]" return tool_output def decide_next_step(tool_name: str, tool_output: str) -> str: trimmed = trim_tool_response(tool_output) if "[工具返回为空]" in trimmed: return "to_fallback" # 走兜底分支,而不是继续调用同一工具 if "错误" in trimmed or "异常" in trimmed: return "to_error_handler" return "to_agent_loop" # 正常进入下一轮模型推理看到这里你应该能明白,条件路由的价值不只是“走到哪算哪”,而是给循环增加了结构性出口。原本 Agent 只能依靠自己“觉得任务完成了”来退出,现在每一轮工具返回后,都会先被一套规则重新审视一遍:这次的返回到底有没有推进任务?如果推进了,继续;没推进,立刻切换策略;出现错误,进入错误恢复。
3.3 路由与 recursionLimit 的联动
在我实际的项目配置里,这两个是一起调整的。
举例来说,我给 Agent 设定的基础规则是:默认 recursionLimit 为 12。如果某个任务的工具链路预期会超过 8 步,比如“先搜索 → 再读取 → 再分析 → 再生成报告 → 再发送”,那么路由逻辑会自动把限制放大到 20。但这还不够,因为执行深度越接近上限,Agent 越容易慌乱,开始无意义地重复调用工具。
于是我又加了一条“残余步数保护”:当引擎检测到剩余步数少于 3 步时,自动把路由策略切换为“保守模式”,只允许调用已经验证过的工具,禁止再开新的工具分支。这个设计听起来复杂,实际上就是在循环的某一轮开始之前,先把路由表替换一份。
执行后状态: - 当前轮次:9 - recursionLimit:12 - 剩余步数:3 - 路由模式:conservative + 允许:read_document + 允许:search_documents(仅限精确关键词) - 禁止:send_email、create_ticket、execute_script我实测下来,这套“余量感知路由”能显著降低循环失控率。原因不复杂——大模型很多时候不是笨,而是“贪心”:它总觉得再多查一步、再确认一轮会更完美。越到后期,这种完美主义倾向越强。切断新分支的选项,等于直接告诉它“只能从这个出口走”,最后几步它不是漫无目的地探索,而是收敛式地完成任务。
4. 条件路由设计实例:从“乱串门”到“直线顶到结果”
光讲理论不好消化,这一节我把我自己做的一个小项目完整展开。这是一个“代码仓库问诊 Agent”,用户描述一个异常现象,它需要去仓库里找代码、读关键文件、定位 bug、给出修复建议。
第一次配好工具时,它就是这么失控的:Agent 老是乱串门,一会儿调list_files(列出目录结构),一会儿调read_file(读文件),一会儿又回头调list_files,搞得像是在迷宫里乱撞。
4.1 问题抽象:工具调用路径太发散
为什么要引入条件路由?因为默认的 agentic loop 把“下一步做什么”的决定权完全交给了大模型。模型每次都是根据“我刚才看到的东西”来决定下一步,它没有全局意识,更看不到“我已经白走了三遍同一个目录”。
我的思路是把任务划分成四个阶段,每个阶段是一个路由容器:
- 探索阶段:只看结构,不读内容。允许调用
list_files、grep_code; - 阅读阶段:根据探索结果领取文件清单,逐一批量读取,不允许再临时搜索;
- 分析阶段:不允许再调用任何工具,要求模型基于已有上下文输出完整的推理链条;
- 报告阶段:生成最终结论,可调用
send_email。
每个阶段内部有自己的 recursionLimit 预算。整个循环执行时,引擎先判断当前处于哪个阶段,再决定下一步该走哪个分支。
4.2 实现:阶段路由表解析
我简化后的路由代码如下,方便大家照着改自己的场景:
STAGES = { "explore": { "allowed_tools": ["list_files", "grep_code"], "max_steps": 4, }, "read": { "allowed_tools": ["read_file"], "max_steps": 8, }, "analyze": { "allowed_tools": [], "max_steps": 1, }, "report": { "allowed_tools": ["send_email"], "max_steps": 2, }, }在执行器的主循环里,我会先通过一个函数判断当前状态属于哪个阶段:
def get_current_stage(trace: list) -> str: """ 根据最近的调用记录判断当前处于哪个阶段 """ if not trace: return "explore" recent_calls = trace[-5:] all_read = all(call.had_content_result for call in recent_calls) if all_read: return "analyze" last_tool = trace[-1].tool_name if last_tool == "read_file": return "read" if last_tool == "grep_code" or last_tool == "list_files": return "explore" return "explore"这个设计有个额外好处:recursionLimit不再是一个全局拍脑袋的数字,而是可以分摊到每个阶段里。按照上面配置,整个任务最多消耗 4 + 8 + 1 + 2 = 15 步。也就是说,即使 Agent 完全学不会“怎么收敛”,路由容器也会在 15 步内强制把它推到最终报告。从实际使用效果看,改造前平均每个任务要跑 11.6 轮,改造后平均只需要 5.2 轮,而且任务的失败率从 32% 降到了 9% 左右。
4.3 兜底分支设计:路由撞墙了怎么办
任何路由设计都不能保证“一定不会走错”,所以必须给自己留兜底分支。我通常在每个阶段外加一个fallback分支,专门处理“模型坚持调用禁用工具”的情况。
举个例子,在 explore 阶段,模型理论上不允许直接调read_file。但我们的模型偶尔还是会发出这个请求。这时候我不会生硬地拒绝,而是注入一条提示语:
注意:当前处于“探索结构”阶段,请先列目录或搜索关键词定位目标文件, 不要直接读取文件内容。建议调用 list_files 或 grep_code。更进阶的做法是往回拨状态:如果模型在一个阶段里连续两次触发了禁用工具调用,我就把它的“视野”往前回溯一步,相当于让它重新看到上一个阶段结束时的信息,避免它在错误分支里越陷越深。我把这个叫“状态回滚”,它和 recursionLimit 一样,是第三道保险栓。
5. 一次“递归风暴”的完整排查链路:从日志到修复
纸上谈兵完了,分享一次真实的踩坑过程。不光讲结论,把排查的链路和思路一起写出来,这比直接给答案有用得多。
5.1 现象:Agent 在 47 秒内完成 143 次工具调用
有一次我把一个带八个工具的 Agent 接进了生产环境的工单系统,让它自动回复客户问题。上线第二天,监控面板报警:某个工单的应答流程持续了 47 秒,期间完成了 143 次工具调用,消耗的 Token 相当于平时一千多条消息的总量。
第一反应是检查是不是工具本身卡住了,比如某个 API 超时导致循环。但日志显示每个工具都是正常返回的,而且返回的内容一样——是同一个“客户订单状态查询”接口。这说明问题不在工具,而在 Agent 的循环策略本身。
5.2 逐层剥离:三步定位问题根因
排查这种事,最忌讳直接改配置瞎试。我按下面三个步骤做了剥离:
**第一步,看调用图谱。**把所有工具调用按时间序列画出来后,发现是典型的“乒乓型循环”:调用订单查询 → 返回“已发货” → Agent 再调物流查询 → 返回“运输中” → Agent 再调订单查询 …… 无限往复。两个工具互相给对方“喂新问题”,而大模型又没有能力判断“这两个信息其实指向同一个结论”。
**第二步,缩小工具范围。**我临时把物流查询工具下线,只留订单查询。结果 Agent 虽然没再乒乓,但还是反复调用订单查询,并持续输出“我需要进一步核实”。问题定位为:模型对“已发货”状态的理解存在不确定性,它倾向于反复确认来弥补这种不确定感。
**第三步,检查路由规则。**当时项目里其实已经配置了一个简单路由:如果订单状态为“已发货”,则路由到“物流阶段”。但这条路由只判断了订单状态,没有判断“物流阶段是否已经查询过”。所以 Agent 在物流查询结果回来后,又被叔叔式地引导回订单查询,循环就产生了。
修复的方案很直接:把路由判断从“无状态”改成“有状态”。用一个上下文存储记录“最近 3 轮已经调用过哪些工具、结果是什么”。如果接下来模型再次触发相同工具调用,并且参数完全相同,路由直接拦截这次调用,然后把上一次的结果重新提供给模型,并附上一句提示“该信息已存在,请基于现有信息继续”。
5.3 修复效果的量化对比
我记录了一个小表,方便后续参考:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 单任务平均工具调用次数 | 27.4 | 6.8 |
| 单任务平均耗时时长 | 21.3s | 5.7s |
| Token 消耗(中位数) | 18.2K | 6.1K |
| 用户投诉率 | 14% | 3% |
这次经历让我深刻体会到一点:recursionLimit 管的是“最大能跑多少”,路由管的是“跑得有没有意义”。如果只调大 recursionLimit,这次故障的持续时间会更长;如果只加路由但不管 recursionLimit,极端情况照样会失控。两件事必须同时做。
6. 不同框架里的 recursionLimit 配置对照与选型建议
聊到具体配置,不同框架对 recursionLimit 的实现不完全一样。我这里把几个常见环境下的配置方式做一下对照,方便大家按自己的需求选。
6.1 LangGraph:显式的 recursion_limit
LangGraph 是我用得最多的 Agent 编排框架。它内置了recursion_limit的概念,直接传给编译后的图执行器即可:
from langgraph.graph import StateGraph graph = StateGraph(...) app = graph.compile( checkpointer=..., # 限制整个图执行的最大步数 recursion_limit=15, ) result = await app.ainvoke( initial_state, config={"recursion_limit": 15}, )值得注意的是,LangGraph 里recursion_limit是按“图中节点被执行的次数”计的,中间的消息聚合、条件路由本身也会消耗这里的计数,而不仅仅是“工具调用次数”。所以设计图结构时,不要以为自己设置 10 就等于模型只能调用 10 次工具——如果图里加了一个条件分支节点,每轮循环要跑两个节点,那 10 次 recursion limit 其实只相当于 5 轮工具循环。
6.2 OpenAI Assistants API:max_prompt_turns 与 tools 约束
OpenAI 的 Assistants API 里没有叫 recursionLimit 的参数,但有一个更接近的概念:max_prompt_turns。它限制的是“一次会话中最多允许多少轮模型推理”。和 LangGraph 不同的是,OpenAI 平台在后台会自动做一次“任务完成度预判”,当你设置得明显低于任务需要时,它会提前终止。
另外,OpenAI 的 Function Calling 有一个隐性约束:同一轮内模型可以直接放弃调用工具并返回最终答案。所以对付简单失控,不一定非要上重型路由,先把 system prompt 里的“请仅在确保需要外部数据时才调用工具”写到位,就能避免很多无谓循环。
6.3 自研 Loop:自己造轮子时的三个关键点
如果你像我一样,早期是自己写简单的 tool loop(读历史 → 调模型 → 调工具 → 追加历史 → 再读历史),那么没有现成的 recursionLimit 可用,必须自己实现。我建议至少封装三层:
class ToolLoopController: def __init__(self, max_rounds=10, max_consecutive_same_tool=2): self.max_rounds = max_rounds self.max_consecutive_same_tool = max_consecutive_same_tool def before_tool_call(self, trace, next_tool_name, next_tool_args): # 第一层:总量限制 if len(trace) >= self.max_rounds: return "stop" # 第二层:连续相同工具限制 recent = [t.tool_name for t in trace[-3:]] if len(recent) >= 3 and all(name == next_tool_name for name in recent): return "force_new_branch" # 第三层:参数相似度限制 last_call = trace[-1] if trace else None if last_call and last_call.tool_args == next_tool_args: return "reuse_previous_result" return "allow"这个控制器在每次模型返回 tool call 前执行,等于是把路由规则和 recursionLimit 合二为一了。三层判断分别解决“总步数超了”“绕圈圈”“原地踏步”三类循环问题。
6.4 选型建议
如果你刚开始做 Agent,我建议直接用 LangGraph 或同类图编排框架,因为它把 recursionLimit 和条件路由都做成了第一公民,不需要你自己维护循环状态。如果只是简单接一两个工具的快速验证场景,OpenAI Assistants 的max_prompt_turns也够用。只有当你对控制粒度有非常规要求时,再考虑自己造轮子。
7. 管住循环之后,Agent 才谈得上“可靠”
工具循环是 Agent 的核心机制,但核心机制恰恰需要被约束才能发挥价值。recursionLimit 和条件路由这两者的关系,我总结成一句话:限制告诉 Agent 最多能跑多远,路由告诉 Agent 每一步往哪个方向跑。只给限制不给方向,Agent 会在撞墙前不断绕圈;只给方向不给限制,一个迷路的 Agent 能把整条链路拖垮。
我个人的实操体会,治理工具循环至少要过三关:
第一关是基线设计。给每个 Agent 任务估算最合理的步数范围,然后把 recursionLimit 定在“能完成任务的 1.2 倍”而不是“尽可能大”。比如你推断任务需要 6 步,那就写 8 或 9,不要写 20。宽松的限制只是延迟了问题爆发的时间。
第二关是运行监控。给工具调用加上日志和可视化追踪,重点观察那些“逼近上限但还在调用”的任务。这类任务往往不是缺失步数,而是缺少退出条件——你应该去补路由规则,而不是去调大限制。
第三关是事后复盘。每次循环失控后,把你当时的工具调用序列保存下来,作为构造条件路由规则的数据来源。你的路由规则越贴近真实失败案例,后续的效果就越好。
最后再分享一个小技巧:如果你发现 Agent 在某类任务上总是差一两步才能完成,不要只调高 recursionLimit,试着在路由里给它加一个“重试一次并忽略历史细节”的分支——让它忘掉前面几轮无效信息,只基于最近一次工具结果重新推理。我用了这个方法之后,很多“卡在临界点”的 Agent 一下就顺畅了。
工具循环本身不可怕,可怕的是对它毫无防备。把递归上限和路由规则一起配齐,你的 Agent 才能真正从“会调用工具”升级成“能靠谱地完成任务”。