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: intattempts 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 stateEach 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_edgesis 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.
