LangGraph conditional edges are the primitive that turns a straight pipeline into a state machine: they let a node decide the next hop based on the current state, and when you point that next hop backward you get a cycle. This guide gives an ordered path to build, debug, and bound such loops in production, with the exact graph APIs and the mistakes that will cost you hours.
1. Define state before you define nodes
State is the contract. In LangGraph it’s a TypedDict; fields without a reducer are replaced by the node’s return, fields with an Annotated reducer are merged. Get this wrong and your cycle silently drops data.
from typing import TypedDict, Annotated
import operator
class AgentState(TypedDict):
query: str
drafts: Annotated[list[str], operator.add]
attempts: int
satisfied: bool
Here drafts accumulates across loop iterations; attempts is overwritten each step unless we return the incremented value. Use Annotated only for append/sum semantics.
2. Write nodes that return partial state
A node is a callable taking the state and returning a dict patch. Keep side effects (LLM calls, tool calls) inside, but don’t mutate the input dict—return the delta.
def draft_answer(state: AgentState) -> dict:
# imagine an LLM call here
text = f"attempt {state['attempts']+1}: placeholder"
return {
"drafts": [text],
"attempts": state["attempts"] + 1,
"satisfied": False,
}
def evaluate(state: AgentState) -> dict:
# heuristic or model-based check
last = state["drafts"][-1]
if "placeholder" not in last:
return {"satisfied": True}
return {}
evaluate returns an empty dict when not satisfied, leaving satisfied as-is (False). That’s intentional: we don’t want to flip it accidentally.
3. Assemble a linear skeleton first
Build the StateGraph, add nodes, and wire a start-to-end path without loops. Confirm it compiles before introducing branches.
from langgraph.graph import StateGraph, START, END
builder = StateGraph(AgentState)
builder.add_node("draft", draft_answer)
builder.add_node("eval", evaluate)
builder.add_edge(START, "draft")
builder.add_edge("draft", "eval")
builder.add_edge("eval", END)
graph = builder.compile()
Run it once with graph.invoke({"query": "x", "drafts": [], "attempts": 0, "satisfied": False}). You should see one draft and an end. This baseline removes graph-shape bugs from later debugging.
4. Insert LangGraph conditional edges
The conditional edge takes a routing function and a mapping from returned strings to node names. This is where branching lives.
def route_after_eval(state: AgentState) -> str:
if state["satisfied"]:
return "finish"
if state["attempts"] >= 5:
return "cap"
return "loop"
builder.add_conditional_edges(
"eval",
route_after_eval,
{
"finish": END,
"loop": "draft",
"cap": END,
},
)
We replaced the unconditional eval -> END with a branch. The mapping keys must match every string the router can return; LangGraph raises at runtime if a key is missing. That strictness is good—it catches typos early.
5. Close the cycle deliberately
Pointing "loop" back to "draft" creates the cycle. The full wiring now looks like:
builder.add_edge(START, "draft")
builder.add_edge("draft", "eval")
# conditional edges from eval as above
graph = builder.compile()
Executing this runs draft → eval → draft → eval … until satisfied or attempts >= 5. The cycle is just an edge to a previous node; there’s no special “loop” API.
6. Bound every cycle
Unbounded cycles are the fastest way to blow up latency and token spend. Always encode a termination condition in state. The attempts counter above is the minimal guard. If you need richer exit logic, compute it in the router:
def route_after_eval(state: AgentState) -> str:
if state["satisfied"]:
return "finish"
if state["attempts"] >= MAX_ITERS:
# log divergence, maybe surface to human
return "cap"
return "loop"
Tradeoff: a low cap saves cost but may truncate incomplete work. A high cap improves completion at linear cost per extra round. Pick the cap from observed task difficulty, not guesswork.
7. Multi-target routing patterns
LangGraph conditional edges aren’t limited to binary choices. A classifier node can fan out to specialized handlers:
def classify(state: AgentState) -> str:
# route on query intent
if state["query"].startswith("refund"):
return "billing"
if state["query"].startswith("bug"):
return "engineering"
return "general"
builder.add_conditional_edges(
START,
classify,
{"billing": "billing_node", "engineering": "eng_node", "general": "gen_node"},
)
Each target can itself loop back to classify for re-routing after partial processing. Keep the router pure; push side effects into the target nodes.
8. Common pitfalls
Reducer omission. Forgetting Annotated[list, operator.add] means each loop iteration overwrites drafts with a one-item list. You’ll lose history and your eval node will see only the last step.
Missing END mapping. If the router returns "finish" but the mapping lacks that key, compilation succeeds but invocation throws. Unit-test the router with representative states.
State mutation. Returning the same dict you were passed, mutated, causes subtle merge bugs. Always construct a new dict.
Checkpointer interplay. When you attach a MemorySaver or other checkpointer, cycles persist across process restarts. That’s powerful for human-in-the-loop, but a mis-set attempts can resume an already-capped loop. Initialize counters from the persisted state, not from defaults.
External call failures. Nodes that call model endpoints can hang or rate-limit. If your draft_answer node throws, the whole cycle dies unless you wrap it. When nodes invoke LLMs, route through an inference gateway that honors client routing directives and provides automatic fallback (e.g., n4n.ai) so a provider outage doesn’t stall your cycle. Otherwise, catch exceptions inside the node and return a state flag the router can act on.
9. Debug with graph visualization
Before adding logging, render the compiled graph:
print(graph.get_graph().draw_mermaid())
This shows nodes and edges, including conditional branches, as a Mermaid diagram. If the cycle isn’t visible as a backward arrow, your edge mapping is wrong. For step-level inspection, invoke with config={"recursion_limit": 10} to fail fast on runaway loops during tests.
10. Production checklist
- State schema reviewed: reducers only where accumulation is needed.
- Every conditional router has a test for each returned string.
- Max iteration or timeout guard present in every cycle.
- Nodes return deltas, never mutate input.
- Checkpointer configured if resumability is required; counters validated on resume.
- External LLM calls wrapped or routed through a fallback-capable gateway.
LangGraph conditional edges plus explicit cycles give you precise control over agent loops. Treat the graph as code, test the routers like functions, and bound the loops like you’d bound any retry policy.