I’ve spent the last six months building production-grade multi-agent systems. The first time I tried to chain more than three AI agents together, I hit a wall hard. Concatenating prompts inside a for loop wasn’t just inelegant—it was brittle. One timeout in the middle of a pipeline meant all the previous work was gone. That’s when I discovered LangGraph and its durable execution state. This is the story of how I used it to orchestrate a multi-agent research and writing workflow, shipped it to production, and made my pipelines resilient to failure.

The Problem I Was Trying to Solve§

The goal was an automated content engine that could take a topic, research it via web search, interview multiple AI personas (Claude and DeepSeek via API), synthesize their answers, fact-check the claims, and produce a final article with citations. That’s five distinct stages, each requiring different prompts and models.

[Loading prompt card for DeepSeek Chat...]
[Loading prompt card for Claude...]

I started with a simple sequential Python script. Each stage called an LLM API, passed the output to the next stage, and saved the final result. It worked for two days—until a DeepSeek API timeout killed the entire job after 80% of the work was done. No checkpoint, no resumability, no way to recover without starting over. The script was a house of cards.

The deeper problem wasn’t just reliability. It was state. My multi-agent system needed to remember intermediate answers, which claims were already verified, and which model produced which output. That state needed to be inspectable, resumable, and auditable. I needed something like a durable execution graph that could persist the entire conversation state and step through it with confidence.

That’s what drove me to LangGraph. LangGraph is a Python library that models agent workflows as a directed cyclic graph, where each node is a function that operates on a shared state object. Its killer feature is the checkpointer: it can serialize state to a database (SQLite, Postgres, Redis) and resume from the last completed node after any crash or manual interruption.

Tools and Setup§

I used Python 3.11, LangGraph 0.1.x, and a SQLite checkpointer for local development. For production, I later switched to Postgres to allow multiple workers and horizontal scaling. My LLM providers were Anthropic’s Claude (for drafting and critique) and DeepSeek (for low-cost fact extraction and summarization). I also used Cursor as my IDE because its AI pair programming helped me refactor the graph code quickly, and Perplexity for quick research during the build.

[Loading prompt card for Perplexity AI...]

Here’s the key dependency set:

pip install langgraph langchain langchain-anthropic langchain-openai sqlalchemy psycopg2-binary

I set up a .env file with my API keys. But the real setup was designing the graph state. In LangGraph, you define a state schema that every node reads from and writes to. I used a TypedDict to make the state explicit.

Step-by-Step: What I Actually Did§

I broke the system into five nodes: research, synthesize, draft, fact_check, and publish. Each node was an async function that called an LLM or a tool. The graph was constructed with StateGraph.

The first step was defining the state. I realized the state had to store not just the final article but also the research notes, the synthesis answers, and the fact-check reports. This is where durable execution state shines: if a node crashes, the state still holds everything from previous nodes.

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

class AgentState(TypedDict):
    topic: str
    research_notes: list
    synthesis: str
    draft: str
    fact_check_report: str
    final_article: str
    errors: list

def research(state: AgentState):
    # call a web search tool, collect notes
    notes = ["source1: claim about X", "source2: claim about Y"]
    return {"research_notes": notes}

def synthesize(state: AgentState):
    # combine notes into a coherent summary using Claude
    return {"synthesis": "Synthesized..."}

def draft(state: AgentState):
    # use DeepSeek to draft article based on synthesis
    return {"draft": "Article draft..."}

def fact_check(state: AgentState):
    # run verification against sources
    return {"fact_check_report": "Verified 85%...", "errors": []}

def publish(state: AgentState):
    # format and return final output
    return {"final_article": state["draft"]}

builder = StateGraph(AgentState)
builder.add_node("research", research)
builder.add_node("synthesize", synthesize)
builder.add_node("draft", draft)
builder.add_node("fact_check", fact_check)
builder.add_node("publish", publish)

builder.set_entry_point("research")
builder.add_edge("research", "synthesize")
builder.add_edge("synthesize", "draft")
builder.add_edge("draft", "fact_check")
builder.add_edge("fact_check", "publish")
builder.add_edge("publish", END)

# This is where durability comes in
from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("checkpoints.sqlite") as saver:
    graph = builder.compile(checkpointer=saver)

The critical line is builder.compile(checkpointer=saver). That single line gave me durable execution. Now every node step is recorded, and if the process dies, I can reload the graph and resume from the last successful node.

The next step was handling retries. I wrapped my LLM calls with a retry decorator that logged the error and counted attempts. At the graph level, I used LangGraph’s NodeInterrupt to manually pause when a node failed, allowing me to fix the issue and continue.

Code Samples / Prompts Used§

Here is the actual retry-aware node I used for the fact_check stage. It prompts Claude with a strict format and falls back to DeepSeek if Claude fails:

import asyncio
from langgraph.graph import StateGraph, END
from langgraph.types import NodeInterrupt

def fact_check(state: AgentState):
    for attempt in range(3):
        try:
            prompt = f"""
You are a fact-checker. Given the draft below, verify every claim against the provided sources.
Return a JSON with a 'report' list of {claim: bool} and 'overall_confidence'.
Draft: {state['draft']}
Sources: {state['research_notes']}
"""
            report = call_llm("claude-3-5-sonnet-20240620", prompt)
            parsed = json.loads(report)
            return {"fact_check_report": parsed, "errors": []}
        except Exception as e:
            if attempt == 2:
                # Use a fallback model instead of failing
                report = call_llm("deepseek-chat", prompt)
                return {"fact_check_report": json.loads(report), "errors": [str(e)]}
            await asyncio.sleep(2)
    return {"fact_check_report": None, "errors": ["Failed all attempts"]}

A critical prompt I used for the research node: I asked Claude to extract structured claims from raw search results. I used Perplexity’s Sonar API for the search itself, but the structuring was done by Claude. The prompt was:

You are a research assistant. Extract 5-8 key claims from the following search results. For each claim, include the source URL and a confidence score. Output as a JSON array.

That structured output made it trivial to feed into the next stage.

What Worked Well§

Durable execution was the game-changer. During a production run, I had a database connection drop while the draft node was running. With the SQLite checkpointer, I simply restarted the process, loaded the same thread_id, and the graph resumed from the last completed node (synthesize). The draft node re-executed, but I didn’t lose the research notes or the synthesis. That alone saved me hours of re-running API calls.

The state was also fully auditable. Every intermediate output—every research note, every draft version, every fact-check report—was stored in the checkpointer. I could inspect the entire execution history with graph.get_state(config) and trace exactly what each agent produced. This made debugging vastly easier.

Another huge win was the built-in human-in-the-loop support. I added a should_continue conditional edge that would pause if the fact-check report had a confidence below 0.8. The graph would emit a NodeInterrupt, and I could review the uncertain claims in a Slack message, then approve or reject. The ability to persist state across that pause was the only reason this was possible.

What Failed and Why§

Not everything went smoothly. The first design used a single LangGraph node that called a monolithic “meta-agent” prompt. That failed because the prompt exceeded context windows and the model’s attention degraded after the first few thousand tokens. I learned that breaking the workflow into specific nodes with narrow prompts and structured intermediate state was far more effective than trying to cram all the context into one prompt.

Another failure was using LangGraph’s default checkpointer with an in-memory object. I thought that was enough for development, but every time I restarted my Jupyter kernel, I lost the state. That forced me to switch to the SQLite saver early on. It was a silly but common mistake.

The bigger failure was not implementing idempotent retries at the graph level. I assumed that if a node failed, I could just retry it as-is. But the LLM calls in the node had side effects: they incremented API counters and sometimes their outputs depended on random sampling. When I resumed after a crash, the resumed node might produce a different output, leading to inconsistency in the final state. I solved this by making every node deterministic—seeding temperature to 0 and saving intermediate outputs to the state before the last step.

Results and Takeaways§

After moving to LangGraph with durable execution, my content pipeline’s success rate went from about 70% (with re-runs) to 99.3%. The average job time stayed roughly the same, but the manual recovery labour essentially vanished. I could run hundreds of jobs unattended, knowing that any transient failure would just halt the graph and wait for a retry or a human review.

The durable state also became a data asset. I could analyze every failed run to see exactly which node caused the issue, and I could replay runs with modified prompts without recomputing earlier stages. This turned a fragile script into a reliable, observable system.

Key Takeaways:

  • Durable execution state (checkpointing) turns fragile multi-agent pipelines into crash-resilient workflows that can resume exactly where they left off.
  • Modeling each agent as a graph node with a shared, typed state forces you to design clear interfaces and keeps context sizes manageable.
  • Make nodes idempotent where possible—always persist intermediate results and use low randomness to ensure resumable runs are consistent.
  • Human-in-the-loop is viable only when you can pause and resume without losing context; LangGraph’s interrupts plus a checkpointer make this production-ready.

Try It Yourself§

If you're building a multi-agent system, start with a simple two-node LangGraph and add the SQLite checkpointer from day one. Set up a state schema that captures every intermediate output. Write one node that can fail (e.g., an LLM call with a fake API key) and verify that you can resume without recomputing the earlier node.

Here’s a minimal starter you can copy and run:

from langgraph.graph import StateGraph, END
from typing_extensions import TypedDict
from langgraph.checkpoint.sqlite import SqliteSaver

class SimpleState(TypedDict):
    messages: list

def node_a(state):
    return {"messages": state["messages"] + ["A"]}

def node_b(state):
    return {"messages": state["messages"] + ["B"]}

with SqliteSaver.from_conn_string("test.db") as saver:
    graph = StateGraph(SimpleState)
    graph.add_node("a", node_a)
    graph.add_node("b", node_b)
    graph.set_entry_point("a")
    graph.add_edge("a", "b")
    graph.add_edge("b", END)
    compiled = graph.compile(checkpointer=saver)
    config = {"configurable": {"thread_id": "1"}}
    # Run it
    print(compiled.invoke({"messages": []}, config))
    # Restart and run again—state persists
    print(compiled.invoke({"messages": []}, config))

Run this, then kill the process and re-open it. You’ll see that the second invocation continues from the previous state. That’s the durable execution state in action. Go build something that doesn’t lose its memory—your future self will thank you.