LangGraph 101: Building Your First Stateful Agent
The Stateless Chain Problem: Why Your Agent Forgets Everything
LangGraph adds state, branching, and loops to LLM chains β build stateful agents with graphs, nodes, and typed state.
π― System Scenario & Requirements
System Goal
LangGraph adds state, branching, and loops to LLM chains β build stateful agents with graphs, nodes, and typed state.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- TLDR: LangGraph adds state, branching, and loops to LLM chains β build stateful agents with graphs, nodes, and typed state.
- You built a LangChain chain that answers questions.
- Then you tried to make it ask a follow-up.
Constraints & Assumptions
- LangGraph solves real problems, but adding it to every project introduces overhead that is not always justified.
- Here is an honest look at both sides.
β‘ Execution Stages & Decision Trace
Stage 1 β LangGraph's Mental Model: Graphs, State, Nodes, and Edges
Before writing any code, you need three clear ideas in your head.
LangGraph is built from exactly these three concepts β nothing more.
Deep Dive
Scenario context
LangGraph adds state, branching, and loops to LLM chains β build stateful agents with graphs, nodes, and typed state.
You built a LangChain chain that answers questions. Then you tried to make it ask a follow-up. The chain had no idea what it just said.
This is the stateless chain problem β and it trips up almost every developer who moves beyond basic LangChain tutorials. Here is what the failure looksβ¦
This walkthrough focuses on The Stateless Chain Problem: Why Your Agent Forgets Everything.
Before making a design choice, clarify:
- You built a LangChain chain that answers questions.
- Then you tried to make it ask a follow-up.
- The chain had no idea what it just said.
Concepts in play: llm-agents-and-langgraph, graph-traversal-algorithms, sorting-and-searching-algorithms, event-driven-architecture
Stage 1 β LangGraph's Mental Model: Graphs, State, Nodes, and Edges
- Before writing any code, you need three clear ideas in your head.
- LangGraph is built from exactly these three concepts β nothing more.
- State is a Python dictionary typed with TypedDict .
- Every node in the graph can read it and write partial updates to it.
Observable outcome: Every node in the graph can read it and write partial updates to it.
Stage 2 β Building Blocks: TypedDict State, Nodes, and How Edges Connect Them
- Let's make everything above concrete with runnable code.
- Here is the minimum viable LangGraph agent β a two-node graph that greets a user and then summarizes the greeting.
- Step 1 β Define your state schema with TypedDict The Annotated[list[str], operator.add] syntax is the first thing that surprises most developers.
- When a node returns {"messages": ["Hello!"]} , LangGraph does not replace the entire messages list β it appends to it.
from typing import Annotated
from typing_extensions import TypedDict
import operator
class AgentState(TypedDict):
messages: Annotated[list[str], operator.add]
# operator.add means "append new values; never overwrite the whole list"
def greet_node(state: AgentState) -> dict:
"""Add a greeting to the message list."""
return {"messages": ["Hi! How can I help you today?"]}
def summarize_node(state: AgentState) -> dict:
"""Summarize everything that has been said so far."""
history = " | ".join(state["messages"])
return {"messages": [f"[Summary]: {history}"]}
Observable outcome: When a node returns {"messages": ["Hello!"]} , LangGraph does not replace the entire messages list β it appends to it.
Stage 3 β Deep Dive: How LangGraph Compiles and Executes Your Graph
- The Internals: Pregel-Style Execution Under the Hood When you call StateGraph.compile() , LangGraph validates the graph structure (checks for disconnected nodes, missing entry points, unreachable END ) and produces an eβ¦
- At each superstep, LangGraph identifies the currently active nodes, runs them, collects their partial state updates, and merges those updates into the shared state dict before advancing to the next step.
- The merge strategy for each field is controlled by its annotated reducer β operator.add for append, or overwrite-by-default for unannotated fields.
- LangGraph also serializes the full state after each superstep.
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# Each run gets a thread_id β state is persisted per thread
config = {"configurable": {"thread_id": "user-session-42"}}
result = graph.invoke({"messages": []}, config=config)
for step in graph.stream({"messages": []}):
node_name = list(step.keys())[0]
print(f"β
Node '{node_name}' completed: {step[node_name]}")
Observable outcome: LangGraph also serializes the full state after each superstep.
Stage 4 β Your First Agent: A Research Assistant That Can Search
- Now let's build the worked example.
- The agent takes a user question, decides whether it needs to search the web before answering, runs a search if needed, and synthesizes the final response.
- Here is the graph structure: The router LLM inspects the question and sets a needs search flag.
- If true, the search node runs and injects results into state.
graph TD
A([ User Question]) --> B[router_node Decide: search needed?]
B -->|needs_search = true| C[search_node Fetch results]
B -->|needs_search = false| D[answer_node Synthesize answer]
C --> D
D --> E([END])
style A fill:#e3f2fd,stroke:#1976D2,color:#000
style E fill:#e8f5e9,stroke:#388E3C,color:#000
style B fill:#fff8e1,stroke:#F57F17,color:#000
style C fill:#f3e5f5,stroke:#7B1FA2,color:#000
style D fill:#e8f5e9,stroke:#2E7D32,color:#000
import operator
from typing import Annotated
from typing_extensions import TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
from langgraph.graph import StateGraph, END
# --- 1. State schema ---
class ResearchState(TypedDict):
messages: Annotated[list[BaseMessage], operator.add]
search_results: str
needs_search: bool
# --- 2. LLM (swap freely β see the LLM-agnostic section below) ---
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# --- 3. Node: decide whether to search ---
def router_node(state: ResearchState) -> dict:
question = state["messages"][-1].content
prompt = (
f"Does answering this question require current web information?\n"
f"Question: {question}\n"
f"Reply with only: YES or NO"
)
response = llm.invoke([HumanMessage(content=prompt)])
return {"needs_search": "YES" in response.content.upper()}
# --- 4. Node: run the search ---
def search_node(state: ResearchState) -> dict:
question = state["messages"][-1].content
# Replace this stub with TavilySearch, Serper, or Bing API in production
results = f"[Search stub] Top results for: '{question}' β relevant facts retrieved."
return {"search_results": results}
# --- 5. Node: synthesize the answer ---
def answer_node(state: ResearchState) -> dict:
context_messages = list(state["messages"])
if state.get("search_results"):
context_messages.append(
HumanMessage(content=f"Use this context to answer: {state['search_results']}")
)
context_messages.append(HumanMessage(content="Now answer the original question."))
response = llm.invoke(context_messages)
return {"messages": [AIMessage(content=response.content)]}
# --- 6. Routing function ---
def route_search(state: ResearchState) -> str:
return "search_node" if state.get("needs_search") else "answer_node"
# --- 7. Build and compile the graph ---
builder = StateGraph(ResearchState)
builder.add_node("router_node", router_node)
builder.add_node("search_node", search_node)
builder.add_node("answer_node", answer_node)
builder.set_entry_point("router_node")
builder.add_conditional_edges("router_node", route_search, {
"search_node": "search_node",
"answer_node": "answer_node",
})
builder.add_edge("search_node", "answer_node")
builder.add_edge("answer_node", END)
graph = builder.compile()
# --- 8. Run it ---
result = graph.invoke({
"messages": [HumanMessage(content="What is the current price of Bitcoin?")],
"search_results": "",
"needs_search": False,
})
print(result["messages"][-1].content)
Observable outcome: If true, the search node runs and injects results into state.
Stage 5 β Real-World Applications: Where Stateful Graphs Show Up in Production
- LangGraph is not a toy framework.
- Stateful graphs power several real production patterns: Customer support routing β A support bot classifies incoming messages: simple FAQs route directly to an answer node; complex issues enter a multi-turn diagnostic lβ¦
- State tracks the conversation history, the issue category, and an escalation flag across every turn without external session storage.
- Code generation with self-repair β An agent generates code, runs a linter node, reads the linter output from state, and conditionally loops back to the LLM to fix errors.
Observable outcome: Code generation with self-repair β An agent generates code, runs a linter node, reads the linter output from state, and conditionally loops back to the LLM to fix errors.
Stage 6 β Practical Example: Making the Graph LLM-Agnostic
- One of LangGraph's cleanest design decisions is that nodes are plain Python functions with no opinion about which LLM you use.
- Swapping providers requires changing exactly one line β the llm assignment at the top.
- This scenario was chosen because it is the most common "day 2" question new LangGraph users ask β and showing it is a one-liner is the best answer.
- As you read through the three provider options, watch how every other line (the research node , the StateGraph , the compile() call) stays identical across all three β that invariance is LangGraph's LLM-agnostic promiseβ¦
# Option 1: OpenAI β best default for production
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# Option 2: Anthropic Claude β great for long-context reasoning
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-3-haiku-20240307", temperature=0)
# Option 3: Ollama β fully local, zero API cost, great for development
from langchain_ollama import ChatOllama
llm = ChatOllama(model="llama3.2", temperature=0)
# The graph, nodes, state schema, and edges are IDENTICAL for all three.
def make_router_node(llm):
"""Factory that binds a specific LLM into the router node."""
def router_node(state: ResearchState) -> dict:
question = state["messages"][-1].content
prompt = f"Does answering this require web search?\nQuestion: {question}\nReply YES or NO"
response = llm.invoke([HumanMessage(content=prompt)])
return {"needs_search": "YES" in response.content.upper()}
return router_node
# At graph build time, pick your LLM and inject it:
builder.add_node("router_node", make_router_node(llm))
Observable outcome: As you read through the three provider options, watch how every other line (the research node , the StateGraph , the compile() call) stays identical across all three β that invariance is LangGraph's LLM-agnostic promiseβ¦
End-to-end outcome
After working through the stages, you should be able to explain:
- Building your first stateful agent surfaces several lessons that are not obvious from the documentation: 1.
- Compile once, invoke many times.
Alternatives considered
Decision Guide: StateGraph vs LCEL Chain vs LangChain AgentExecutor
The key signal is decision-making inside the loop .
Failure and degraded modes
Trade-offs and Failure Modes: When LangGraph Helps and When It Adds Complexity
- Detection: LangGraph solves real problems, but adding it to every project introduces overhead that is not always justified.
- User or operator impact: LangGraph solves real problems, but adding it to every project introduces overhead that is not always justified.
