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

By Cyprian AaronsUpdated 2026-04-21
duplicate-tool-calls-in-productionlanggraphpython

What the error means

If you’re seeing duplicate tool calls in production, LangGraph is telling you the same tool invocation was emitted more than once for the same agent turn. In practice, this usually shows up when your graph retries a node, replays state, or your model response gets processed twice because the node is not idempotent.

This is common in production when you add persistence, retries, streaming, or multiple workers. The bug is rarely in the tool itself; it’s usually in how the graph state and tool execution are wired.

The Most Common Cause

The #1 cause is re-executing tool calls from a non-idempotent assistant message after a retry or state replay.

A common broken pattern is: read the last AI message, extract tool_calls, run them, then append results back into state without guarding against re-processing the same message. If the node runs again, LangGraph sees the same AIMessage.tool_calls and your tool executes again.

Broken vs fixed pattern

Broken patternFixed pattern
Executes every time node runsTracks processed message/tool call IDs
Replays same AIMessage.tool_callsDeduplicates before execution
Assumes node runs onceAssumes retries and replay are normal
# BROKEN
from langchain_core.messages import AIMessage, ToolMessage

def tool_node(state):
    last_msg = state["messages"][-1]

    # This will run again on retry/replay
    if isinstance(last_msg, AIMessage) and last_msg.tool_calls:
        results = []
        for call in last_msg.tool_calls:
            result = tools[call["name"]].invoke(call["args"])
            results.append(
                ToolMessage(
                    content=str(result),
                    tool_call_id=call["id"],
                )
            )

        return {"messages": state["messages"] + results}

    return {}
# FIXED
from langchain_core.messages import AIMessage, ToolMessage

def tool_node(state):
    processed = set(state.get("processed_tool_call_ids", []))
    last_msg = state["messages"][-1]

    if isinstance(last_msg, AIMessage) and last_msg.tool_calls:
        new_messages = []
        new_processed = set(processed)

        for call in last_msg.tool_calls:
            if call["id"] in processed:
                continue

            result = tools[call["name"]].invoke(call["args"])
            new_messages.append(
                ToolMessage(
                    content=str(result),
                    tool_call_id=call["id"],
                )
            )
            new_processed.add(call["id"])

        return {
            "messages": state["messages"] + new_messages,
            "processed_tool_call_ids": list(new_processed),
        }

    return {}

The important part is that tool_call_id alone does not save you if your graph replays state and your code blindly re-executes the same AIMessage. You need an explicit dedupe key in state.

Other Possible Causes

1. Your retry policy is re-running a non-idempotent node

If you use RetryPolicy on a node that triggers tools, the retry may execute side effects twice.

from langgraph.graph import StateGraph
from langgraph.pregel import RetryPolicy

builder.add_node(
    "tools",
    tool_node,
    retry_policy=RetryPolicy(max_attempts=3)
)

Fix: only retry pure computation nodes. Keep side-effecting tool execution outside automatic retries, or make the tool idempotent.


2. You’re streaming and consuming the same event twice

This happens when one consumer handles both partial chunks and final output, then dispatches tools on each pass.

# BAD: dispatching on every streamed update
for event in app.stream(inputs):
    if "messages" in event:
        handle_tool_calls(event["messages"])

Fix: only act on finalized assistant messages, or gate by message ID.

seen_message_ids = set()

for event in app.stream(inputs):
    msg = event.get("messages", [])[-1] if event.get("messages") else None
    if msg and getattr(msg, "id", None) not in seen_message_ids:
        seen_message_ids.add(msg.id)
        handle_tool_calls([msg])

3. Your checkpoint/state store is restoring stale messages

If you use a checkpointer like MemorySaver, SQLite, Postgres, or Redis-backed persistence, stale messages can come back after a crash or deploy.

from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)

Fix: verify thread/session IDs are stable per conversation, but not reused across unrelated requests. Also inspect whether old AIMessage.tool_calls are being appended again instead of replaced.


4. You have multiple workers processing the same thread

Two app instances can pick up the same conversation state and both execute the same pending tool call.

# Example symptom: two replicas with shared checkpoint store
replicas: 2
shared_checkpoint_store: true

Fix: add per-thread locking or ensure only one worker owns a given thread_id at a time. Without that, LangGraph will faithfully replay the same pending action in both processes.

How to Debug It

  1. Log message IDs and tool call IDs

    • Print AIMessage.id, tool_call_id, and your thread ID before executing tools.
    • If the same IDs appear twice, you have replay or duplicate consumption.
  2. Check whether the node is retried

    • Search logs for repeated execution of the same node name.
    • If you see RetryPolicy activity or exception recovery before duplication, that’s your path.
  3. Inspect persisted state

    • Dump checkpointed messages before and after failure.
    • Look for repeated AIMessage.tool_calls entries instead of one assistant message plus one ToolMessage.
  4. Temporarily disable streaming and retries

    • Run a single-threaded synchronous path.
    • If duplication disappears, the bug is in orchestration, not model output.

Prevention

  • Make every tool-execution node idempotent.
  • Store processed tool_call_ids in graph state or durable storage.
  • Keep retries away from side-effecting nodes unless those tools can safely dedupe themselves.
  • Use one owner per conversation thread when sharing checkpoints across workers.
  • Treat streamed events as delivery hints, not as proof that execution should happen again.

If you fix this at the graph boundary instead of inside each individual tool, you’ll stop chasing duplicate executions every time you add retries or persistence.


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