ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lesson 3 of 6 30 min

Debugging stateful workflows

Find and fix real graph bugs by drawing the graph, streaming state updates, reading checkpoint history, forking with time travel and setting deliberate recursion limits, with LangSmith and LangGraph Studio for heavier cases.

Words in this lessonStream mode · Checkpointer · Time travel · Recursion limit · Reducer · Trace · LangSmith · LangGraph Studio
Stream mode
A setting that chooses what you see after each step when you stream a graph run. Like choosing between watching the whole kitchen on CCTV or reading each station's note as it is written.
Checkpointer
The component that saves a snapshot of a graph's state after every step so a run can be inspected or resumed later. Like the auto-save in a video game, written to a memory card that any console can read.
Time travel
Going back to an earlier saved step of a graph run and continuing from there, optionally with changed state. Like rewinding a recorded cooking show to just before the cake went wrong and trying a different oven temperature from there.
Recursion limit
A cap on how many steps one run of a graph may take before it is stopped. Like a rule that any order bounced between stations more than ten times goes straight to the manager.
Reducer
A rule that says how a new value for a state key is combined with the old one. Like the difference between rubbing out the last note on a ticket and adding a new line under it.
Trace
A detailed record of everything one request did, step by step, including every model call and its timing. Like a parcel tracking history that lists every depot the parcel passed through and how long it waited at each.
LangSmith
A hosted service from the LangChain team that records and displays traces of LLM app runs. Like a flight recorder for your app that you can replay in a web browser.
LangGraph Studio
A visual debugger that draws your graph and lets you run it, watch its state and rewind it by clicking. Like a glass-walled kitchen where you can pause service and inspect every ticket on the rail.
On this page
  1. The flight recorder
  2. Tool one: draw the graph
  3. Tool two: stream the run and watch the state
  4. Tool three: checkpoints, snapshots and history
  5. Tool four: time travel
  6. Fixing the structure
  7. A field guide to common graph bugs
  8. Seeing more: LangSmith and LangGraph Studio
  9. Try it yourself
  10. Common mistakes
  11. Recap
  12. Go deeper

The first evening of Diwali week, 7:40 pm. The WhatsApp line was taking a message every few seconds, and Meera forwarded Arjun a screenshot with no comment. A customer had asked, “Does the large Diwali hamper have anything with peanuts? Gifting it to my sister-in-law, she’s allergic.” Forty seconds later the bot had replied: “Sorry, something went wrong. A teammate will follow up.”

The server log had one useful line: GraphRecursionError. The graph had run out of steps.

Two days earlier, Arjun had made the allergy branch “safer”. After the branch drafted an answer, a new check node asked the model to verify that the draft quoted the recipe notes. If not, it sent the draft back with feedback for another try. It had worked perfectly on the brownie question. On the hamper question, it apparently never stopped.

Arjun did what most people do first. He added print statements to every node and ran it on his laptop with “Does the walnut brownie box have peanuts?” It worked. He tried the hamper question and got a different-looking loop from the one in production. Then he raised the step limit from 12 to 100 “to give it room”. The error came back anyway, after forty more model calls, about ₹27 in API spend for one customer message, and a minute and a half of waiting.

Priya stopped him before he raised it again. “You’re reading the smoke, not looking at the fire. Let’s find out what the graph actually did, step by step, with the exact state it had.”

Important

The idea in one sentence: because a LangGraph run is a sequence of nodes acting on an explicit state, you can debug it by watching that state change step by step (streaming), inspecting and rewinding saved snapshots of it (checkpoints and time travel), and fixing the structure (reducers, routers and loop limits) rather than guessing from the final error.

The flight recorder

“When a plane has a problem,” Priya said, “investigators don’t interview the plane. They pull the flight recorder, which logged every instrument reading, second by second. Then they replay the flight up to the moment it went wrong.”

“And we have one of those?”

“You have three. The graph’s own drawing, which shows every path that is possible. Streaming, which shows the path it is taking right now, live. And checkpoints, which record the full state after every step so you can rewind to any of them and try again from there.”

Where the analogy breaks: a flight recorder only records. A LangGraph checkpoint lets you change the recorded state and fly on from that point, which no investigator can do with a real plane. That is the superpower in this lesson.

Here is the simplified allergy branch Arjun had shipped, as a standalone graph you can run. Read it carefully; it contains two bugs.

# allergy_check.py
from typing import TypedDict

from dotenv import load_dotenv
from langchain_anthropic import ChatAnthropic
from langchain_core.output_parsers import StrOutputParser
from langgraph.graph import StateGraph, START, END

load_dotenv()
to_text = ChatAnthropic(model="claude-opus-5-5", max_tokens=2000) | StrOutputParser()

RECIPES = {
    "walnut brownie box": "Walnut brownie box: flour, cocoa, butter, sugar, eggs, "
                          "walnuts. Shared equipment with peanuts and almonds.",
    "plum cake": "Christmas plum cake: raisins, almonds, cashews, orange peel.",
}

class AllergyState(TypedDict):
    question: str
    notes: str
    draft: str
    verdict: str
    feedback: list[str]          # every critique so far... or so Arjun thought

def retrieve(state: AllergyState) -> dict:
    hits = [text for name, text in RECIPES.items() if name in state["question"].lower()]
    return {"notes": "\n".join(hits)}

def draft(state: AllergyState) -> dict:
    fixes = "\n".join(state["feedback"]) or "none"
    text = to_text.invoke(
        f"Answer this allergy question using ONLY the notes.\nNotes:\n{state['notes']}\n"
        f"Question: {state['question']}\nFix these problems from earlier drafts: {fixes}")
    return {"draft": text}

def check(state: AllergyState) -> dict:
    verdict = to_text.invoke(
        "Does this answer quote the recipe notes to support its claim? Reply 'pass' "
        f"or 'retry: <reason>'.\nNotes:\n{state['notes']}\nAnswer:\n{state['draft']}"
    ).strip().lower()
    if verdict.startswith("pass"):
        return {"verdict": "pass"}
    return {"verdict": "retry", "feedback": [verdict]}

def after_check(state: AllergyState) -> str:
    return "done" if state["verdict"] == "pass" else "retry"

builder = StateGraph(AllergyState)
builder.add_node("retrieve", retrieve)
builder.add_node("draft", draft)
builder.add_node("check", check)
builder.add_edge(START, "retrieve")
builder.add_edge("retrieve", "draft")
builder.add_edge("draft", "check")
builder.add_conditional_edges("check", after_check, {"done": END, "retry": "draft"})
graph = builder.compile()

Tool one: draw the graph

Before running anything, look at the shape. Every compiled graph can describe itself:

print(graph.get_graph().draw_mermaid())

That prints Mermaid text (a simple language for diagrams, which many Markdown viewers and GitHub render as a picture). Rendered, it looks like this:

flowchart LR
    S([START]) --> R[retrieve]
    R --> D[draft]
    D --> C[check]
    C -->|done| E([END])
    C -->|retry| D

Priya put her finger on the check → draft arrow. “There’s a cycle. What’s the only way out of it?”

“The checker says pass.”

“And if it never does?”

Arjun had no answer. The drawing had shown the first bug before a single model call: a loop whose only exit depends on a model’s judgement. Nothing in the structure guarantees the loop ends.

Tool two: stream the run and watch the state

Next, watch it happen. graph.stream yields an item after every step, and the stream_mode setting chooses what each item contains:

stream_mode Each item is Use it to
"values" the full state after the step see the whole ticket at every station
"updates" {node_name: what_it_returned} see who changed what (start here)
"messages" (token_chunk, metadata) pairs from LLM calls stream text to a user as it is written
"debug" detailed events for each task and checkpoint dig into ordering and timing

You can also pass a list, like stream_mode=["updates", "values"], and each item then comes back as a (mode, data) pair.

from langgraph.errors import GraphRecursionError

q = "Does the large Diwali hamper have anything with peanuts? Gifting it to my sister-in-law."
start = {"question": q, "notes": "", "draft": "", "verdict": "", "feedback": []}
try:
    for update in graph.stream(start, stream_mode="updates",
                               config={"recursion_limit": 12}):
        print(update)
except GraphRecursionError:
    print("STOPPED: hit the recursion limit")

Here is what came back (drafts shortened):

{'retrieve': {'notes': ''}}
{'draft': {'draft': "I don't have recipe information for the Diwali hamper, so I can't confirm..."}}
{'check': {'verdict': 'retry', 'feedback': ['retry: the answer does not quote any recipe note']}}
{'draft': {'draft': "I don't have recipe information for the Diwali hamper, so I can't confirm..."}}
{'check': {'verdict': 'retry', 'feedback': ['retry: no recipe notes are cited']}}
{'draft': {'draft': "I don't have recipe information for the Diwali hamper, so I can't confirm..."}}
...
STOPPED: hit the recursion limit

Read it like a detective. Three facts jump out.

The very first line: notes is empty. retrieve matches recipe names inside the question. “Diwali hamper” is not a recipe; it is a box of several products. So the drafter had nothing to quote, and honestly said so.

The checker can never pass an honest “I don’t know”. It was asked whether the answer quotes the notes. With empty notes, no answer can. The loop’s exit was unreachable for this question.

The drafts never change. The drafter is supposed to see all previous feedback and adjust. Yet each draft is word for word the same. Why?

Note

Pause and think. Look at AllergyState and at what check returns. Why does the drafter never see more than one piece of feedback, even after five rounds?

Show the answer

feedback: list[str] has no reducer. When check returns {"feedback": ["retry: ..."]}, LangGraph’s default rule replaces the old list with the new one-item list. So state["feedback"] only ever holds the latest critique, not the history. The fix is feedback: Annotated[list[str], operator.add], which appends instead. This is the silent “last writer wins” bug from the first lesson of this module, and it is one of the most common LangGraph bugs in real code.

Tool three: checkpoints, snapshots and history

Streaming showed the path. To rewind it, you need the state saved after every step. That is what a checkpointer does: after each step, it saves a snapshot of the full state, called a checkpoint, under a conversation ID called a thread ID. The next lesson is all about storing checkpoints durably in a database. For debugging, the in-memory one is enough:

from langgraph.checkpoint.memory import InMemorySaver

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "diwali-hamper-bug"}, "recursion_limit": 12}

try:
    graph.invoke(start, config=config)
except GraphRecursionError:
    pass

snapshot = graph.get_state(config)              # the latest checkpoint
print(snapshot.next)                            # which node would run next
print(snapshot.values["feedback"])
print(snapshot.metadata["step"])
('draft',)
['retry: no recipe notes are cited']
11

A StateSnapshot has the parts you need: .values (the state), .next (the node or nodes due to run), .metadata (including the step number) and .config (the exact address of this checkpoint, which you will use to rewind).

get_state_history walks backwards through every checkpoint on the thread, newest first:

for snap in graph.get_state_history(config):
    print(snap.metadata["step"], snap.next, repr(snap.values.get("verdict")))
11 ('draft',) 'retry'
10 ('check',) 'retry'
9 ('draft',) 'retry'
...
2 ('check',) ''
1 ('draft',) ''
0 ('retrieve',) ''
-1 ('__start__',) ''

(The exact step numbers can differ slightly between LangGraph versions. The pattern is what matters.)

This is the flight recorder, readable as a list. Notice that the error did not wipe it out: everything up to the last completed step was saved. In production, with the persistent checkpointer from the next lesson, you can open the customer’s actual thread the next morning and read it exactly like this, instead of trying to reproduce it on your laptop with a different question.

Tool four: time travel

Now the superpower. Every snapshot’s .config points to that exact moment. Two things you can do with it:

Replay. graph.invoke(None, config=snap.config) resumes from that checkpoint. Passing None as the input means “no new input; continue from here”. Steps before the checkpoint are not re-run; steps after it run again (and model calls are made again, so they may produce different text).

Fork. graph.update_state(snap.config, {...}) writes a change into the state at that point, as if a node had returned it, and gives you a new config. Invoking from the new config runs a new branch of history. The original stays intact.

Priya used the fork to test a hypothesis cheaply: if retrieval had found the hamper’s contents, would the rest of the graph have worked? She did not need to fix the retriever first to find out.

HAMPER_NOTES = ("Large Diwali hamper: kaju katli (cashews), almond cookies, walnut "
                "brownie box (shared equipment with peanuts), plum cake (almonds, "
                "cashews). Packed in a facility that handles peanuts.")

history = list(graph.get_state_history(config))
after_retrieve = [s for s in history if s.next == ("draft",)][-1]   # the oldest one

fork_config = graph.update_state(after_retrieve.config, {"notes": HAMPER_NOTES})
result = graph.invoke(None, config=fork_config)
print(result["verdict"], "|", result["draft"][:120])
pass | Yes, please be careful. The large Diwali hamper includes a walnut brownie box made on
shared equipment with peanuts, and the whole hamper is packed in a facility that handles peanuts...

One draft, one check, a pass. Two model calls instead of re-running the whole thing from the top, and the hypothesis confirmed: the loop logic was fine when retrieval worked. The root cause was the retriever, and the loop’s real crime was having no way out when retrieval failed.

The [-1] is there because the history contains many checkpoints whose next node is draft, newest first; you want the oldest one, right after retrieve. On a long thread, print the history first and choose by eye. That is normal practice, not a hack.

Note

Pause and think. Arjun’s instinct was to raise recursion_limit from 12 to 100. Given what the stream and the history showed, would any limit have made this question succeed?

Show the answer

No. With empty notes, the checker could never return “pass”, and because of the missing reducer the drafter could not even learn from accumulated feedback. A higher limit only made the failure slower and more expensive: about forty more model calls and ₹27 for one message. The recursion limit is a safety net, not a fix. When you hit it, the question is always “why is there no exit?”, never “how do I allow more steps?”.

Fixing the structure

Three changes, each aimed at a cause the tools exposed:

import operator
from typing import Annotated

class AllergyState(TypedDict):
    question: str
    notes: str
    draft: str
    verdict: str
    feedback: Annotated[list[str], operator.add]   # 1. accumulate, don't overwrite
    attempts: int

def draft(state: AllergyState) -> dict:
    ...  # same as before
    return {"draft": text, "attempts": state["attempts"] + 1}

MAX_ATTEMPTS = 2

def after_check(state: AllergyState) -> str:
    if state["verdict"] == "pass":
        return "done"
    if state["attempts"] >= MAX_ATTEMPTS:          # 2. a guaranteed exit
        return "handoff"
    return "retry"

def handoff(state: AllergyState) -> dict:
    return {"draft": "I want to be careful with allergies, so I've asked our store "
                     "team to check this hamper and message you within the hour."}

builder.add_node("handoff", handoff)
builder.add_conditional_edges("check", after_check,
                              {"done": END, "retry": "draft", "handoff": "handoff"})
builder.add_edge("handoff", END)

And the third change, outside this file: the hamper’s contents were added to the recipe archive, and retrieval was switched to proper search (Module 09’s job). Even then, the handoff exit stays, because some future question will always find nothing.

Run the hamper question again. Streaming now shows retrieve, draft, check (retry), draft (with two feedback lines visible), check (retry), handoff. Six steps, four model calls, a safe answer in about eight seconds, and it would be the same with any recursion limit above six.

Keep a generous recursion_limit anyway, and catch GraphRecursionError at the top of your app so that if some other loop misbehaves, the customer gets a polite handoff, not a stack trace.

A field guide to common graph bugs

Priya made Arjun write this list on the team wiki. Each bug has a symptom you will recognise.

  • Missing reducer. Symptom: a list that should grow only holds the latest item. Fix: Annotated[list, operator.add], or add_messages for chat.
  • Router returns a name not in the map. Symptom: the run crashes at a conditional edge with an error naming an unknown branch or node, often only for unusual inputs (“Allergies” instead of “allergy”). Fix: normalise the label, check it against a set, return a fallback.
  • Mutating state in place. Symptom: state["feedback"].append(x); return {} seems to work in a quick test, then values vanish, appear in the wrong checkpoint, or double up when a reducer also runs. Fix: never modify state; always return the change as a new value.
  • Loops with a model-only exit. Symptom: GraphRecursionError, often on rare inputs. Fix: a counter in state and a router that exits when it reaches a maximum.
  • Subgraph key mismatch. Symptom: a subgraph runs but behaves as if it got empty input. Fix: check that parent and subgraph share the keys you expect, or wrap the subgraph in a function node that maps keys explicitly.

Seeing more: LangSmith and LangGraph Studio

Printing streams is enough to find most bugs. Two tools from the LangChain team make it more comfortable at scale.

LangSmith is a hosted tracing service. A trace is a recorded tree of everything one run did: every node, every model call with its full prompt and reply, token counts and timings. Turning it on for LangChain and LangGraph code needs only environment variables in your .env:

LANGSMITH_TRACING=true
LANGSMITH_API_KEY=your-key-here

Every run then appears in LangSmith’s web interface, where you can click into the exact prompt the check node sent on round four. Module 22 compares it with open alternatives like Langfuse. Remember that traces contain everything customers wrote, so treat them as personal data.

LangGraph Studio is a visual debugger for graphs: it draws your graph, lets you send inputs, watch state change node by node, inspect threads and fork from any checkpoint by clicking. You run it locally from the LangGraph command-line tool:

uv add "langgraph-cli[inmem]"

Add a langgraph.json in the project folder that says where your graph lives:

{
  "dependencies": ["."],
  "graphs": { "support": "./support_graph.py:graph" },
  "env": ".env"
}

Then start the development server, which prints a link to open Studio in your browser:

uv run langgraph dev

"support" is the name Studio shows; ./support_graph.py:graph means “the variable graph in that file”. Studio supplies its own checkpointer in this mode, so export the graph compiled without one.

Library APIs change quickly. If a name has moved, check the docs linked below.

Try it yourself

Take the fixed allergy graph, compile it with InMemorySaver, and run the hamper question on thread "exercise-1". Then:

  1. Use get_state_history to find the checkpoint just before the first check ran.
  2. Fork from it with update_state, setting draft to an answer that clearly quotes the hamper notes.
  3. Invoke from the fork and confirm it ends after a single check.
Show the answer
config = {"configurable": {"thread_id": "exercise-1"}}
graph.invoke({"question": q, "notes": "", "draft": "", "verdict": "",
              "feedback": [], "attempts": 0}, config=config)
history = list(graph.get_state_history(config))
before_check = [s for s in history if s.next == ("check",)][-1]   # oldest one
fork = graph.update_state(before_check.config,
                          {"draft": "Per our notes, the hamper includes a walnut brownie "
                                    "box made on shared equipment with peanuts."})
print(graph.invoke(None, config=fork)["verdict"])   # pass

[-1] picks the oldest matching snapshot because history is newest first. If you see handoff instead of pass, your draft did not quote the notes clearly enough for the checker; that is the checker doing its job.

Common mistakes

  • Raising the recursion limit to “fix” a loop. It only makes the failure slower and costlier. Find out why there is no exit.
  • Debugging on a different input. Reproduce with the exact customer input, or better, read the real thread’s history from the checkpointer.
  • Forgetting that replay re-runs model calls. Steps after the checkpoint run again and may produce different text. Fork with update_state when you need a controlled experiment.
  • Logging only the final error. Stream updates in development and turn on tracing in production; the path is the evidence.
  • Leaving tracing on with no data policy. Traces hold full customer messages. Decide who can see them and how long you keep them.

Recap

  • Draw the graph with get_graph().draw_mermaid() to spot loops with no guaranteed exit.
  • Stream with stream_mode="updates" to see each node’s change; "values" for full state, "messages" for tokens, "debug" for everything.
  • With a checkpointer, get_state and get_state_history let you read every step of a real run.
  • Time travel: replay with invoke(None, config=snapshot.config), or fork with update_state and test a fix without rerunning everything.
  • Most bugs are structural: missing reducers, unvalidated routers, in-place mutation and loops without counters. recursion_limit is the net, not the fix.

The next morning Arjun explained it to Meera without a single code term. “The bot had a proofreader that kept sending answers back. For the hamper, there was nothing to quote, so the proofreader could never be happy, and the writer couldn’t even see the earlier complaints. Now the writer sees all of them, and after two tries it hands over to the store team. And I didn’t have to guess: I rewound the conversation to the exact moment before it went wrong and tried the fix from there.”

“Rewound it?” said Meera. “Then why did that woman on WhatsApp yesterday have to repeat her whole order after you deployed the update?”

Arjun opened his mouth and closed it again. The rewinding worked because the checkpoints were in memory, on his laptop. On the server, every deploy wiped them. Priya, who had been listening, said: “That’s tomorrow. We’re going to put the flight recorder somewhere that survives a restart.”

Go deeper

Check your understanding

3 quick questions
1. A draft-and-check loop hits GraphRecursionError on one customer question. What is the best first move?
2. What does `graph.update_state(snapshot.config, {"notes": new_notes})` followed by `graph.invoke(None, config=new_config)` do?
3. Which stream_mode would you use to show a customer the reply token by token as the model writes it?