你的研究型Agent已经找了很久的答案。模型不断要求"再查一次",工具不断返回看似有用的东西,然后整个运行直接抛错。没有答案,没有中间笔记,只有一个 GraphRecursionError,以及一句提示你去调高某个配置键的消息。

打开网易新闻 查看精彩图片

在 LangGraph.js 里,这个限制数的是超级步(super-step)。它和 token 数无关,也和实际耗时无关。如果你不设置它,默认值就是 25。在 @langchain/langgraph 1.5.2 的当前源码里,这个数字是一个硬编码常量:const DEFAULT_RECURSION_LIMIT = 25;。官方 graph API 文档的说法一致。

超级步是什么,25 为什么很快用完

文档把递归限制定义为:图在一次执行中最多能跑多少个超级步。一个超级步就是对图的一次迭代——并行运行的节点共享一个超级步,先后串行运行的节点各占一个。

一个 ReAct 风格的 Agent 会先跑 agent,再跑 tools,再回到 agent。每一次工具往返要花掉两个超级步。所以 25 大约只够十几次工具调用,还得扣掉图里其他环节的开销。一个喜欢"再核对一个来源"的模型,很快就撞墙。

别信博客文章,包括这篇,去查你自己装的版本。Python 和 JavaScript 已经出现分歧:Python 包 main 分支的 _config.py 读取环境变量 LANGGRAPH_DEFAULT_RECURSION_LIMIT,回退值是 10007,而 JS 核心硬编码为 25。用这条命令翻你自己的 node_modules:

  • grep -rn "DEFAULT_RECURSION_LIMIT\|DEFAULT_LOOP_LIMIT" node_modules/@langchain/langgraph/dist | head

搭一个会撞墙的循环

先装包和它的 peer 依赖:npm i @langchain/langgraph @langchain/core。下面这个 agent 节点是一个"永远想再搜一次"的模型替身。真实图里你会在这里调用 model.bindTools([...]),并根据回复里有没有工具调用做路由。这个桩让示例不需要 API key 也能跑,图的形状是一样的。

示例用 Annotation.Root 定义了 question、notes、stepsUsed、route、answer 几个字段,其中 notes 带 reducer,会把结果拼接起来。naiveAgent 永远返回 route: "tools",search 节点每次往 notes 里追加一条"search result N"。图里只有 START→agent→tools→agent 这几条边,没有出口边,所以运行只会在步数耗尽时结束。

在 Pregel 循环源码里,每个 tick 检查 this.step > this.stop,一旦成立就把状态置为 "out_of_steps"。运行器把这个状态转成 GraphRecursionError,错误码是 GRAPH_RECURSION_LIMIT,消息以"Recursion limit of 25 reached without hitting a stop condition."开头。

用 try/catch 捕获它是文档里的被动方案。它能让进程活着,但不会给用户答案,工具收集到的每一条笔记都从返回值里消失了。

每次 invoke 都设 recursionLimit

recursionLimit 是你传给 invoke 或 stream 的 config 的顶层键,不放在 configurable 里面。写法是:await naive.invoke({ question: "Why is checkout slow?" }, { recursionLimit: 60 });

错误页建议对确实复杂的图调高它,并给出了 recursionLimit: 1000 的例子。对一个没有出口的循环来说,调高只是把墙往外挪,每次失败还要多烧几次模型调用。数字应该按任务来定:快速查询给小限制,长研究任务给大限制,每个调用点都写清楚自己是哪种。

自己数步数,先路由到总结

主动的修法是让图看得见自己的预算。节点能拿到运行配置,文档 PR 指出 config.recursionLimit 在那里总是存在的,默认 25(docs PR #1166)。在 state 里维护一个步数计数器,当"再来一轮往返加一次总结"已经放不下时,路由到一个写出当前最优答案的节点。

示例里设了 MARGIN = 3。agent 节点先算 used = (state.stepsUsed ?? 0) + 1,再取 limit = config.recursionLimit ?? 25。判断条件是 used + 2 + 1 + MARGIN 是否还小于 limit——多一次搜索花 2 个超级步(tools、agent),总结花 1 个,MARGIN 覆盖记账开销。放得下就走 tools,放不下就走 summarize,summarize 连到 END。图用 MemorySaver 做 checkpointer 编译。

接真实模型时,agent 会有第三条路由:回复里没有工具调用就说明模型做完了,直接去 END。预算检查只决定"模型没做完而步数快没了"时怎么办。summarize 节点里通常会对着笔记做最后一次模型调用,示例里用字符串保持离线可跑。

这个余量是故意的。输入步附近的精确记账属于循环内部实现,所以宁可差几步到限制,也不要去卡最后一步。MARGIN = 3 是起点,不是实测值。

带 checkpoint 的线程:预算每次调用都重置

为什么用 state 里的计数器,而不是文档建议读的 config.metadata.langgraph_step?因为限制在线程上的计算方式不一样。循环源码里,每次运行都从保存的 checkpoint 的步数开始:const step = (checkpointMetadata.step ?? 0) + 1; const stop = step + (config.recursionLimit ?? DEFAULT_LOOP_LIMIT) + 1;

这两行推出两件事。第一,限制是按每次 invoke 算的,不是按线程算的。同一个 thread_id 上的每次调用都从线程当前所在位置起算一份新预算。一个捕获 GraphRecursionError 后在同一线程上再次 invoke 的重试包装器,等于每次都给循环中的 Agent 发一份全新预算。要在整段对话上设上限,得自己写在代码或 state 里。

第二,langgraph_step 是线程的绝对步数,来自同一个循环计数器。对话第二轮开始时,它已经远超第一轮结束的位置。拿 langgraph_step 去判断"到 recursionLimit 的 80% 就停",在新线程上能用,到第二轮会触发得太早。在输入里重置的计数器只度量当前这次运行:调用时传 { question: ..., stepsUsed: 0 },stepsUsed 没有 reducer,输入里的 0 会覆盖 checkpoint 里上一轮留下的值;notes 有 reducer,前几轮的笔记会留在线程上,这通常正是研究型 Agent 想要的行为。

下次部署前,把每个 invoke 和 stream 调用点都 grep 一遍,看有没有漏掉 recursionLimit,再检查有没有哪个重试包装器在 GraphRecursionError 之后对同一个 thread_id 重新 invoke。