This LangGraph tutorial for beginners skips the hype and builds a working stateful agent loop from scratch. You will define a graph with explicit nodes and edges, run a generate-critique cycle, and see how LangGraph manages state across iterations. By the end you’ll have a debuggable pattern you can extend into a real agent.
Prerequisites
- Python 3.10 or newer (LangGraph uses modern typing features).
- Comfort with Python functions, dicts, and basic LLM API calls.
- A virtual environment to avoid polluting global packages.
- An API key from any OpenAI-compatible provider. If you don’t have one, use OpenAI directly or a gateway.
You should already know what an AIMessage is from LangChain core. If not, read the LangChain messaging docs first; LangGraph assumes that vocabulary.
Install and configure
Pin versions to avoid surprise breakages. LangGraph moves fast.
pip install langgraph==0.2.0 langchain-openai==0.1.0 langchain-core==0.2.0 python-dotenv
Create a .env file in your project root:
OPENAI_API_KEY=sk-your-key-here
# N4N_API_KEY=your-key-here
Load it and instantiate the model. If you want a single OpenAI-compatible endpoint that fronts 240+ models with automatic fallback when a provider is rate-limited, point ChatOpenAI at n4n.ai and pick any supported model name.
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
load_dotenv()
llm = ChatOpenAI(
model="gpt-4o-mini",
# base_url="https://api.n4n.ai/v1", # uncomment to route via n4n.ai
# api_key=os.getenv("N4N_API_KEY"),
temperature=0,
)
Keep the model call at module level so nodes can reuse it. Don’t instantiate a new client per node; that wastes connections.
Define your graph state
LangGraph persists state between node runs. The state is a TypedDict; fields with Annotated get reducers.
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
iterations: int
Why a reducer matters
The add_messages reducer appends returned messages to the existing list instead of replacing it. Without it, every node must echo the full history back. The iterations field is a plain integer; returning it overwrites the prior value, which is what we want for a counter.
Write the node functions
Nodes are callables that accept the current state and return a partial update. They should be side-effect free aside from the LLM call.
from langchain_core.messages import HumanMessage, AIMessage
def generate(state: AgentState):
response = llm.invoke(state["messages"])
return {"messages": [response], "iterations": state["iterations"] + 1}
def critique(state: AgentState):
crit_instruction = HumanMessage(
content="Review the last AI response. Reply with 'PASS' if it is correct and concise, otherwise 'REVISE: <problem>'."
)
response = llm.invoke(state["messages"] + [crit_instruction])
return {"messages": [response]}
Calling the LLM without inventing abstractions
Notice generate returns a list containing one AIMessage. The reducer extends the timeline. critique does not modify iterations; omitting a field leaves it unchanged. This explicitness is the point of LangGraph.
Debugging with a print node
Drop a tiny node to log state without altering flow.
def debug(state: AgentState):
print("ITER", state["iterations"], "MSGS", len(state["messages"]))
return {}
workflow.add_node("debug", debug)
workflow.add_edge("generate", "debug")
workflow.add_edge("debug", "critique")
In production replace print with structured logging, but the pattern of inserting an observability node is idiomatic.
Connect nodes with edges
A StateGraph declares topology. START and END are sentinels.
from langgraph.graph import StateGraph, START, END
workflow = StateGraph(AgentState)
workflow.add_node("generate", generate)
workflow.add_node("critique", critique)
workflow.add_node("debug", debug)
workflow.add_edge(START, "generate")
workflow.add_edge("generate", "debug")
workflow.add_edge("debug", "critique")
Conditional edges are where LangGraph earns its keep
After critique, branch. Stop if the critique passed or we hit a max loop count.
def should_continue(state: AgentState) -> str:
last = state["messages"][-1]
if isinstance(last, AIMessage) and last.content.strip().upper().startswith("PASS"):
return END
if state["iterations"] >= 3:
return END
return "generate"
workflow.add_conditional_edges(
"critique",
should_continue,
{"generate": "generate", END: END}
)
app = workflow.compile()
The mapping dict must cover every string should_continue can return. Missing keys raise at compile time, not runtime—a good guardrail.
Compile and run
Invoke with an initial human message and a starting counter.
result = app.invoke({
"messages": [HumanMessage(content="Explain what a mutex is in one sentence.")],
"iterations": 0,
})
for msg in result["messages"]:
print(f"{type(msg).__name__}: {msg.content[:80]}")
Expected output from the first run
A solid model usually passes on the first try:
HumanMessage: Explain what a mutex is in one sentence.
AIMessage: A mutex is a locking mechanism that ensures only one thread accesses a resource at a time.
AIMessage: PASS
If the answer is vague, critique returns REVISE: ... and the graph loops. The third message (the critique) stays in the list, so generate sees it and can self-correct. After three failed attempts, the iterations guard forces END.
Run asynchronously
In a server you shouldn’t block the event loop. LangGraph mirrors the invoke/ainvoke split from LangChain.
import asyncio
async def main():
res = await app.ainvoke({
"messages": [HumanMessage(content="What is RAII in C++?")],
"iterations": 0,
})
print("final iterations:", res["iterations"])
asyncio.run(main())
Use ainvoke inside FastAPI or any async worker. The graph logic is identical; only the entrypoint changes.
Inspect the graph
LangGraph compiles to a runnable object with introspection.
print(app.get_graph().draw_mermaid())
You get a Mermaid snippet: START --> generate --> debug --> critique --> (generate|END). Paste it into any Mermaid viewer to confirm control flow. Do this before adding complexity.
Common pitfalls
- Forgetting the reducer: returning
{"messages": response}(not a list) throws or overwrites. Always wrap in a list. - Synchronous blocking:
invokeblocks. In a web app useawait app.ainvoke(...). - Case sensitivity: models emit
pass,Pass,PASS. Normalize with.upper(). - Uncovered branches: a conditional edge missing a mapping key fails at compile, but a function returning an unexpected string crashes at runtime. Add a default.
- State drift: if you later add a Pydantic state, remember reducers still need
Annotated. TypedDict is simpler for prototypes.
When not to use LangGraph
If your task is a single LLM call with no branching, LangGraph is overhead. Use llm.invoke directly. The graph pays off when you have loops, human-in-the-loop checkpoints, or multiple specialized nodes. Don’t reach for it to feel fashionable.
Extend the pattern
Replace critique with a tool-calling node: if the LLM requests a tool, execute it and append the result, then loop to generate. Or add a route node that sends coding questions to a code_gen node and factual ones to answer. LangGraph’s value is that every transition is visible in your code, not buried in a framework’s agent loop.
This LangGraph tutorial for beginners gave you the skeleton: state, nodes, conditional edges, and a compile step. Build from here with real tools and you have an agent you can actually debug in production.