LangChain Debugging: Using Verbose Mode and Tracing Effectively
Why LangChain debugging feels harder than it should
You built a chain. It worked in your first test. Then you added a retriever, a second tool, and a conditional branch — and now the output is wrong, or worse, silently wrong. No exception, no stack trace, just a confidently incorrect answer. If you've felt that specific flavor of frustration, you already understand why debugging LangChain applications is a different skill from debugging normal Python code.
The problem is not that LangChain is badly built. The problem is that a chain is a pipeline of black boxes: a prompt template renders something you don't see by default, that rendered text goes to a model you don't control, the model returns text that gets parsed by a parser you didn't write carefully, and the parsed result gets routed by logic that depends on all of the above. When something breaks, the bug could live in any one of those four stages, and a plain Python traceback only tells you where the *code* raised an exception — not where the *reasoning* went wrong.
This is where verbose mode and tracing stop being "nice to have" and become the actual debugging methodology. In this article we'll go through both in enough depth that you can use them as a daily habit, not just a last resort when everything is on fire. We'll also cover callbacks, structured logging, and a few patterns that separate people who fight LangChain from people who just fix it and move on.
Start with verbose mode, not print statements
The instinct when something goes wrong is to sprinkle print() statements everywhere. Resist it. LangChain already has a built-in verbosity system that shows you the exact prompt sent to the model, the raw response, and every intermediate step in a chain — without you touching the internals.
The simplest way to turn it on is per-chain:
from langchain.chains import LLMChain
from langchain_openai import ChatOpenAI
from langchain.prompts import PromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = PromptTemplate.from_template(
"Summarize this support ticket in one sentence:\n\n{ticket}"
)
chain = prompt | llm
response = chain.invoke(
{"ticket": "Customer says the export button freezes on Safari."},
config={"callbacks": [], "verbose": True},
)
print(response.content)If you're still on the older LLMChain style rather than LCEL (LangChain Expression Language) pipes, the same flag works directly on the chain constructor:
chain = LLMChain(llm=llm, prompt=prompt, verbose=True)
result = chain.invoke({"ticket": "Export button freezes on Safari."})With verbose=True, you get a formatted trace in your terminal showing the exact prompt text that was sent (after all variables were substituted), and the raw text that came back before any parsing. This single change resolves a huge fraction of "the LLM gave a weird answer" bugs, because you frequently discover the prompt itself was malformed — a variable that rendered as None, a template that didn't escape curly braces correctly, or a system message that got duplicated.
If you want verbosity everywhere without setting the flag on every chain, set it globally:
import langchain
langchain.debug = True # extremely detailed, includes nested calls
langchain.verbose = True # human-readable, less noisylangchain.debug is the more aggressive option — it dumps nested calls, retries, and internal LangChain machinery. Use langchain.verbose for day-to-day work and reach for debug only when you need to see what's happening inside something like an agent's tool-selection loop.
Reading a verbose trace like a debugger reads a stack
A verbose trace is only useful if you know what to look for. When a chain runs with verbose=True, you'll typically see three things printed in order:
- The entering chain name and its inputs, so you can confirm the data flowing in is what you expected
- The fully rendered prompt, which is the single most valuable line in the whole trace — read it character by character the first few times you debug something serious
- The raw output from the LLM, before any output parser touches it
Here's the discipline: before you assume the model is "hallucinating" or "ignoring instructions," check whether the rendered prompt actually contained the instructions you think it did. A shockingly large number of "the model won't listen to me" bugs are actually "my prompt template had a typo in the variable name, so the instruction rendered as literal text {instructions} instead of the real content."
A second common failure mode verbose mode exposes: mismatched output parsers. If you attach a PydanticOutputParser or a StructuredOutputParser and the model's raw text doesn't match the expected schema, the parser will throw — but the exception message alone often doesn't tell you *why* the model produced malformed output. Seeing the raw text in the verbose trace usually makes it obvious: maybe the model added a markdown code fence around the JSON, added a trailing comment, or used single quotes instead of double quotes.
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
parser = JsonOutputParser()
prompt = ChatPromptTemplate.from_messages([
("system", "Return ONLY valid JSON with keys 'severity' and 'summary'. No markdown."),
("human", "{ticket}"),
])
chain = prompt | llm | parser
try:
result = chain.invoke({"ticket": "App crashes on login for iOS users."})
except Exception as e:
print("Parser failed:", e)Run this with verbose=True on the underlying LLM call, and if the parser fails, you'll see the exact raw string the model returned — often revealing a stray "Here is the JSON you requested:" prefix that broke the parse.
Debugging agents: where verbose mode earns its keep
Chains are relatively linear, so bugs are easier to isolate. Agents are where verbose mode goes from helpful to essential, because an agent's behavior is a loop: think, pick a tool, call it, observe the result, decide whether to loop again or answer. Any of those steps can go wrong, and the failure often only becomes visible several iterations later.
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.tools import tool
@tool
def get_order_status(order_id: str) -> str:
"""Look up the shipping status for a given order id."""
# pretend this hits a database
return "shipped" if order_id.startswith("ORD") else "not found"
tools = [get_order_status]
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True,
max_iterations=6,
return_intermediate_steps=True,
)
result = executor.invoke({"input": "What's the status of order ORD-4821?"})With verbose=True on the AgentExecutor, you'll see each iteration: which tool the agent decided to call, what arguments it passed, and what the tool returned. This is where you catch the classic agent bugs:
- The agent calling the wrong tool because two tool descriptions overlap in meaning
- The agent passing malformed arguments because the tool's docstring didn't specify the expected format clearly enough
- The agent looping past
max_iterationsbecause it never received a satisfying observation to stop on
Also set return_intermediate_steps=True. This gives you the full list of (action, observation) pairs in the returned dictionary, which you can log or assert against in tests — turning a one-off manual debug session into something you can actually write a regression test for.
for action, observation in result["intermediate_steps"]:
print(f"Tool: {action.tool} | Input: {action.tool_input} | Output: {observation}")Callbacks: the mechanism underneath verbose mode
Verbose mode is actually implemented using LangChain's callback system, and once you understand callbacks, you stop being limited to whatever verbose mode happens to print. A callback handler is an object with methods like on_llm_start, on_llm_end, on_chain_error, and on_tool_end that fire at each stage of execution. You can write your own to capture exactly the information you care about, in whatever format you want.
from langchain_core.callbacks import BaseCallbackHandler
import time
class TimingLogger(BaseCallbackHandler):
def on_llm_start(self, serialized, prompts, **kwargs):
self._start = time.time()
print(f"[LLM CALL] prompt chars: {sum(len(p) for p in prompts)}")
def on_llm_end(self, response, **kwargs):
elapsed = time.time() - self._start
print(f"[LLM DONE] took {elapsed:.2f}s")
def on_chain_error(self, error, **kwargs):
print(f"[CHAIN ERROR] {type(error).__name__}: {error}")
def on_tool_error(self, error, **kwargs):
print(f"[TOOL ERROR] {type(error).__name__}: {error}")
logger = TimingLogger()
response = chain.invoke(
{"ticket": "Payment failed but card was charged twice."},
config={"callbacks": [logger]},
)This is the pattern to reach for once you've outgrown verbose=True. Custom callbacks let you:
- Log latency per LLM call so you can find which step of a multi-step chain is actually slow
- Capture token usage per call for cost debugging, not just correctness debugging
- Ship structured logs to your own observability stack (JSON lines to stdout, a file, or an internal metrics service) instead of unstructured terminal noise
- Trigger alerts on
on_chain_errorin production, rather than discovering a silent failure days later
A practical habit: build one shared callback handler for your team that logs prompt length, response length, latency, and any errors, and attach it globally through langchain.globals.set_llm_cache adjacent configuration or by passing it in config={"callbacks": [...]} on every top-level invocation. Centralizing this in one place means every engineer on your team debugs with the same signal instead of everyone inventing their own print statements.
LangSmith tracing: the upgrade from terminal logs to a real UI
Verbose mode and callbacks are excellent for local development, but they fall apart once your chains get nested — a retriever inside a chain inside an agent inside a router — because terminal output becomes an unreadable wall of text. This is the exact problem LangSmith tracing solves. Instead of printing everything to your terminal, it sends a structured trace of every run to a web UI where you can expand each step, see timing waterfalls, and compare runs side by side.
Turning it on requires no code changes to your chains, only environment variables:
export LANGCHAIN_TRACING_V2=true
export LANGCHAIN_API_KEY=your-langsmith-api-key
export LANGCHAIN_PROJECT=support-ticket-summarizerOnce these are set, every chain, agent, and tool call you run gets automatically traced and uploaded. You get a tree view of the entire execution: top-level chain, nested LLM calls, tool calls, retriever calls, all with their individual inputs, outputs, latency, and token counts. This is dramatically easier to read than console output once a chain has more than two or three steps, because you're looking at a structured tree instead of scrolling through interleaved text.
If you want to trace only specific runs rather than everything, you can use the context manager version instead of environment variables:
from langchain_core.tracers.context import tracing_v2_enabled
with tracing_v2_enabled(project_name="support-ticket-summarizer"):
result = chain.invoke({"ticket": "Refund not processed after 10 days."})This is useful in a shared codebase where you don't want tracing on by default for every developer, but you do want the option to turn it on surgically when investigating a specific bug report.
Tagging and metadata: making traces searchable
A trace is only useful if you can find the one you're looking for among hundreds of runs. LangChain lets you attach tags and metadata to any invocation, which show up as filters in the LangSmith UI.
response = chain.invoke(
{"ticket": "App won't sync across devices."},
config={
"tags": ["support-summarizer", "prod", "v2-prompt"],
"metadata": {"customer_tier": "enterprise", "ticket_id": "T-9931"},
},
)Tag by environment (dev, staging, prod), by prompt version (v1-prompt, v2-prompt), and by any business-relevant dimension like customer tier or feature flag. When a bug report comes in about ticket T-9931, you search LangSmith by that metadata field and land directly on the exact trace, instead of guessing which of hundreds of runs corresponds to the incident.
This becomes especially valuable when you're running A/B tests between two prompt versions. Tag each variant distinctly, run both in production behind a flag, and then filter traces by tag to compare latency, token usage, and failure rate between the two — all without touching your application's core logic.
Debugging retrieval: the silent failure mode
Retrieval-augmented generation introduces a failure mode that neither verbose mode nor a stack trace will catch by default: retrieving the *wrong* documents. The chain runs fine, the LLM responds fluently, and the answer is simply based on irrelevant context. Nothing crashes. This is arguably the hardest class of LangChain bug because there's no error to chase — only a wrong answer that looks confident.
The fix is to make retrieval a first-class, inspectable step rather than something buried inside a chain.
from langchain_core.runnables import RunnablePassthrough
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
def debug_retrieval(query: str):
docs = retriever.invoke(query)
for i, doc in enumerate(docs):
print(f"--- doc {i} (score context: {doc.metadata}) ---")
print(doc.page_content[:200])
print()
return docs
docs = debug_retrieval("What's our refund policy for annual plans?")Run this in isolation, separate from the full RAG chain, whenever an answer looks off. You want to answer one question first: did retrieval return the right documents at all? If the retrieved chunks don't contain the answer, no amount of prompt engineering downstream will fix it — you have a chunking, embedding, or indexing problem, not a prompting problem.
When you trace this through LangSmith, the retriever shows up as its own span in the trace tree with the query and the returned documents visible, which means you don't even need the manual debug_retrieval helper once tracing is on — but it's still useful for quick, offline checks against a local vector store before you've wired anything into your app.
Practical debugging workflow: put it all together
When something breaks in a real LangChain application, a repeatable sequence saves you from randomly toggling flags:
- Reproduce the failure with
verbose=Trueon the smallest possible chain that isolates the bug — don't debug against your full agent if a two-step chain reproduces the same issue - Read the rendered prompt first, before looking at the model's output — most bugs are prompt bugs, not model bugs
- If it's an agent, check
return_intermediate_stepsto see the tool-call sequence and catch loops or wrong tool selection - If it's RAG, test the retriever in isolation before blaming the generation step
- Turn on LangSmith tracing for anything with more than two or three nested steps, since terminal verbosity stops being readable past that point
- Tag production traces with meaningful metadata from day one, so that when a real user reports a bug, you can find the exact trace in seconds instead of minutes
- Write a custom callback handler once you have a debugging need that verbose mode doesn't cover — latency budgets, token cost tracking, or alerting on
on_chain_error
This sequence works because it moves from cheapest to most expensive: printing a prompt costs nothing, tracing a whole system costs some setup, and writing custom callback infrastructure costs the most but pays off across your whole team. Most day-to-day bugs get caught in steps one through four. LangSmith earns its place once your system has grown past what a terminal can reasonably display.
Common mistakes that make debugging harder than necessary
A few habits consistently make LangChain debugging worse, and they're easy to fix once you notice them:
- Debugging the full production chain instead of isolating the smallest failing piece — always cut down to the minimal reproduction first
- Assuming the model "hallucinated" before checking whether the rendered prompt actually contained the context you thought it did
- Leaving
langchain.debug = Trueon permanently, which buries the signal you actually need under internal machinery noise - Not versioning prompts, so when a trace shows an unexpected output, you can't tell whether the prompt changed since the last known-good run
- Treating agent
max_iterationserrors as a timeout bug, when it's usually a sign the agent never got a satisfying tool observation to stop the loop - Ignoring token counts in traces, which quietly tells you when a prompt has grown too large and is pushing relevant context out of the model's effective attention
Fixing these habits, more than any single tool, is what separates fast debugging sessions from hour-long ones.
Wrapping up
Verbose mode and tracing aren't optional extras bolted onto LangChain — they're the primary way you get visibility into a system that is otherwise a chain of black boxes. Start every debugging session with verbose=True on the smallest reproducible chain, read the rendered prompt before you blame the model, use return_intermediate_steps for agents, and graduate to LangSmith tracing the moment your terminal output stops being readable. Add custom callbacks once you need structured, queryable signal instead of print statements, and tag your production traces so that a bug report turns into a two-minute lookup instead of an afternoon of guessing.
If you want to go deeper into building and debugging production-grade LangChain systems — agents, retrieval pipelines, tool orchestration, and the observability patterns that keep them maintainable — our LangChain Tutorial 2026 course on teachyou.ai walks through all of this hands-on, with real chains you build, break, and fix using the exact techniques covered here.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.