Debugging a multi-agent pipeline gets painful the moment one agent silently calls the wrong tool. This tutorial shows you how to implement tracing tool calls LangGraph across a supervised multi-agent system so you can see every invocation, argument, and result without littering your business logic with print statements.
Prerequisites
- Python 3.10 or newer
langgraph,langchain-openai, andlangchain-coreinstalled (pip install langgraph langchain-openai langchain-core)- An OpenAI-compatible API key. You can use OpenAI directly, or point
ChatOpenAIat n4n.ai’s OpenAI-compatible endpoint to get automatic fallback across providers and per-token metering. - Familiarity with Python decorators and basic LangChain tool definitions
System design
We’ll build a two-agent graph: a researcher that calls a web search tool, and a writer that calls a file-write tool. A central tools node executes any tool calls emitted by the researcher. The writer summarizes the search result and writes it to a file.
The core problem in tracing tool calls LangGraph is context propagation: each agent runs in a separate node function, but we want one correlated trace per outer invocation. A ContextVar solves this cleanly.
Step 1: Set up the trace recorder
We use a module-level list and a contextvars.ContextVar to tag entries with a run ID. This survives across node boundaries because LangGraph runs the graph in a single thread by default.
import time
import threading
from contextvars import ContextVar
trace_id = ContextVar("trace_id", default="default")
TRACE_LOG = []
TRACE_LOCK = threading.Lock()
def record_tool_call(name: str, args: dict, result: str, agent: str):
entry = {
"ts": time.time(),
"trace_id": trace_id.get(),
"agent": agent,
"tool": name,
"args": args,
"result": result,
}
with TRACE_LOCK:
TRACE_LOG.append(entry)
Step 2: Define and wrap tools
Define two tools with @tool. Then bind a tracer that records each execution. This is the foundation for tracing tool calls LangGraph across agents.
from langchain_core.tools import tool
@tool
def search_web(query: str) -> str:
"""Simulated web search."""
return f"Top result for '{query}': LangGraph is a stateful orchestration library."
@tool
def write_file(filename: str, content: str) -> str:
"""Simulate writing a file."""
return f"Wrote {len(content)} chars to {filename}"
def bind_trace(tool_obj, agent: str):
original = tool_obj.func
def traced(*args, **kwargs):
res = original(*args, **kwargs)
record_tool_call(tool_obj.name, kwargs, res, agent)
return res
tool_obj.func = traced
return tool_obj
search_web = bind_trace(search_web, "researcher")
write_file = bind_trace(write_file, "writer")
Step 3: Build the multi-agent graph
Create a minimal state and node functions. The researcher and writer are LLM instances bound to their tools. We set the trace_id at the start of the run via state.
from typing import TypedDict, Annotated
from langchain_core.messages import HumanMessage, ToolMessage, AIMessage
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
class AgentState(TypedDict):
messages: Annotated[list, "messages"]
run_id: str
llm = ChatOpenAI(base_url="https://api.n4n.ai/v1", api_key="YOUR_KEY", model="gpt-4o-mini")
researcher_llm = llm.bind_tools([search_web])
writer_llm = llm.bind_tools([write_file])
def researcher_node(state):
trace_id.set(state["run_id"])
ai: AIMessage = researcher_llm.invoke(state["messages"])
return {"messages": [ai]}
def execute_tools(state):
last = state["messages"][-1]
if not hasattr(last, "tool_calls"):
return {}
new_msgs = []
for tc in last.tool_calls:
tool_map = {"search_web": search_web, "write_file": write_file}
res = tool_map[tc["name"]].invoke(tc["args"])
new_msgs.append(ToolMessage(content=str(res), tool_call_id=tc["id"]))
return {"messages": new_msgs}
def writer_node(state):
ai = writer_llm.invoke(state["messages"])
return {"messages": [ai]}
builder = StateGraph(AgentState)
builder.add_node("researcher", researcher_node)
builder.add_node("tools", execute_tools)
builder.add_node("writer", writer_node)
builder.add_edge(START, "researcher")
builder.add_edge("researcher", "tools")
builder.add_edge("tools", "writer")
builder.add_edge("writer", END)
graph = builder.compile()
The execute_tools node is where tracing tool calls LangGraph becomes visible: every tool invoked by an agent passes through our wrapped .func, appending to TRACE_LOG with the active trace_id.
Step 4: Run and inspect the trace
Invoke with a run_id and print the collected log.
result = graph.invoke({
"messages": [HumanMessage("Research LangGraph and save a summary.")],
"run_id": "run-abc-123"
})
print("TRACE LOG:")
for entry in TRACE_LOG:
print(f"{entry['agent']:>10} | {entry['tool']:>12} | args={entry['args']} -> {entry['result']}")
Expected output (abridged):
TRACE LOG:
researcher | search_web | args={'query': 'LangGraph'} -> Top result for 'LangGraph': LangGraph is a stateful orchestration library.
writer | write_file | args={'filename': 'summary.txt', 'content': 'LangGraph is...'} -> Wrote 42 chars to summary.txt
You now have a complete record of tracing tool calls LangGraph emitted, correlated by run-abc-123. Each line tells you which agent called which tool, with what arguments, and what came back.
Step 5: Extending the tracer for production
The basic recorder works for single-process graphs. For distributed agents, replace the list with a queue that ships to OpenTelemetry or a log aggregator. Keep the ContextVar pattern; it interoperates with asyncio and threading if you set it in the entrypoint.
Correlating nested agents
If a tool itself triggers another graph (sub-agents), propagate trace_id by reading it inside the tool and passing as run_id to the nested invoke. This keeps the hierarchy flat in your backend but linked by the shared ID.
Streaming traces
For live debugging, wrap record_tool_call to also push to a websocket or logging.info. LangGraph’s streaming API can emit node events; combine both for a full timeline.
Why custom tracing instead of LangSmith?
LangSmith gives you traces out of the box, but it couples you to a hosted platform and its metadata format. A lightweight local tracer lets you filter by agent, export JSON, or pipe into your own dashboards. The pattern above is also portable to any orchestration framework, not just LangGraph.
Caveats
Tool wrapping via tool_obj.func is stable for langchain-core 0.2.x. If you upgrade, verify the attribute name. For production LLM calls, add retries and handle ToolException. The trace store here is in-memory; a process restart loses data. If you need durability, write entries to a file or database inside record_tool_call.
With this scaffold, tracing tool calls LangGraph across any number of agents becomes a matter of wrapping tools and setting a context variable at the edge of each run.