NovuSpark
All articles

December 26, 2025 · NovuSpark Team

Building Your First Stateful Agent with LangGraph

This is the second post in our LangGraph fundamentals series, building on why graphs instead of chains.

The previous post's example was intentionally simple — a straight line, to introduce nodes, edges, and state without any real complexity yet. This post builds the workflow that actually motivates reaching for LangGraph in the first place: generate an answer, evaluate whether it's actually good enough, and loop back to regenerate if it isn't — a genuine loop with real state carried across every attempt, which a linear chain has no natural way to express.

Defining state that accumulates across a loop

from typing import TypedDict
from langgraph.graph import StateGraph, END
 
class ResearchState(TypedDict):
    question: str
    draft_answer: str
    quality_score: int
    attempts: int

attempts here is the detail that matters: this field needs to persist and increment across multiple passes through the same nodes — exactly what a shared, persistent state object (rather than each step's output only feeding the immediately next step, as in a plain chain) is actually for.

Writing the nodes

def draft(state: ResearchState) -> ResearchState:
    response = model.invoke(f"Answer this question: {state['question']}")
    state["draft_answer"] = response.content
    state["attempts"] += 1
    return state
 
def evaluate(state: ResearchState) -> ResearchState:
    eval_prompt = (
        f"Rate this answer's quality from 1-10, respond with just the number.\n\n"
        f"Question: {state['question']}\nAnswer: {state['draft_answer']}"
    )
    response = model.invoke(eval_prompt)
    state["quality_score"] = int(response.content.strip())
    return state

Each node is an ordinary function taking the current state and returning an updated version of it — nothing about a node itself is LangGraph-specific; it's the graph structure connecting them that provides the actual looping behavior.

The conditional edge: where the actual loop happens

def should_continue(state: ResearchState) -> str:
    if state["quality_score"] >= 7:
        return "end"
    if state["attempts"] >= 3:
        return "end"  # give up after 3 attempts, don't loop forever
    return "retry"
 
graph = StateGraph(ResearchState)
graph.add_node("draft", draft)
graph.add_node("evaluate", evaluate)
 
graph.set_entry_point("draft")
graph.add_edge("draft", "evaluate")
graph.add_conditional_edges(
    "evaluate",
    should_continue,
    {"retry": "draft", "end": END},
)
 
app = graph.compile()
result = app.invoke({"question": "Explain quantum computing simply.", "draft_answer": "", "quality_score": 0, "attempts": 0})
print(f"Final answer (after {result['attempts']} attempts): {result['draft_answer']}")
Final answer (after 2 attempts): Quantum computing uses quantum bits,
or qubits, which can represent both 0 and 1 simultaneously...

add_conditional_edges is the actual mechanism making this a graph rather than a chain: after evaluate runs, should_continue inspects the current state and returns a string key, which determines which node runs next — draft again (a genuine loop back to an earlier step) or END (finishing the graph entirely). This is precisely the shape a plain LCEL chain has no clean way to express: the next step depends on a runtime decision based on the accumulated state, not a fixed position in a fixed sequence.

The safeguard that matters as much as the loop itself

Notice should_continue checks attempts >= 3 before checking whether quality is still insufficient. Without an explicit cap like this, a workflow where quality genuinely never reaches the target threshold loops indefinitely — the exact same "bound your loop before you rely on it converging" instinct that matters for the retry-with-backoff logic covered in our OpenAI API post, or the max_iterations safeguard on a LangChain agent, covered in our previous series. Any loop driven by a runtime, model-judged condition needs an explicit, unconditional exit, not just an optimistic assumption that the condition will eventually be satisfied.

Why this genuinely needed a graph

Try expressing this same "draft, evaluate, conditionally loop back" workflow as a plain LCEL chain, and there's no clean way to do it — a chain has no built-in concept of "go back to an earlier step based on this step's result." An AgentExecutor's loop (from our LangChain series) could approximate something similar by treating "regenerate" as a tool the agent chooses to call, but that overloads the tool-calling mechanism for something that's really a structural control-flow decision, not an action taken in the world. LangGraph's conditional edges express this pattern directly, as the actual structural feature it is.

What to actually remember from this post

  • A shared state object is what lets attempts (and other accumulated values) persist correctly across multiple loop iterations — not something a plain chain naturally supports.
  • add_conditional_edges is the actual loop mechanism — a function inspects state and returns a key determining which node runs next, including looping back to an earlier one.
  • Always cap a runtime-driven loop with an explicit, unconditional exit — an "evaluate and retry" loop with no attempt limit can run indefinitely if the quality bar is never met.
  • This pattern (generate, evaluate, conditionally retry) is a genuine example of something a plain chain can't cleanly express — the actual justification for reaching for a graph here, not just added complexity for its own sake.

Next in the series: LangGraph Conditional Edges and Control Flow, where we go deeper on branching patterns beyond the simple retry loop shown here.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.