跳到正文
DEV Community· Gabriel Anhaia·· 4 小時前AI 評分64

LangGraph.js 的 Agent 為何在 25 個 super-step 後停止,以及如何設定 recursionLimit

LangGraph.js Stops Your Agent at 25 Super-Steps. Set recursionLimit on Purpose

AI 導讀

LangGraph.js 的 @langchain/langgraph 1.5.2 將單次圖執行的預設上限定為 25 個 super-step,超出會拋出 GraphRecursionError;上限不按 token 數或實際執行時間計算。

正文

Your research agent has been searching for the same answer for a while. The model keeps asking for one more lookup, the tool keeps returning something almost useful, and then the whole run throws. No answer, no partial notes, just GraphRecursionError and a message telling you to raise a config key.

In LangGraph.js that limit counts super-steps. It has nothing to do with tokens or wall-clock time, and if you don't set it, the count is 25. In the current source of @langchain/langgraph 1.5.2 that number is a hardcoded constant: const DEFAULT_RECURSION_LIMIT = 25;. The official graph API docs say the same.

What a super-step is, and why 25 runs out fast

The docs define the recursion limit as the maximum number of super-steps the graph can execute in a single execution. A super-step is one iteration over the graph: nodes that run in parallel share a super-step, nodes that run one after another each take their own (graph API docs).

A ReAct-style agent runs agent, then tools, then agent again. Every tool round trip costs two super-steps. So 25 buys you roughly a dozen tool calls, minus whatever the rest of the graph spends. A model that likes to "check one more source" gets there quickly.

Check what your installed version uses rather than trusting a blog post, including this one. Python and JavaScript have already drifted apart: the Python package's _config.py on main reads a LANGGRAPH_DEFAULT_RECURSION_LIMIT environment variable with a fallback of 10007, while the JS core hardcodes 25. Grep your own node_modules:

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

Build the loop that hits the wall

Install the package and its peer:

npm i @langchain/langgraph @langchain/core

The agent node below is a stand-in for a model that always wants one more search. In a real graph you'd call model.bindTools([...]) there and route on whether the reply has tool calls. The stub keeps the example runnable without an API key, and the graph shape is the same.

import {
  Annotation,
  StateGraph,
  START,
  END,
  MemorySaver,
  GraphRecursionError,
  type LangGraphRunnableConfig,
} from "@langchain/langgraph";

const State = Annotation.Root({
  question: Annotation<string>,
  notes: Annotation<string[]>({
    reducer: (left, right) => left.concat(right),
    default: () => [],
  }),
  stepsUsed: Annotation<number>,
  route: Annotation<"tools" | "summarize">,
  answer: Annotation<string>,
});

type S = typeof State.State;

// Stand-in for a model that never feels done.
async function naiveAgent(_state: S) {
  return { route: "tools" as const };
}

async function search(state: S) {
  const n = state.notes.length + 1;
  return { notes: [`search result ${n}`] };
}

const naive = new StateGraph(State)
  .addNode("agent", naiveAgent)
  .addNode("tools", search)
  .addEdge(START, "agent")
  .addEdge("agent", "tools")
  .addEdge("tools", "agent")
  .compile();

try {
  await naive.invoke({ question: "Why is checkout slow?" });
} catch (err) {
  if (err instanceof GraphRecursionError) {
    console.error(err.message);
  } else {
    throw err;
  }
}

There is no exit edge, so the run ends only when the loop runs out of steps. In the Pregel loop source, each tick checks this.step > this.stop and sets the status to "out_of_steps". The runner turns that status into a GraphRecursionError with the error code GRAPH_RECURSION_LIMIT and a message that starts "Recursion limit of 25 reached without hitting a stop condition."

The try/catch is the reactive pattern from the LangGraph graph API docs. It keeps the process alive. It does not give the user an answer, and every note the tool collected is gone from the return value.

Set recursionLimit on every invoke

recursionLimit is a top-level key of the config you pass to invoke or stream. It does not go inside configurable (graph API docs):

await naive.invoke(
  { question: "Why is checkout slow?" },
  { recursionLimit: 60 },
);

The error page suggests raising it for graphs that are legitimately complex and shows recursionLimit: 1000. For a loop with no exit, raising it only moves the wall further out and makes each failure cost more model calls. Pick the number from the job: a quick lookup gets a small limit, a long research task gets a bigger one, and every call site says which it is.

Count your own steps and route to a summary first

The proactive fix is to let the graph see its budget. A node receives the run config, and the docs PR notes that config.recursionLimit is always present there, defaulting to 25 (docs PR #1166). Keep a step counter in state, and when one more round trip plus a summary would no longer fit, route to a node that writes the best answer it has. Same State and imports as above:

const MARGIN = 3;

async function agent(state: S, config: LangGraphRunnableConfig) {
  const used = (state.stepsUsed ?? 0) + 1;
  const limit = config.recursionLimit ?? 25;
  // One more search costs 2 super-steps (tools, agent).
  // The summary costs 1. MARGIN covers bookkeeping steps.
  const fits = used + 2 + 1 + MARGIN <= limit;
  return {
    stepsUsed: used,
    route: fits ? ("tools" as const) : ("summarize" as const),
  };
}

async function tools(state: S) {
  const n = state.notes.length + 1;
  return {
    stepsUsed: state.stepsUsed + 1,
    notes: [`search result ${n}`],
  };
}

async function summarize(state: S) {
  const recent = state.notes.slice(-3).join("; ");
  return {
    stepsUsed: state.stepsUsed + 1,
    answer:
      `Stopped after ${state.notes.length} searches ` +
      `without a final answer. Latest notes: ${recent}`,
  };
}

const guarded = new StateGraph(State)
  .addNode("agent", agent)
  .addNode("tools", tools)
  .addNode("summarize", summarize)
  .addEdge(START, "agent")
  .addConditionalEdges("agent", (s) => s.route, [
    "tools",
    "summarize",
  ])
  .addEdge("tools", "agent")
  .addEdge("summarize", END)
  .compile({ checkpointer: new MemorySaver() });

With a real model, agent gets a third route: no tool calls in the reply means the model is done and you go to END. The budget check only decides what happens when the model is not done and the steps are running out. In the summarize node you'd usually make one last model call over the notes; the string here keeps the example offline.

The margin is deliberate. The exact accounting around the input step is internal to the loop, so aim a few steps short of the limit rather than at the last one. MARGIN = 3 is a starting point, not a measured value.

Checkpointed threads: the budget resets on every call

Why a counter in state, and not the config.metadata.langgraph_step value the docs suggest reading? Because of how the limit is computed on a thread. In the loop source, each run starts from the saved checkpoint's step:

const step = (checkpointMetadata.step ?? 0) + 1;
const stop =
  step + (config.recursionLimit ?? DEFAULT_LOOP_LIMIT) + 1;

Two things follow from those lines.

First, the limit applies per invoke, not per thread. Each call on the same thread_id gets a fresh budget measured from wherever the thread already is. A retry wrapper that catches GraphRecursionError and calls invoke again on the same thread hands a looping agent another full budget each time. If you need a cap across the whole conversation, keep it in your own code or in state.

Second, langgraph_step is the thread's absolute step, and it comes from that same loop counter. On the second turn of a conversation it already starts well past where the first turn ended. A check like "stop at 80% of recursionLimit" against langgraph_step works on a fresh thread and fires far too early on turn two. A counter you reset in the input measures the current run only:

const thread = { configurable: { thread_id: "ticket-42" } };

const out = await guarded.invoke(
  { question: "Why is checkout slow?", stepsUsed: 0 },
  { ...thread, recursionLimit: 30 },
);
console.log(out.answer);

stepsUsed has no reducer, so the 0 in the input overwrites whatever the last turn left in the checkpoint. The notes key does have a reducer, so notes from earlier turns stay on the thread, which is usually what you want from a research agent.

Before the next deploy, grep every invoke and stream call site for a missing recursionLimit, and check whether any retry wrapper re-invokes the same thread_id after a GraphRecursionError.


If this was useful

Loops, state, checkpoints and the edges that decide when an agent stops are what AI That Plans is about: LangGraph.js graphs in TypeScript, from a single tool loop to multi-step and multi-agent work with memory that survives across turns. If the step-budget pattern here is the kind of thing you want worked through end to end, that's the book.

AI That Plans — AI in TypeScript

It's book 4 of five in AI in TypeScript, which goes from your first LLM call to agents in production — the whole series is here.

來源:DEV Community · dev.to