How to Fix 'duplicate tool calls when scaling' in LangGraph (Python)
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
ToolMessageentries for the sametool_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:
| Broken | Fixed |
|---|---|
| Mutates shared state in place | Returns a new state update |
Reuses the same messages list | Copies and appends safely |
| Easy to duplicate on retry/scale | Idempotent 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
- •
Print every message type at each node
- •Log
message.type,tool_calls, andtool_call_id. - •You want to see exactly where the duplicate first appears.
- •Log
- •
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.
- •
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().
- •
Trace graph transitions
- •Add logs around conditional edges.
- •Confirm that the path is
assistant -> tools -> assistant, not looping back intotoolstwice.
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_idor 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
ToolMessageper 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
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
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