How to Fix 'duplicate tool calls when scaling' in LangGraph (Python)

By Cyprian AaronsUpdated 2026-04-21
duplicate-tool-calls-when-scalinglanggraphpython

When LangGraph starts throwing duplicate tool calls during scaling, it usually means the same assistant/tool step is being executed more than once, not that the model is “confused.” In practice, this shows up when you add concurrency, retries, streaming, or multiple workers and your graph/state handling stops being idempotent.

The error often looks like one of these:

  • ValueError: Duplicate tool call detected
  • InvalidUpdateError: Node 'tools' wrote to state more than once
  • repeated ToolMessage entries for the same tool_call_id

The Most Common Cause

The #1 cause is reusing mutable state across graph runs or appending messages in place inside a node. LangGraph expects each node to return a fresh update, not mutate shared objects that can be replayed by retries or parallel execution.

Here’s the broken pattern:

BrokenFixed
Mutates shared state in placeReturns a new state update
Reuses the same messages listCopies and appends safely
Easy to duplicate on retry/scaleIdempotent and replay-safe
# BROKEN
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator

class State(TypedDict):
    messages: Annotated[list, operator.add]

def tools_node(state: State):
    # Mutating the existing list is the problem
    state["messages"].append({
        "role": "tool",
        "content": "result",
        "tool_call_id": "call_123"
    })
    return state

graph = StateGraph(State)
graph.add_node("tools", tools_node)
# FIXED
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator

class State(TypedDict):
    messages: Annotated[list, operator.add]

def tools_node(state: State):
    # Return only the delta; don't mutate input state
    return {
        "messages": [{
            "role": "tool",
            "content": "result",
            "tool_call_id": "call_123"
        }]
    }

graph = StateGraph(State)
graph.add_node("tools", tools_node)

Why this matters: when LangGraph retries a node or replays execution after a checkpoint restore, in-place mutation can cause the same tool message to be appended twice. Under scale, that becomes visible fast.

Other Possible Causes

1) Tool node runs twice because your conditional edge loops back incorrectly

A bad routing function can send the graph back into the tool node after it already handled the call.

def route(state):
    last = state["messages"][-1]
    if getattr(last, "tool_calls", None):
        return "tools"
    return END

If last is already a ToolMessage, this can still route back incorrectly. Make sure you only route from assistant messages that actually contain tool calls.

def route(state):
    last = state["messages"][-1]
    if last.type == "ai" and getattr(last, "tool_calls", None):
        return "tools"
    return END

2) Your agent executor and graph both execute tools

This happens when you wrap a tool-calling agent inside LangGraph and also add a separate tools node. The model emits tool calls once, but both layers try to execute them.

# BROKEN: two executors handling tools
agent = create_react_agent(llm, tools)
graph.add_node("agent", agent)
graph.add_node("tools", tools_node)

Pick one orchestration layer. If LangGraph owns execution, let it handle routing and tool invocation directly.

# FIXED: LangGraph owns the loop
graph.add_node("agent", agent_llm_node)
graph.add_node("tools", tools_node)
graph.add_conditional_edges("agent", should_call_tools)

3) Checkpoint restore replays a side-effecting tool

If your tool writes to Stripe, Salesforce, or an internal DB and you don’t make it idempotent, replay after retry can create duplicates even if LangGraph is behaving correctly.

@tool
def create_case(case_id: str):
    # Bad if retried twice with same input
    db.insert({"case_id": case_id})

Use an idempotency key from tool_call_id or a stable business key.

@tool
def create_case(case_id: str, tool_call_id: str):
    if db.exists({"tool_call_id": tool_call_id}):
        return {"status": "already_processed"}
    db.insert({"case_id": case_id, "tool_call_id": tool_call_id})

4) Parallel branches merge the same message twice

If two branches both emit the same ToolMessage, your reducer may concatenate both copies into messages.

class State(TypedDict):
    messages: Annotated[list, operator.add]

That reducer is fine for append-only flows, but not for deduping. If branches can converge on the same output, dedupe by tool_call_id before returning.

How to Debug It

  1. Print every message type at each node

    • Log message.type, tool_calls, and tool_call_id.
    • You want to see exactly where the duplicate first appears.
  2. Disable concurrency temporarily

    • Set workers to 1.
    • Turn off async fan-out.
    • If the bug disappears, you likely have a race or shared-state issue.
  3. Check whether retries are enabled

    • A node with side effects plus automatic retry is a common duplicate source.
    • Inspect your checkpointer and any retry wrapper around .invoke() / .astream().
  4. Trace graph transitions

    • Add logs around conditional edges.
    • Confirm that the path is assistant -> tools -> assistant, not looping back into tools twice.

Example debug hook:

def debug_state(node_name: str, state: dict):
    last = state["messages"][-1]
    print({
        "node": node_name,
        "last_type": getattr(last, "type", None),
        "tool_calls": getattr(last, "tool_calls", None),
        "tool_call_id": getattr(last, "tool_call_id", None),
    })

Prevention

  • Make every tool idempotent using tool_call_id or a business-level dedupe key.
  • Never mutate incoming LangGraph state in place; always return deltas.
  • Keep one owner for tool execution: either LangGraph routing or an external agent wrapper, not both.
  • Add tests that replay the same run twice and assert you only get one ToolMessage per call ID.

If you’re seeing this at scale specifically after moving from local testing to workers or streaming infrastructure, start with shared-state mutation and double-execution paths. Those two account for most real-world duplicate tool call bugs in LangGraph Python setups.


Keep learning

By Cyprian Aarons, AI Consultant at Topiax.

Want the complete 8-step roadmap?

Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.

Get the Starter Kit

Related Guides