LangGraph Intro
Lesson 05 · Building Your Own Agents · ~12 min read
What LangGraph is — and what it isn’t
Let’s clear up the most common confusion first: LangGraph is not
LangChain. LangChain is the older, broader library — chains, retrievers,
integrations, prompt templates, the (now-deprecated) agent classes. It bundles a lot
of opinions and a lot of dependencies. LangGraph is its newer, narrower sibling
focused on one thing: explicit graph-shaped orchestration.
You can use LangGraph without ever importing langchain, and this course
does exactly that — we call anthropic directly from inside node
functions and the dependency footprint stays small.
Contrast LangGraph with the other framework SDKs you’ll meet on this course.
The Claude Agent SDK (L03–L04) lives inside the anthropic package
— one install, Claude-only. LangGraph is its own package
(pip install langgraph) and is LLM-provider-agnostic:
nothing in langgraph cares whether your node function calls Claude, GPT,
Llama, or a local rules engine. The OpenAI Agents SDK (Module 4) is
openai-agents alongside the base openai client. CrewAI
(Module 5) is its own package with a role/task vocabulary. LangGraph picks a different
abstraction than any of them: it asks you to draw your agent as a graph and
hands you the runtime that walks it.
The three primitives
LangGraph’s API surface is tiny. The whole of L05 fits into three concepts:
-
StateGraph(State)— the graph builder. The type parameterStateis usually aTypedDict(or a PydanticBaseModel) describing the schema of the data that flows between nodes. The runtime merges per-node returns into this shared state automatically — you don’t pass state around by hand. -
Nodes — plain Python functions of shape
(state: State) -> dict. They receive the current state, do their work (call an LLM, hit an API, mutate local data, run a heuristic), and return a partial dict of updates. The runtime merges that partial into the global state with a default reducer: last-write-wins per key, oroperator.addfor lists if you annotate the field withAnnotated[list[X], operator.add](more on reducers in section 4). -
Edges —
add_edge(src, dst)for unconditional transitions, andadd_conditional_edges(src, decide_fn, mapping)for branching. The decision function reads the current state and returns a string key into the mapping; the graph walks to the mapped node next.STARTandENDare constants fromlanggraph.graphmarking the entry and exit nodes.
That’s the whole surface area of L05. Module 6 (Plan F) will revisit advanced
primitives — sub-graphs, send/receive for parallel
fan-out, persistence-store integrations — when the cross-framework comparison
demands them. For now, three concepts is enough to build any of the agents in this
module.
Why graphs over chains
A chain assumes the path is fixed: A → B → C. The
moment your control flow has any of:
- A branch (after B, sometimes do C, sometimes do D),
- A loop (do B until some condition holds),
- A retry (if B failed, run it again with different arguments),
- A conditional skip (sometimes skip B entirely),
- A human-in-the-loop pause (after B, wait for a person before C),
…you’re now in graph territory. A chain can fake some of these with
conditional logic inside a single step, but the topology stops being visible.
LangGraph makes the topology a first-class declaration: you can render the graph with
app.get_graph().draw_mermaid() and look at it.
This visibility pays back in operability. When something goes wrong in production, “show me the graph of what this agent does” is a question with a one-line answer. With a tool-heavy agent (L04 shape), the same question is closer to “read the system prompt and squint.” The graph is both documentation and runtime — they cannot drift apart.
The cost: more boilerplate up front (declare the state, declare the nodes, wire the edges) compared to the Claude Agent SDK’s “just hand me a list of tools.” That cost is worth it when the control flow has a shape worth drawing. It’s not worth it when it doesn’t — which is exactly when you should reach for L04’s setup instead.
State is a TypedDict
Here’s the canonical state-declaration pattern you’ll see in every LangGraph codebase:
from typing import TypedDict, Annotated
import operator
class S(TypedDict):
question: str
notes: Annotated[list[str], operator.add] # accumulating list
answer: str
Two details matter here:
-
Reducer functions. By default, when a node returns
{"answer": "..."}, the new value replaces the old one in state. For lists you usually want append instead of replace —Annotated[list[X], operator.add]tells the runtime to use+(which on lists is concatenation). Reducers are how LangGraph stays declarative about merging: each field knows its own update strategy. -
Pydantic models work too. If you want runtime validation of every
state mutation, declare
Stateas aBaseModelsubclass. Slightly more overhead, more error-checking, friendlier IDE support. The lab usesTypedDictfor clarity, but production codebases often prefer Pydantic.
Don’t mutate state in place
Always return a fresh dict from your node function. Mutating
state["notes"].append(...) and returning {} will sometimes
work and sometimes silently lose data depending on the checkpointer — the
contract is “return a dict of updates,” not “mutate.” Stay
on the right side of the contract and reducers will Do The Right Thing.
Edges, conditional edges, and loops
The two-flavour API in code:
g.add_edge("plan", "fetch") # unconditional
g.add_conditional_edges( # conditional
"critique",
lambda s: "rewrite" if s["needs_revision"] else "finish",
{"rewrite": "write", "finish": END},
)
The decision function receives the current state and returns a string. The mapping translates that string into the next node. Two patterns this enables:
-
Branches.
critiquedecides betweenrewrite(back towrite) andfinish(off toEND). The branch point is named and inspectable in the mermaid diagram — not buried inside a tool result somewhere. -
Loops. If
rewriteroutes back towrite, which routes back throughcritique, you have a loop. Loops need a bailout — usually a counter in state that the decision function consults — otherwise the graph runs forever (or until LangGraph’s internal recursion limit fires, which defaults to 25).
Caps are still your responsibility
Loops are why max_turns-style caps remain your job in LangGraph. The
runtime won’t stop a graph because it has been running for 30 iterations on
its own. If your loop’s exit condition has a bug, you’ll find out via
the cost bill. Bake a counter into state and check it in the decision function
— the same discipline L03 taught for the Claude Agent SDK applies here too.
Checkpoints with SqliteSaver
This is the headline LangGraph feature and the thing that most justifies the extra boilerplate. The pattern:
from langgraph.checkpoint.sqlite import SqliteSaver
memory = SqliteSaver.from_conn_string("checkpoints.db")
app = g.compile(checkpointer=memory)
config = {"configurable": {"thread_id": "user-42-task-7"}}
app.invoke(initial_state, config=config)
Pass a checkpointer to .compile(), give every invocation a
thread_id, and you get three things for free:
-
Crash resume. After a crash, restart the process. Call
app.invoke({}, config={"configurable": {"thread_id": "user-42-task-7"}})(empty input, same thread). The graph picks up at the last completed checkpoint — the LLM calls and tool calls that already succeeded are not re-run. For a $0.30 pipeline this is the difference between a cheap retry and a full re-bill. -
State inspection.
app.get_state(config)returns the current snapshot.app.get_state_history(config)walks backwards through every checkpoint. You can stare at the exact state the graph was in before any node fired — invaluable for debugging. -
Time-travel / re-run. Find a checkpoint in the history, call
.update_state(...)to tweak any field, then.invoke(...)to re-run from there with the new values. This is how you answer questions like “what would have happened if the planner had returned X instead of Y?” without re-running the whole pipeline.
SqliteSaver uses sqlite (stdlib, no extra dependency — pulls from
sqlite3) and can run in-memory (:memory:) for tests or on
disk for production. Postgres and Redis savers exist
(langgraph-checkpoint-postgres, langgraph-checkpoint-redis)
for scale; the lab and L06’s pipeline agent stick with sqlite.
When to pick LangGraph (and when not to)
A short decision matrix the reader will revisit in Module 6:
Pick LangGraph when:
- Your control flow has a shape (branches, loops, conditional skips) that’s worth declaring explicitly.
- You need durable state — long-running tasks, crash resume, time-travel debugging.
- You need human-in-the-loop pauses (
interrupt_beforeis a one-line config on.compile()). - You want LLM-provider flexibility — swap Claude for GPT inside a node without touching graph wiring.
- You’re already in the LangChain ecosystem and have existing retrievers, loaders, or vector stores to reuse.
Don’t pick LangGraph when:
- The LLM is in charge of control flow (L04’s tool-heavy shape — the Claude Agent SDK is dramatically less ceremony for that case).
- Your agent is one or two simple tool calls — the graph overhead isn’t worth it.
- Your team prefers convention-over-configuration (CrewAI’s role/task metaphor is friendlier for non-engineers).
This is the framing L06 builds on: the planner → fetcher → critique → writer pipeline is the canonical sweet-spot demo — named stages, a conditional branch, a possible loop, and a checkpointer that makes mid-pipeline crashes a non-event. Read on.
References
- LangGraph docs —
langchain-ai.github.io/langgraph/. - LangGraph Checkpointing guide —
langchain-ai.github.io/langgraph/concepts/persistence/. - LangGraph Conditional edges tutorial —
langchain-ai.github.io/langgraph/how-tos/branching/. - L02 —
agents/lesson02-agent-loop/theory.html, the hand-rolled version of the same loop. - L04 —
agents/lesson04-tool-heavy-agent/theory.html, the Claude Agent SDK contrast.