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
Deep Dive: 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.
Deep Dive: 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.
Deep Dive: 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.
Deep Dive: 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.
Deep Dive: 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.
Deep Dive: 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β¦
π System Outcome & Trade-offs
Observed User & Operator Behavior
User: Compile once, invoke many times.
Operator: Building your first stateful agent surfaces several lessons that are not obvious from the documentation: 1.
Key Architectural Trade-offs
- 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.
π Evaluated Alternatives
Decision Guide: StateGraph vs LCEL Chain vs LangChain AgentExecutor
The key signal is decision-making inside the loop .
β οΈ Failure Modes & Degraded Operation
Trade-offs and Failure Modes: When LangGraph Helps and When It Adds Complexity
LangGraph solves real problems, but adding it to every project introduces overhead that is not always justified.
AI Product Engineering
Apply Concepts from this Scenario
Test your understanding of the architecture decisions and trade-offs presented in this case study.
