← Back to Agentic AI map
Lesson 3.4 · Building with LangGraph

Cycles and Loops

Rebuild Module 1’s ReAct loop as a LangGraph cycle - agent -> tools -> agent - with llama3 deciding and a step limit that actually stops it.

langgraph

What you will be able to do

  • Explain what a cycle is and why agents need one
  • Build the agent -> tool -> agent loop with an edge back and a conditional exit
  • Map Thought, Action and Observation onto nodes, state and edges
  • Give a loop a stopping condition and a step limit - and know what happens without them
  • Run a real ReAct cycle with llama3 choosing the actions
  • Read a loop’s run step by step to find wasted or wrong steps

The idea, in plain English

Module 1’s agent was a for loop: ask the model, parse an action, run the tool, append the observation, repeat until a final answer or max_steps. A graph expresses the same thing with one extra edge: add_edge("tools", "agent") sends every tool result back to the agent. That edge back is a cycle.

A cycle alone runs forever. What makes it a loop you can trust is a conditional edge after the agent - should_continue(state) - with exits: an answer goes to END, too many steps goes to a give-up node, anything else goes to the tools. The state carries what the loop needs to decide: the history of actions and observations, the step count, the answer once there is one.

We built it twice. First with a hard-coded agent, to see the mechanics. Then with llama3 choosing each action through a schema-enforced Step (Lesson 2.14’s approach, since llama3 cannot call tools). Asked "Should I bring an umbrella in Mumbai, and what’s 12 * 8?", it called get_weather, then calculate, then answered: "Yes, bring an umbrella in Mumbai, and the result of 12 * 8 is 96."

The real runs also showed what loops do wrong: llama3 repeated calls it had already made, and once passed the calculator the wrong expression. The graph executed every step faithfully. A loop is only as good as its exits, its limits, and what you check inside it. LangGraph 1.2.14 throughout.

Worked example: The ReAct loop from Module 1, rebuilt as a graph cycle.

state machineThe ReAct loop as a graph - one real runstep 1 / 5

1 - Thought and action: the weather

The agent node shows llama3 the question and the (empty) history. It returns action get_weather, input Mumbai. should_continue sees no answer and few steps: go to tools.

step
1
action
get_weather("Mumbai")
answer
none yet
route
tools

"Should I bring an umbrella in Mumbai, and what’s 12 * 8?" - llama3 chooses each action; the tools node runs it. Three agent steps, two tool runs, 4.2 seconds.

From a for loop to a cycle

Module 1 wrote the loop as code: for step in range(max_steps): ask the model, stop on a final answer, parse the action, run the tool, append the observation. The loop lived inside a function, and the only way to see what it did was to print from inside it.

The graph version has the same parts, laid out as structure: an agent node (ask the model), a tools node (run what it asked for), a conditional edge after the agent (answer, limit, or another round), and a plain edge from tools back to agent. Nothing about ReAct changed. What changed is that the loop is now something you can draw, stream, and extend.

Module 1 -> Module 3
for step in range(max_steps)The cycle agent -> tools -> agent, plus a step count in state.
response = model(...)The agent node.
if final answer: returnshould_continue routes to END.
observation = run_tool(action)The tools node.
messages.append(observation)The tools node returns the longer history.
Thought / Action / ObservationAgent output / action in state / observation in history.

The mechanics, without a model

The first version uses a hard-coded agent: with no observation yet it asks for calculate; with one it writes the answer. The calculator returns "30". Streaming the run with stream_mode="updates" shows the cycle exactly: agent -> {step 1, action calculate}, calculator -> {observation "30"}, agent -> {step 2, answer "The calculation result is 30"}.

The agent runs twice because it has to see the tool’s result. That is the whole idea of ReAct: think, act, observe, think again.

Every loop needs an exit and a limit

A cycle plus a stopping condition is a loop; a cycle without one is a runaway. The stopping condition is should_continue: answer present -> END. The limit is a step count in state, incremented by the agent, checked by the same router.

We tested both failures. An agent that never answers, with MAX_STEPS = 5, ran agent -> calculator four times and stopped on the fifth agent step - with an empty answer. Better to route to a give_up node that says so: "Stopped after 6 steps without an answer." With no limit at all, LangGraph’s own guard caught it - after 10,007 node runs, the default recursion_limit. With a real model that is around five thousand LLM calls before anything stops.

Watch out: LangGraph’s default recursion_limit is 10,007. Treat it as a crash barrier, not a step limit: count steps in state, route to a give-up node, and pass a recursion_limit sized to your loop.

A real ReAct cycle with llama3

The agent node asks llama3 for a Step - action ("calculate", "get_weather" or "final_answer") and action_input - with structured output, so the reply is always a valid action. The tools node looks the action up and runs it: the model decides, the program executes. The observation goes into the history the agent sees next time.

The umbrella question took exactly the path a person would: get_weather("Mumbai") -> Rainy, 27°C; calculate("12 * 8") -> 96; then the answer. Mixed questions worked the same way - weather in Hyderabad, then the arithmetic. Each run took about 4 seconds.

What the real runs got wrong

Repetition. For "What is 125 * 48?" llama3 called calculate("125 * 48"), got 6000, and called it again before answering; for the Delhi weather it called get_weather three times. The loop still ended, but every repeat is a wasted model call and a wasted tool run. A small guard in the tools node fixed most of it: if the exact call is already in the history, do not run it again - reply "you already have this result (Sunny, 34°C); answer now". Delhi dropped from three weather calls to one.

A wrong argument. For "(250 - 37) * 4" llama3 sent calculate("250 - 37 * 4") - brackets dropped. The calculator correctly returned 102, and the model answered "the result of (250 - 37) * 4 is 102". Every node did its job; the answer is wrong. No loop guard catches that - it needs a check on what the model sends, or a test set of questions with known answers.

Five questions, llama3, real runs
Umbrella in Mumbai, and 12 * 8?get_weather -> calculate -> answer. Correct.
What is 125 * 48?calculate twice, then 6000. Correct, one wasted round.
Weather in Delhi?get_weather three times, then Sunny, 34°C. Correct, two wasted rounds - one with the repeat guard.
Raining in Hyderabad, and (250 - 37) * 4?Sent "250 - 37 * 4", answered 102. Wrong - the right answer is 852.
What is 17 * 23 + 9?calculate twice, then 400. Correct.

Debugging a loop

Stream the run and read it as a sequence of state changes. For each step ask: which node ran, what did it change, which route did should_continue pick, and why. Every trip around the cycle should move the state closer to an answer - a step that does not (the same call, the same observation) is the first thing to fix.

Keep the loop in the graph, not inside a node. A while True inside the agent node hides exactly what streaming would show you, and you do not need recursion either: the edge back is the loop.

Cycles beyond agents

The same shape covers retries (call -> failed -> attempts < 3 -> call), validation (generate -> validate -> fix -> validate), review (draft -> review -> revise -> review) and planning (plan -> execute -> observe -> re-plan). Each needs the same three parts: work, a decision, and an exit - plus a count so the exit is guaranteed.

Lesson 3.5 replaces the hand-written tools node with LangGraph’s prebuilt ToolNode.

Step-by-step code

The cycle, with a hard-coded agent
from typing import TypedDict from langgraph.graph import END, START, StateGraph class State(TypedDict): question: str step: int action: str observation: str answer: str MAX_STEPS = 5 def agent(state: State): step = state["step"] + 1 if state["observation"]: return {"step": step, "answer": f"The calculation result is {state['observation']}"} return {"step": step, "action": "calculate"} def calculator(state: State): return {"observation": str(10 + 20)} def should_continue(state: State): if state["answer"]: return "end" if state["step"] >= MAX_STEPS: return "end" return "tool" builder = StateGraph(State) builder.add_node("agent", agent) builder.add_node("calculator", calculator) builder.add_edge(START, "agent") builder.add_conditional_edges("agent", should_continue, {"tool": "calculator", "end": END}) builder.add_edge("calculator", "agent") # the edge back - this is the cycle graph = builder.compile() start = {"question": "What is 10 + 20?", "step": 0, "action": "", "observation": "", "answer": ""} for update in graph.stream(start, stream_mode="updates"): print(update) # {'agent': {'step': 1, 'action': 'calculate'}} # {'calculator': {'observation': '30'}} # {'agent': {'step': 2, 'answer': 'The calculation result is 30'}}
Without an exit - checked
An agent that never answers, MAX_STEPS = 5 agent -> calculator -> agent -> calculator -> agent -> calculator -> agent -> calculator -> agent stopped at step 5, answer: '' <- stopped, but says nothing The same agent, no step check at all GraphRecursionError: Recursion limit of 10007 reached without hitting a stop condition. 10007 node runs first graph.invoke(start, {"recursion_limit": 6}) GraphRecursionError: Recursion limit of 6 reached without hitting a stop condition.
A real ReAct cycle - llama3 decides, the tools node acts
from typing import Literal, TypedDict from langchain_ollama import ChatOllama from langgraph.graph import END, START, StateGraph from pydantic import BaseModel, Field TOOLS = {"calculate": calculate, "get_weather": get_weather} # Lesson 2.13's safe tools MAX_STEPS = 6 class Step(BaseModel): action: Literal["calculate", "get_weather", "final_answer"] action_input: str = Field(description="The expression, the city name, or the final answer text") decide = ChatOllama(model="llama3.1", temperature=0).with_structured_output(Step) SYSTEM = ( "You answer questions using two tools: get_weather(city) and calculate(expression). " "Choose ONE next action. Use a tool for each piece of information you do not have yet. " "When the Observations contain everything needed, reply with final_answer." ) class AgentState(TypedDict): question: str history: list step: int action: str action_input: str answer: str def agent(state: AgentState): transcript = "\n".join([f"Question: {state['question']}"] + state["history"]) step = decide.invoke([("system", SYSTEM), ("human", transcript)]) update = {"step": state["step"] + 1, "action": step.action, "action_input": step.action_input} if step.action == "final_answer": update["answer"] = step.action_input return update def tools(state: AgentState): call = f"Action: {state['action']}({state['action_input']!r})" if call in state["history"]: # the same call again done = state["history"][state["history"].index(call) + 1] note = f"Observation: you already have this result ({done[13:]}). Do not repeat it; answer now if you can." return {"history": state["history"] + [call, note]} result = TOOLS[state["action"]](state["action_input"]) # your code runs the tool return {"history": state["history"] + [call, f"Observation: {result}"]} def give_up(state: AgentState): return {"answer": f"Stopped after {state['step']} steps without an answer."} def should_continue(state: AgentState) -> Literal["tools", "give_up", "__end__"]: if state["answer"]: return END if state["step"] >= MAX_STEPS: return "give_up" return "tools" builder = StateGraph(AgentState) builder.add_node("agent", agent) builder.add_node("tools", tools) builder.add_node("give_up", give_up) builder.add_edge(START, "agent") builder.add_conditional_edges("agent", should_continue, ["tools", "give_up", END]) builder.add_edge("tools", "agent") builder.add_edge("give_up", END) graph = builder.compile() question = "Should I bring an umbrella in Mumbai, and what's 12 * 8?" start = {"question": question, "history": [], "step": 0, "action": "", "action_input": "", "answer": ""} print(graph.invoke(start, {"recursion_limit": 20})["answer"])
Output - llama3, real runs
Should I bring an umbrella in Mumbai, and what's 12 * 8? agent[get_weather('Mumbai')] -> tools: Rainy, 27°C agent[calculate('12 * 8')] -> tools: 96 agent[final] "Yes, bring an umbrella in Mumbai, and the result of 12 * 8 is 96." What's the weather in Delhi? without the repeat guard: get_weather('Delhi') x3, then the answer with the guard: get_weather('Delhi') -> agent repeats it -> "you already have this result" -> answer Is it raining in Hyderabad, and what is (250 - 37) * 4? agent[get_weather('Hyderabad')] -> tools: Cloudy, 29°C agent[calculate('250 - 37 * 4')] -> tools: 102 <- the model dropped the brackets agent[final] "... the result of (250 - 37) * 4 is 102." <- wrong: it is 852 graph.get_graph() edges __start__ --> agent; agent -.-> tools; agent -.-> give_up; agent -.-> __end__; tools --> agent; give_up --> __end__;

Tip: Pass a recursion_limit sized to your loop - two nodes per round, plus a little: with MAX_STEPS = 6, recursion_limit=20 is ample and still stops a bug long before 10,007.

Loops at a glance

The edge back

Makes the cycle.

builder.add_edge("tools", "agent")
should_continue

Exit, give up, or go round again.

add_conditional_edges("agent", should_continue, [...])
Step count in state

Your own limit, checked by the router.

if state["step"] >= MAX_STEPS: return "give_up"
give_up node

An honest answer when the limit is hit.

return {"answer": "Stopped after ..."}
recursion_limit

LangGraph’s hard stop - default 10,007.

graph.invoke(state, {"recursion_limit": 20})
GraphRecursionError

Raised when the hard stop is hit.

from langgraph.errors import GraphRecursionError

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Watch it go round

“Stream the hard-coded graph with stream_mode="updates" and count the agent runs.”

Break the exit

“Make the agent never answer. Run it with MAX_STEPS, then without, then with recursion_limit=6.”

Add a tool

“Add a third tool to the llama3 graph - a currency converter - and ask a question that needs all three.”

Catch the bracket bug

“Ask the llama3 graph about (250 - 37) * 4 several times. Add a check that compares the expression with the numbers in the question.”

What usually goes wrong

A cycle with no exit

A router that can only return the loop node never ends. Every cycle needs a route to END.

✗ def should_continue(state):
    return "tools"
✓ def should_continue(state):
    if state["answer"]:
        return END
    ...
No step limit

A correct exit can still be missed by a confused model. Without a count, LangGraph runs 10,007 nodes before stopping.

Stopping silently

Ending at the limit with an empty answer leaves the user with nothing. Route to a node that explains.

✗ if state["step"] >= MAX_STEPS:
    return END
✓ if state["step"] >= MAX_STEPS:
    return "give_up"   # writes "Stopped after N steps"
Hiding the loop inside one node

A while True inside the agent node defeats the graph - nothing to stream, nothing to extend. The edge back is the loop.

Letting the model write observations

Observations come from the tools node, never from the model - the lesson from Module 1 and 2.14.

Trusting the arguments

llama3 turned (250 - 37) * 4 into 250 - 37 * 4. The loop worked and the answer was wrong. Validate inputs and test with known answers.

Key points

  • A cycle is an edge back to an earlier node: add_edge("tools", "agent").
  • ReAct maps directly: agent node (think), tools node (act), history in state (observe).
  • A conditional edge after the agent decides: END, give_up, or another round.
  • Count steps in state and route to a give-up node - LangGraph’s own limit is 10,007.
  • With llama3 deciding, the umbrella question ran weather -> calculate -> answer.
  • Real loops repeat calls; a repeat guard in the tools node cut Delhi from three weather calls to one.
  • A correct loop can still give a wrong answer: the model sent 250 - 37 * 4.

Quick check before you move on

What makes a graph contain a cycle?
A path that leads back to a node that already ran - here, the edge from tools back to agent.
Why do agents need cycles?
They may need to act, observe and decide several times before they can answer, and nobody knows in advance how many.
What happens when the agent no longer needs a tool?
It writes an answer, and should_continue routes to END.
Why is a step limit important?
A confused model may never answer. Without your own limit, LangGraph stops only after 10,007 node runs.
Who executes the tool?
The tools node - your code. The model only chooses the action.

Quiz

  1. 1.

    In the hard-coded example, why does the agent node run twice?

  2. 2.

    An agent hits MAX_STEPS and the router returns END. What is wrong with that?

  3. 3.

    What did the repeat guard change?

  4. 4.

    The loop answered (250 - 37) * 4 with 102. Which part failed?

Interview questions

How does a ReAct agent map onto LangGraph?

An agent node calls the model, a tools node executes the chosen action and records the observation in state, an edge leads from tools back to the agent, and a conditional edge after the agent exits to END when there is an answer.

How do you prevent an infinite agent loop?

A logical exit in the router, a step count in state with a give-up route, and a recursion_limit sized to the loop as the hard stop - the default is 10,007.

How would you implement a retry workflow?

A node that does the work and records success and an attempt count; a conditional edge that routes success onward, failure with attempts left back to the node, and failure without attempts to an escalation node.

Why keep the loop in the graph rather than in a while loop inside a node?

The graph makes every step visible - streamable, traceable, checkpointable - and lets you add nodes such as validation or human approval inside the loop without rewriting it.

A loop finishes but gives a wrong answer. Where do you look?

At each step’s state update: which action and arguments the model chose and what the tool returned. In our run the model sent the wrong expression; the fix is input validation and a set of test questions with known answers.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...