Skip to content

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:

  1. An iteration counter in state — iteration: int. Increment in every loop body.
  2. A max_iterations guard — 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 fieldAnnotated[int, operator.add] so each iteration's +1 accumulates.
  • 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:

graph.get_graph().print_ascii()
# generate → evaluate → optimize → (back to) generate
#                   └→ END

Set a recursion safety net at invoke time:

graph.invoke(initial, config={"recursion_limit": 15})

7. Common pitfalls

  • No iteration counter. A bug in the router can loop forever. Always track iteration and check it.
  • Counter not using operator.add. Without the reducer, every iteration replaces the counter with 1 instead 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. With add, 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

# Simulating a "while loop" in graph form: increment until threshold
state = {"count": 0}
while state["count"] < 5:
    state["count"] += 1
print(state["count"])

Set recursion_limit so the graph doesn't loop forever

Expected: True

config = {"recursion_limit": None}      # bug: unlimited recursion = potential infinite loop
safe = config["recursion_limit"] is not None
print(not safe)

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 END when false
  • A for loop 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").