Intermediate

LangGraph in Practice

LangGraph models agents as directed graphs where every step is explicit. This lesson builds the benchmark research agent in LangGraph - with the state model, tool node, conditional edges, checkpoint persistence, and human-in-the-loop interrupt all annotated.

✍️ AI School Editorial Team · Lilly Tech Systems 📅 Published Jun 11, 2026 · Reviewed Jun 11, 2026

LangGraph’s Mental Model

LangGraph is built on a single idea: an agent is a directed graph where each node is a Python function, each edge is a transition, and the state is a typed dict that flows through the graph. You define the graph explicitly - every node, every edge condition, every loop - rather than relying on the framework to decide what happens next. This makes LangGraph the most verbose of the three frameworks and also the most debuggable: you can inspect the graph structure before running it and trace exactly which path was taken after.

The core components:

  • StateGraph - the graph container. You add nodes and edges to it, then compile it into a runnable graph.
  • State (TypedDict) - the shared data structure passed between nodes. Nodes read from state and return updates to it.
  • Nodes - Python functions that receive the current state and return a state update.
  • Edges - transitions between nodes. Conditional edges use a routing function to decide the next node based on state.
  • Checkpointer - a persistence layer (in-memory, SQLite, or external store) that saves state after every node execution.

Defining the State

state.py - TypedDict for the research agent
from typing import TypedDict, Annotated, List
import operator

class ResearchState(TypedDict):
    # The original research question
    question: str
    # Accumulated list of LangChain messages (auto-appended)
    messages: Annotated[list, operator.add]
    # Sourced claims collected so far
    claims: List[dict]
    # Whether the agent has enough information
    research_complete: bool
    # Number of search iterations (circuit breaker)
    search_count: int
💡
Why Annotated[list, operator.add]: LangGraph merges state updates by replacing fields by default. For the messages list, we want appending instead - the Annotated reducer tells LangGraph to use operator.add to combine updates. This is LangGraph’s mechanism for fields that accumulate rather than overwrite.

Defining the Nodes

nodes.py - agent node and tool node
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import ToolMessage
from langgraph.prebuilt import ToolNode
import json

llm = ChatAnthropic(model="claude-opus-4-8")
tools = [web_search, fetch_document]  # tools defined in Lesson 3
llm_with_tools = llm.bind_tools(tools)

def agent_node(state: ResearchState) -> dict:
    """
    The reasoning node: calls the LLM with current messages.
    Returns a state update containing the model's response.
    """
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

# ToolNode handles tool dispatch, execution, and result formatting
# It also catches tool exceptions and formats them as tool error messages
# rather than letting them propagate as unhandled exceptions
tool_node = ToolNode(tools)

def write_report_node(state: ResearchState) -> dict:
    """
    Final node: synthesizes accumulated messages into the output report.
    Runs only when research_complete is True.
    """
    synthesis_prompt = f"""Based on your research, write a structured report
    that answers: {state['question']}
    Include: (1) at least 3 sourced claims with URLs, (2) any information gaps."""

    from langchain_core.messages import HumanMessage
    messages = state["messages"] + [HumanMessage(content=synthesis_prompt)]
    response = llm_with_tools.invoke(messages)
    return {"messages": [response]}

Wiring the Graph

graph.py - assembling and compiling the graph
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.messages import AIMessage

def should_continue(state: ResearchState) -> str:
    """
    Routing function: decides which node comes after the agent node.
    Returns the name of the next node.
    """
    messages = state["messages"]
    last = messages[-1]

    # Circuit breaker: stop after 5 searches to prevent runaway loops
    if state["search_count"] >= 5:
        return "write_report"

    # If the model called tools, execute them
    if isinstance(last, AIMessage) and last.tool_calls:
        return "tools"

    # No tool calls: agent is done researching
    return "write_report"

# Build the graph
builder = StateGraph(ResearchState)

# Add nodes
builder.add_node("agent", agent_node)
builder.add_node("tools", tool_node)
builder.add_node("write_report", write_report_node)

# Entry point
builder.set_entry_point("agent")

# Conditional edge: after agent, route based on should_continue()
builder.add_conditional_edges("agent", should_continue)

# After tools run, always return to agent
builder.add_edge("tools", "agent")

# write_report is the terminal node
builder.add_edge("write_report", END)

# Compile with an in-memory checkpointer (swap for SQLite for persistence)
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)

# Run it
config = {"configurable": {"thread_id": "research-001"}}
result = graph.invoke(
    {
        "question": "What are the main approaches to AI agent memory?",
        "messages": [{"role": "user", "content":
                      "Research: What are the main approaches to AI agent memory?"}],
        "claims": [],
        "research_complete": False,
        "search_count": 0
    },
    config
)

Adding Human-in-the-Loop

LangGraph’s interrupt_before mechanism pauses the graph before a specified node and waits for an external signal to resume. This is the production pattern for agent approval gates:

human-in-the-loop pattern
# Compile with interrupt_before the tools node
graph_with_hitl = builder.compile(
    checkpointer=checkpointer,
    interrupt_before=["tools"]  # pause before every tool execution
)

# First invocation runs until it reaches the "tools" node
snapshot = graph_with_hitl.invoke(initial_state, config)
# snapshot.next == ("tools",) - graph is paused here

# Inspect the pending tool calls before approving
pending = snapshot.values["messages"][-1].tool_calls
print(f"Agent wants to call: {[tc['name'] for tc in pending]}")

# Human approves - resume by calling invoke again with same config
# LangGraph reads the saved checkpoint and continues from the interrupt
result = graph_with_hitl.invoke(None, config)
Why this matters: The checkpoint saved before the interrupt means you can inspect the agent’s intent, modify the pending action if needed, or reject it and let the graph route elsewhere - all without rerunning the steps before the interrupt. This is the pattern behind every production agent that shows "Claude wants to delete these files - approve?" confirmation dialogs.

Observability: Inspecting the Graph Run

LangGraph integrates with LangSmith for production tracing. For local debugging, the stream_mode="updates" option emits each state update as it happens:

streaming updates for local debugging
for event in graph.stream(initial_state, config, stream_mode="updates"):
    node_name = list(event.keys())[0]
    update = event[node_name]
    print(f"--- {node_name} ---")
    if "messages" in update:
        last_msg = update["messages"][-1]
        print(f"  type: {type(last_msg).__name__}")
        if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
            print(f"  tools: {[tc['name'] for tc in last_msg.tool_calls]}")

LangGraph: What It’s Good At

  • Complex, branching flows - the graph model handles multi-path agents, parallel branches, and conditional routing naturally. If your agent has genuinely complex control flow, LangGraph is the most expressive tool for it.
  • Checkpoint-based resumption - production agents that may run for minutes or hours, pause for human approval, or recover from partial failures benefit most from LangGraph’s checkpointing.
  • Debuggability - the explicit graph structure means you can visualize what the agent will do before running it (graph.get_graph().draw_mermaid()) and trace exactly what happened after.

LangGraph: What It Costs

  • Verbosity - simple agents require the same graph machinery as complex ones. A two-step agent in LangGraph involves StateGraph, nodes, edges, conditional routing, and compilation - more code than the equivalent raw loop.
  • Learning curve - the graph/state/reducer model is different from both raw API calls and the role-based model in CrewAI. Budget 1-2 weeks for a developer to become productive.
  • LangChain dependency - LangGraph is built on LangChain. You get the full LangChain ecosystem; you also get its version churn and its opinionated message format.
📚
Authoritative reference: LangGraph documentation at langchain-ai.github.io/langgraph (official docs) and the LangSmith tracing guide at docs.smith.langchain.com. Both are actively maintained by the LangChain team.

Ready to Go Deeper?

Live instructor-led courses from our partners. Affiliate disclosure.