How to Fix 'duplicate tool calls during development' in LangChain (Python)

By Cyprian AaronsUpdated 2026-04-21
duplicate-tool-calls-during-developmentlangchainpython

If you’re seeing duplicate tool calls during development, LangChain is telling you the model produced the same tool invocation more than once in a single run. In practice, this usually shows up while you’re wiring up an agent loop, streaming responses, or manually calling invoke() inside code that already has an executor running.

The fix is usually not in the LLM itself. It’s almost always in how your agent loop, callbacks, or retry logic is structured.

The Most Common Cause

The #1 cause is running the same agent step twice: once manually and once through LangChain’s executor, or reusing state across reruns in a way that replays the last assistant message with tool calls.

A common broken pattern looks like this:

BrokenFixed
Manually calling the model and then passing the result into an agent executorLetting AgentExecutor own the full tool-calling flow
Reusing messages with an already-generated tool_calls payloadCreating a fresh message list per request
# BROKEN
from langchain_openai import ChatOpenAI
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.tools import tool

llm = ChatOpenAI(model="gpt-4o-mini")

@tool
def get_balance(account_id: str) -> str:
    return "Balance: $1200"

tools = [get_balance]
agent = create_tool_calling_agent(llm, tools)
executor = AgentExecutor(agent=agent, tools=tools)

messages = [
    ("system", "You are a banking assistant."),
    ("human", "Check account 123")
]

# Wrong: first call gets tool_calls
first = llm.invoke(messages)

# Wrong: second call replays/duplicates execution path
result = executor.invoke({"input": messages})
print(result)
# FIXED
from langchain_openai import ChatOpenAI
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.tools import tool

llm = ChatOpenAI(model="gpt-4o-mini")

@tool
def get_balance(account_id: str) -> str:
    return "Balance: $1200"

tools = [get_balance]
agent = create_tool_calling_agent(llm, tools)
executor = AgentExecutor(agent=agent, tools=tools)

result = executor.invoke({
    "input": "Check account 123"
})

print(result)

If you’re using LangGraph or a custom loop, the same rule applies: only one component should own tool execution. If your code already executes tool calls from AIMessage.tool_calls, don’t also wrap that message in another agent runner.

Other Possible Causes

1) Streaming callback fires tool execution twice

This happens when your callback handler processes both partial chunks and final messages.

# Example symptom: on_llm_new_token + on_llm_end both trigger tool dispatch
class MyHandler(BaseCallbackHandler):
    def on_llm_new_token(self, token, **kwargs):
        self.maybe_run_tools(token)

    def on_llm_end(self, response, **kwargs):
        self.maybe_run_tools(response)

Fix: only execute tools after the final assistant message is assembled.


2) Retry wrapper repeats a non-idempotent agent step

If you wrap the agent call with retries, a timeout can replay the same request and duplicate the tool call.

from tenacity import retry

@retry(stop=stop_after_attempt(3))
def run_agent():
    return executor.invoke({"input": "Fetch policy status"})

Fix: retry only the network boundary if needed, and make tool execution idempotent.


3) You are appending assistant messages with tool_calls back into history

This is common when manually managing conversation state.

# BROKEN: reusing assistant message with tool_calls in next turn
history.append(ai_message)   # ai_message contains tool_calls
history.append(tool_result)
history.append(user_message)

Fix: store clean turn state and avoid replaying the exact assistant message into a second executor pass.


4) Your frontend hot-reload is triggering duplicate backend requests

During development, React strict mode or auto-reload can send two identical POSTs. LangChain then sees two identical runs and logs duplicate tool behavior.

# FastAPI endpoint called twice by frontend dev tooling
@app.post("/chat")
def chat(payload: dict):
    return executor.invoke({"input": payload["message"]})

Fix: confirm whether your backend receives two requests before touching LangChain code.

How to Debug It

  1. Log every entry into your agent endpoint

    • Print request IDs, user input, and timestamps.
    • If you see two identical requests, the bug is outside LangChain.
  2. Inspect the final AI message before tool execution

    • Check whether AIMessage.tool_calls already exists.
    • If it does, don’t send that same message back through another agent runner.
  3. Disable retries and callbacks temporarily

    • Remove Tenacity wrappers.
    • Turn off custom callback handlers.
    • If the issue disappears, one of those layers is duplicating execution.
  4. Add one guard around tool dispatch

    • Track (run_id, tool_name, args) for a single request.
    • Reject repeated dispatches in the same run.
seen = set()

def dispatch_tool(run_id: str, name: str, args: str):
    key = (run_id, name, args)
    if key in seen:
        raise RuntimeError(f"Duplicate tool call detected: {key}")
    seen.add(key)

Prevention

  • Let one layer own orchestration:

    • either AgentExecutor
    • or your manual loop
    • not both
  • Make tools idempotent where possible:

    • read-only lookups are safer than write actions
    • for writes, use request IDs or dedup keys
  • Keep dev reload behavior under control:

    • watch for double submits from frontend frameworks
    • verify server logs before debugging LangChain internals

If you want a fast mental model: duplicate tool calls during development usually means duplicated orchestration, not a broken model. Start by checking request duplication and repeated agent execution paths before changing prompts or swapping models.


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