Topic 9 of 15 · Core patterns

LangGraph.

Topic 8's while loop works, but it hides its own structure inside Python control flow — hard to visualize, hard to pause and resume, hard to extend with branches. LangGraph makes that structure explicit: a graph of nodes and edges, with state that flows between them, that you can inspect, branch, persist, and interrupt.

The mental model

State machine, not script.

If you've built a Compose UI with a sealed-class UI state and explicit transitions, this will feel familiar: LangGraph asks you to name your states and transitions up front, instead of letting control flow emerge implicitly from nested if/while logic. The payoff is the same as it is in Compose — the state machine is inspectable, testable in isolation, and resumable, because it's data, not just code that already ran.

State: a typed dict/
model threaded through
Nodes: functions that
read & update state
Edges: fixed or
conditional transitions
Compiled graph:
runnable, resumable
Section 0

Why a Graph Instead of a Loop

NeedTopic 8's while loopLangGraph
Simple tool-calling loopPerfectly fine — don't add a dependency you don't needOverkill for this alone
Branch on outcome (success path vs retry path vs escalate)Nested ifs that get hard to follow past 2-3 branchesA named conditional edge per branch — visible in the graph's shape
Pause mid-run and resume laterYou'd have to hand-roll serialization of loop stateBuilt-in checkpointing (Section 5)
Multiple cooperating sub-agentsGets tangled fastEach sub-agent is its own subgraph or node
Don't reach for LangGraph on day one. Start with Topic 8's loop; move to a graph when branching or persistence genuinely earns the added structure — the same judgment call as reaching for a state machine library over a few well-placed when expressions in Kotlin.
Section 1

State, Nodes, Edges

StateA typed structure (often a TypedDict or Pydantic model — Topic 4 again) that flows through the whole graph. Every node reads it and returns updates to it.
NodeA plain Python function: takes the current state, does something (call an LLM, run a tool, query a database), returns a partial state update.
EdgeA connection between nodes — fixed ("always go from A to B") or conditional ("go to B or C depending on state").
pip install langgraph
Section 2

Building a Simple Graph

from typing import TypedDict
from langgraph.graph import StateGraph, END

class State(TypedDict):
    question: str
    answer: str

def generate_answer(state: State) -> dict:
    response = client.chat.completions.create(
        model="gpt-4.1-mini",
        messages=[{"role": "user", "content": state["question"]}],
    )
    return {"answer": response.choices[0].message.content}

builder = StateGraph(State)
builder.add_node("generate", generate_answer)
builder.set_entry_point("generate")
builder.add_edge("generate", END)

graph = builder.compile()
result = graph.invoke({"question": "What is a vector embedding?", "answer": ""})
print(result["answer"])
Node functions only return the fields they changed — LangGraph merges updates into the running state rather than requiring you to pass the whole object back each time, similar to a Kotlin copy() on a data class but handled by the framework.
Section 3

Conditional Edges

A conditional edge is a function that inspects the current state and returns the name of the next node — this is where branching logic that would be nested ifs in Topic 8's loop becomes a named, visible part of the graph's shape.

class State(TypedDict):
    question: str
    answer: str
    needs_escalation: bool

def route_after_answer(state: State) -> str:
    return "escalate" if state["needs_escalation"] else END

builder = StateGraph(State)
builder.add_node("generate", generate_answer)
builder.add_node("escalate", escalate_to_human)
builder.set_entry_point("generate")
builder.add_conditional_edges("generate", route_after_answer, {
    "escalate": "escalate",
    END: END,
})
Section 4

Cycles for Agent Loops

Topic 8's while loop becomes a cycle: an edge that points back to an earlier node. This is exactly how LangGraph represents the tool-calling loop from Topic 5 and Topic 8 — "call the model, maybe call a tool, go back and call the model again" is a two-node cycle with a conditional exit.

def route_after_model(state: State) -> str:
    last_message = state["messages"][-1]
    return "tools" if last_message.tool_calls else END

builder = StateGraph(AgentState)
builder.add_node("model", call_model)
builder.add_node("tools", execute_tools)
builder.set_entry_point("model")
builder.add_conditional_edges("model", route_after_model, {"tools": "tools", END: END})
builder.add_edge("tools", "model")  # the cycle: tool result goes back to the model
This is precisely Topic 8's while loop, redrawn as a graph — same two logical steps (call model, execute tools), same exit condition. The max_steps guardrail from Topic 8 Section 5 becomes a recursion_limit passed to graph.invoke().
Section 5

Persistence & Checkpointing

LangGraph can save state after every node runs, keyed by a thread ID — the mechanism that makes "pause this agent run and resume it tomorrow" or "keep memory across separate conversations" possible without hand-rolled serialization.

from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()  # swap for a Postgres-backed saver in production (Topic 12)
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "user-42-conversation-7"}}
graph.invoke({"question": "What's pgvector?"}, config=config)
# ... later, possibly a different process entirely ...
graph.invoke({"question": "How does that relate to what I asked before?"}, config=config)
# resumes with full prior state, because it's keyed by the same thread_id
Section 6

Human-in-the-Loop

Combine persistence with an explicit interrupt point: the graph pauses before a specific node — typically the one Topic 5 Section 6 flagged as destructive — and waits for a human decision before continuing.

graph = builder.compile(checkpointer=checkpointer, interrupt_before=["send_email"])

state = graph.invoke({"draft": "..."}, config=config)
# graph paused right before send_email — a human reviews state["draft"] here
# ... after approval ...
graph.invoke(None, config=config)  # resumes from exactly where it paused
This is the code-level version of Topic 5 Section 6's "human confirmation for destructive actions" — the graph structure makes the pause-and-resume mechanics a solved problem instead of something you'd otherwise build by hand with a job queue.
Section 7 · Checkpoint

Capstone: Rebuild Topic 8's Agent as a Graph

Take the two-tool research agent from Topic 8's checkpoint and redraw its loop as a LangGraph graph with an explicit cycle and exit condition.

import json
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph import StateGraph, END
from openai import OpenAI

client = OpenAI()

class AgentState(TypedDict):
    messages: Annotated[list, add]  # accumulates across nodes instead of overwriting

TOOLS = {"web_search": fake_web_search, "search_notes": search_notes}  # from Topic 8

def call_model(state: AgentState) -> dict:
    response = client.chat.completions.create(
        model="gpt-4.1-mini", messages=state["messages"], tools=TOOL_SCHEMAS,
    )
    return {"messages": [response.choices[0].message]}

def execute_tools(state: AgentState) -> dict:
    last_message = state["messages"][-1]
    results = []
    for call in last_message.tool_calls:
        args = json.loads(call.function.arguments)
        result = TOOLS[call.function.name](**args)
        results.append({"role": "tool", "tool_call_id": call.id,
                         "content": json.dumps(result)})
    return {"messages": results}

def route(state: AgentState) -> str:
    return "tools" if state["messages"][-1].tool_calls else END

builder = StateGraph(AgentState)
builder.add_node("model", call_model)
builder.add_node("tools", execute_tools)
builder.set_entry_point("model")
builder.add_conditional_edges("model", route, {"tools": "tools", END: END})
builder.add_edge("tools", "model")
graph = builder.compile()

if __name__ == "__main__":
    result = graph.invoke(
        {"messages": [
            {"role": "system", "content": "Answer using both tools as needed."},
            {"role": "user", "content":
                "How long had pgvector existed before our team started using it?"},
        ]},
        config={"recursion_limit": 12},  # the graph's equivalent of Topic 8's max_steps
    )
    print(result["messages"][-1].content)
Expected behavior: identical output to Topic 8's checkpoint — same tool calls, same final answer — but the control flow is now a compiled, inspectable graph instead of a plain loop.
  • Ran the graph and confirmed it produces the same answer as Topic 8's hand-rolled loop
  • Set recursion_limit very low (e.g. 2) and confirmed the graph raises rather than silently truncating
  • Added a MemorySaver checkpointer and confirmed a second invoke() with the same thread_id has access to the first run's messages
  • Can explain out loud, in one or two sentences, what this graph version buys you over Topic 8's loop for this specific two-tool agent — and be honest if the answer is "not much yet"
  • Next up

    Topic 10: AI Evaluation

    Move past "it seems to work" — build a real test set and measure prompt, RAG, and agent quality systematically.