← Back to DevBytes

Debugging Infinite Loops in LangGraph Agent Workflows

Debugging Infinite Loops in LangGraph Agent Workflows

LangGraph is a powerful framework for building stateful, multi-actor agent workflows using directed graphs. However, as your agent grows in complexity—with conditional edges, tool calls, and dynamic routing—one of the most frustrating issues you can encounter is the dreaded infinite loop. An agent that keeps calling the same tool, revisiting the same node, or bouncing between two states without ever reaching the END node will burn tokens, stall your application, and frustrate users. This tutorial walks you through identifying, diagnosing, and fixing infinite loops in LangGraph workflows.

What Is an Infinite Loop in LangGraph?

In LangGraph, a workflow is defined as a graph where nodes represent computation steps (such as calling an LLM, executing a tool, or transforming state) and edges represent transitions between those steps. An infinite loop occurs when the graph's execution never terminates because the control flow keeps cycling through one or more nodes without reaching a terminal state.

Common forms of infinite loops include:

Why It Matters

Infinite loops are not just a nuisance—they have real consequences. Each iteration typically invokes an LLM call, which costs money and adds latency. A runaway agent can rack up significant API charges in minutes. Worse, if your agent is deployed in a production environment, an infinite loop can hang request handlers, exhaust connection pools, and degrade the experience for every user on the system. Debugging these loops quickly is therefore an essential skill for anyone building production-grade LangGraph applications.

Setting Up a Reproducible Example

Before diving into debugging techniques, let's create a minimal LangGraph workflow that contains a realistic infinite loop. We'll build a simple agent that can call a search tool. The bug will be that the agent never decides it has enough information to stop searching.

from typing import Annotated, TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode

class State(TypedDict):
    messages: Annotated[list, add_messages]

@tool
def search(query: str) -> str:
    """Search the web for information."""
    # Simulated search that always returns a vague answer
    return f"Partial results for: {query}. More research may be needed."

tools = [search]
llm = ChatOpenAI(model="gpt-4o").bind_tools(tools)

def agent_node(state: State) -> dict:
    response = llm.invoke(state["messages"])
    return {"messages": [response]}

def should_continue(state: State) -> str:
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tools"
    return END

builder = StateGraph(State)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools))

builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, ["tools", END])
builder.add_edge("tools", "agent")  # Always go back to agent after tools

graph = builder.compile()

If you run this graph with a prompt like "Find out everything about quantum computing", the agent may keep calling search over and over because the tool always returns a vague response suggesting more research is needed. The loop is: agent → tools → agent → tools → ... forever.

Technique 1: Add a Recursion Limit

The first line of defense is LangGraph's built-in recursion limit. Every compiled graph accepts a recursion_limit parameter that caps the number of super-steps the graph can take before raising a GraphRecursionError. This prevents truly unbounded execution.

graph = builder.compile()

try:
    result = graph.invoke(
        {"messages": [("user", "Find out everything about quantum computing")]},
        config={"recursion_limit": 10}
    )
except Exception as e:
    print(f"Graph stopped: {type(e).__name__}")
    print(f"Last state had {len(result['messages'])} messages" if 'result' in dir() else "No result")

The default recursion limit is 25. While this prevents infinite execution, it does not fix the underlying logic problem—it merely stops the bleeding. You still need to diagnose why the loop occurs.

Technique 2: Trace Execution with Streaming

To understand where the loop happens, stream the graph's execution and log each node transition. LangGraph's stream method yields updates after every node execution, letting you watch the control flow in real time.

def run_with_tracing(graph, user_input: str, max_steps: int = 15):
    step = 0
    for event in graph.stream(
        {"messages": [("user", user_input)]},
        config={"recursion_limit": max_steps},
        stream_mode="updates"
    ):
        step += 1
        for node_name, node_output in event.items():
            print(f"[Step {step}] Node: {node_name}")
            if "messages" in node_output:
                last_msg = node_output["messages"][-1]
                if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
                    for tc in last_msg.tool_calls:
                        print(f"  -> Tool call: {tc['name']}({tc['args']})")
                elif hasattr(last_msg, "content"):
                    print(f"  -> Content: {last_msg.content[:120]}...")
        print("-" * 60)

Run this and you will see a clear pattern: the agent node emits a tool call, the tools node executes it, and then the agent emits the same or a very similar tool call again. The trace immediately reveals which nodes are involved in the cycle.

Technique 3: Inspect the Full Message History

Often the root cause is visible in the message history. The LLM may be receiving tool results that do not help it decide to stop. Dump the full message list after a failed run to inspect what the model actually sees on each iteration.

import json

def dump_messages(state: State):
    for i, msg in enumerate(state["messages"]):
        msg_type = type(msg).__name__
        print(f"\n--- Message {i}: {msg_type} ---")
        if hasattr(msg, "tool_calls") and msg.tool_calls:
            print(f"Tool calls: {json.dumps(msg.tool_calls, indent=2)}")
        if hasattr(msg, "tool_call_id"):
            print(f"Tool call ID: {msg.tool_call_id}")
        if hasattr(msg, "content") and msg.content:
            print(f"Content: {msg.content[:300]}")

In our example, you will notice the tool always returns "More research may be needed." The LLM dutifully interprets this as an instruction to keep searching. The fix is to change either the tool's output or the system prompt so the agent knows when to stop.

Technique 4: Add a Loop Counter to State

For more sophisticated control, track how many times a particular node or tool has been invoked directly in your state. This lets you implement custom termination logic inside conditional edges.

from typing import Annotated, TypedDict
from operator import add

class State(TypedDict):
    messages: Annotated[list, add_messages]
    tool_call_count: Annotated[int, add]

def agent_node(state: State) -> dict:
    response = llm.invoke(state["messages"])
    increment = 1 if response.tool_calls else 0
    return {"messages": [response], "tool_call_count": increment}

def should_continue(state: State) -> str:
    last_message = state["messages"][-1]
    if not last_message.tool_calls:
        return END
    if state.get("tool_call_count", 0) >= 5:
        print("WARNING: Tool call limit reached, forcing end.")
        return END
    return "tools"

This pattern gives you fine-grained control. You can set different limits per tool, reset counters when the agent switches topics, or escalate to a human-in-the-loop when the counter exceeds a threshold.

Technique 5: Use LangSmith for Deep Traces

For production debugging, LangSmith integration is invaluable. When enabled, every LLM call, tool execution, and state transition is captured with full inputs, outputs, latency, and token usage. You can visually inspect the trace in the LangSmith UI and immediately spot where the loop begins.

import os

os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "ls__your_key_here"
os.environ["LANGCHAIN_PROJECT"] = "debug-infinite-loops"

# Now any graph.invoke() or graph.stream() call is automatically traced
result = graph.invoke(
    {"messages": [("user", "Find out everything about quantum computing")]},
    config={"recursion_limit": 10}
)

In the LangSmith UI, look for repeated blocks in the trace tree. Each repeated block represents one iteration of the loop. Click into the LLM call to see the exact prompt and response, which often reveals why the model keeps looping.

Common Root Causes and Fixes

1. Ambiguous Tool Outputs

If a tool returns open-ended responses like "More research may be needed" or "Results inconclusive", the LLM may interpret this as a signal to keep trying. Fix the tool to return definitive, structured output:

@tool
def search(query: str) -> str:
    """Search the web for information. Returns a definitive summary."""
    return (
        f"Summary for '{query}': Quantum computing uses qubits to "
        f"perform computation. Key topics include superposition, "
        f"entanglement, and quantum error correction. This is a "
        f"complete answer; no further searches are required."
    )

2. Missing or Weak System Prompts

The system prompt should explicitly tell the agent when to stop. Without guidance, the model may default to exhaustive behavior.

SYSTEM_PROMPT = """You are a research assistant. Follow these rules:
1. Call the search tool at most 3 times per user question.
2. After receiving tool results, synthesize a final answer.
3. Never call the same tool with the same arguments twice.
4. If you have enough information to answer, respond directly
   without making any tool calls."""

def agent_node(state: State) -> dict:
    messages = [("system", SYSTEM_PROMPT)] + state["messages"]
    response = llm.invoke(messages)
    return {"messages": [response]}

3. Conditional Edges That Never Terminate

A conditional edge function must always have a path to END. If your logic only returns node names and never returns END for certain states, you have built a loop by construction. Audit every conditional edge:

def should_continue(state: State) -> str:
    last_message = state["messages"][-1]
    if not last_message.tool_calls:
        return END  # Always have this escape hatch

    # Check for duplicate tool calls
    seen_queries = set()
    for msg in state["messages"]:
        if hasattr(msg, "tool_calls"):
            for tc in msg.tool_calls:
                if tc["name"] == "search":
                    seen_queries.add(tc["args"].get("query", ""))

    new_calls = [
        tc for tc in last_message.tool_calls
        if tc["args"].get("query", "") not in seen_queries
    ]

    if not new_calls:
        return END  # All tool calls are duplicates; stop

    return "tools"

4. Tool Errors That Trigger Retries

If a tool raises an exception, the ToolNode returns an error message to the LLM. The LLM may then retry the same call, creating a loop. Handle errors gracefully inside your tools:

@tool
def search(query: str) -> str:
    """Search the web for information."""
    try:
        # Your actual search logic here
        results = perform_search(query)
        if not results:
            return f"No results found for '{query}'. Use this as a final answer."
        return f"Results: {results}"
    except Exception as e:
        return f"Search failed permanently for '{query}': {e}. Do not retry."

Best Practices for Preventing Infinite Loops

Putting It All Together: A Loop-Resistant Agent

Here is a revised version of our original agent that incorporates all the debugging techniques discussed:

from typing import Annotated, TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from operator import add

MAX_TOOL_CALLS = 5

class State(TypedDict):
    messages: Annotated[list, add_messages]
    tool_call_count: Annotated[int, add]

@tool
def search(query: str) -> str:
    """Search the web. Returns a definitive summary."""
    return f"Complete summary for '{query}': [relevant facts here]."

tools = [search]
llm = ChatOpenAI(model="gpt-4o").bind_tools(tools)

SYSTEM_PROMPT = f"""You are a research assistant.
- Call search at most {MAX_TOOL_CALLS} times per question.
- Never repeat the same search query.
- When you have enough information, answer directly without tool calls."""

def agent_node(state: State) -> dict:
    messages = [("system", SYSTEM_PROMPT)] + state["messages"]
    response = llm.invoke(messages)
    increment = 1 if getattr(response, "tool_calls", None) else 0
    return {"messages": [response], "tool_call_count": increment}

def should_continue(state: State) -> str:
    last = state["messages"][-1]
    if not getattr(last, "tool_calls", None):
        return END
    if state.get("tool_call_count", 0) >= MAX_TOOL_CALLS:
        return END

    # Detect duplicate queries
    seen = set()
    for msg in state["messages"]:
        for tc in getattr(msg, "tool_calls", []) or []:
            if tc["name"] == "search":
                seen.add(tc["args"].get("query", ""))
    new_calls = [
        tc for tc in last.tool_calls
        if tc["args"].get("query", "") not in seen
    ]
    if not new_calls:
        return END
    return "tools"

builder = StateGraph(State)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, ["tools", END])
builder.add_edge("tools", "agent")

graph = builder.compile()

# Run with safety net
result = graph.invoke(
    {"messages": [("user", "Tell me about quantum computing")], "tool_call_count": 0},
    config={"recursion_limit": 20}
)
print(result["messages"][-1].content)

This agent has multiple layers of protection: a system prompt that constrains behavior, a tool that returns definitive output, a state-based counter that caps total tool calls, duplicate detection that prevents the same query from running twice, and a recursion limit as a final safety net. Together, these measures make infinite loops extremely unlikely.

Conclusion

Infinite loops in LangGraph workflows are a common but manageable problem. They typically arise from ambiguous tool outputs, weak system prompts, conditional edges that lack a termination path, or error-handling gaps that trigger retries. By combining LangGraph's built-in recursion limits with custom state tracking, duplicate-call detection, strong prompting, and tracing tools like LangSmith, you can quickly identify the cycle, understand its root cause, and implement a robust fix. The key is to build defensive layers into your agent from the start—never rely on a single mechanism to prevent runaway execution. With the patterns in this tutorial, you can ship LangGraph agents that terminate reliably, cost predictably, and deliver a smooth experience for your users.

— Ad —

Google AdSense will appear here after approval

← Back to all articles