LangGraph is a low-level orchestration framework that models agentic AI applications as a stateful graph of nodes and edges, enabling cycles…
You trace the exact moment a plain agent loop breaks — it can't loop back, branch, or pause — and see why LangGraph was built to fix that. The running example is a research assistant that must search, evaluate, and retry.
Explains why LangGraph exists by showing exactly where a plain agent loop breaks — no cycles, no shared state.
Why this matters: Before writing a single line of LangGraph code, you need to know the problem it solves so you don't over-engineer simple tasks or under-engineer complex ones.
Decision this forces: Do I need LangGraph, or will a simple chain or single agent loop do?
Your research assistant searches and gets a bad result. It needs to retry. A plain can't loop back — it stops. fixes this.
A plain agent loop runs steps one way: call model, run tool, read result, repeat. Two hard limits exist.
LangGraph represents your agent as a . Nodes do work. Edges decide next steps. A shared object carries everything forward.
A runs fixed steps in a straight line — step 1, step 2, done. You can't branch or go back.
A lets you branch ("if the result is bad, retry") and loop ("keep searching until confident"). That's flexible routing.
Imagine a research assistant agent. It must: search the web, evaluate results, retry if thin.
With a plain loop, step 3 fails. The loop moved on — it can't return to step 1.
With LangGraph, add a : "if quality < threshold, search again." The shared remembers every search. No repetition.
def research_agent(query): result = search(query) # Step 1: search answer = evaluate(result) # Step 2: evaluate if answer["quality"] < 0.5: result = search(query) # Step 3: retry — but we lost context! answer = evaluate(result) # evaluate again with no memory return answer
This is the obvious first attempt — a plain Python function that searches, evaluates, and retries.
It looks like it works, but it has two silent problems: it forgets prior searches, and it can't loop more than once without copy-pasting the retry block.
No. The code only retries once — there's no loop. A third bad result is silently returned as the final answer. You'd need to copy the retry block again by hand, and the agent still has no memory of what it already searched.
# Shared state — every node reads and writes this state = {"query": "", "result": None, "quality": 0, "attempts": 0} def search_node(state): state["result"] = search(state["query"]) state["attempts"] += 1 return state def evaluate_node(state): state["quality"] = score(state["result"]) return state def route(state): # conditional edge if state["quality"] < 0.5 and state["attempts"] < 3: return "search_node" # loop back return "END"
Each step is now a — a plain Python function that reads and updates shared . The (route) decides whether to loop back or stop.
The agent can now retry up to 3 times and remembers every attempt — no copy-paste, no lost context.
It returns "END" immediately — quality 0.8 is above the 0.5 threshold, so no retry happens. The graph exits after one search-evaluate pass.
Three ways the naive approach fails:
You know the two failures of plain loops: no cycles, no shared state. solves both.
Next: learn what a and are in code. See how locks the graph into a runnable object.
You build the mental model of a LangGraph application: nodes are Python functions that do work, edges are routing rules that say where to go next, and compiling the graph locks in the structure. The research-assistant example grows its first two nodes: 'search' and 'evaluate'.
Nodes are Python functions that do work; edges are routing rules; compile() locks the graph so it can run.
Why this matters: These three concepts are the skeleton of every LangGraph app — you can't build or read one without them.
Decision this forces: What should each node own — one tool call, one LLM call, or a whole sub-task?
A plain runs in a straight line. It cannot loop back, branch, or pause.
fixes that by modelling your app as a graph. A graph is a set of steps and routing rules you design.
This module gives you three building blocks: nodes, edges, and compile.
A is a plain Python function that does one piece of work.
An is a routing rule that says which node runs next.
You register both inside a , then call to lock the structure before running it.
Your research assistant needs to search the web, then judge whether the result is good enough.
That maps to exactly two nodes: search and evaluate.
search — calls a search tool and stores the raw results.evaluate — reads those results and decides: good enough, or search again?One edge connects START → search; a second connects search → evaluate. Each node owns exactly one job — nothing more.
# state is a plain dict for now — next module makes it typed def search(state): query = state["query"] results = run_search_tool(query) # one tool call return {"results": results} def evaluate(state): results = state["results"] verdict = call_llm(f"Good enough? {results}") # one LLM call return {"verdict": verdict}
Each node is a plain function: it reads from and returns only the keys it changed.
search owns one tool call; evaluate owns one LLM call — each node does exactly one job.
The LLM would try to judge results that don't exist yet (state["results"] is empty). You'd get a KeyError or an LLM hallucinating an answer. Node order matters because each node depends on what the previous one wrote to state.
from langgraph.graph import StateGraph, START, END graph = StateGraph(dict) # typed schema comes next module graph.add_node("search", search) graph.add_node("evaluate", evaluate) graph.add_edge(START, "search") graph.add_edge("search", "evaluate") graph.add_edge("evaluate", END) app = graph.compile() # locks structure; returns runnable result = app.invoke({"query": "LangGraph tutorial"})
add_node registers the function; add_edge sets the route; validates everything and returns the runnable app.
Execution order: START → search → evaluate → END.
You get an AttributeError — the raw StateGraph object has no invoke() method. compile() is what creates the runnable app. Without it, there is nothing to call.
from langgraph.graph import StateGraph, START, END def fetch(state): return {"data": download(state["url"])} def summarise(state): return {"summary": call_llm(state["data"])} graph = StateGraph(dict) graph.add_node("fetch", fetch) graph.add_node("summarise", summarise) # TODO: add the three edges and compile the graph
Stop — attempt the TODO before revealing. Hints: you need edges from START → fetch, fetch → summarise, and summarise → END, then one compile call.
Changed lines:
graph.add_edge(START, "fetch") # new
graph.add_edge("fetch", "summarise") # new
graph.add_edge("summarise", END) # new
app = graph.compile() # new
app.invoke({"url": "https://example.com"}) returns {"url": "...", "data": "...", "summary": "..."}.
The crux: every node needs an outgoing edge, and compile() must come before invoke() — exactly the two failure modes from the previous block.
graph.compile() first.compile() is called before invoke(). Each node should return only its own keys, not the whole state dict.You define a TypedDict state schema, watch it flow through the research-assistant graph, and see how each node reads the current state and writes only its own field. This module also revisits the 'why' from module 1: state is what lets LangGraph resume after a pause or failure.
Defines what LangGraph state is, how to write a TypedDict schema, and how nodes read and write only their own fields.
Why this matters: State is the backbone of every LangGraph graph — without it, nodes can't share data and the graph can't resume after a pause or failure.
Decision this forces: Which fields belong in shared state vs. inside a single node's local variables?
Answer: (Python functions that do work) and (routing rules that say where to go next).
But what do those nodes actually pass between each other? That's the gap this module closes.
is a single Python dictionary that every can read and update.
You define its shape with a — a plain Python dict with named, typed fields.
Each node returns only the fields it changed. LangGraph merges that partial update into shared state.
This enables . LangGraph saves a after every node. The graph can resume exactly where it paused or failed.
Picture the research-assistant graph from module 2: a search node and an evaluate node.
The shared has three fields: query, results, and score.
{query: 'LangGraph', results: None, score: None}query, fetches results, returns {results: [...]}. State is now {query: 'LangGraph', results: [...], score: None}results, scores them, returns {score: 0.85}. State is now {query: 'LangGraph', results: [...], score: 0.85}Notice: each node touched only its own field. Neither node rewrote the whole dict.
from typing import TypedDict, Optional, List class ResearchState(TypedDict): query: str results: Optional[List[str]] score: Optional[float] # Initial state — passed in when the graph is invoked initial = {"query": "LangGraph", "results": None, "score": None}
ResearchState is the shared notebook for the whole graph. Every field is declared here so every node knows what to expect.
Optional means the field starts as None and gets filled in as nodes run.
{"query": "LangGraph", "results": ["doc1", "doc2"], "score": None} — LangGraph merges the partial return into the existing state, leaving query and score untouched.
def search_node(state: ResearchState) -> dict: query = state["query"] # read one field results = run_search(query) # do the work return {"results": results} # write only this field def evaluate_node(state: ResearchState) -> dict: score = score_results(state["results"]) return {"score": score} # write only this field
Each node receives the full dict but returns only the fields it changed.
Returning a new partial dict (not mutating the input) is what keeps updates safe and traceable.
It works — but it's risky. If search_node accidentally overwrites score with a stale value, evaluate_node's output is silently lost. Returning only changed fields prevents one node from clobbering another's work.
from typing import TypedDict, Optional, List class ResearchState(TypedDict): query: str results: Optional[List[str]] score: Optional[float] # TODO: add a field to store the final answer (a string, starts as None) def answer_node(state: ResearchState) -> dict: answer = summarise(state["results"]) # TODO: return only the field this node writes pass
Stop — attempt this before revealing. Hint 1: the new field is a string that starts as None. Hint 2: answer_node should return a dict with exactly one key.
# CHANGED LINE 1 — new field in the schema:
answer: Optional[str] # ← added; starts as None
# CHANGED LINE 2 — node returns only its field:
return {"answer": answer} # ← not the whole state dict
# Why: returning only {"answer": answer} lets LangGraph merge it safely.
# Returning the full state risks overwriting results or score.
Three mistakes that break state — and what you'll see:
{"sources": [...]} but sources isn't in ResearchState. LangGraph silently drops the key. The next node reads None with no error.state["score"] = 0.9 inside a node instead of returning a new dict. Works locally, but breaks checkpointing. The saved snapshot won't reflect the change.You add a conditional edge to the research-assistant graph so it retries the search when the evaluation score is low, and you add a human-in-the-loop interrupt before the final answer is sent. This module also covers the main failure modes: infinite loops, missing checkpoints, and over-broad tool permissions.
You add a conditional retry loop and a human-approval interrupt to the research-assistant graph, then learn the three most common LangGraph mistakes.
Why this matters: These two patterns — looping until quality passes and pausing for human review — are the core control moves you need for any real agentic workflow.
Decision this forces: When should a loop have a hard retry limit, and where does that limit live — in the edge condition or in state?
and writes only its own field.
saves, enabling pause and resume.
that pauses for human approval.
is a routing rule that picks the next node based on a function result.
— the graph retries until the condition passes.
, surfaces a question to a human, and waits for resume.
Your research assistant searches the web, then an evaluator scores the result 0–10.
A score below 7 means the answer isn't good enough yet.
, the graph always moves forward — it sends a weak answer to the user.
— every node can read it.
The edge function checks it before deciding where to go.
# State now tracks retry count class ResearchState(TypedDict): query: str result: str score: int retry_count: int def route_after_eval(state: ResearchState) -> str: if state["score"] >= 7 or state["retry_count"] >= 3: return "human_review" # good enough, or limit hit return "search" # loop back for another try
route_after_eval and returns a node name as a plain string.
>= 3) lives inside the edge function — not in the node — so the graph structure controls flow, not the node's own logic.
It returns "search" — score is below 7 AND retry_count is below 3, so the graph loops back to the search node for another attempt.
from langgraph.types import interrupt def human_review(state: ResearchState): decision = interrupt({"result": state["result"], "score": state["score"]}) return {"result": decision["approved_text"]} graph = StateGraph(ResearchState) graph.add_node("search", search_node) graph.add_node("evaluate", evaluate_node) graph.add_node("human_review", human_review) graph.add_edge(START, "search") graph.add_edge("search", "evaluate") graph.add_conditional_edges("evaluate", route_after_eval) graph.add_edge("human_review", END) app = graph.compile(checkpointer=memory)
human_review pauses the graph and surfaces the result + score to whoever is calling the graph.
checkpointer=memory) holds all state across that pause.
The graph crashes with an error — interrupts require a checkpointer to save state across the pause. Without it, there is nowhere to store the graph's position while it waits.
# New requirement: also increment retry_count inside the search node. def search_node(state: ResearchState): result = run_search(state["query"]) return { "result": result, "retry_count": # TODO: what goes here? } def route_after_eval(state: ResearchState) -> str: if state["score"] >= 7 or state["retry_count"] >= 3: return "human_review" return "search"
retry_count >= 3, but the counter never grows — so the loop runs forever.
Your job: fill in the TODO so each retry increments the counter by 1.
state["retry_count"] + 1
Changed line: "retry_count": state["retry_count"] + 1
Why: the node returns only the fields it changes; adding 1 to the current value gives the graph an accurate count so the edge can enforce the limit.
retry_count never increments. The graph runs with no output and climbing API costs. Fix: increment in the node AND check the limit in the edge.ValueError: No checkpointer set. Add checkpointer=memory().compile() have checkpointer= when an interrupt is used?Before you read the summary: close your eyes and reconstruct the four-layer chain — what problem does LangGraph solve, what two things make up the graph, what object carries information between nodes, and what three control patterns does that object unlock? Sketch it, then check.
Apply what you learned to SEO/GEO page — the page title is the H1 and the exact search query. OPEN with a 40-60 word self-contained, quotable answer capsule that names the entity explicitly in sentence one (no "in this lesson…" preamble) — this is the sentence an LLM lifts. Name the entity by its full name every time (not pronoun-only). Structure each section as ONE sub-question (How it works · X vs Y · When to use it · Example · Common mistakes), each opening with its own 1-2 sentence direct answer then depth. End with a genuine 3-5 question FAQ phrased as real user questions ending in "?" (these become FAQPage schema + the knowledge check). Prefer quotable specifics — concrete numbers, defaults, version names, one short snippet — over vague prose. Self-contained for a reader who arrived cold from a search engine or an LLM. Intent = definition: define the named entity + how it works + a concrete example. Target query: "what is langgraph"..
A plain LangChain agent loop has two structural failure modes that LangGraph was built to fix. Which pair names them correctly?
LangGraph exists specifically because a plain agent loop is a fixed linear chain — it cannot route back to an earlier step (no cycles) and has no shared state object that every step can read and write. The other options describe limitations of different tools or layers, not the structural gaps LangGraph addresses.
Read this short snippet:
graph = StateGraph(MyState)
graph.add_node("check", check_fn)
graph.set_entry_point("check")
app = graph.compile()
What does the call to graph.compile() do?
graph.compile() checks that the graph is well-formed (entry point set, no dangling edges) and returns a compiled runnable — the object you actually call with app.invoke(). It does not execute the graph, persist it to disk, or freeze the schema; those are common misconceptions about what compilation means in LangGraph.
Write a minimal TypedDict state schema for a two-node graph where the first node stores a user query and the second node stores the LLM's answer. Show only the class definition — no graph wiring needed.
A LangGraph state schema is a plain Python TypedDict. Each field represents a piece of information the graph needs to share across nodes. Only fields that must travel between nodes belong here; anything used only inside one node stays as a local variable in that function.
When would you choose a conditional edge in LangGraph over simply putting an if/else block inside a node?
A conditional edge belongs in the graph layer because it controls flow — which node executes next. Putting routing logic inside a node mixes concerns: nodes should do work (an LLM call, a tool call, a sub-task), not decide the graph's next step. Parallel execution uses fan-out edges, not conditional edges, and the number of state fields or compile calls are unrelated to this decision.
A LangGraph loop retries an LLM call until the output passes a quality check. Where should the hard retry limit (e.g., max 3 attempts) live, and why?
The retry count must survive across loop iterations, so it belongs in shared state — the only memory that persists between node executions. The conditional edge then reads that counter to route to END or back to the retry node. Hiding the limit inside the node breaks the separation of work (node) vs. routing (edge), and graph.compile() has no such parameter in LangGraph.