AI EngineeringZero to ProductionHome·About·What’s new·Contact
LangChain & LangGraph · Chapter L5

LangGraph — Cycles, Human-in-the-Loop & Persistence

The three features that make a graph a production agent: cycles (loop until done, safely), persistence (durable state you can pause, resume, and inspect), and human-in-the-loop (stop for approval before a risky action, then continue). This is where the safety gate you've been promised since Chapter 4 finally becomes real.

⏱️ ~65 min🧪 3 labs🎯 Advanced

Learning objectives

  • Build cycles safely and bound them with a recursion limit and state counters.
  • Add a checkpointer so graph state is durable and threads are resumable.
  • Pause a graph with an interrupt, get human approval, and resume.
  • Inspect and edit graph state between steps (time-travel debugging).
  • Assemble it all into a real approval-gated agent — the capstone's core.
Everything here rests on L4You need State, Nodes, Edges, and conditional routing from L4. This chapter adds the three capabilities that a plain invoke can't give you — and they're exactly what separate a demo agent from one you'd let touch production.

Cycles, bounded advanced

Picture the 3 a.m. pager alert that started this feature's whole existence. Someone shipped an agent that "retries until it succeeds." It hit a task it could never finish, so it tried again. And again. By morning it had made forty thousand model calls, run up a four-figure bill, and never once stopped to ask whether it was actually making progress. Nobody wrote a bug — they just wrote a loop with no way out. That failure mode is so common it has a name in every agent framework, and it is the reason this section comes before we let you build a single cycle.

So let's be honest about what a cycle really is. Everything up to L4 was a pipeline — a one-way street where work flows start-to-finish and the graph is guaranteed to end because it never revisits a node. A cycle breaks that guarantee on purpose: it is an edge that points backward, letting the agent return to an earlier node and try again. That backward arrow is exactly what turns a rigid workflow into something that can reason — re-run a tool with better arguments, re-plan after a failure, reflect and rewrite. It is the single most powerful thing LangGraph adds over a plain chain. It is also the single most dangerous.

The analogy that makes the danger obvious: a cycle is a while loop, and you already know the first rule of while loops — something inside the loop must eventually make the condition false, or it spins forever. In ordinary code your loop counter sits right in front of you. In an agent the "condition" is a fuzzy judgement made by a language model ("am I done yet?"), and models are cheerfully, confidently wrong. They will declare a broken task nearly-finished on pass one and still-nearly-finished on pass fifty. You cannot trust the agent to know when to stop, so you have to guarantee it does.

That is why LangGraph gives you two independent brakes, and "independent" is the whole point — the common beginner mistake is leaning on only one. The first brake is your own state counter: you track something like attempts in the graph's state and, in the routing function, return END the moment it crosses a cap. This is the brake you want to fire, because it is meaningful ("we tried five times, give up gracefully"). The second brake is LangGraph's built-in recursion limit, a raw step cap you pass to invoke that raises an error after N node executions no matter what your state says. This is the brake for the day your own counter has a bug — a belt-and-suspenders backstop, not your primary plan. You already made a cycle in L4 (tools → model). The power of a loop is also its danger: a confused agent can loop forever, burning tokens. LangGraph bounds cycles two ways — a global recursion limit and your own state counters.

work node checkdone? / n<max? loop while not done AND n < max END A cycle needs a guaranteed exit. Track an attempt counter in state and check it in the routing function; LangGraph also enforces a recursion_limit as a backstop. Two independent brakes so a loop can never run away.
🗺️ How to read this diagram

A cycle is just an arrow in the graph that loops back to an earlier node so the agent can try again. This picture shows how to make that loop safe — how to guarantee it can't run forever.

  • Follow the solid arrow left-to-right: the work node does something, then hands off to a check node that asks two questions — done? and n < max? (have we tried too many times?).
  • The dashed arrow curving back to work node is the cycle: it fires only "while not done AND n < max". That condition is the loop's brake.
  • If either brake trips — the task is done, or the attempt counter hit its cap — the solid arrow goes to END instead and the graph stops.
  • The caption's key phrase is two independent brakes: your own counter in state, plus LangGraph's built-in recursion_limit as a last-resort backstop.

In short: A loop needs a guaranteed way out. Here that way out is the check node routing to END the moment the task finishes or the tries run out.

Setup to run this snippet
END = "__end__"          # sentinel used by graph examples
class _app_t:
    invoke = 'demo'
    def invoke(self, *a, **k): return 'demo'
    def __getattr__(self, k): return 'demo'
app = _app_t()
class _Any:
    '''stands in for any undefined demo value; supports call/attr/index/
    iteration and basic arithmetic (as 0.7) so demo snippets run.'''
    def __call__(self, *a, **k): return _Any()
    def __getattr__(self, k): return _Any()
    def __getitem__(self, k): return _Any()
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __contains__(self, o): return True
    def __enter__(self, *a): return _Any()
    def __exit__(self, *a): return False
    def __float__(self): return 0.7
    def __int__(self): return 1
    def __lt__(self, o): return True
    def __gt__(self, o): return False
    def __le__(self, o): return True
    def __ge__(self, o): return False
    def __add__(self, o): return o
    def __radd__(self, o): return o
    def __bool__(self): return True
    def __repr__(self): return 'demo'
    def __str__(self): return 'demo'
inputs = _Any()
bounded_cycle.pydef route(state) -> str:
    if state["done"]:              return END
    if state["attempts"] >= 5:  return END   # hard stop in your own logic
    return "work"

# backstop: the framework raises if the graph runs too long
app.invoke(inputs, config={"recursion_limit": 25})
▶ How this works

This is the routing function that decides, after each pass, whether the graph loops back to do more work or exits. It's the software brake the diagram above described. LangGraph calls it and uses the string it returns to pick the next node.

  1. route(state) receives the current state (a dictionary the graph carries between steps) and returns a string naming where to go next.
  2. if state["done"]: return END — if the work is finished, return the special END value and the graph stops. This is the normal, happy exit.
  3. if state["attempts"] >= 5: return END — even if not done, stop after 5 tries. This is your own counter guarding against an agent that never converges.
  4. return "work" — otherwise, loop back to the work node and try again. That returned name is what creates the cycle.
  5. The last line, app.invoke(inputs, config={"recursion_limit": 25}), is the backstop: even if your counter had a bug, LangGraph raises an error after 25 steps so the loop can't burn tokens forever.

What the output means: Nothing prints here — this is structure. In a real run, the graph would bounce between work and route until done is true or attempts reaches 5, then head to END.

Try this: Lower recursion_limit to 2 and give the agent a task that needs several passes — you'll see LangGraph raise a recursion error, proving the backstop works even when your own counter would have let it keep going.

Lab L5.1 · Persistence with a checkpointer advanced

Here is a demo that quietly lies to you. You build a chat graph, tell it your name, ask "what's my name?" on the next line, and it answers correctly. It looks like the agent has memory. It doesn't — it only appeared to because both turns rode inside the same invoke call, sharing one in-flight state object. Close that call, come back tomorrow, ask again, and the agent has never heard of you. By default a graph has the memory of a goldfish: its state is born when invoke starts and dies the instant it returns.

The mental model that fixes this: think of a plain graph run like a browser tab in incognito mode. Everything works beautifully during the session, and the moment you close the tab it is all gone — no history, no cookies, nothing to come back to. A checkpointer is what takes it out of incognito. Attach one and LangGraph writes the entire graph state to durable storage after every single step, so the run leaves a trail you can return to, resume, and read.

The second half of the idea is the thread id, and it is worth slowing down on because it is where most persistence bugs live. A checkpointer does not store "the state" — it stores state per thread, where a thread id is just a label you choose (a user id, a ticket number, a session token). Picture a filing cabinet: the checkpointer is the cabinet, and each thread_id is a labelled folder inside it. Two calls with the same id read and write the same folder, so they share history; two calls with different ids never see each other. This is exactly how one deployed graph serves a thousand users without their conversations bleeding together — and it is why the classic production incident is one hard-coded thread_id merging every user into a single, horrifying shared memory. One folder per conversation, always.

Why this section comes before human-in-the-loop is not an accident. An interrupt has to pause a run and hold it — possibly for days — until a human responds. You cannot pause something that has nowhere to be stored. Persistence is the foundation the pause-and-resume gate is built on, so we lay it first. Attach a checkpointer and LangGraph saves the state after every step, keyed by a thread id. Now the same conversation can span many calls, survive a restart, and be inspected step by step.

one checkpointer · one isolated folder per thread_id checkpointer (durable store) thread_id = "user-1" thread_id = "user-2" step 1 step 2 step 3 step 1 step 2 "My name is Alice." → remembered never heard of Alice same id → shared history · different id → isolated State is saved per thread, not globally. The checkpointer writes a snapshot after every step into the folder named by thread_id; two ids never see each other's history — which is how one graph serves many users without their conversations bleeding together.
🗺️ How to read this diagram

Read top-to-bottom: one checkpointer at the top fans out into separate labelled folders, and each folder fills up with per-step snapshots below it.

  • The top box is the checkpointer — the durable store you attach with compile(checkpointer=...).
  • The two blue boxes are threads, each named by a thread_id ("user-1", "user-2"). A thread id is just a label you choose — a user, a ticket, a session.
  • The green cells under each thread are the state snapshots saved after every step. The left thread has three, the right has two — they grow independently.
  • The payoff is the bottom line: calls sharing an id read/write the same folder (so "user-1" remembers Alice), while a different id starts empty ("user-2" never heard of her).

In short: A checkpointer is a filing cabinet keyed by thread_id — one folder per conversation, saved step by step, and never mixed together.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab L5.1

Requires: pip install langgraph

persist.pyfrom langgraph.checkpoint.memory import MemorySaver
# production: from langgraph.checkpoint.sqlite import SqliteSaver, etc.

app = g.compile(checkpointer=MemorySaver())

cfg = {"configurable": {"thread_id": "user-1"}}   # names the conversation
app.invoke({"messages": [("user", "My name is Alice.")]}, cfg)
app.invoke({"messages": [("user", "What's my name?")]}, cfg)   # remembers — same thread

snapshot = app.get_state(cfg)          # the full state right now
print(snapshot.values["messages"])
▶ How this works

By default a graph forgets everything the moment invoke returns. A checkpointer changes that: LangGraph saves the whole state after every step, so the same conversation can span many calls and even survive a restart. This is real, durable memory.

  1. from langgraph.checkpoint.memory import MemorySaver imports the simplest checkpointer — it keeps state in memory. The comment notes that production uses a durable store like SQLite or Postgres instead.
  2. app = g.compile(checkpointer=MemorySaver()) attaches the checkpointer when you build the graph. That one argument is what turns saving on.
  3. cfg = {"configurable": {"thread_id": "user-1"}} names the conversation. A thread id is like a folder label — every call using the same id shares one saved history, so different users get different threads.
  4. The two app.invoke(...) calls happen in separate requests, yet the second ("What's my name?") can answer "Alice" because both used thread_id "user-1" — the checkpointer carried the first turn forward.
  5. snapshot = app.get_state(cfg) reads back the full saved state for that thread; snapshot.values["messages"] is the stored conversation so far.

What the output means: The final print shows the message list for thread user-1 — including Alice's name — proving the state persisted between the two independent invoke calls.

Try this: Run a third invoke with a different thread_id (say "user-2") and ask "What's my name?". It won't know — a fresh thread starts with an empty history. That's how one graph safely serves many users.

Checkpointing gives you…Why it matters
Durable memoryConversation state survives across calls and restarts — real multi-session memory (vs L2's in-memory history)
ThreadsEach thread_id is an isolated conversation — one graph serves many users
ResumabilityA crashed or paused run continues from the last checkpoint, not from scratch
Inspectionget_state / get_state_history expose exactly what the agent believes at each step
Checkpointing is the prerequisite for HITLYou can't pause for human approval and resume later without somewhere to store the paused state. That store is the checkpointer — which is why persistence comes before the interrupt lab.

Lab L5.2 · Human-in-the-loop with interrupts advanced

Would you give a brand-new intern the production database password on their first day and say "delete whatever looks stale"? Of course not — you'd have them draft the delete, show it to you, and run it only after you nodded. That is the entire idea of human-in-the-loop, and it is the difference between an agent you demo and an agent you actually let touch anything that matters. An LLM is a talented, tireless, slightly overconfident intern; the interrupt is the moment it turns around and asks "are you sure?" before it does something it cannot undo.

Notice why the naive alternatives fail, because you will be tempted by them. You could have the tool itself pop up an input() prompt and block — but then your whole server thread is frozen holding a half-finished run in RAM, and if the process restarts the run is gone. You could have the agent "just be careful" in its system prompt — but you are back to trusting a confidently-wrong model with an irreversible action. Neither is a real gate. A real gate has to stop the machine, put the work down somewhere safe, and walk away until a human comes back — maybe in thirty seconds, maybe on Monday.

That is precisely what an interrupt does, and this is the chapter's payoff. When a node calls interrupt(...), three things happen in order, and the order matters: the graph halts right there; its full state is checkpointed (this is why the previous lab had to come first — the pause needs somewhere to live); and control hands back to your application, along with a little package describing the pending action. The risky step has not run. Nothing has happened. Your app can now render "the agent wants to delete records-2019, approve?" in a UI, an email, a Slack message — whatever fits — and the run will sit patiently, costing nothing, until you feed a decision back in.

The mistake to inoculate against right now: "resume" does not mean "start over." When you approve, LangGraph reloads the checkpointed state and continues from the exact point it paused — the model doesn't re-reason, the earlier tool results aren't recomputed, and the human's answer flows in as the return value of that interrupt(...) call. Approve and the irreversible step finally runs; reject and it never does. You show the pending action to a human; on approval you resume and the graph continues exactly where it stopped.

model interruptbefore risky tool human decides resume tool runs END Pause → approve → resume. The graph stops before the risky node, its state safely checkpointed. Your app surfaces the pending action; a human approves or rejects; you resume. The agent never took the action without sign-off — this is the real human-in-the-loop gate.
🗺️ How to read this diagram

This is the human-in-the-loop gate: how the graph pauses before doing something irreversible, waits for a person to approve, and then continues. Read it as a left-to-right flow with one detour downward.

  • The model node runs first and decides it wants to use a risky tool. The solid arrow carries the flow into the interrupt node ("before risky tool").
  • At interrupt the graph stops. The dashed arrow drops down to human decides — control has left the graph and gone back to your application, which shows the pending action to a person.
  • Nothing has happened yet — the risky action has not run. The graph's state is safely saved (checkpointed) while it waits, possibly for minutes or days.
  • On approval, the resume arrow re-enters the graph at tool runs — it continues exactly where it paused, runs the tool, and flows to END.
  • Notice the ordering in the caption: pause → approve → resume. The action can only happen after a human signs off.

In short: An interrupt is a checkpointed pause: the agent proposes, a human disposes, and only then does the irreversible step run. No approval, no action.

Lab L5.2

Requires: pip install langgraph

hitl.pyfrom langgraph.types import interrupt, Command

def risky_action(state):
    # pause and surface the proposed action to a human
    decision = interrupt({"action": "delete", "target": state["target"]})
    if decision != "approve":
        return {"result": "cancelled by human"}
    return {"result": _do_delete(state["target"])}

# --- running it ---
cfg = {"configurable": {"thread_id": "job-42"}}
app.invoke(inputs, cfg)                 # runs until the interrupt, then STOPS

state = app.get_state(cfg)              # inspect what it wants to do
print(state.next, state.values)         # show the human the pending action

# human approves → resume with their decision
app.invoke(Command(resume="approve"), cfg)
▶ How this works

This is the interrupt in code — the real safety gate the whole course has been building toward. A node calls interrupt(...), the graph halts, your app asks a human, and you resume with their answer. The risky work only runs on approval.

  1. from langgraph.types import interrupt, Command pulls in the two pieces: interrupt to pause, and Command to feed a decision back in when you resume.
  2. Inside risky_action(state), the call decision = interrupt({...}) does two things: it pauses the whole graph, and it surfaces a small package (the action and its target) so your app can show a human what's about to happen.
  3. When you later resume, that human's answer becomes the value of decision. if decision != "approve": return {"result": "cancelled by human"} means a rejection stops safely and the delete never runs.
  4. Only on approval does _do_delete(state["target"]) actually execute — the irreversible step.
  5. Below the function is the driver: the first app.invoke(inputs, cfg) runs until the interrupt and then STOPS. app.get_state(cfg) lets you read state.next (the paused node) and state.values to show the human. Finally app.invoke(Command(resume="approve"), cfg) feeds "approve" back in and the graph finishes the job.

What the output means: First invoke: the run halts at the interrupt, nothing deleted. The print shows the pending action. After Command(resume="approve"), the delete runs and the graph completes. Resume with anything other than "approve" and it returns "cancelled by human".

Try this: Change the resume value to Command(resume="reject") and confirm _do_delete never runs. That one line is the difference between an agent that asks permission and one that acts on its own.

This is the gate the whole course pointed atChapter 4 introduced human-in-the-loop; Claude Code (C3) showed the permission prompt; L3 could only refuse inside a tool. The interrupt is the real thing: the run halts with durable state and cannot proceed until a human resumes it. Every irreversible action in the capstone sits behind one.
Two ways to pauseThe interrupt() function (shown) pauses inside a node and can return the human's input into the flow — the flexible, recommended approach. There's also a static interrupt_before=["node"] option at compile time that pauses before a named node. Both rely on the checkpointer to hold state while paused.

Lab L5.3 · Inspecting & editing state expert

Every debugger you have ever loved gives you one superpower: the ability to stop time and look inside. A normal agent denies you even that. When something goes wrong on pass eleven of a long, expensive run, your only tools are print statements and the willingness to run the whole thing again from scratch and pray it fails the same way. Anyone who has debugged a flaky agent knows that particular flavour of despair — the bug that costs ten dollars and four minutes to reproduce, if it reproduces at all.

The checkpointer quietly abolishes that problem, and it is the reason this lab feels like magic. Because LangGraph saved a snapshot after every step, the run is no longer a black box that either finishes or explodes — it is a flip-book you can page through frame by frame. Each checkpoint records what the state held and which node was about to run next, so you can watch exactly how the agent's beliefs evolved: where it first got the wrong idea, which tool result poisoned the plan, the precise step the reasoning bent. This is the "time travel" of the name — not a metaphor, a literal history you iterate over.

Then it goes one step further, from read to write. You can not only inspect an old checkpoint, you can edit it and resume from the corrected version. Think of a film director calling "cut," fixing one prop, and reshooting from that mark rather than the top of the scene: the agent picked the wrong record, so you patch target to the right one and let it continue — no re-running the ten good steps that came before. That is enormously cheaper than a full restart for any agent that is slow, costly, or non-deterministic.

One honest caveat, because this power invites misuse: editing live state is a debugging and correction tool, not a substitute for fixing your graph. Hand-patching state in production to paper over a bug is how you get an agent that only works when you are watching it. Use time-travel to understand the failure, then fix the node or the prompt that caused it. Because every step is checkpointed, you can walk the history, and even edit state before resuming — invaluable for debugging and for correcting an agent that went slightly wrong without restarting it.

get_state_history: one saved snapshot per step ckpt 0 ckpt 1wrong target ckpt 2 ckpt 3 (now) rewind to ckpt 1 update_statetarget = right-record resume from fix the 10 good steps before ckpt 1 are never re-run Rewind, patch, replay. The checkpoint history is a flip-book; you jump back to the step where the state went wrong, edit it with update_state, and resume from there — no re-running the good steps that came before.
🗺️ How to read this diagram

Read the top row left-to-right as the run's timeline, then follow the amber arrow down and back up as the "time travel".

  • The top row of boxes is get_state_history — one saved checkpoint per step, from ckpt 0 up to ckpt 3 (now). The blue arrows are the run advancing forward.
  • The red box (ckpt 1) is where the state first went wrong — here, a wrong target.
  • The amber dashed arrow curving back is the rewind: you don't restart, you jump straight to the bad checkpoint.
  • The green box is update_state patching the mistake; the final box resumes from that corrected snapshot.
  • The payoff is the bottom line: everything before ckpt 1 is reused, not recomputed — the whole point for a slow or costly agent.

In short: Checkpoints turn a run into a flip-book — page back to the frame that broke, fix it, and play forward without redoing the good frames.

Lab L5.3

Illustrative fragment — defines demo values / files are needed before this runs standalone.

timetravel.py# see every checkpoint (newest first)
for snap in app.get_state_history(cfg):
    print(snap.next, len(snap.values["messages"]))

# correct the state, then resume from the fixed version
app.update_state(cfg, {"target": "the-right-record"})
app.invoke(Command(resume="approve"), cfg)
▶ How this works

Because every step is saved, you can not only read the state but walk its whole history and even edit it before resuming. This is "time-travel debugging": rewind, fix a mistake, and replay — without re-running the agent from scratch.

  1. for snap in app.get_state_history(cfg): loops over every saved checkpoint for the thread, newest first. Each snap is a frozen snapshot of the graph at one step.
  2. print(snap.next, len(snap.values["messages"])) shows, for each snapshot, which node was about to run and how many messages the state held then — a compact audit trail of how the run progressed.
  3. app.update_state(cfg, {"target": "the-right-record"}) edits the stored state. Here it fixes a wrong target before continuing — correcting the agent instead of restarting it.
  4. app.invoke(Command(resume="approve"), cfg) then resumes from that corrected state, so the agent proceeds with the fixed value.

What the output means: The loop prints one line per checkpoint (the pending node and message count for each), letting you see exactly how the state evolved. After the edit and resume, the agent acts on "the-right-record" rather than the original mistaken target.

Try this: Add print(snap.values.get("target")) inside the loop to watch the target value at each checkpoint — you'll see the wrong value in history and the corrected one after update_state.

Time-travel debuggingYou can resume from any past checkpoint, not just the latest — rewind to before a bad decision, tweak the state, and replay. For an agent that's expensive or slow to re-run from scratch, this turns "reproduce the bug" from an ordeal into a click.

Putting it together: an approval-gated agent expert

Here is the quiet truth about production agents: none of the three ideas in this chapter is impressive on its own. A loop is just a loop. A save file is just a save file. A pause is just a pause. What makes a system you would actually let touch a real incident is the way they interlock — each one covering exactly the weakness the other two leave open. Miss any single one and you get a recognisable failure: a cycle without a checkpointer forgets everything when it crashes mid-incident; a checkpointer without an interrupt happily persists the state of an agent that already did the damage; an interrupt without a cycle can gate one action but can't keep working afterward.

So watch how they compose into a single shape. The cycle is the engine — it lets the agent diagnose, act, and re-diagnose until the problem is actually resolved instead of stopping after one hopeful pass, and its two brakes keep it from running away. The checkpointer is the memory and the safety net underneath that engine — every incident becomes its own durable thread, so a restart resumes instead of restarting, and every step is on the record. The interrupt is the hand-brake wired to the dangerous levers — read-only diagnosis flows freely, but nothing irreversible (terraform apply, kubectl delete, a refund, a mass email) happens until a human signs off. Cycle to keep going, checkpointer to never lose the thread, interrupt to never cross the line without permission.

That is not a toy pattern — it is the literal skeleton of this course's capstone AI DevOps Engineer, and of most serious agents in the wild. If you internalise one thing from L5, make it this: the primitives are simple, but the discipline of combining all three is what separates a demo from a system on call. Cycles + persistence + interrupts compose into the shape of a real production agent — and of the capstone.

the cycle: diagnose → act → re-diagnose (bounded) diagnose routeread-only? risky? report → END interrupt human approves execute → back to diagnose checkpointer: every step above is durableone thread per incident · recursion_limit caps runaway Three primitives, one shape. The bounded cycle keeps working, the interrupt gates the irreversible step behind a human, and the checkpointer underneath makes the whole thing durable and resumable — miss any one and you get a recognisable failure.
🗺️ How to read this diagram

Read the top band left-to-right as the loop, follow the branch down to the human gate, and notice the wide bar underneath that everything sits on.

  • The cycle across the top: diagnose feeds route, which splits two ways — a read-only finding goes straight to report → END (green), a risky fix goes to the red interrupt.
  • The interrupt drops to human approves; only after sign-off does the grey arrow curve back to diagnose — the loop keeps going, having executed the gated action.
  • The wide bottom bar is the checkpointer: every node above is saved after it runs, each incident is its own thread, and recursion_limit caps a runaway loop.
  • The payoff is the interlock: the cycle needs the checkpointer to survive a crash, the checkpointer needs the interrupt to avoid persisting damage already done, and the interrupt needs the cycle to keep working after approval.

In short: Cycle to keep going, interrupt to never cross the line without permission, checkpointer to never lose the thread — that interlock is the production-agent skeleton.

Production agent skeleton graph = diagnose → route: read-only finding → report → END risky fix → interrupt (human approves) → execute → back to diagnose compile(checkpointer=# durable, resumable) run per incident on its own thread_id # cycle re-diagnoses after acting; recursion_limit caps runaway; every irreversible step gated
CapabilityWhat it buys the agent
Cycle (bounded)Keeps working until the problem is resolved — without looping forever
CheckpointerSurvives restarts; each incident is an inspectable, resumable thread
InterruptNever takes an irreversible action without human sign-off
State inspectionFull audit trail of what it believed and did at each step

Common pitfalls expert

PitfallFix
Cycle with no exitGuard in the router + set recursion_limit
Interrupt without a checkpointerHITL requires persistence — compile with a checkpointer
Reusing one thread_id for different usersOne thread per conversation; mixing them cross-contaminates state
MemorySaver in productionIt's lost on restart — use a durable checkpointer (SQLite/Postgres)
Resuming without showing the human the actionInspect get_state and surface the pending step before approving
Gating nothing / gating everythingGate irreversible actions; let read-only steps run — match the gate to risk

Exercises expert

Exercise L5.1 — Resumable chat

Context: Multi-user memory is a checkpointer plus a thread id — about ten lines.

Your task: Compile a simple chat graph with MemorySaver, run two turns on one thread_id, then a turn on a different one, and confirm the second thread doesn't know the first's context.

Requirements:

  • Compile the chat graph with a checkpointer
  • Run two turns on one thread id
  • Run a turn on a second thread id
  • Confirm the two threads have independent memory

💡 Hint: The thread id is the whole isolation mechanism — same graph, different thread, separate state.

Exercise L5.2 — Approve-before-delete

Context: An interrupt-gated delete is the concrete test that a human sits between intent and a destructive action.

Your task: Build a graph with a delete node behind an interrupt, run it, inspect the pending action, then reject it once and approve it once.

Requirements:

  • A delete node sits behind an interrupt
  • After the first invoke, the pending action is inspectable and the delete has NOT happened
  • Rejecting cancels without ever running the delete
  • Approving (resume) runs the delete exactly once

💡 Hint: Check get_state().next points at the interrupted node before approval; only a resume with an approve decision runs the delete.

Show what to verify

After the first invoke, get_state().next should point at the interrupted node and no delete has happened. Only after Command(resume="approve") does _do_delete run. Rejection returns "cancelled" and never calls it.

Exercise L5.3 — Bounded reflection loop

Context: The capstone combines every cycle idea: a bounded, checkpointed, self-correcting loop you can inspect step by step.

Your task: Combine everything into a generate→reflect cycle that loops until a quality check passes or 3 attempts, checkpointed so you can inspect each revision in get_state_history.

Requirements:

  • A generate→reflect cycle (L1's reflection) as a graph
  • Exit when the quality check passes
  • Cap at 3 attempts as the backstop
  • Checkpoint each step so revisions are inspectable via history
  • Confirm you can walk the revisions after the run

💡 Hint: It's the bounded cycle (rung 1) plus a checkpointer (rung 3) plus reflection — the state history is what lets you audit each revision.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · A bounded cycle with a counterBeginner

Context: A loop needs a guaranteed exit. The robust pattern is two independent brakes: a done-check and an attempt counter.

Your task: Model a cycle that runs a work node until 'done' OR an attempt counter hits its cap.

Requirements:

  • Run a work step that makes progress and increments an attempt counter
  • Exit when the work is done
  • Also exit when attempts reach a cap
  • Report which brake ended the loop

💡 Hint: Two independent conditions on the while guard; the counter guarantees termination even if 'done' never becomes true.

Show solution

Two brakes: a done-check and a counter. Runnable stdlib:

def work(state):
    state["progress"] += 40
    state["attempts"] += 1
    return state

def run(max_attempts=5):
    state = {"progress": 0, "attempts": 0}
    while state["progress"] < 100 and state["attempts"] < max_attempts:
        state = work(state)
    reason = "done" if state["progress"] >= 100 else "hit attempt cap"
    return state, reason

print(run())   # ({'progress': 120, 'attempts': 3}, 'done')

The counter guarantees the loop can't run forever even if "done" never becomes true.

Exercise 2 · The recursion_limit backstopIntermediate

Context: LangGraph enforces a global recursion_limit as a last resort — a second, framework-level brake for the bug where your own exit condition never trips.

Your task: Model a driver that raises if total steps exceed a limit, independent of your own counter.

Requirements:

  • Count total steps taken by the driver
  • Raise a dedicated error when steps exceed the recursion limit
  • The limit is independent of the node's own done-check
  • Show it firing on a node that never sets 'done'

💡 Hint: Your counter is the intended exit; the recursion limit is the backstop — demonstrate it with a deliberately buggy step function.

Show solution

A second, framework-level brake. Runnable:

class RecursionLimit(Exception):
    pass

def run_graph(step_fn, state, recursion_limit=25):
    steps = 0
    while not state.get("done"):
        steps += 1
        if steps > recursion_limit:
            raise RecursionLimit(f"exceeded {recursion_limit} steps")
        state = step_fn(state)
    return state, steps

# a buggy node that never sets done -> the backstop fires
buggy = lambda s: {**s, "n": s.get("n", 0) + 1}
try:
    run_graph(buggy, {}, recursion_limit=10)
except RecursionLimit as e:
    print("backstop:", e)

Your counter is the intended exit; recursion_limit is the backstop for the bug where your exit condition never trips.

Exercise 3 · A checkpointer: durable, resumable stateAdvanced

Context: Persistence saves graph state per thread so a run can pause, resume, and be inspected — exactly what the real MemorySaver/SqliteSaver do.

Your task: Model an in-memory checkpointer keyed by thread_id that a run reads from and writes back to.

Requirements:

  • A store maps thread_id to that thread's state
  • A step loads the thread's state, updates it, and persists it back
  • State survives across separate step calls on the same thread
  • A different thread has independent state
  • Demonstrate two threads not seeing each other's state

💡 Hint: Load-update-save keyed by thread id; durable per-thread state is what makes a graph pausable and resumable.

Show solution

A checkpointer is a thread-keyed state store. Runnable:

class MemorySaver:
    def __init__(self):
        self.store = {}
    def get(self, thread_id):
        return self.store.get(thread_id, {"messages": [], "step": 0})
    def put(self, thread_id, state):
        self.store[thread_id] = state

saver = MemorySaver()
def step(thread_id, user_msg):
    state = saver.get(thread_id)                 # resume where we left off
    state["messages"].append(user_msg)
    state["step"] += 1
    saver.put(thread_id, state)                  # persist
    return state

step("chat-1", "hello")
s = step("chat-1", "again")
print(s["step"], "steps on thread chat-1")       # 2 -- state survived
print(saver.get("chat-2")["step"])                # 0 -- separate thread

The real MemorySaver/SqliteSaver do exactly this — durable per-thread state is what makes a graph pause/resume/inspectable.

Exercise 4 · Interrupt for human approval, then resumeExpert

Context: Human-in-the-loop pauses the graph before a risky action, waits for approval, then continues — the safety gate promised since Chapter 4, built on interrupts.

Your task: Model an interrupt: the run stops with a pending action, a human decides, and the run resumes.

Requirements:

  • Progress until a dangerous action is pending, then pause with status 'interrupted'
  • Surface the pending action while paused
  • A resume step takes the human's decision (approve/deny)
  • Approve executes the action; deny skips it
  • Show both the approve and the deny outcome from the same paused state

💡 Hint: The real graph pauses at interrupt_before a node and resumes by invoking again on the same thread — model the stop, surface, decide, resume cycle.

Show solution

Interrupt = stop, surface the pending action, resume on a decision. Runnable:

def run_until_interrupt(state):
    # progresses until it wants to run a dangerous action
    state["plan"] = "delete_records"
    state["status"] = "interrupted"          # pause here for approval
    return state

def resume(state, approved):
    if state["status"] != "interrupted":
        return state
    if approved:
        state["result"] = f"executed {state['plan']}"
    else:
        state["result"] = f"skipped {state['plan']} (denied)"
    state["status"] = "done"
    return state

s = run_until_interrupt({})
print("paused with pending:", s["plan"])
print(resume(dict(s), approved=False)["result"])   # skipped ...
print(resume(dict(s), approved=True)["result"])     # executed ...

The graph pauses at interrupt_before a node, a human approves, and invoke with the same thread resumes — the safety gate promised since Chapter 4.

Exercise 5 · Time-travel: inspect and edit state between stepsProfessional

Context: Because every step is checkpointed, you can rewind to any point, edit the state, and resume — time-travel debugging without rerunning from scratch.

Your task: Model a state history you can rewind to and patch, then continue from.

Requirements:

  • Snapshot the state after each step into a history
  • Rewind to restore an earlier checkpoint
  • Edit the restored state before continuing
  • Resume stepping from the edited state
  • Show that the resumed run reflects the edit

💡 Hint: Keep a list of per-step snapshots; rewinding returns a copy of an earlier one that you patch and feed back into the step function.

Show solution

Checkpoint history enables rewind + edit. Runnable:

class Graph:
    def __init__(self):
        self.history = []
    def step(self, state):
        state = {**state, "n": state.get("n", 0) + 1}
        self.history.append(dict(state))     # snapshot each step
        return state
    def rewind_to(self, i):
        return dict(self.history[i])         # restore an earlier checkpoint

g = Graph()
s = {}
for _ in range(3):
    s = g.step(s)
print("now:", s)                             # {'n': 3}
patched = g.rewind_to(0)                      # go back to after step 1
patched["n"] = 99                             # edit state
s = g.step(patched)                           # resume from the edit
print("after time-travel:", s)               # {'n': 100}

Because every step is checkpointed, you can rewind to any point, edit the state, and resume — debugging a run without rerunning from scratch.

Exercise 6 · The real approval-gated agent (needs langchain installed)Industry scenario

Context: The capstone's core is a production HITL graph: a checkpointer, interrupt_before a tools node for approval, then resume — the durable version of every gate so far.

Your task: Write the real approval-gated graph using documented langgraph APIs. (Needs the libraries installed.)

Requirements:

  • A typed State with an add-messages reducer, plus a model node and a tools node
  • Compile with a checkpointer (e.g. MemorySaver) and interrupt_before=["tools"]
  • Invoke with a thread_id config so state is durable
  • Resume by invoking with None on the same thread after approval
  • Label it as needing langgraph/langchain-anthropic + a key

💡 Hint: The checkpointer makes state durable, interrupt_before pauses at the risky node, and resuming with None on the same thread continues — verify imports against your version.

Show solution

Correct HITL graph. Needs pip install langgraph langchain-anthropic + ANTHROPIC_API_KEY:

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver

class State(TypedDict):
    messages: Annotated[list, add_messages]

def model(state): return {"messages": []}     # calls Claude + tools in reality
def tools(state): return {"messages": []}

builder = StateGraph(State)
builder.add_node("model", model)
builder.add_node("tools", tools)
builder.add_edge(START, "model")
builder.add_edge("model", "tools")
builder.add_edge("tools", END)

# checkpointer + interrupt = pause before running tools for human approval
graph = builder.compile(checkpointer=MemorySaver(), interrupt_before=["tools"])

cfg = {"configurable": {"thread_id": "run-1"}}
graph.invoke({"messages": [{"role": "user", "content": "delete old records"}]}, cfg)
# ...inspect graph.get_state(cfg), get human approval, then:
graph.invoke(None, cfg)   # resume from the interrupt on the same thread

The checkpointer makes state durable; interrupt_before pauses at the risky node; resuming with None on the same thread continues. This is the capstone's core. Verify imports against your version.

✓ Checkpoint — you can move on when you can…

  • Build a cycle and bound it with a counter and recursion_limit.
  • Add a checkpointer and run a resumable, threaded conversation.
  • Pause with interrupt, inspect the pending action, and resume.
  • Walk get_state_history and edit state before resuming.
  • Describe how cycles + persistence + interrupts compose into a production agent.
🏗️ Toward the capstone — and what's nextYou now have every LangGraph primitive the AI DevOps Engineer needs: a bounded diagnose↔fix cycle, a durable checkpointer so each incident is a resumable thread, and an interrupt so no terraform apply or kubectl delete runs without human approval. Chapter 8c builds exactly this gate. Next module takes these single-graph skills into multi-agent systems — CrewAI, AutoGen, and the supervisor/network topologies from L1. See the safety-gate build →

Knowledge check check yourself

✓ Knowledge check

Why must a checkpointer be attached before you can use an interrupt for human-in-the-loop, and what does the checkpointer buy you?

Show answer
An interrupt pauses the graph mid-run and waits — possibly for minutes or days — so the paused state has to be stored somewhere durable; that store is the checkpointer. It provides durable memory, isolated threads (per thread_id), resumability from the last checkpoint, and step-by-step inspection, which is exactly what a pause-and-resume gate requires.
✓ Knowledge check

What are the two independent brakes that keep a LangGraph cycle from running forever?

Show answer
Your own state counter checked in the routing function (e.g. return END once attempts >= 5 or the task is done), and LangGraph's built-in recursion_limit passed to invoke as a framework backstop that raises after N steps even if your counter has a bug.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in