Module 5: Multi-Step Reasoning and Planning
2. ReAct Deep Dive
Description
You've used ReAct three times already. In Module 1 you implemented the loop by hand with the OpenAI SDK — prompt, parse tool_calls, execute, append, repeat — and then you replicated it with create_react_agent. In Module 4 you turned that same loop into a StateGraph with reason and tools nodes connected by conditional edges. You know what ReAct does. What you don't know is how it does it under the hood.
When you call create_react_agent(model, tools), the framework injects a system prompt you never see. That invisible prompt tells the model how to interleave reasoning with action, when to use tools and when to answer directly. It sets the rules of the game. If you don't know that prompt, you're operating blind — you can't diagnose why the agent picked the wrong tool, why it generated a redundant Thought, or why it decided to finish early. This capsule opens that black box.
What you'll do here is different from everything before: you're going to read ReAct's internal prompt, understand the real stop conditions (beyond max_iterations), learn to debug the reasoning chain step by step, and customize the agent's behavior with custom system prompts. By the end, the difference between "my agent works but I don't know why" and "I know exactly what it decided at each step and I can tune it" will be the difference between you before and after this capsule.
Opening ReAct's Black Box
What happens when you call create_react_agent
When you write create_react_agent(model, tools), LangGraph does five invisible things:
- Builds a StateGraph with two nodes:
agent(reasoning) andtools(execution) - Injects a system prompt that instructs the model on how to reason
- Binds the tools to the model with
model.bind_tools(tools) - Configures the routing — a conditional edge that sends to
toolsif there are tool_calls, or to END if not - Defines stop conditions — a default
recursion_limitto prevent infinite loops
You see agent = create_react_agent(model, tools). What exists internally is something like:
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage, SystemMessage
from typing import TypedDict, Annotated
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
def agent_node(state: AgentState) -> dict:
system_prompt = SystemMessage(content=REACT_SYSTEM_PROMPT)
messages = [system_prompt] + state["messages"]
response = model_with_tools.invoke(messages)
return {"messages": [response]}
def tool_node(state: AgentState) -> dict:
# Execute every tool_call from the last message
...
def should_continue(state: AgentState) -> str:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
graph.add_edge("tools", "agent")
That's the skeleton. But the detail that makes the difference is in REACT_SYSTEM_PROMPT — the prompt you never see and which controls the agent's entire behavior.
The key decision: think vs act
On every iteration of the loop, the model makes a binary decision:
| Decision | Signal | What it means |
|---|---|---|
| Act | Returns tool_calls in the AIMessage | The model needs external information |
| Answer | Returns content with no tool_calls | The model has enough information to answer |
There's no third option. The model can't "think out loud" separately (as in the original ReAct paper) — in LangGraph's implementation, the "Thought" is embedded in the decision of which tool to call and with what arguments. When the model chooses search("best testing practices in Python"), the implicit Thought is "I need to look up information about testing in Python." When it returns with no tool_calls, the implicit Thought is "I already have what I need."
This differs from the original ReAct paper (Yao et al., 2022), where Thought, Action, and Observation were explicit textual steps:
Thought: I need to look up the population of France
Action: search("population of France")
Observation: France has a population of approximately 67.75 million
Thought: I have the information. I'm going to answer.
Answer: The population of France is approximately 67.75 million.
In LangGraph's implementation, Thoughts aren't visible text — they're implicit model decisions expressed through the presence or absence of tool_calls. This has direct implications for debugging: you can't read the agent's "thinking" directly; you have to infer it from its actions.
The full cycle: a real step-by-step
Let's run an agent and trace each step:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool
@tool
def web_search(query: str) -> str:
"""Search the web for up-to-date information."""
return f"Results for '{query}': Python 3.13 was released in October 2024 with GIL improvements."
@tool
def calculator(expression: str) -> str:
"""Evaluate a mathematical expression."""
return str(eval(expression))
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [web_search, calculator])
result = agent.invoke(
{"messages": [("user", "What's the latest version of Python and how much is 2**10?")]},
{"recursion_limit": 20}
)
for msg in result["messages"]:
print(f"[{msg.__class__.__name__}] {msg.content[:100] if msg.content else 'tool_calls: ' + str(msg.tool_calls)}")
The trace shows something like:
[HumanMessage] What's the latest version of Python and how much is 2**10?
[AIMessage] tool_calls: [web_search("latest Python version"), calculator("2**10")]
[ToolMessage] Results for 'latest Python version': Python 3.13 was...
[ToolMessage] 1024
[AIMessage] The latest version of Python is 3.13, and 2^10 = 1024.
Notice: the model made parallel tool calls — it called web_search and calculator at the same time. It didn't need to wait for one result to call the other. That's a real optimization that cuts latency in half in this case.
ReAct's Internal Prompt
The system prompt you never see
When you use create_react_agent with the prompt parameter, that text is injected as a system message. But even without a custom prompt, the framework structures the context in a specific way. ReAct behavior doesn't emerge magically from the model — it emerges from how the conversation is presented.
The effective structure the model receives on each invocation is:
[System] Your system prompt (if you passed one) or default instructions
[Human] The user's original question
[AI] Tool calls from iteration 1 (if any)
[Tool] Result of tool 1
[Tool] Result of tool 2
[AI] Tool calls from iteration 2 (if any)
[Tool] Result of tool 3
...
The model sees the entire history of the conversation on every iteration. This is crucial: when the agent decides whether it needs more information or can answer, it's looking at all the previous tool results, not just the last one. The agent's "working memory" is literally the list of messages.
How the system prompt influences the reasoning
You can pass an explicit system prompt to create_react_agent:
agent = create_react_agent(
model,
tools=[web_search, calculator],
prompt="You are a research assistant. Always verify information with a search before answering. If you're not sure, search more."
)
That prompt changes the agent's reasoning behavior:
| Without a custom prompt | With a research prompt |
|---|---|
| Answers directly if it "knows" the answer | Always searches before answering |
| Uses tools only when necessary | Uses tools proactively |
| Finishes fast (1-2 iterations) | More iterations (2-4), more thorough |
| Lower cost ($0.01-0.02) | Higher cost ($0.03-0.06), better result |
Anatomy of an effective ReAct prompt
A prompt for a ReAct agent has three components:
REACT_RESEARCH_PROMPT = """You are a specialized research agent.
## Role and Behavior
- Research thoroughly before answering
- Use the available tools to get up-to-date data
- Don't make up information — if you can't find it, say so explicitly
## Research Strategy
- For factual questions: search first, answer after
- For calculations: use the calculator, don't compute mentally
- For compound questions: split into sub-questions and solve each one
## Response Format
- Answer in English
- Cite sources whenever possible
- If the information is contradictory, mention the different perspectives
"""
agent = create_react_agent(model, tools, prompt=REACT_RESEARCH_PROMPT)
Each section plays a role:
| Section | What it controls | Effect on the agent |
|---|---|---|
| Role and Behavior | Identity and rules | Defines when to search vs answer directly |
| Research Strategy | Reasoning logic | Guides the sequence of tool calls |
| Response Format | Final output | Structures the answer to the user |
Experiment with the prompt to see the effect
Create two agents with opposite prompts and compare their behavior on the same question:
agent_minimo = create_react_agent(
model,
tools=[web_search, calculator],
prompt="Answer as briefly as possible. Use tools only if absolutely necessary."
)
agent_thorough = create_react_agent(
model,
tools=[web_search, calculator],
prompt="Research exhaustively. Run multiple searches with different queries. Verify the data by cross-checking sources."
)
query = {"messages": [("user", "How many people live in Ciudad de México?")]}
result_min = agent_minimo.invoke(query, {"recursion_limit": 20})
result_thor = agent_thorough.invoke(query, {"recursion_limit": 20})
print(f"Minimal agent: {len(result_min['messages'])} messages")
print(f"Thorough agent: {len(result_thor['messages'])} messages")
Typically you'll see: the minimal agent answers with 3 messages (user → AI with 1 tool call → answer), while the thorough one generates 7-9 messages (multiple searches, cross-verification). Same model, same tools — the prompt changed the reasoning.
Advanced Stop Conditions
The stop conditions you already know
In M1 and M4 you worked with two stop conditions:
- No tool_calls: the model returns an AIMessage with
contentbut notool_calls→ the agent is done - recursion_limit: the graph reaches the maximum number of steps → it stops by force
These are enough for prototypes. In production, you need more granularity.
recursion_limit: the real mechanics
recursion_limit counts graph steps, not agent iterations. If your graph has an agent → tools → agent cycle, each iteration consumes 2 steps. So recursion_limit=10 allows ~5 full agent iterations.
agent = create_react_agent(model, tools)
try:
result = agent.invoke(
{"messages": [("user", "Research everything about quantum computing")]},
{"recursion_limit": 6}
)
except Exception as e:
print(f"Limit reached: {e}")
If the agent needs 4 iterations (8 steps) but the limit is 6, it gets interrupted halfway through the third cycle. The agent produces no final answer — it just stops. That's a problem in production because the user gets nothing useful.
Stop condition with a fallback message
To prevent the agent from dying silently when it hits the limit, implement a wrapper that catches the interruption and generates a partial answer:
from langchain_core.messages import HumanMessage
def invoke_with_fallback(agent, query: str, max_iterations: int = 5):
"""Invoke the agent with a fallback if it hits the limit."""
recursion_limit = max_iterations * 2 + 1
try:
result = agent.invoke(
{"messages": [("user", query)]},
{"recursion_limit": recursion_limit}
)
return result
except Exception:
result = agent.invoke(
{"messages": [
("user", query),
("assistant", "I researched partially but reached my iteration limit."),
("user", "With the information you have so far, give the best possible answer.")
]},
{"recursion_limit": 3}
)
return result
result = invoke_with_fallback(agent, "Explain the history of machine learning", max_iterations=3)
print(result["messages"][-1].content)
Stop condition based on tokens/cost
In production, cost matters. You can implement a stop condition that monitors consumed tokens:
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
class BudgetState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
total_tokens: int
max_tokens_budget: int
def agent_node(state: BudgetState) -> dict:
response = model_with_tools.invoke(state["messages"])
tokens_used = response.usage_metadata.get("total_tokens", 0) if hasattr(response, "usage_metadata") and response.usage_metadata else 0
return {
"messages": [response],
"total_tokens": state.get("total_tokens", 0) + tokens_used
}
def should_continue(state: BudgetState) -> str:
if state.get("total_tokens", 0) >= state.get("max_tokens_budget", 10000):
return "budget_exceeded"
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
This lets you say "this agent has a budget of 5,000 tokens" and have it stop before exceeding it, instead of discovering the cost afterward.
Stop condition based on quality
The most sophisticated one: the agent evaluates whether its answer is good enough before finishing. This is a preview of reflection (capsule 05), but the stop condition mechanics are useful on their own:
def quality_check_node(state: BudgetState) -> dict:
"""Evaluate whether the current answer is good enough."""
last_response = state["messages"][-1].content
check_prompt = f"""Rate this answer on a scale of 1-10:
Answer: {last_response}
Criteria:
- Does it answer the question directly? (1-3 points)
- Does it include specific data? (1-3 points)
- Is it coherent and complete? (1-4 points)
Reply with the number ONLY."""
score_response = model.invoke([("user", check_prompt)])
try:
score = int(score_response.content.strip())
except ValueError:
score = 5
from langchain_core.messages import SystemMessage
return {
"messages": [SystemMessage(content=f"[QUALITY_SCORE: {score}/10]")]
}
def route_after_quality(state: BudgetState) -> str:
for msg in reversed(state["messages"]):
if hasattr(msg, "content") and "[QUALITY_SCORE:" in msg.content:
score = int(msg.content.split(":")[1].split("/")[0].strip())
if score >= 7:
return "deliver"
return "retry"
return "deliver"
The trade-off: every quality check is an additional LLM call (~$0.005-0.01). In a 5-iteration investigation, adding quality checks doubles the cost. But if your agent delivers bad answers 30% of the time, the quality check may be worth it.
Debugging the Reasoning
The problem: invisible reasoning
When a ReAct agent fails, the symptom is obvious — wrong answer, infinite loop, wrong tool. The cause is invisible — at which reasoning step did it go wrong? Did it pick the wrong tool? Did it misread a result? Did it finish too soon?
Without debugging the reasoning, you're guessing. With debugging, you're diagnosing.
Level 1: Manual message inspection
The most basic and the most useful. After running the agent, examine each message:
result = agent.invoke({"messages": [("user", "What is Mexico's GDP and how much is that in euros?")]})
for i, msg in enumerate(result["messages"]):
msg_type = msg.__class__.__name__
if msg_type == "HumanMessage":
print(f"\n--- Step {i}: INPUT ---")
print(f" {msg.content}")
elif msg_type == "AIMessage":
if msg.tool_calls:
print(f"\n--- Step {i}: DECISION → ACT ---")
for tc in msg.tool_calls:
print(f" Tool: {tc['name']}({tc['args']})")
else:
print(f"\n--- Step {i}: DECISION → ANSWER ---")
print(f" {msg.content[:200]}")
elif msg_type == "ToolMessage":
print(f"\n--- Step {i}: OBSERVATION ---")
print(f" {msg.content[:200]}")
Example output:
--- Step 0: INPUT ---
What is Mexico's GDP and how much is that in euros?
--- Step 1: DECISION → ACT ---
Tool: web_search({'query': 'Mexico GDP 2024'})
Tool: web_search({'query': 'USD EUR exchange rate today'})
--- Step 2: OBSERVATION ---
Mexico's GDP: approximately 1.3 trillion USD...
--- Step 3: OBSERVATION ---
1 USD = 0.92 EUR...
--- Step 4: DECISION → ACT ---
Tool: calculator({'expression': '1.3e12 * 0.92'})
--- Step 5: OBSERVATION ---
1196000000000.0
--- Step 6: DECISION → ANSWER ---
Mexico's GDP is approximately 1.3 trillion dollars...
With this trace you can see the full reasoning chain: the model split the question into two parallel searches, got the data, then computed the conversion, and finally synthesized. If something is wrong, you know exactly where to look.
Level 2: Step-by-step streaming
For slow agents or ones with many iterations, you don't want to wait until the end. Use streaming:
for step in agent.stream(
{"messages": [("user", "Research the trends in AI agents for 2026")]},
{"recursion_limit": 20},
stream_mode="updates"
):
for node_name, update in step.items():
print(f"\n{'='*50}")
print(f"Node: {node_name}")
if "messages" in update:
for msg in update["messages"]:
if hasattr(msg, "tool_calls") and msg.tool_calls:
for tc in msg.tool_calls:
print(f" → Calling: {tc['name']}({tc['args']})")
elif msg.content:
print(f" → {msg.content[:150]}")
Streaming gives you real-time visibility. Instead of waiting 30 seconds and getting the final result, you see each decision as it happens. This is indispensable when the agent enters a loop — you catch it immediately instead of waiting for the timeout.
Level 3: LangSmith traces
LangSmith is LangChain's official observability tool. It gives you the full trace with timings, tokens, and costs:
import os
os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_API_KEY"] = "ls__..."
os.environ["LANGSMITH_PROJECT"] = "react-debugging"
result = agent.invoke({"messages": [("user", "What is MCP and what is it used for?")]})
In the LangSmith dashboard you see:
| Data | What it shows you |
|---|---|
| Trace timeline | Visual sequence of every LLM call and tool execution |
| Latency per step | How many ms each node took (reason: 1200ms, tool: 300ms) |
| Tokens per step | Input/output tokens of each LLM call |
| Accumulated cost | Total $ for the full execution |
| Exact input/output | The exact prompt and response of every call to the model |
Common failure patterns and how to diagnose them
When you examine the reasoning trace, you look for these patterns:
Pattern 1: Tool loop — the agent calls the same tool with the same arguments over and over.
Step 1: web_search("AI trends 2026")
Step 2: web_search("AI trends 2026") ← Repetition
Step 3: web_search("AI trends 2026") ← Loop detected
Cause: the tool result doesn't provide enough information and the model doesn't know what else to do. Fix: improve the system prompt so the agent varies its queries, or add a repetition detector.
Pattern 2: Premature termination — the agent answers without using tools when it should have used them.
Step 0: "What's the current price of Bitcoin?"
Step 1: "Bitcoin's price is approximately $45,000" ← Answered without searching
Cause: the model "believes" it knows the answer (training data). Fix: a system prompt that forces a search for data that changes: "Always search for prices, statistics, and recent data — never use your prior knowledge for data that changes over time."
Pattern 3: Wrong tool — the agent picks a tool that doesn't apply.
Step 0: "How much is 15% of 340?"
Step 1: web_search("15% of 340") ← Should use calculator
Cause: the tools' docstrings aren't clear enough to tell them apart. Fix: improve the tool descriptions.
Pattern 4: Over-investigation — the agent runs 10 searches when 2 would have been enough.
Step 1: web_search("Python history")
Step 3: web_search("Python creator")
Step 5: web_search("Python first release")
Step 7: web_search("Python versions timeline")
Step 9: web_search("Python language evolution") ← Too much
Cause: the system prompt emphasizes "research thoroughly" without defining when to stop. Fix: add sufficiency criteria: "Run 3 searches at most. If after 3 searches you don't have the answer, synthesize with what you have."
ReAct with Custom System Prompts
Why customize the prompt
The default behavior of create_react_agent is generic: the agent uses tools when it thinks it's convenient and answers when it thinks it has enough information. For specific domains, that "thinks" needs explicit guidance.
A technical support agent needs a different prompt from a financial research agent. Not because of the tools — both can have web_search and database_query — but because of the reasoning strategy: when to search, when to ask the user, when to escalate, what level of certainty is acceptable before answering.
Prompt for a research agent
RESEARCH_PROMPT = """You are a rigorous research agent.
## Research Rules
1. NEVER answer with information you haven't verified with the tools
2. For factual questions, run at least 2 searches with different queries
3. If two sources contradict each other, mention it explicitly
4. Cite the source of every important data point
## Search Strategy
- First search: a direct query about the question
- Second search: a reformulated query or one about a specific aspect
- If the results are insufficient: try a third perspective
- Maximum 4 searches per question
## Response Format
- Direct answer first (1-2 sentences)
- Details and context after
- Sources at the end
"""
research_agent = create_react_agent(model, tools, prompt=RESEARCH_PROMPT)
Prompt for a code agent
CODE_PROMPT = """You are an agent specialized in programming.
## Code Rules
1. Check the current version of any library before giving code
2. Always look up the official documentation — don't assume the API from memory
3. If a function you need doesn't exist, say so instead of inventing it
## Response Process
1. Search the relevant documentation
2. Check the version and compatibility
3. Write the code based on the documentation you found
4. If there are recent breaking changes, mention them
## Anti-patterns
- DON'T generate code without verifying that the API exists
- DON'T assume versions — verify them
- DON'T use imports you haven't confirmed
"""
code_agent = create_react_agent(model, tools, prompt=CODE_PROMPT)
Prompt for a conservative agent (production)
In production, you often want an agent that does less, not more:
PRODUCTION_PROMPT = """You are a production assistant. Prioritize accuracy over completeness.
## Strict Rules
1. If you're not 100% sure, search before answering
2. Maximum 3 tool iterations — if you don't have an answer in 3, say you couldn't find the information
3. NEVER invent data, URLs, numbers, or statistics
4. If the question is out of scope, say so immediately without searching
## Efficiency
- One well-formulated search > three vague searches
- Prefer short, precise answers over long, speculative ones
"""
production_agent = create_react_agent(model, tools, prompt=PRODUCTION_PROMPT)
Measuring the impact of the prompt
The prompt isn't cosmetic — it changes real metrics. Compare empirically:
import time
def benchmark_agent(agent, query: str, label: str):
start = time.time()
result = agent.invoke(
{"messages": [("user", query)]},
{"recursion_limit": 20}
)
elapsed = time.time() - start
num_messages = len(result["messages"])
num_tool_calls = sum(
len(m.tool_calls) for m in result["messages"]
if hasattr(m, "tool_calls") and m.tool_calls
)
response = result["messages"][-1].content
print(f"\n[{label}]")
print(f" Messages: {num_messages}")
print(f" Tool calls: {num_tool_calls}")
print(f" Time: {elapsed:.1f}s")
print(f" Answer: {response[:150]}...")
return {"messages": num_messages, "tools": num_tool_calls, "time": elapsed}
query = "What are the 3 most used ML libraries in Python in 2026?"
benchmark_agent(research_agent, query, "Research")
benchmark_agent(production_agent, query, "Production")
Typical data:
| Metric | Research Agent | Production Agent |
|---|---|---|
| Tool calls | 3-4 | 1-2 |
| Time | 8-12s | 3-5s |
| Messages | 7-9 | 3-5 |
| Approx cost | $0.04-0.06 | $0.01-0.03 |
| Quality | High — verifies, cross-checks sources | Good — direct, no verification |
Neither is "better." The research agent is better for reports where accuracy matters. The production agent is better for fast answers where cost and latency dominate.
ReAct's Limitations
When ReAct isn't enough
ReAct is powerful for tasks that require interleaving reasoning with action, but it has structural limitations you need to know in order to pick the right pattern.
Limitation 1: No advance planning
ReAct is reactive — it decides what to do at each step based on what it has so far. It doesn't generate a complete plan before executing. That's a problem for tasks where order matters:
Task: "Compare the GDP per capita of the 5 fastest-growing G7 countries,
adjusted for purchasing power parity, over the last 3 years."
ReAct (reactive):
Step 1: Searches "G7 GDP per capita" → gets partial data
Step 2: Searches "G7 fastest growth" → gets another partial list
Step 3: Searches "purchasing power parity" → gets definitions, not data
Step 4: ??? → confused, fragmented results
Plan-and-Execute (deliberative):
Plan: 1) Identify the 7 G7 countries
2) Look up the PPP GDP of each one for 2023, 2024, 2025
3) Compute the percentage growth
4) Rank and select the top 5
5) Format the comparison
Execution: follows the plan step by step
For multi-step tasks with dependencies, Plan-and-Execute (capsule 03) beats ReAct.
Limitation 2: No self-evaluation
ReAct has no internal mechanism for evaluating the quality of its answer. When the agent decides to finish (no more tool_calls), it delivers what it has — without checking whether it answers the question, whether the data is coherent, or whether there are gaps.
Task: "Research the pros and cons of microservices vs monolith"
ReAct: searches, gets 2 results, synthesizes → delivers
(the "cons of microservices" perspective may be missing)
ReAct + Reflection: searches, gets 2 results, synthesizes → evaluates
→ "The cons perspective is missing" → searches more → re-synthesizes → delivers
Reflection (capsule 05) adds that self-evaluation capability.
Limitation 3: Inefficiency on repetitive tasks
If the agent needs to do the same operation 10 times with different data, ReAct does it sequentially — search one, process, search another, process. It has no ability to "batch" or "parallelize" at the strategy level.
Task: "Look up the current price of AAPL, GOOGL, MSFT, AMZN, META"
ReAct: 5 sequential searches (if the model doesn't do parallel calls)
Task Decomposition: identifies the 5 sub-tasks, runs them in parallel
Task Decomposition (capsule 04) solves this with parallel execution of sub-tasks.
Limitation 4: Context that grows without limit
Every ReAct iteration adds messages to the history. In an agent that runs 10 iterations, the context can grow to thousands of tokens. This has two effects:
- Cost: every LLM invocation processes the entire history (input tokens)
- Lost in the middle: models struggle to pay attention to information in the middle of long contexts
There's no solution within pure ReAct. Memory Systems (M6) address this with compaction and summarization strategies.
When to use each pattern
| Scenario | Recommended pattern |
|---|---|
| Simple question needing 1-2 tools | ReAct |
| Complex task with 5+ dependent steps | Plan-and-Execute |
| Tasks where quality is critical | ReAct + Reflection |
| Many independent sub-tasks | Task Decomposition |
| All of the above combined | Custom StateGraph (M4) |
ReAct is your default. Scale up to other patterns when ReAct's limitations hurt the quality of the result.
Comparison: Plain ReAct vs Configured ReAct
| Aspect | Plain ReAct (create_react_agent default) | Configured ReAct (custom prompt + stop conditions) |
|---|---|---|
| Setup | 3 lines of code | 20-40 lines |
| System prompt | None or generic | Domain-specific |
| Stop conditions | Only recursion_limit | Budget, quality, timeout, custom |
| Debugging | Manual post-execution inspection | Streaming + structured logging |
| Search strategy | The model decides freely | Guided by the prompt (min/max searches) |
| Error handling | Fails silently | Fallback, retry, clear error messages |
| Typical cost | $0.01-0.03 per query | $0.02-0.08 (more control = more LLM calls) |
| Typical latency | 2-5s | 5-15s |
| Answer quality | Variable — depends on the model | Consistent — guided by prompt and checks |
| Ideal for | Prototypes, demos, simple tasks | Production, complex tasks, specific domains |
| Observability | Basic | Traces, metrics, LangSmith integration |
| Customization | Minimal | Total — every aspect of the reasoning is tunable |
The rule: start with plain ReAct. When you identify a specific failure pattern (premature termination, over-investigation, wrong tool), add the configuration that solves it. Don't configure prematurely.
Connection with the Project
Module 5 — Research Agent with Planning and Reflection
In capsule 08 you'll add planning and reflection to the Research Agent. This capsule's ReAct deep dive is the direct foundation:
- Custom system prompt: Your Research Agent will have a research prompt that defines when to search, when to analyze, and when to synthesize — exactly the pattern you saw in the custom prompts section
- Stop conditions: You'll implement budget-based stops so the agent doesn't exceed a reasonable number of searches
- Debugging: You'll use streaming and structured logging to verify that planning and reflection work correctly
The next capsules in this module
What you learned here connects directly with:
| Capsule | Connection with ReAct Deep Dive |
|---|---|
| 03 — Plan-and-Execute | Solves ReAct's "no advance planning" limitation |
| 04 — Task Decomposition | Solves the "inefficiency on repetitive tasks" limitation |
| 05 — Reflection | Solves the "no self-evaluation" limitation |
| 06 — Reasoning Traces | Formalizes the debugging you did by hand here |
| 07 — Comparing Patterns | Compares the trade-offs you mentioned with empirical data |
Every ReAct limitation you identified has an entire capsule dedicated to solving it.
Troubleshooting
Problem 1: The agent answers without using tools when it should search
Symptom: You ask it for recent data ("Bitcoin's price today") and it answers with training data without calling any tool.
Cause: The model "believes" it knows the answer. Without a system prompt that forces tool use for certain kinds of data, the model optimizes for speed and answers directly.
Solution: Add explicit instructions in the prompt:
agent = create_react_agent(
model, tools,
prompt="""MANDATORY RULE: For any data that changes over time
(prices, statistics, recent events, software versions),
ALWAYS use web_search before answering. NEVER answer this kind of data from memory."""
)
Problem 2: The agent loops repeating the same search
Symptom: The trace shows 3+ calls to web_search with the same query or very similar queries.
Cause: The tool result doesn't meet the agent's need, but the agent doesn't know how to reformulate the search.
Solution: Implement repetition detection in a custom StateGraph:
def detect_loop(state: AgentState) -> str:
tool_messages = [m for m in state["messages"] if hasattr(m, "tool_calls") and m.tool_calls]
if len(tool_messages) >= 2:
last_calls = [tc["args"] for tc in tool_messages[-1].tool_calls]
prev_calls = [tc["args"] for tc in tool_messages[-2].tool_calls]
if last_calls == prev_calls:
return "force_respond"
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
Problem 3: recursion_limit is reached and there's no answer
Symptom: The agent raises GraphRecursionError and the user gets no answer at all.
Cause: recursion_limit is too low for the complexity of the task, or the agent doesn't converge.
Solution: Combine a reasonable limit with the fallback you saw in the stop conditions section:
def safe_invoke(agent, query: str):
try:
return agent.invoke(
{"messages": [("user", query)]},
{"recursion_limit": 20}
)
except Exception:
return agent.invoke(
{"messages": [
("user", query),
("user", "Answer with the information you have available now, without searching further.")
]},
{"recursion_limit": 3}
)
Problem 4: The agent consistently picks the wrong tool
Symptom: It uses web_search for calculations or calculator for factual questions.
Cause: The tool docstrings aren't descriptive enough or they overlap.
Solution: Improve the docstrings so they're mutually exclusive:
@tool
def web_search(query: str) -> str:
"""Search the internet for up-to-date FACTUAL information.
Use for: news, current data, definitions, people, events.
DON'T use for: mathematical calculations, unit conversions."""
...
@tool
def calculator(expression: str) -> str:
"""Evaluate MATHEMATICAL expressions.
Use for: additions, multiplications, percentages, numeric conversions.
DON'T use for: searching for information or factual data."""
...
The "DON'T use for" lines are as important as the "Use for" ones — they give the model clear exclusion signals.
Problem 5: Streaming shows nothing for several seconds
Symptom: When you use agent.stream(), there are long pauses (10-30s) with no visible output.
Cause: The LLM is processing (generating the answer or deciding which tool to call). Streaming at the stream_mode="updates" level shows updates per node, not per token. The pause is the agent node waiting on the LLM.
Solution: Use stream_mode="messages" for token-level streaming:
for event in agent.stream(
{"messages": [("user", "Research ReAct")]},
{"recursion_limit": 20},
stream_mode="messages"
):
msg, metadata = event
if msg.content:
print(msg.content, end="", flush=True)
Exercises
Exercise 1: Inspect the reasoning chain (Easy)
Run the following agent and classify each message of the trace as INPUT, DECISION→ACT, OBSERVATION, or DECISION→ANSWER. Identify: how many iterations of the ReAct loop were there? Did the model make parallel tool calls?
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search the web for information."""
return f"Python 3.13 was released in October 2024. FastAPI is the most popular Python web framework."
@tool
def calc(expression: str) -> str:
"""Compute a mathematical expression."""
return str(eval(expression))
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [search, calc])
result = agent.invoke({"messages": [("user", "What's the most recent version of Python and how much is 3.13 * 100?")]})
for i, msg in enumerate(result["messages"]):
print(f"[{i}] {msg.__class__.__name__}: {msg.content[:120] if msg.content else 'tool_calls: ' + str([(tc['name'], tc['args']) for tc in msg.tool_calls])}")
View solution
The typical trace shows:
[0] HumanMessage: What's the most recent version of Python and how much is 3.13 * 100?
[1] AIMessage: tool_calls: [('search', {'query': '...'}), ('calc', {'expression': '3.13 * 100'})]
[2] ToolMessage: Python 3.13 was released in October 2024...
[3] ToolMessage: 313.0
[4] AIMessage: The most recent version of Python is 3.13... and 3.13 × 100 = 313
Classification:
[0]— INPUT: the user's question[1]— DECISION→ACT: the model chose to call 2 tools at the same time (parallel tool calls: YES)[2]— OBSERVATION: result ofsearch[3]— OBSERVATION: result ofcalc[4]— DECISION→ANSWER: the model synthesized and answered (no more tool_calls)
ReAct loop iterations: 2. First: the model receives the input and acts (tool calls). Second: the model receives the observations and answers.
Parallel tool calls: Yes — in step [1], the model called search and calc at the same time. That's possible because the two queries are independent.
Exercise 2: Effect of the system prompt on behavior (Medium)
Create two agents with opposite prompts: one that's "aggressive" about searching (minimum 2 searches per question) and one that's "lazy" (answers from memory whenever it can, searches only if it has no idea). Run both with the query "What is LangGraph?" and compare: number of tool calls, total number of messages, and the content of the answer.
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool
@tool
def web_search(query: str) -> str:
"""Search the web for up-to-date information."""
return f"LangGraph is a LangChain framework for building agents as state graphs. Stable version: 1.0+. It uses StateGraph, nodes, and edges."
model = init_chat_model("openai:gpt-4.1-mini")
# Create the two agents with opposite prompts and compare
# Your code here...
View solution
aggressive = create_react_agent(
model, [web_search],
prompt="Always search for information before answering. Run at least 2 searches with different queries for every question. Never answer from memory."
)
lazy = create_react_agent(
model, [web_search],
prompt="Answer with your own knowledge whenever you can. Search only if you truly have no idea about the topic. Prioritize speed over thoroughness."
)
query = {"messages": [("user", "What is LangGraph?")]}
result_agg = aggressive.invoke(query, {"recursion_limit": 20})
result_lazy = lazy.invoke(query, {"recursion_limit": 20})
def count_tool_calls(result):
return sum(len(m.tool_calls) for m in result["messages"] if hasattr(m, "tool_calls") and m.tool_calls)
print(f"Aggressive — Messages: {len(result_agg['messages'])}, Tool calls: {count_tool_calls(result_agg)}")
print(f"Lazy — Messages: {len(result_lazy['messages'])}, Tool calls: {count_tool_calls(result_lazy)}")
print(f"\nAggressive answer: {result_agg['messages'][-1].content[:200]}")
print(f"\nLazy answer: {result_lazy['messages'][-1].content[:200]}")
Typical results:
- Aggressive: 5-7 messages, 2-3 tool calls. Answer with data from the search.
- Lazy: 2-3 messages, 0-1 tool calls. Answer from the model's memory.
The aggressive agent probably has more up-to-date data (version, recent features). The lazy one answers faster but may have stale data. The prompt fundamentally changed when the model decides to act vs answer.
Exercise 3: Implement loop detection (Medium)
Build a custom StateGraph with a detection node that identifies when the agent has called the same tool with the same arguments more than once. If it detects a repetition, force the agent to answer with what it has.
from dotenv import load_dotenv
load_dotenv()
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langchain_core.messages import BaseMessage, ToolMessage
from langgraph.graph.message import add_messages
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def web_search(query: str) -> str:
"""Search the web for information."""
return f"Generic results for: {query}"
tools = [web_search]
tools_by_name = {t.name: t for t in tools}
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools)
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# Implement: agent_node, tool_node, and a routing function that detects loops
# Your code here...
View solution
def agent_node(state: AgentState) -> dict:
return {"messages": [model_with_tools.invoke(state["messages"])]}
def tool_node(state: AgentState) -> dict:
last = state["messages"][-1]
results = []
for tc in last.tool_calls:
if tc["name"] in tools_by_name:
result = tools_by_name[tc["name"]].invoke(tc["args"])
results.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
else:
results.append(ToolMessage(content=f"Tool '{tc['name']}' not available", tool_call_id=tc["id"]))
return {"messages": results}
def route_with_loop_detection(state: AgentState) -> str:
last = state["messages"][-1]
if not (hasattr(last, "tool_calls") and last.tool_calls):
return "end"
current_calls = set()
for tc in last.tool_calls:
current_calls.add((tc["name"], str(tc["args"])))
previous_ai_messages = [
m for m in state["messages"][:-1]
if hasattr(m, "tool_calls") and m.tool_calls
]
for prev_msg in previous_ai_messages:
prev_calls = set()
for tc in prev_msg.tool_calls:
prev_calls.add((tc["name"], str(tc["args"])))
if current_calls == prev_calls:
print("[LOOP DETECTED] Forcing a final answer.")
return "force_end"
return "tools"
def force_respond(state: AgentState) -> dict:
from langchain_core.messages import HumanMessage
force_msg = HumanMessage(
content="You have repeated the same searches. Answer now with the information you already have."
)
response = model.invoke(state["messages"] + [force_msg])
return {"messages": [response]}
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.add_node("force_respond", force_respond)
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_with_loop_detection, {
"tools": "tools",
"end": END,
"force_end": "force_respond"
})
graph.add_edge("tools", "agent")
graph.add_edge("force_respond", END)
agent = graph.compile()
result = agent.invoke({"messages": [("user", "Search for information about AI agents")]})
print(result["messages"][-1].content)
The route_with_loop_detection function compares the current tool_calls with all previous ones. If it finds an exact duplicate (same tool, same arguments), it redirects to force_respond, which injects a message forcing the final answer. The agent never enters an infinite loop.
Exercise 4: Stop condition with a token budget (Hard)
Implement an agent with a custom StateGraph that tracks the tokens consumed and stops when it exceeds a budget. The state must include total_tokens: int and token_budget: int. The routing must check the budget before sending to tools.
# Hint: use response.usage_metadata to get the tokens of each LLM call
# Your agent must:
# 1. Track accumulated tokens
# 2. If it exceeds the budget: generate a partial answer with what it has
# 3. Print a log of the tokens consumed at each step
View solution
from dotenv import load_dotenv
load_dotenv()
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langchain_core.messages import BaseMessage, ToolMessage, HumanMessage
from langgraph.graph.message import add_messages
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def web_search(query: str) -> str:
"""Search the web for information."""
return f"Results for '{query}': relevant information about the topic."
tools_list = [web_search]
tools_by_name = {t.name: t for t in tools_list}
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools(tools_list)
class BudgetState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
total_tokens: int
token_budget: int
def agent_node(state: BudgetState) -> dict:
response = model_with_tools.invoke(state["messages"])
tokens = 0
if hasattr(response, "usage_metadata") and response.usage_metadata:
tokens = response.usage_metadata.get("total_tokens", 0)
new_total = state.get("total_tokens", 0) + tokens
print(f" [TOKENS] This step: {tokens} | Accumulated: {new_total} / {state.get('token_budget', 5000)}")
return {"messages": [response], "total_tokens": new_total}
def tool_node(state: BudgetState) -> dict:
last = state["messages"][-1]
results = []
for tc in last.tool_calls:
result = tools_by_name[tc["name"]].invoke(tc["args"])
results.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
return {"messages": results}
def budget_respond(state: BudgetState) -> dict:
msg = HumanMessage(content="Token budget exceeded. Answer with the information available.")
response = model.invoke(state["messages"] + [msg])
return {"messages": [response]}
def route_with_budget(state: BudgetState) -> str:
if state.get("total_tokens", 0) >= state.get("token_budget", 5000):
print(" [BUDGET] Limit reached — forcing an answer")
return "budget_stop"
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
graph = StateGraph(BudgetState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.add_node("budget_respond", budget_respond)
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_with_budget, {
"tools": "tools",
"end": END,
"budget_stop": "budget_respond"
})
graph.add_edge("tools", "agent")
graph.add_edge("budget_respond", END)
budget_agent = graph.compile()
result = budget_agent.invoke({
"messages": [("user", "Research the trends in AI agents in depth")],
"total_tokens": 0,
"token_budget": 2000
})
print(f"\nAnswer: {result['messages'][-1].content[:200]}")
print(f"Total tokens: {result['total_tokens']}")
The agent tracks tokens at each step and, when it exceeds the budget, redirects to budget_respond, which generates a partial answer with what's available. The user always gets something useful — never an exception.
Exercise 5: Empirical comparison of prompts (Hard)
Create a benchmark_prompts function that takes a list of system prompts and a list of test queries. For each prompt×query combination, run the agent and record: number of tool calls, number of messages, and the length of the answer. Present the results in a table. Use at least 3 different prompts and 3 queries.
# Your function must:
# 1. Create one agent per prompt
# 2. Run each query on each agent
# 3. Record metrics
# 4. Print a results table
View solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool
@tool
def web_search(query: str) -> str:
"""Search the web for up-to-date information."""
return f"Information found about: {query}. Relevant and up-to-date data."
model = init_chat_model("openai:gpt-4.1-mini")
prompts = {
"minimal": "Answer briefly. Use tools only if indispensable.",
"balanced": "Search for relevant information and give a complete but concise answer.",
"thorough": "Research exhaustively. Run multiple searches. Verify the data. Give detailed answers with sources."
}
queries = [
"What is LangGraph?",
"What are the best practices for AI agents in production?",
"Compare ReAct with Plan-and-Execute"
]
def benchmark_prompts(prompts: dict, queries: list, tools: list):
results = []
for prompt_name, prompt_text in prompts.items():
agent = create_react_agent(model, tools, prompt=prompt_text)
for query in queries:
try:
result = agent.invoke(
{"messages": [("user", query)]},
{"recursion_limit": 15}
)
tool_calls = sum(
len(m.tool_calls) for m in result["messages"]
if hasattr(m, "tool_calls") and m.tool_calls
)
num_msgs = len(result["messages"])
resp_len = len(result["messages"][-1].content)
results.append({
"prompt": prompt_name,
"query": query[:40],
"tool_calls": tool_calls,
"messages": num_msgs,
"resp_length": resp_len
})
except Exception as e:
results.append({
"prompt": prompt_name,
"query": query[:40],
"tool_calls": -1,
"messages": -1,
"resp_length": 0
})
print(f"{'Prompt':<12} {'Query':<42} {'Tools':>5} {'Msgs':>5} {'Resp Len':>9}")
print("-" * 75)
for r in results:
print(f"{r['prompt']:<12} {r['query']:<42} {r['tool_calls']:>5} {r['messages']:>5} {r['resp_length']:>9}")
benchmark_prompts(prompts, queries, [web_search])
Example output:
Prompt Query Tools Msgs Resp Len
---------------------------------------------------------------------------
minimal What is LangGraph? 0 2 180
minimal What are the best practices for AI agent 1 4 250
minimal Compare ReAct with Plan-and-Execute 0 2 300
balanced What is LangGraph? 1 4 350
balanced What are the best practices for AI agent 1 4 420
balanced Compare ReAct with Plan-and-Execute 1 4 380
thorough What is LangGraph? 2 6 550
thorough What are the best practices for AI agent 3 8 680
thorough Compare ReAct with Plan-and-Execute 2 6 600
The pattern is clear: the more aggressive the prompt → the more tool calls → the more messages → the longer the answers. "minimal" doesn't even search in 2 of 3 queries. "thorough" runs 2-3 searches per query. The data lets you pick the prompt based on your cost vs quality priorities.
Summary
In this capsule you learned:
- What create_react_agent does under the hood: it builds a StateGraph with
agentandtoolsnodes, injects a system prompt, and configures routing based on the presence of tool_calls - How the model decides to think vs act: the decision is binary — it returns tool_calls (act) or content with no tool_calls (answer). The Thoughts of the original ReAct paper are implicit in LangGraph's implementation
- The system prompt controls the reasoning: a research prompt generates 3-4 tool calls; a conservative prompt generates 0-1. The prompt isn't cosmetic — it changes real metrics of cost, latency, and quality
- Stop conditions beyond recursion_limit: budget-based (tokens), quality-based (automatic evaluation), loop detection (repeated tool calls), and fallback responses (a partial answer when the limit is hit)
- Debugging the reasoning chain: manual message inspection (classifying each step as INPUT/DECISION/OBSERVATION), real-time streaming, and LangSmith for full traces with costs and latencies
- Diagnosable failure patterns: tool loop, premature termination, wrong tool, over-investigation — each with an identifiable cause and a specific fix
- ReAct's structural limitations: no advance planning, no self-evaluation, inefficient on repetitive tasks, context that grows without limit
Next capsule: Plan-and-Execute Agents — the first pattern that solves ReAct's most important limitation: the lack of advance planning. You'll learn to separate the planning phase (decomposing the task into steps) from the execution phase (running each step), with the ability to re-plan when a step fails.
Additional Resources
- ReAct Paper — Synergizing Reasoning and Acting in Language Models — The original paper by Yao et al. (2022) that formalized Thought→Action→Observation
- LangGraph create_react_agent Reference — The full API of the prebuilt agent: parameters, configuration, and customization
- LangGraph Streaming Guide — How to implement streaming at the node level and at the token level
- LangSmith Documentation — Setup, tracing, and dashboard for agent observability
- LangGraph ReAct Agent Tutorial — Step-by-step tutorial from the LangGraph team