Module 12: LangSmith and Production
Visual Agent Debugging
Capsule overview
LangSmith turns agent debugging from "add print statements and pray" into "click the trace and SEE what happened." When your agent produces an unexpected result, you don't need to re-run it, you don't need to guess, you don't need to spend money on extra API calls. You open the trace, walk the timeline, and find the exact step where things went wrong.
This isn't a nice-to-have. It's the difference between fixing a bug in 2 minutes and spending an hour guessing. And in production, where bugs cost real money and affect real users, that difference is critical.
In the previous capsule you learned how to set up tracing and read individual traces. In this one, you learn the full debugging workflow: from "the output is wrong" to "I found the root cause and fixed it."
The debugging workflow with LangSmith
Every time your agent produces an unexpected result, follow these 7 steps:
1. The agent produces unexpected output
↓
2. Open LangSmith → find the trace for that run
↓
3. Look at the timeline: how many steps? Any errors?
↓
4. Walk it step by step: click each run to see input/output
↓
5. Spot the step where things went off the rails
↓
6. Examine that step's input and output in detail
↓
7. Root cause → fix → verify
Let's walk each step with a concrete example.
A debugging scenario: the mixed-up report
Your Research Assistant researched "AI in education" but the report mixes education data with finance data. What happened?
Step 1: Reproduce and trace
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
class ResearchState(TypedDict):
topic: str
sources: Annotated[list[str], operator.add]
analysis: str
report: str
def search_web(state: ResearchState) -> dict:
return {"sources": [
f"Web: AI is transforming education with personalized tutors",
f"Web: Banks use AI for fraud detection",
]}
def search_papers(state: ResearchState) -> dict:
return {"sources": [
f"Paper: 'Adaptive Learning with LLMs' (2025)",
]}
def analyze(state: ResearchState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(
f"Analyze these sources about '{state['topic']}'. "
f"Identify the 2 main trends.\n\n"
f"Sources:\n" + "\n".join(f"- {s}" for s in state["sources"])
)
return {"analysis": response.content}
def write_report(state: ResearchState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(
f"Write a short report (3 sentences) about '{state['topic']}' "
f"based on this analysis:\n\n{state['analysis']}"
)
return {"report": response.content}
builder = StateGraph(ResearchState)
builder.add_node("search_web", search_web)
builder.add_node("search_papers", search_papers)
builder.add_node("analyze", analyze)
builder.add_node("report", write_report)
builder.add_edge(START, "search_web")
builder.add_edge(START, "search_papers")
builder.add_edge("search_web", "analyze")
builder.add_edge("search_papers", "analyze")
builder.add_edge("analyze", "report")
builder.add_edge("report", END)
graph = builder.compile()
config = RunnableConfig(
run_name="Debug: Mixed-Up Report",
tags=["debug-exercise"],
metadata={"scenario": "mixed-report"},
)
result = graph.invoke(
{"topic": "AI in education", "sources": [], "analysis": "", "report": ""},
config=config,
)
print(f"FINAL REPORT:\n{result['report']}")
print(f"\n⚠️ Does the report mention finance/banks? If so, there's a bug.")
print(f"\nTo debug, follow the steps in LangSmith:")
print(f" 1. Open the trace 'Debug: Mixed-Up Report'")
print(f" 2. Look at the child run 'search_web' → its output includes finance data")
print(f" 3. Root cause: search_web returned an irrelevant source about banks")
print(f" 4. Fix: improve the search so it filters by relevance to the topic")
# Expected output:
# FINAL REPORT:
# AI is transforming education with personalized tutors...
# (possibly mentions banking fraud detection)
#
# ⚠️ Does the report mention finance/banks? If so, there's a bug.
Steps 2-4: What you see in LangSmith
In the LangSmith dashboard, the trace "Debug: Mixed-Up Report" shows:
Trace: "Debug: Mixed-Up Report" Total: 2.3s
│
├─ search_web (0.01s)
│ Output: ["Web: AI is transforming education...",
│ "Web: Banks use AI for fraud detection"]
│ ← ⚠️ IRRELEVANT DATA
│
├─ search_papers (0.01s)
│ Output: ["Paper: 'Adaptive Learning with LLMs' (2025)"]
│
├─ analyze (1.2s) — gpt-4.1-mini
│ Input: "Analyze these sources about 'AI in education'...
│ - Web: AI is transforming education...
│ - Web: Banks use AI for fraud detection ← THERE IT IS
│ - Paper: 'Adaptive Learning with LLMs'"
│ Output: "Trend 1: Personalized tutors...
│ Trend 2: Fraud detection..." ← BUG PROPAGATED
│
└─ report (1.1s) — gpt-4.1-mini
Input: analysis contaminated with finance data
Output: a report that mixes education and finance
Steps 5-7: Root cause found
The bug is in search_web: it returns a source about banks that isn't relevant to "AI in education." The analysis includes it because it received the sources unfiltered. The report reflects it because it was built on the contaminated analysis.
The root cause isn't the model — it's the search. The fix is to add relevance filtering after the search.
Visualizing graph execution
LangSmith shows the graph's structure in the trace. For every run, you can see exactly which nodes were visited and in what order:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
class State(TypedDict):
query: str
category: str
result: str
path: str
def classify(state: State) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(
f"Classify this query as 'factual' or 'opinion'. "
f"Answer with ONE word only.\n\n{state['query']}"
)
category = response.content.strip().lower()
return {"category": category}
def handle_factual(state: State) -> dict:
return {
"result": f"[FACTUAL] Data-based answer for: {state['query']}",
"path": "classify → factual → end",
}
def handle_opinion(state: State) -> dict:
return {
"result": f"[OPINION] Analytical perspective on: {state['query']}",
"path": "classify → opinion → end",
}
def route(state: State) -> str:
return "factual" if "factual" in state["category"] else "opinion"
builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_node("factual", handle_factual)
builder.add_node("opinion", handle_opinion)
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route, {
"factual": "factual",
"opinion": "opinion",
})
builder.add_edge("factual", END)
builder.add_edge("opinion", END)
graph = builder.compile()
queries = [
("How many parameters does GPT-4 have?", "factual-query"),
("Is it better to use RAG or fine-tuning?", "opinion-query"),
]
for query, tag in queries:
config = RunnableConfig(
run_name=f"Routing: {query[:40]}",
tags=[tag, "routing-debug"],
)
result = graph.invoke({"query": query, "category": "", "result": "", "path": ""}, config=config)
print(f"Query: {query}")
print(f" Category: {result['category']}")
print(f" Path: {result['path']}")
print(f" Result: {result['result'][:60]}...")
print()
# Expected output:
# Query: How many parameters does GPT-4 have?
# Category: factual
# Path: classify → factual → end
# Result: [FACTUAL] Data-based answer for: How many parameters d...
#
# Query: Is it better to use RAG or fine-tuning?
# Category: opinion
# Path: classify → opinion → end
# Result: [OPINION] Analytical perspective on: Is it better to us...
In LangSmith, the two traces show different paths through the graph. If the classification is wrong (the factual query got routed to "opinion"), you see exactly: what prompt the classifier received, what it answered, and which node the query was sent to.
Finding bottlenecks: the slowest node
When your agent's latency is unacceptable, you need to know where the time goes. LangSmith shows the duration of each operation as bars on a timeline — the bottleneck is visual and immediate:
from dotenv import load_dotenv
load_dotenv()
import time
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
class State(TypedDict):
topic: str
web_results: str
paper_results: str
analysis: str
report: str
timings: Annotated[list[str], operator.add]
def search_web(state: State) -> dict:
start = time.time()
time.sleep(0.3)
elapsed = time.time() - start
return {
"web_results": f"3 articles about {state['topic']}",
"timings": [f"search_web: {elapsed:.2f}s"],
}
def search_papers(state: State) -> dict:
start = time.time()
time.sleep(0.2)
elapsed = time.time() - start
return {
"paper_results": f"2 papers about {state['topic']}",
"timings": [f"search_papers: {elapsed:.2f}s"],
}
def analyze(state: State) -> dict:
start = time.time()
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(
f"Analyze this briefly (1 sentence): {state['web_results']}, {state['paper_results']}"
)
elapsed = time.time() - start
return {
"analysis": response.content,
"timings": [f"analyze: {elapsed:.2f}s"],
}
def write_report(state: State) -> dict:
start = time.time()
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(
f"Write a 1-sentence summary: {state['analysis']}"
)
elapsed = time.time() - start
return {
"report": response.content,
"timings": [f"write_report: {elapsed:.2f}s"],
}
builder = StateGraph(State)
builder.add_node("search_web", search_web)
builder.add_node("search_papers", search_papers)
builder.add_node("analyze", analyze)
builder.add_node("report", write_report)
builder.add_edge(START, "search_web")
builder.add_edge(START, "search_papers")
builder.add_edge("search_web", "analyze")
builder.add_edge("search_papers", "analyze")
builder.add_edge("analyze", "report")
builder.add_edge("report", END)
graph = builder.compile()
config = RunnableConfig(
run_name="Bottleneck Analysis",
tags=["performance"],
)
total_start = time.time()
result = graph.invoke(
{"topic": "AI observability", "web_results": "", "paper_results": "",
"analysis": "", "report": "", "timings": []},
config=config,
)
total_elapsed = time.time() - total_start
print(f"Report: {result['report'][:80]}...")
print(f"\n=== PERFORMANCE ANALYSIS ===")
print(f"Total: {total_elapsed:.2f}s\n")
for timing in result["timings"]:
name, duration_str = timing.split(": ")
duration = float(duration_str.replace("s", ""))
pct = (duration / total_elapsed) * 100
bar = "█" * int(pct / 2)
print(f" {name:<15} {duration_str:>6} ({pct:4.0f}%) {bar}")
print(f"\n→ In LangSmith, the timeline bars are proportional to time")
print(f"→ The longest node is your bottleneck — focus your optimization there")
# Expected output:
# Report: The sources analyzed show that AI observability is...
#
# === PERFORMANCE ANALYSIS ===
# Total: 2.50s
#
# search_web 0.30s ( 12%) ██████
# search_papers 0.20s ( 8%) ████
# analyze 1.20s ( 48%) ████████████████████████
# write_report 0.80s ( 32%) ████████████████
Debugging tool calls: arguments and responses
When an agent uses tools, each tool call shows up in the trace with the exact arguments it sent and the response it got back:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
from langchain_core.runnables import RunnableConfig
@tool
def search_database(query: str, limit: int = 5) -> str:
"""Search the research database."""
if "error" in query.lower():
raise ValueError(f"Invalid query: '{query}'")
return f"[DB] {limit} results for '{query}': relevant data found."
@tool
def analyze_data(data: str, method: str = "statistical") -> str:
"""Analyze data with a specific method."""
return f"[ANALYSIS] Method {method}: {data[:50]}... → 3 insights found."
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [search_database, analyze_data])
config = RunnableConfig(
run_name="Debug Tool Calls",
tags=["tool-debug"],
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "Search for papers on RAG and analyze them statistically"}]},
config=config,
)
print(f"Answer: {result['messages'][-1].content[:150]}...")
print(f"\nIn LangSmith you'll see each tool call:")
print(f" 1. search_database(query='RAG', limit=5) → output")
print(f" 2. analyze_data(data=..., method='statistical') → output")
print(f"\n→ If the agent passed the wrong arguments, you see it right away")
print(f"→ If the tool returned unexpected data, you see it right away")
# Expected output:
# Answer: I found 5 results about RAG in the database...
#
# In LangSmith you'll see each tool call:
# 1. search_database(query='RAG', limit=5) → output
# 2. analyze_data(data=..., method='statistical') → output
Comparing traces: a good run vs a bad one
One of the most powerful uses of LangSmith is comparing two runs: one that produced a good result and one that failed. The difference between them shows you exactly what changed:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
class State(TypedDict):
query: str
context: str
response: str
def get_context(state: State) -> dict:
contexts = {
"good": "RAG combines retrieval with generation. It's implemented with vector stores. "
"The key metrics are precision@k and recall@k.",
"bad": "",
}
variant = "good" if "RAG" in state["query"] else "bad"
return {"context": contexts.get(variant, "")}
def generate(state: State) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
prompt = f"Answer based ONLY on this context:\n\n"
prompt += f"Context: {state['context'] or 'No context available.'}\n\n"
prompt += f"Question: {state['query']}"
response = model.invoke(prompt)
return {"response": response.content}
builder = StateGraph(State)
builder.add_node("context", get_context)
builder.add_node("generate", generate)
builder.add_edge(START, "context")
builder.add_edge("context", "generate")
builder.add_edge("generate", END)
graph = builder.compile()
config_good = RunnableConfig(
run_name="[GOOD] RAG query",
tags=["comparison", "good"],
metadata={"expected_quality": "high"},
)
good = graph.invoke(
{"query": "How does RAG work?", "context": "", "response": ""},
config=config_good,
)
config_bad = RunnableConfig(
run_name="[BAD] Empty context query",
tags=["comparison", "bad"],
metadata={"expected_quality": "low"},
)
bad = graph.invoke(
{"query": "How does quantum computing work?", "context": "", "response": ""},
config=config_bad,
)
print(f"GOOD run:")
print(f" Context: {good.get('context', '')[:60]}...")
print(f" Response: {good['response'][:80]}...")
print(f"\nBAD run:")
print(f" Context: '{bad.get('context', '')[:60] or 'EMPTY'}'")
print(f" Response: {bad['response'][:80]}...")
print(f"\n=== COMPARISON ===")
print(f"Difference: the good run has relevant context.")
print(f"The bad one has empty context → the model improvises → low-quality answer.")
print(f"\nIn LangSmith, filter by the 'comparison' tag and compare both traces:")
print(f" [GOOD] → context has data → generate produces an informed answer")
print(f" [BAD] → context is empty → generate produces a generic answer")
# Expected output:
# GOOD run:
# Context: RAG combines retrieval with generation. It's implemented wi...
# Response: RAG works by combining a retrieval system with generation...
#
# BAD run:
# Context: 'EMPTY'
# Response: Without specific context available, I can't provide...
In LangSmith, you open both traces side by side. In the "GOOD" trace, the context node produces useful data. In the "BAD" trace, it produces an empty string. The root cause is obvious: the context node found no data for "quantum computing."
Error traces: when something blows up
When a node throws an exception, LangSmith captures the full error including the traceback, the agent's state at that moment, and which nodes had already run:
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langchain_core.runnables import RunnableConfig
class State(TypedDict):
data: str
processed: str
log: Annotated[list[str], operator.add]
def fetch_data(state: State) -> dict:
return {"data": "raw data from API", "log": ["fetch: OK"]}
def process_data(state: State) -> dict:
if not state["data"]:
raise ValueError("There is no data to process")
result = state["data"].upper()
return {"processed": result, "log": ["process: OK"]}
def validate(state: State) -> dict:
if len(state["processed"]) < 5:
raise ValueError(f"Processed data is too short: '{state['processed']}'")
return {"log": ["validate: OK"]}
builder = StateGraph(State)
builder.add_node("fetch", fetch_data)
builder.add_node("process", process_data)
builder.add_node("validate", validate)
builder.add_edge(START, "fetch")
builder.add_edge("fetch", "process")
builder.add_edge("process", "validate")
builder.add_edge("validate", END)
graph = builder.compile()
config = RunnableConfig(
run_name="Error Trace Demo",
tags=["error-debug"],
)
try:
result = graph.invoke({"data": "", "processed": "", "log": []}, config=config)
print(f"Result: {result}")
except Exception as e:
print(f"Error caught: {type(e).__name__}: {e}")
print(f"\nIn LangSmith you'll see:")
print(f" ✅ fetch: done (returned 'raw data from API')")
print(f" ✅ process: done (returned 'RAW DATA FROM API')")
print(f" ✅ validate: done (data > 5 characters)")
print(f"\nIf fetch had returned empty data, you'd see:")
print(f" ✅ fetch: done (returned '')")
print(f" ❌ process: ERROR — 'There is no data to process'")
print(f" State at the moment of the error: data=''")
print(f" ⏭️ validate: never ran")
# Expected output:
# Result: {'data': 'raw data from API', 'processed': 'RAW DATA FROM API', 'log': ['fetch: OK', 'process: OK', 'validate: OK']}
In the LangSmith trace, an error shows up in red with the full traceback. You can see exactly what state the agent was in when it failed, which nodes had already run successfully, and which nodes never ran at all.
Connection with time-travel debugging (M8)
In Module 8 you learned time-travel debugging with checkpoints: walking your agent's state history locally. LangSmith complements that with a visual, remote layer:
| Aspect | Time-travel (M8) | LangSmith traces (M12) |
|---|---|---|
| Access | Programmatic (Python code) | Visual (web dashboard) |
| Data | Graph state (values) | Input/output of each operation + tokens + latency |
| Granularity | Per node (one checkpoint per node) | Per operation (each LLM call, each tool call) |
| Availability | Only if you have a checkpointer | Automatic with LANGSMITH_TRACING=true |
| Production | Requires access to the store | Dashboard reachable from anywhere |
| Comparison | Fork + replay (code) | Side-by-side in the dashboard (visual) |
Use time-travel when you need to manipulate state (fork, replay, update_state). Use LangSmith when you need the full picture of a run and want to share the debugging with your team.
Debugging patterns in production
Pattern 1: Reactive debugging — "a user reported a problem"
from dotenv import load_dotenv
load_dotenv()
from langsmith import Client
client = Client()
user_id = "user_abc123"
runs = list(client.list_runs(
project_name="research-assistant-prod",
filter=f'has(metadata, {{"user_id": "{user_id}"}})',
limit=5,
))
print(f"Latest runs for {user_id}:")
for run in runs:
status = "✅" if run.status == "success" else "❌"
latency = (run.end_time - run.start_time).total_seconds() if run.end_time else 0
print(f" {status} {run.name} — {latency:.1f}s — {run.total_tokens or 0} tokens")
if run.error:
print(f" Error: {run.error[:100]}")
# Expected output:
# Latest runs for user_abc123:
# ✅ Research Query - AI Safety — 3.2s — 1450 tokens
# ❌ Research Query - Quantum — 0.5s — 0 tokens
# Error: RateLimitError: Rate limit exceeded...
# ✅ Research Query - RAG — 2.8s — 1200 tokens
Pattern 2: Proactive debugging — "is anything weird today?"
from dotenv import load_dotenv
load_dotenv()
from langsmith import Client
from datetime import datetime, timedelta
client = Client()
since = datetime.now() - timedelta(hours=6)
runs = list(client.list_runs(
project_name="research-assistant-prod",
is_root=True,
start_time=since,
limit=50,
))
if runs:
errors = [r for r in runs if r.status == "error"]
latencies = [(r.end_time - r.start_time).total_seconds() for r in runs if r.end_time]
slow_runs = [l for l in latencies if l > 5.0]
print(f"=== HEALTH CHECK (last 6 hours) ===")
print(f" Total runs: {len(runs)}")
print(f" Errors: {len(errors)} ({len(errors)/len(runs)*100:.0f}%)")
if latencies:
print(f" Average latency: {sum(latencies)/len(latencies):.1f}s")
print(f" Slow runs (>5s): {len(slow_runs)}")
if len(errors) / len(runs) > 0.1:
print(f"\n ⚠️ ALERT: error rate > 10%")
if slow_runs and len(slow_runs) / len(runs) > 0.2:
print(f" ⚠️ ALERT: > 20% of runs are slow")
# Expected output:
# === HEALTH CHECK (last 6 hours) ===
# Total runs: 45
# Errors: 3 (7%)
# Average latency: 3.2s
# Slow runs (>5s): 4
Pattern 3: Comparative debugging — "is the new version better?"
When you change an agent's prompt, you use version tags to compare:
from dotenv import load_dotenv
load_dotenv()
from langsmith import Client
client = Client()
def get_version_stats(project: str, version_tag: str) -> dict:
runs = list(client.list_runs(
project_name=project,
is_root=True,
filter=f'has(tags, "{version_tag}")',
limit=20,
))
if not runs:
return {"count": 0, "avg_latency": 0, "avg_tokens": 0, "error_rate": 0}
latencies = [(r.end_time - r.start_time).total_seconds() for r in runs if r.end_time]
tokens = [r.total_tokens for r in runs if r.total_tokens]
errors = sum(1 for r in runs if r.status == "error")
return {
"count": len(runs),
"avg_latency": sum(latencies) / len(latencies) if latencies else 0,
"avg_tokens": sum(tokens) / len(tokens) if tokens else 0,
"error_rate": errors / len(runs) if runs else 0,
}
v1 = get_version_stats("research-assistant-prod", "v6")
v2 = get_version_stats("research-assistant-prod", "v7")
print(f"{'Metric':<20} {'v6':>10} {'v7':>10} {'Change':>10}")
print(f"{'-'*50}")
print(f"{'Runs':<20} {v1['count']:>10} {v2['count']:>10}")
print(f"{'Latency (s)':<20} {v1['avg_latency']:>10.1f} {v2['avg_latency']:>10.1f}")
print(f"{'Average tokens':<20} {v1['avg_tokens']:>10.0f} {v2['avg_tokens']:>10.0f}")
print(f"{'Error rate':<20} {v1['error_rate']:>9.0%} {v2['error_rate']:>9.0%}")
# Expected output:
# Metric v6 v7 Change
# --------------------------------------------------
# Runs 15 20
# Latency (s) 3.5 2.8
# Average tokens 1400 1100
# Error rate 7% 5%
Troubleshooting
Problem 1: "I don't see the graph structure in the trace"
Symptom: The trace shows flat operations instead of the graph hierarchy.
Cause: Tracing captures the hierarchy automatically, but if you call model.invoke() outside a graph, there's no graph structure to show.
Fix: Check that you're invoking the compiled graph (graph.invoke()), not individual nodes. The hierarchy appears automatically when LangGraph manages the execution.
Problem 2: "The trace doesn't show the tool call's arguments"
Symptom: You can see the tool ran, but not which arguments it received.
Cause: The tool isn't decorated properly, or tracing didn't capture the details.
Fix: Make sure you're using @tool from langchain_core.tools. Native LangChain tools report their arguments automatically.
Problem 3: "I want to compare two traces but I can't find them"
Symptom: You need to compare a good run with a bad one.
Cause: Without consistent naming/tagging, finding specific traces is hard.
Fix: Use descriptive run_name values and tags to categorize. For example: tags=["research", "v7", "user_abc"] lets you filter by any combination.
Problem 4: "The error trace doesn't give me enough context"
Symptom: You can see a node failed but you don't understand why.
Cause: The agent's state at the moment of the error may not be obvious from the traceback alone.
Fix: Combine LangSmith with time-travel debugging (M8). The trace tells you where it failed; get_state_history() gives you the complete state at that point.
Exercises
Exercise 1: Debugging a wrong route (Easy)
Build a graph with a router that classifies queries as "technical" or "business." Send it an ambiguous query ("How much does it cost to implement RAG?") and use the trace in LangSmith to check which classification the model chose and why.
See solution
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
class State(TypedDict):
query: str
classification: str
result: str
def classify(state: State) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(
f"Classify as 'technical' or 'business'. Answer with ONE word only.\n\n"
f"Query: {state['query']}"
)
return {"classification": response.content.strip().lower()}
def technical(state: State) -> dict:
return {"result": f"[TECH] Technical analysis: {state['query']}"}
def business(state: State) -> dict:
return {"result": f"[BIZ] Business analysis: {state['query']}"}
def route(state: State) -> str:
return "technical" if "technical" in state["classification"] else "business"
builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_node("technical", technical)
builder.add_node("business", business)
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route, {"technical": "technical", "business": "business"})
builder.add_edge("technical", END)
builder.add_edge("business", END)
graph = builder.compile()
config = RunnableConfig(
run_name="Route Debug: Ambiguous Query",
tags=["routing-debug"],
)
result = graph.invoke(
{"query": "How much does it cost to implement RAG?", "classification": "", "result": ""},
config=config,
)
print(f"Query: {result['query']}")
print(f"Classification: {result['classification']}")
print(f"Result: {result['result']}")
print(f"\n→ Open the trace in LangSmith")
print(f"→ Click the 'classify' run → see the prompt and the model's answer")
print(f"→ Is the classification right? The query is ambiguous (it has a technical AND a business side)")
# Expected output:
# Query: How much does it cost to implement RAG?
# Classification: business
# Result: [BIZ] Business analysis: How much does it cost to implement RAG?
Exercise 2: Find the problem node (Medium)
Build a 4-node graph where the third node has an intentional bug (it adds junk data to the result). Run it with tracing on. Then use the LangSmith SDK to fetch the trace's child runs and find programmatically which node introduced the junk.
See solution
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langchain_core.runnables import RunnableConfig
class State(TypedDict):
data: Annotated[list[str], operator.add]
def collect_a(state: State) -> dict:
return {"data": ["Valid data A: transformers"]}
def collect_b(state: State) -> dict:
return {"data": ["Valid data B: attention"]}
def collect_c(state: State) -> dict:
return {"data": ["JUNK: buy now with a discount!!!"]}
def summarize(state: State) -> dict:
return {"data": [f"Summary of {len(state['data'])} items"]}
builder = StateGraph(State)
builder.add_node("a", collect_a)
builder.add_node("b", collect_b)
builder.add_node("c_buggy", collect_c)
builder.add_node("summarize", summarize)
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("b", "c_buggy")
builder.add_edge("c_buggy", "summarize")
builder.add_edge("summarize", END)
graph = builder.compile()
config = RunnableConfig(
run_name="Find the Buggy Node",
tags=["bug-hunt"],
)
result = graph.invoke({"data": []}, config=config)
print(f"Final data: {result['data']}")
print(f"\n=== AUTOMATIC ANALYSIS ===")
suspect_keywords = ["buy", "discount", "free", "click", "spam"]
for i, item in enumerate(result["data"]):
is_suspect = any(kw in item.lower() for kw in suspect_keywords)
status = "⚠️ SUSPICIOUS" if is_suspect else "✅ OK"
print(f" [{status}] {item}")
print(f"\n→ In LangSmith, click 'c_buggy' → you'll see its output is junk")
print(f"→ Nodes 'a' and 'b' produced valid data")
# Expected output:
# Final data: ['Valid data A: transformers', 'Valid data B: attention', 'JUNK: buy now with a discount!!!', 'Summary of 3 items']
#
# === AUTOMATIC ANALYSIS ===
# [✅ OK] Valid data A: transformers
# [✅ OK] Valid data B: attention
# [⚠️ SUSPICIOUS] JUNK: buy now with a discount!!!
# [✅ OK] Summary of 3 items
Exercise 3: Compare a good run vs a bad one (Medium)
Build an agent that generates summaries. Run it twice: once with rich context and once with empty context. Tag them "good" and "bad." Then use the SDK to fetch both traces and compare: latency, tokens, and status.
See solution
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
import time
class State(TypedDict):
context: str
summary: str
def summarize(state: State) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
prompt = f"Summarize in 1 sentence:\n\n{state['context'] or 'No context.'}"
response = model.invoke(prompt)
return {"summary": response.content}
builder = StateGraph(State)
builder.add_node("summarize", summarize)
builder.add_edge(START, "summarize")
builder.add_edge("summarize", END)
graph = builder.compile()
good_config = RunnableConfig(
run_name="[GOOD] Rich Context",
tags=["comparison-ex", "good"],
)
good_result = graph.invoke(
{"context": "RAG combines retrieval and generation. It uses vector stores like Chroma or Pinecone. "
"The key metrics are precision@k and recall@k. In 2025, hybrid search beats dense retrieval.",
"summary": ""},
config=good_config,
)
bad_config = RunnableConfig(
run_name="[BAD] Empty Context",
tags=["comparison-ex", "bad"],
)
bad_result = graph.invoke(
{"context": "", "summary": ""},
config=bad_config,
)
print(f"GOOD: {good_result['summary'][:80]}...")
print(f"BAD: {bad_result['summary'][:80]}...")
time.sleep(3)
from langsmith import Client
client = Client()
for tag_filter, label in [("good", "GOOD"), ("bad", "BAD")]:
runs = list(client.list_runs(
project_name="research-assistant",
filter=f'and(has(tags, "comparison-ex"), has(tags, "{tag_filter}"))',
limit=1,
))
if runs:
run = runs[0]
latency = (run.end_time - run.start_time).total_seconds() if run.end_time else 0
print(f"\n[{label}] {run.name}")
print(f" Status: {run.status}")
print(f" Latency: {latency:.2f}s")
print(f" Tokens: {run.total_tokens or 'N/A'}")
# Expected output:
# GOOD: RAG is a technique that combines retrieval with generation using...
# BAD: There is no context available to summarize.
#
# [GOOD] [GOOD] Rich Context
# Status: success
# Latency: 0.95s
# Tokens: 120
#
# [BAD] [BAD] Empty Context
# Status: success
# Latency: 0.62s
# Tokens: 35
Exercise 4: Automated health check (Advanced)
Write a script that works as a health check: it fetches the last 20 production traces, computes error rate, p50/p95 latency, average tokens, and produces a report with alerts if any metric crosses a threshold.
See solution
from dotenv import load_dotenv
load_dotenv()
from langsmith import Client
from datetime import datetime, timedelta
client = Client()
runs = list(client.list_runs(
project_name="research-assistant",
is_root=True,
limit=20,
))
print(f"{'='*60}")
print(f" HEALTH CHECK — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print(f" Last {len(runs)} runs")
print(f"{'='*60}\n")
if not runs:
print(" ⚠️ No traces available.")
else:
errors = [r for r in runs if r.status == "error"]
latencies = sorted(
[(r.end_time - r.start_time).total_seconds() for r in runs if r.end_time]
)
tokens = [r.total_tokens for r in runs if r.total_tokens]
error_rate = len(errors) / len(runs)
p50 = latencies[len(latencies) // 2] if latencies else 0
p95 = latencies[int(len(latencies) * 0.95)] if latencies else 0
avg_tokens = sum(tokens) / len(tokens) if tokens else 0
THRESHOLDS = {
"error_rate": 0.10,
"p95_latency": 5.0,
"avg_tokens": 3000,
}
print(f" Metric Value Threshold Status")
print(f" {'-'*55}")
er_status = "❌ ALERT" if error_rate > THRESHOLDS["error_rate"] else "✅ OK"
print(f" Error rate {error_rate:>6.0%} <{THRESHOLDS['error_rate']:.0%} {er_status}")
p95_status = "❌ ALERT" if p95 > THRESHOLDS["p95_latency"] else "✅ OK"
print(f" Latency p50 {p50:>6.1f}s — —")
print(f" Latency p95 {p95:>6.1f}s <{THRESHOLDS['p95_latency']:.0f}s {p95_status}")
tok_status = "❌ ALERT" if avg_tokens > THRESHOLDS["avg_tokens"] else "✅ OK"
print(f" Average tokens {avg_tokens:>6.0f} <{THRESHOLDS['avg_tokens']} {tok_status}")
alerts = []
if error_rate > THRESHOLDS["error_rate"]:
alerts.append(f"High error rate: {error_rate:.0%}")
if p95 > THRESHOLDS["p95_latency"]:
alerts.append(f"High p95 latency: {p95:.1f}s")
if avg_tokens > THRESHOLDS["avg_tokens"]:
alerts.append(f"High token usage: {avg_tokens:.0f}")
if alerts:
print(f"\n ⚠️ ALERTS:")
for alert in alerts:
print(f" - {alert}")
else:
print(f"\n ✅ Everything within normal parameters")
# Expected output:
# ============================================================
# HEALTH CHECK — 2025-12-15 15:45
# Last 20 runs
# ============================================================
#
# Metric Value Threshold Status
# -------------------------------------------------------
# Error rate 5% <10% ✅ OK
# Latency p50 2.3s — —
# Latency p95 4.8s <5s ✅ OK
# Average tokens 1200 <3000 ✅ OK
#
# ✅ Everything within normal parameters
Summary
In this capsule you learned:
- The debugging workflow with LangSmith is systematic: unexpected output → find the trace → walk the timeline → spot the problem step → examine input/output → root cause → fix. No more "add a print and re-run"
- Visualizing graph execution shows which nodes were visited, in what order, and which path the routing took. If the agent went down the wrong path, you see it immediately
- Finding bottlenecks is visual: the timeline bars are proportional to time. The longest bar is your bottleneck — focus your optimization there
- Debugging tool calls shows the exact arguments and responses. If the agent sent the wrong arguments to a tool, you don't have to guess — you see it in the trace
- Comparing traces (good vs bad) reveals exactly what changed between a successful run and a failed one. It's the most efficient debugging there is
- Error traces capture the agent's complete state at the moment of failure: traceback, state, nodes that ran, nodes that never did
- Three debugging patterns in production: reactive (a user reported something), proactive (a periodic health check), and comparative (is the new version better?)
Next capsule: Evaluation: Datasets and Evaluators — how to go from "seems to work" to systematic quality measurement against specific criteria.
Additional resources
- LangSmith — Tracing FAQ — Frequently asked questions about tracing and debugging
- LangSmith — Filter runs — How to filter and search traces in the dashboard
- LangGraph — Debugging — Debugging graphs with built-in tracing
- LangSmith SDK — list_runs — SDK reference for programmatic access to traces
- LangSmith — Comparison View — How to compare runs in the dashboard
Module 12 — LangChain & LangGraph: From Chains to Agents