Skip to main content

Reliable Agent Design

You've built agents. You've gotten them to loop, call tools, and produce answers. Now comes the hard part: making those agents reliable under real-world conditions β€” flaky APIs, long-running conversations, unexpected failures, and the need for human oversight.

This document codifies our team's standards for writing LangGraph code that runs in production. Every recommendation is opinionated, actionable, and grounded in the framework's capabilities. Treat this as a living standard β€” not a suggestion list.


What Makes an Agent "Reliable"​

A production agent is not just one that gives correct answers on a happy path. It is a system that behaves predictably when things go wrong. Reliability across five dimensions:

DimensionWhat It MeansWhat Happens Without It
Fault toleranceNodes fail without crashing the graphSingle tool timeout kills the entire conversation
State durabilityExecution survives process crashesUsers lose context and must restart from scratch
Human oversightCritical actions require approvalAgents take irreversible actions autonomously
ObservabilityYou can see what the agent is doingDebugging means adding print statements and redeploying
TestabilityBehavior is verified, not hoped forRegression bugs reach production undetected

These five dimensions map directly to LangGraph's core capabilities: error handling in nodes, checkpointers, interrupts, LangSmith tracing, and structured testing.


Error Handling Patterns​

Agents call external APIs, query databases, and invoke LLMs β€” all of which fail. Your nodes must handle failure gracefully.

Try/Catch in Nodes​

Every node is a function. Wrap the core logic in try/except so that a single failure does not terminate the entire graph:

from langchain.messages import ToolMessage


def execute_tools(state: AgentState) -> dict:
"""Execute tool calls with per-tool error handling."""
results = []
last_message = state["messages"][-1]

for tool_call in last_message.tool_calls:
tool_name = tool_call["name"]
tool_fn = tools_by_name.get(tool_name)

if tool_fn is None:
results.append(
ToolMessage(
content=f"Unknown tool '{tool_name}'. Available tools: {list(tools_by_name.keys())}.",
tool_call_id=tool_call["id"],
)
)
continue

try:
observation = tool_fn.invoke(tool_call["args"])
results.append(
ToolMessage(content=str(observation), tool_call_id=tool_call["id"])
)
except Exception as exc:
results.append(
ToolMessage(
content=(
f"Tool '{tool_name}' failed ({type(exc).__name__}): {exc}. "
"Try a different approach or rephrase your request."
),
tool_call_id=tool_call["id"],
)
)

return {"messages": results}
Never let exceptions bubble out of nodes

An unhandled exception in a node terminates graph execution. If you use a checkpointer, the checkpoint is still saved β€” but the agent stops responding. Always catch exceptions and return a state update, even if that update signals failure.

Retry Strategies​

For transient failures (network timeouts, rate limits), retry with exponential backoff. LangGraph provides a @retry_decorator for this:

from langgraph.retry import retry_decorator
import httpx


@retry_decorator(
max_retries=3,
initial_backoff=1.0,
backoff_factor=2.0, # 1s, 2s, 4s
jitter=True,
retry_on=(httpx.TimeoutException, httpx.NetworkError),
)
def call_external_api(query: str) -> str:
"""Fetch data from an external API with automatic retry."""
with httpx.Client(timeout=10.0) as client:
response = client.get(f"https://api.example.com/search?q={query}")
response.raise_for_status()
return response.json()

When to use retries:

Failure TypeRetry?Reason
Network timeoutYesTransient; server may recover
429 Rate limitYes (with backoff)Provider will accept after delay
5xx server errorYesMay be temporary
400 bad requestNoRetry won't fix invalid input
401/403 auth errorNoCredentials are wrong
Tool returned empty resultMaybeDepends on whether re-invocation helps

Fallback Nodes​

When a node fails repeatedly, route to an alternative path rather than retrying indefinitely. Use conditional edges to detect failure state:

from typing import Literal
from langgraph.graph import StateGraph, START, END


def route_after_search(
state: AgentState,
) -> Literal["synthesize", "fallback_summary", END]:
"""Route based on search results quality."""
search_results = state.get("search_results", [])

if not search_results:
# No results β€” try the fallback path
return "fallback_summary"
if len(search_results) < 2:
# Weak results β€” still try synthesis but flag it
return "synthesize"
return "synthesize"


def fallback_summary_node(state: AgentState) -> dict:
"""Provide a best-effort answer when search fails."""
return {
"answer": (
"I couldn't find specific information for your query. "
"Here's what I know from general knowledge: ..."
),
"confidence": "low",
}


# Graph wiring
graph = StateGraph(AgentState)
graph.add_node("search", search_node)
graph.add_node("synthesize", synthesize_node)
graph.add_node("fallback_summary", fallback_summary_node)

graph.add_edge(START, "search")
graph.add_conditional_edges(
"search",
route_after_search,
["synthesize", "fallback_summary", END],
)
graph.add_edge("synthesize", END)
graph.add_edge("fallback_summary", END)

Graceful Degradation​

Design agents that operate at reduced capability rather than failing completely. The key principle: a partial answer is better than no answer.

def research_node(state: AgentState) -> dict:
"""Research with graceful degradation across multiple sources."""
findings = []

# Primary source: web search
try:
web_results = web_search(state["query"])
if web_results:
findings.extend(web_results)
except Exception:
logger.warning("Web search unavailable, continuing with other sources")

# Secondary source: internal knowledge base
try:
kb_results = knowledge_base_search(state["query"])
if kb_results:
findings.extend(kb_results)
except Exception:
logger.warning("Knowledge base unavailable")

# Tertiary: LLM's own knowledge
if not findings:
return {
"findings": [],
"source": "llm_knowledge",
"degraded": True,
}

return {
"findings": findings,
"source": "external",
"degraded": False,
}

Human-in-the-Loop​

Production agents should not take irreversible actions without human approval. LangGraph provides interrupt() to pause execution at any point.

Interrupt Before Action​

The interrupt() function halts graph execution and returns control to the caller. You can then inspect state, modify it, and resume:

from langgraph.types import interrupt


def send_email_node(state: AgentState) -> dict:
"""Pause before sending any email for human review."""
draft = state["email_draft"]

# Interrupt triggers a pause β€” execution stops here
feedback = interrupt({
"action": "send_email",
"recipient": state["email_recipient"],
"subject": state["email_subject"],
"body_preview": draft[:200] + "...",
"request": "Review and approve this email before sending.",
})

# After human approves (or modifies), execution resumes
if feedback.get("approved"):
sent_email = email_client.send(
to=state["email_recipient"],
subject=state["email_subject"],
body=feedback.get("modified_body", draft),
)
return {"email_sent": True, "email_id": sent_email.id}

return {"email_sent": False, "reason": "Rejected by reviewer"}

Approval Workflows​

Use interrupts for any action that is costly, irreversible, or policy-sensitive:

ActionInterrupt?Why
Sending external communicationsYesWrong messages damage reputation
Writing to production databasesYesData corruption is hard to undo
Financial transactionsYesObvious
Internal data analysisNoLow risk, fast iteration
Drafting responses (not sending)NoHuman reviews the final output

Breaking Execution and Resuming​

When an interrupt fires, the graph pauses. The caller receives the interrupt payload and can:

  1. Approve β€” pass back a value that satisfies the interrupt and continues.
  2. Modify β€” update state (e.g., edit the email body) before resuming.
  3. Reject β€” signal that execution should not continue.
import uuid
from langgraph.checkpoint.memory import MemorySaver

# Compile with a checkpointer β€” required for interrupts
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)

# Invoke with a thread ID
thread_id = str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}

# This will pause at the interrupt() call
for event in app.stream({"messages": [HumanMessage(content="Send an email to...")]}, config):
pass

# At this point, the graph is paused. Inspect state:
current_state = app.get_state(config)
print(f"Paused at node: {current_state.next}")
print(f"State: {current_state.values}")

# Resume with human feedback
for event in app.stream(
None, # No new input
config,
stream_mode="values",
):
# The interrupt receives this as the return value
pass

# Or modify state before resuming
app.update_state(config, {"email_draft": "Modified version of the email..."})

Interrupt Patterns in Practice​

from langgraph.types import interrupt

def approve_action(state: AgentState) -> dict:
should_proceed = interrupt({
"message": f"Agent wants to: {state['planned_action']}. Approve?",
"default": False,
})
return {"approved": bool(should_proceed)}

Checkpointing and Persistence​

Checkpointing is the mechanism by which LangGraph saves agent state between steps. It is the foundation for crash recovery, human-in-the-loop, time travel, and debugging.

Why Checkpointing Matters​

Without checkpointing, every graph execution is ephemeral. A process crash, a network interruption, or a human review pause means losing all accumulated state. With checkpointing:

  • Crash recovery β€” restart from the last saved step, not from the beginning.
  • Human-in-the-loop β€” interrupts require a checkpointer to pause and resume.
  • Time travel β€” inspect or revert to any previous state in the execution history.
  • Debugging β€” replay execution from any checkpoint to reproduce issues.
  • Conversation threading β€” maintain context across multiple user interactions.

In-Memory vs. Persistent Checkpointers​

from langgraph.checkpoint.memory import MemorySaver

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

# State lives only in the Python process.
# Great for development and testing. Lost on restart.
Checkpointer Selection Guide
ScenarioCheckpointer
Local development, testingMemorySaver
Single-server, low trafficSqliteSaver
Multi-server, productionPostgresSaver
High-throughput, low latencyRedisSaver

Thread IDs and Conversation Threading​

Every graph execution belongs to a thread, identified by a thread_id. The thread ID is how LangGraph associates checkpoints with a particular conversation or workflow:

import uuid

# Each conversation gets its own thread
thread_id = str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}

# First turn
result = app.invoke(
{"messages": [HumanMessage(content="Hello")]},
config=config,
)

# Second turn β€” same thread, state is preserved
result = app.invoke(
{"messages": [HumanMessage(content="Follow-up question")]},
config=config,
)

# The checkpointer links both invocations to the same thread

Thread IDs are typically:

  • User session IDs for chat applications.
  • Task IDs for batch processing workflows.
  • UUIDs generated at the start of each interaction.

Inspecting Checkpoint History​

You can traverse the full execution history of any thread:

# Get the current state
current_state = app.get_state(config)
print(f"Current node: {current_state.next}")
print(f"State values: {current_state.values}")

# Get all checkpoints in the thread
for checkpoint in app.get_state_history(config):
print(f"Checkpoint ID: {checkpoint.id}")
print(f" Next nodes: {checkpoint.next}")
print(f" Metadata: {checkpoint.metadata}")

# Get state at a specific checkpoint
specific_state = app.get_state(
{"configurable": {"thread_id": thread_id, "checkpoint_id": "some-checkpoint-id"}}
)
Next: PostgreSQL Checkpointing

For detailed guidance on configuring PostgreSQL as your production checkpointer β€” including connection pooling, schema setup, and migration strategies β€” see the PostgreSQL Checkpointing & Memory Setup guide.


Debugging LangGraph Agents​

When an agent behaves unexpectedly, you need tools to understand what happened. LangGraph integrates with LangSmith and provides built-in observability.

Using LangSmith for Debugging​

Enable LangSmith tracing at the environment level. Every graph invocation, node execution, and tool call is automatically traced:

export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY="lsv2_..."
export LANGSMITH_PROJECT="my-agent-project"
# No additional code needed β€” LangSmith traces automatically
# when LANGSMITH_TRACING=true is set

# Optional: add metadata for filtering
config = {
"configurable": {"thread_id": thread_id},
"metadata": {
"user_id": "user-123",
"agent_version": "2.1.0",
"environment": "production",
},
}

In the LangSmith UI, you can:

  • See the full execution trace with timing for each node.
  • Inspect input and output state at every step.
  • Filter traces by metadata (user, version, environment).
  • Compare traces side-by-side to identify regressions.

Visualizing Graph Execution​

LangGraph Studio provides a visual graph editor and execution debugger:

# Install LangGraph CLI
uv add langgraph-cli

# Start the studio server
langgraph dev

The studio shows:

  • Graph structure β€” nodes, edges, and conditional routing.
  • Execution path β€” which nodes ran and in what order.
  • State at each step β€” inspect the full state after every node.
  • Interactive replay β€” step through execution manually.

Inspecting State at Each Step​

Use astream with stream_mode="values" to observe state changes in real time:

async for event in app.astream(
{"messages": [HumanMessage(content="Research quantum computing")]},
config=config,
stream_mode="values",
):
print(f"--- After step ---")
print(f"Step count: {event.get('step_count', 'N/A')}")
print(f"Messages so far: {len(event.get('messages', []))}")
print(f"Search results: {len(event.get('search_results', []))}")

For more granular debugging, use stream_mode="updates" to see each node's contribution:

async for event in app.astream(
{"messages": [HumanMessage(content="Research quantum computing")]},
config=config,
stream_mode="updates",
):
# event is a dict: {"node_name": {"key": "value"}}
for node_name, updates in event.items():
print(f"[{node_name}] Updated: {list(updates.keys())}")

Logging Strategies Within Nodes​

Add structured logging to nodes for production debugging:

import logging
import time

logger = logging.getLogger("agent")


def timed_node(node_func):
"""Decorator that logs execution time and status for any node."""
def wrapper(state: AgentState) -> dict:
start = time.perf_counter()
node_name = node_func.__name__
logger.info("Node %s starting", node_name)
try:
result = node_func(state)
elapsed = time.perf_counter() - start
logger.info("Node %s completed in %.2fs", node_name, elapsed)
return result
except Exception as exc:
elapsed = time.perf_counter() - start
logger.error(
"Node %s failed after %.2fs: %s",
node_name,
elapsed,
exc,
exc_info=True,
)
raise
return wrapper


@timed_node
def call_llm(state: AgentState) -> dict:
"""LLM call with automatic timing and error logging."""
response = model_with_tools.invoke([system] + state["messages"])
return {"messages": [response], "step_count": state.get("step_count", 0) + 1}

Getting Stack Traces from Failed Nodes​

When a node fails, LangGraph does not swallow the exception. The full traceback is available:

try:
result = app.invoke(
{"messages": [HumanMessage(content="trigger error")]},
config=config,
)
except Exception as exc:
# Full traceback is available
import traceback
traceback.print_exc()

# If using LangSmith, the error is also captured in the trace
# with full state context at the point of failure

For nodes that catch their own exceptions, log the traceback internally:

def resilient_node(state: AgentState) -> dict:
try:
return do_risky_thing(state)
except Exception:
logger.exception("Resilient node failed β€” details logged above")
return {"error": "Node encountered an error", "recovered": True}

Graph Design Patterns​

Different problems call for different graph architectures. Here are the patterns we use most.

Supervisor Pattern​

A supervisor node orchestrates multiple specialist sub-agents. The supervisor decides which specialist to call, routes results, and determines when the task is complete.

from typing import Literal
from langgraph.graph import StateGraph, START, END


def supervisor_node(state: AgentState) -> dict:
"""Decide which specialist to call next."""
last_response = state["messages"][-1]
# The LLM acts as the supervisor, choosing the next specialist
decision = supervisor_model.invoke([
SystemMessage(content=supervisor_system_prompt),
*state["messages"],
])
return {
"messages": [decision],
"next_specialist": parse_specialist(decision),
}


def route_to_specialist(state: AgentState) -> Literal["researcher", "coder", "writer", END]:
"""Route based on supervisor's decision."""
specialist = state.get("next_specialist")
if specialist == "done":
return END
return specialist or END


# Build the graph
graph = StateGraph(AgentState)
graph.add_node("supervisor", supervisor_node)
graph.add_node("researcher", researcher_node)
graph.add_node("coder", coder_node)
graph.add_node("writer", writer_node)

graph.add_edge(START, "supervisor")
graph.add_conditional_edges(
"supervisor",
route_to_specialist,
["researcher", "coder", "writer", END],
)
# Each specialist routes back to the supervisor
graph.add_edge("researcher", "supervisor")
graph.add_edge("coder", "supervisor")
graph.add_edge("writer", "supervisor")

When to use: Multi-step tasks requiring different expertise at different stages (research then code then write).

Map-Reduce Pattern​

Process multiple inputs in parallel, then combine results. LangGraph's Send API enables fan-out execution:

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import Send


def map_node(state: ResearchState) -> dict:
"""Fan out: process each sub-query independently."""
sub_queries = state["sub_queries"]
# Send creates parallel execution branches
return [
Send("process_query", {"query": q, "query_index": i})
for i, q in enumerate(sub_queries)
]


def process_query(state: QueryState) -> dict:
"""Process a single query (runs in parallel for each sub-query)."""
results = search_tool.invoke(state["query"])
return {
"query_results": [
{"query": state["query"], "results": results, "index": state["query_index"]}
],
}


def reduce_node(state: ResearchState) -> dict:
"""Combine all parallel results into a unified answer."""
all_results = state.get("query_results", [])
combined = synthesize_model.invoke([
SystemMessage(content="Synthesize these search results into a comprehensive answer."),
HumanMessage(content="\n\n".join(str(r) for r in all_results)),
])
return {"final_answer": combined.content}


graph = StateGraph(ResearchState)
graph.add_node("split_queries", split_queries_node)
graph.add_node("map", map_node)
graph.add_node("process_query", process_query)
graph.add_node("reduce", reduce_node)

graph.add_edge(START, "split_queries")
graph.add_edge("split_queries", "map")
graph.add_edge("reduce", END)

When to use: Processing multiple independent inputs (documents, queries, data sources) and combining results.

Pipeline Pattern​

Sequential processing with early exit. Each stage validates its output before passing to the next:

from typing import Literal
from langgraph.graph import StateGraph, START, END


def validate_input(state: PipelineState) -> Literal["extract", "error"]:
"""Gate: reject bad input early."""
if not state.get("input_text") or len(state["input_text"]) < 10:
return "error"
return "extract"


def validate_extraction(
state: PipelineState,
) -> Literal["analyze", "error"]:
"""Gate: check extraction quality before expensive analysis."""
entities = state.get("entities", [])
if not entities:
return "error"
return "analyze"


graph = StateGraph(PipelineState)
graph.add_node("extract", extract_entities_node)
graph.add_node("analyze", analyze_sentiment_node)
graph.add_node("summarize", summarize_node)
graph.add_node("error", error_handler_node)

graph.add_edge(START, "extract")
graph.add_conditional_edges("extract", validate_extraction, ["analyze", "error"])
graph.add_edge("analyze", "summarize")
graph.add_edge("summarize", END)
graph.add_edge("error", END)

When to use: Multi-stage processing where early validation saves cost (extract β†’ validate β†’ analyze β†’ summarize).

Reflection Pattern​

The agent generates output, critiques it, and iteratively improves:

from typing import Literal
from langgraph.graph import StateGraph, START, END


def generate(state: ReflectionState) -> dict:
"""Generate or regenerate the answer."""
prompt = state["prompt"]
if state.get("critique"):
prompt += f"\n\nPrevious critique: {state['critique']}\nImprove your answer:"
response = model.invoke([SystemMessage(content=system), HumanMessage(content=prompt)])
return {"answer": response.content, "iteration": state.get("iteration", 0) + 1}


def critique(state: ReflectionState) -> dict:
"""Evaluate the generated answer."""
critique_response = critic_model.invoke([
SystemMessage(content=critic_system_prompt),
HumanMessage(content=f"Answer to critique:\n{state['answer']}"),
])
return {"critique": critique_response.content}


def should_improve(state: ReflectionState) -> Literal["generate", END]:
"""Stop if the critique is satisfied or max iterations reached."""
if state.get("iteration", 0) >= MAX_ITERATIONS:
return END
if "approve" in state.get("critique", "").lower():
return END
return "generate"


graph = StateGraph(ReflectionState)
graph.add_node("generate", generate)
graph.add_node("critique", critique)

graph.add_edge(START, "generate")
graph.add_edge("generate", "critique")
graph.add_conditional_edges("critique", should_improve, ["generate", END])

When to use: Tasks where quality matters more than speed (writing, code generation, analysis).

Pattern Selection Guide​

PatternBest ForComplexityLLM Calls
SupervisorMulti-expert orchestrationMediumHigh (supervisor + specialists)
Map-ReduceParallel processingMediumHigh (many parallel + one reduce)
PipelineSequential stages with validationLowModerate
ReflectionQuality-critical generationLow-MediumHigh (iterate until satisfied)

Performance Optimization​

Agents are expensive. Every LLM call costs money and adds latency. Optimize aggressively.

Parallel Node Execution​

LangGraph executes nodes in parallel when they are independent. Use the Send API to fan out work:

from langgraph.graph.message import Send


def parallel_research(state: AgentState) -> dict:
"""Fan out multiple research queries in parallel."""
queries = [
"LangGraph architecture overview",
"LangGraph checkpointing mechanisms",
"LangGraph streaming patterns",
]
# Each Send creates an independent execution branch
return [
Send("research_node", {"query": q})
for q in queries
]

Streaming Responses​

Stream output to reduce perceived latency. LangGraph supports multiple streaming modes:

# Stream updated state after each step
for event in app.stream(input_data, config=config, stream_mode="values"):
last_message = event.get("messages", [])[-1]
if hasattr(last_message, "content"):
print(last_message.content, end="\n\n")

Caching LLM Calls​

Cache repeated LLM calls to eliminate redundant API charges:

from langchain.globals import set_llm_cache
from langchain_community.cache import SQLiteCache

# Set up persistent cache
set_llm_cache(SQLiteCache(database_path=".llm_cache.db"))

# First call: hits the API
result1 = model.invoke(messages)

# Second identical call: returns cached result instantly
result2 = model.invoke(messages) # No API call
Caching Strategy

Cache aggressively for deterministic prompts (system prompts, fixed instructions). Avoid caching for prompts that include dynamic user input β€” the cache key includes the full prompt, so user-specific prompts rarely hit.

Reducing Unnecessary State Updates​

State is checkpointed at every step. Minimize what you store:

# Bad: storing large intermediate data in state
def bad_node(state: AgentState) -> dict:
raw_data = fetch_huge_dataset() # 10MB of data
processed = extract_insights(raw_data)
return {
"raw_data": raw_data, # Don't checkpoint this
"insights": processed,
}

# Good: only store what you need
def good_node(state: AgentState) -> dict:
raw_data = fetch_huge_dataset()
processed = extract_insights(raw_data)
return {
"insights": processed,
"data_summary": f"Processed {len(raw_data)} records", # Lightweight
}

State optimization checklist:

  • Remove large data from state after processing.
  • Store references (IDs, paths) instead of full objects.
  • Use state fields only for data shared across multiple nodes.
  • Keep per-node intermediate data local to the node function.

Testing LangGraph Agents​

Untested agents are production liabilities. Test at three levels: nodes in isolation, graph execution with mocks, and full integration with fake models.

Unit Testing Individual Nodes​

Test each node function independently. Nodes are pure functions that take state and return updates:

import pytest
from langchain.messages import HumanMessage, AIMessage, ToolMessage


def test_execute_tools_handles_unknown_tool():
"""Tool node returns error message for unknown tool names."""
state = {
"messages": [
AIMessage(
content="",
tool_calls=[{"name": "nonexistent_tool", "args": {}, "id": "call_1"}],
)
]
}
result = execute_tools(state)
assert len(result["messages"]) == 1
assert "Unknown tool" in result["messages"][0].content


def test_execute_tools_handles_tool_error():
"""Tool node catches exceptions and returns error ToolMessage."""
state = {
"messages": [
AIMessage(
content="",
tool_calls=[{"name": "failing_tool", "args": {}, "id": "call_2"}],
)
]
}
result = execute_tools(state)
assert len(result["messages"]) == 1
assert "failed" in result["messages"][0].content.lower()


def test_route_after_llm_routes_to_tools():
"""Routing function sends to tool node when LLM requests tools."""
state = {
"messages": [
AIMessage(content="", tool_calls=[{"name": "search", "args": {}}]),
]
}
assert route_after_llm(state) == "execute_tools"


def test_route_after_llm_routes_to_end():
"""Routing function ends when LLM has no tool calls."""
state = {
"messages": [AIMessage(content="The answer is 42.")]
}
assert route_after_llm(state) == END

Integration Testing Full Graph Execution​

Test the complete graph with mocked LLM calls:

from unittest.mock import MagicMock, patch
from langchain.messages import AIMessage, HumanMessage
from langchain_core.language_models import BaseChatModel


@pytest.fixture
def mock_llm():
"""Mock LLM that returns predetermined responses."""
mock = MagicMock(spec=BaseChatModel)
mock.invoke.side_effect = [
# First call: request a tool
AIMessage(
content="",
tool_calls=[{"name": "web_search", "args": {"query": "test"}, "id": "call_1"}],
),
# Second call: final answer
AIMessage(content="Based on the search results, here is the answer."),
]
return mock


def test_agent_full_execution(mock_llm):
"""Agent completes a full research cycle."""
with patch("my_agent.model_with_tools", mock_llm):
app = build_agent_graph()
result = app.invoke({
"messages": [HumanMessage(content="What is LangGraph?")],
"step_count": 0,
})

assert result["step_count"] == 2
assert len(result["messages"]) >= 3 # Human + AI + Tool + AI

Mocking LLM and Tool Calls​

Never call real models in tests. Mock at the right level:

from langchain_community.chat_models import FakeListChatModel
from langchain.messages import AIMessage


def test_agent_with_fake_model():
"""Test agent behavior using FakeListChatModel."""
fake_model = FakeListChatModel(responses=[
AIMessage(content="", tool_calls=[
{"name": "calculator", "args": {"expression": "2+2"}, "id": "call_1"}
]),
AIMessage(content="The answer is 4."),
])

app = build_agent_graph(model=fake_model)
result = app.invoke({
"messages": [HumanMessage(content="What is 2+2?")],
})

assert "4" in result["messages"][-1].content

Testing Checklist​

  • Every node function has a unit test for the happy path.
  • Every node function has a test for the error path.
  • Every conditional edge function has tests for each branch.
  • The full graph has an integration test with mocked LLM.
  • Interrupt behavior is tested (pause and resume).
  • Checkpointing is tested (state survives across invocations).
  • No test calls a real LLM API.

Summary​

AreaStandard
Error HandlingEvery node catches exceptions. Transient failures retry with backoff. Unrecoverable failures route to fallback nodes.
Human-in-the-LoopIrreversible actions use interrupt(). Approval workflows for emails, writes, and financial operations.
CheckpointingAlways compile with a checkpointer in production. MemorySaver for dev, PostgresSaver for production.
DebuggingLangSmith tracing enabled in all environments. Structured logging in nodes. stream_mode for real-time observation.
Graph DesignChoose pattern by problem: Supervisor for orchestration, Map-Reduce for parallelism, Pipeline for sequential validation, Reflection for quality.
PerformanceStream to users. Cache deterministic calls. Parallelize independent work. Minimize state size.
TestingUnit test nodes. Integration test graphs. Mock LLMs always. Test error paths and interrupt behavior.

Follow these patterns and your LangGraph agents will be reliable, observable, and maintainable. Break them only with a documented reason.


Further Reading​