Iterative Workflows — Loops & Cycles¶
1. Why this matters¶
Real LLM apps often need to try again:
- Generate a tweet → evaluate → if it's mediocre, regenerate with feedback.
- Call a tool → if it errors, retry with adjusted args.
- Write code → run tests → if failing, fix and retry.
Plain DAG chains can't express "go back and try again". LangGraph's cyclic graphs can.
2. Mental model¶
A loop is just a conditional edge that points backward + a termination condition:
flowchart LR
S((START)) --> G[generate]
G --> E[evaluate]
E --> R{good enough OR max_iter?}
R -->|no| O[optimize feedback]
O --> G
R -->|yes| EN((END))
Two ingredients you need:
- An iteration counter in state —
iteration: int. Increment in every loop body. - A
max_iterationsguard — without it, a broken graph runs forever.
3. Architecture / Flow¶
The "generate-evaluate-optimize" pattern in detail:
flowchart TD
START([START]) --> G[Generate v1]
G --> E[Evaluate quality]
E --> R{score >= threshold<br/>OR iter >= max?}
R -->|terminate| END([END])
R -->|retry| O[Optimize: incorporate feedback]
O --> G
4. Core concepts¶
- Loop = conditional edge to an earlier node. That's the whole trick.
- Counter field —
Annotated[int, operator.add]so each iteration's+1accumulates. - Termination guard — every loop MUST have at least one path to
END. Otherwise infinite. recursion_limit— global hard cap on total node executions in a run (default 25). Set higher when you genuinely need long loops, lower in production to fail fast on bugs.- Stateful feedback — store the evaluator's feedback in state so the next "generate" pass can use it.
5. Code — minimal working example¶
from typing import TypedDict, Annotated, Literal
from operator import add
from langgraph.graph import StateGraph, START, END
class S(TypedDict):
n: int
iteration: Annotated[int, add]
def double(state: S):
return {"n": state["n"] * 2, "iteration": 1}
def route(state: S) -> Literal["double", "__end__"]:
return END if state["n"] >= 100 else "double"
b = StateGraph(S)
b.add_node("double", double)
b.add_edge(START, "double")
b.add_conditional_edges("double", route, {"double": "double", END: END})
graph = b.compile()
print(graph.invoke({"n": 3, "iteration": 0}))
# Doubles 3 → 6 → 12 → 24 → 48 → 96 → 192, stops at first n >= 100
6. Code — real-world pattern¶
Tweet generate → evaluate → optimize loop (the CampusX pattern):
from typing import TypedDict, Annotated, Literal
from operator import add
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
from pydantic import BaseModel, Field
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
class Eval(BaseModel):
score: int = Field(description="quality score 1-10")
feedback: str = Field(description="how to improve")
class TweetState(TypedDict):
topic: str
draft: str
feedback: str
score: int
iteration: Annotated[int, add]
MAX_ITER = 4
GOOD_ENOUGH = 8
def generate_tweet(state: TweetState):
fb = state.get("feedback", "")
prompt = f"Write a sharp, witty tweet about: {state['topic']}."
if fb:
prompt += f"\nPrevious draft feedback: {fb}\nImprove accordingly."
out = llm.invoke([HumanMessage(prompt)])
return {"draft": out.content, "iteration": 1}
def evaluate_tweet(state: TweetState):
judge = llm.with_structured_output(Eval)
e = judge.invoke([
SystemMessage("Score the tweet 1-10 for wit, clarity, and topic fit. Give feedback."),
HumanMessage(state["draft"]),
])
return {"score": e.score, "feedback": e.feedback}
def optimize_tweet(state: TweetState):
# No-op pass-through; the feedback is already in state — generate uses it next loop
return {}
def route(state: TweetState) -> Literal["optimize", "__end__"]:
if state["score"] >= GOOD_ENOUGH or state["iteration"] >= MAX_ITER:
return END
return "optimize"
b = StateGraph(TweetState)
b.add_node("generate", generate_tweet)
b.add_node("evaluate", evaluate_tweet)
b.add_node("optimize", optimize_tweet)
b.add_edge(START, "generate")
b.add_edge("generate", "evaluate")
b.add_conditional_edges("evaluate", route, {"optimize": "optimize", END: END})
b.add_edge("optimize", "generate") # loop back
graph = b.compile()
final = graph.invoke({
"topic": "vector databases for AI engineers",
"draft": "", "feedback": "", "score": 0, "iteration": 0,
})
print(f"After {final['iteration']} iteration(s), score={final['score']}")
print(final["draft"])
Visualize the cycle:
Set a recursion safety net at invoke time:
7. Common pitfalls¶
- ❗ No iteration counter. A bug in the router can loop forever. Always track
iterationand check it. - ❗ Counter not using
operator.add. Without the reducer, every iteration replaces the counter with1instead of adding to it. - ❗ Feedback overwritten by next iteration. Make sure evaluator's feedback survives into the next generate — store in state and read it in the generator.
- ❗ Termination condition checked in the wrong node. Check it in the router right after evaluation, not inside the generator (cleaner separation).
- ❗ Cost runaway. Each loop is N LLM calls. Cap iterations and warn when budget grows.
8. When to use vs not use¶
| Use a loop when | Don't when |
|---|---|
| Quality must improve through retries | Single-pass is good enough |
| You can score the output | You can't measure when to stop |
| Feedback can inform the next try | Retrying doesn't change anything |
| You need to call a tool until it succeeds | Use try/except inside a node |
9. Cheatsheet¶
# Recipe: generate → check → loop or end
from typing import Annotated, TypedDict, Literal
from operator import add
class LoopState(TypedDict):
output: str
iteration: Annotated[int, add] # crucial: use reducer
# ...
def step(state):
return {"output": new_output, "iteration": 1}
def router(state) -> Literal["step", "__end__"]:
if good_enough(state) or state["iteration"] >= MAX:
return END
return "step"
builder.add_conditional_edges("step", router,
{"step": "step", END: END})
# At runtime
graph.invoke(initial, config={"recursion_limit": 20})
10. Q&A — recall test¶
-
Q: How do you create a loop in LangGraph? A: Add a conditional edge from a downstream node back to an earlier one. Combine with a termination check.
-
Q: Why does the iteration counter need
operator.add? A: Without a reducer, each{"iteration": 1}write replaces the value, so it stays at 1 forever. Withadd, each write increments it. -
Q: Where should the termination decision live? A: In the router function. The router reads state (including iteration count) and returns either the loop-back node or
END. -
Q: What is
recursion_limit? A: A safety cap on total node executions per run. Default 25. Protects against buggy graphs that don't terminate. -
Q: How does feedback from one iteration influence the next? A: Store it in state. The generator node reads
state["feedback"]next time it runs.
Practice¶
What does this print?
Expected: 5
Set recursion_limit so the graph doesn't loop forever
Expected: True
Quiz — Quick check¶
What you remember
Q1. How does LangGraph implement a loop?
- A conditional edge that points back to an earlier node when a condition is true; points to
ENDwhen false - A
forloop inside a node - Special loop construct
- Not supported
Why: LangGraph's "loops" are just cycles in the graph. The router decides "keep looping" or "exit" based on state. Each iteration is a full pass through the loop nodes.
Q2. What's recursion_limit and why is it important?
- Maximum number of steps the graph can execute — prevents infinite loops from runaway agents
- Number of nodes allowed
- Memory limit
- Depth of nested calls
Why: Without a recursion limit, a misbehaving agent could loop forever. Set it explicitly (e.g., 25) when invoking. Hitting it raises a clean error you can handle.
Q3. A "Reflect" loop in LangGraph typically alternates between which two roles?
- Generator and Critic — one produces, the other critiques; loop until critic approves
- Read and Write
- Input and Output
- Train and Test
Why: Iterative refinement pattern. Generator creates output → critic evaluates → if not good enough, feedback goes back to generator. Common for writing, code generation, planning.
Common doubts¶
When should I use a loop instead of multiple sequential calls?
When the number of iterations is dynamic — depends on the LLM's output or some condition. If you always need exactly 3 refinements, sequential is fine. If you need "refine until good enough", a loop is the answer.
How do I avoid infinite loops?
(1) Set recursion_limit explicitly. (2) Add an iteration counter to state; the router checks it and exits at max. (3) Validate exit conditions — if the LLM never says "done", the loop runs forever. Hard-code a max number of refinements as a safety net.
Can I have nested loops?
Yes — nested cycles in the graph. Be cautious; nested loops compound fast. Often clearer to flatten into one loop with a multi-state router (e.g., "phase 1 then phase 2").