What Is a ReAct Agent and Why Build It in LangGraph
Most LLM apps stop at one prompt → one response. A ReAct agent loops: it reasons, picks a tool, observes the result, then reasons again until it has an answer.
LangGraph gives you explicit control over that loop. You define the graph nodes, edges, and state — so you know exactly what runs, when, and why. This matters in production when you need to debug a wrong answer or add a new tool without breaking existing ones.
You'll build:
- A working ReAct agent that calls real tools (web search + calculator)
- A typed
AgentStateto track messages across steps - A conditional edge that decides "call tool" vs "return answer"
- A pattern you can extend to 10+ tools without changing the loop
Time: 25 min | Difficulty: Intermediate
How ReAct Works
The ReAct pattern (Reasoning + Acting) was introduced in a 2022 paper and is now the default loop behind most tool-calling agents.
User query
│
▼
┌─────────────┐
│ Reason │ ◀── LLM sees: system prompt + messages + tool schemas
│ (LLM call) │
└──────┬──────┘
│
Tool call?
┌────┴────┐
YES NO
│ │
▼ ▼
┌──────┐ Final
│ Act │ answer
│(tool)│
└──┬───┘
│
▼
Observe (append tool result to messages)
│
└──▶ back to Reason
Each iteration appends to the message list. The LLM always sees the full history: original query, every tool call, every tool result. This is how it accumulates context across steps.
LangGraph models this as a state graph. Each node is a function. Edges are transitions — either fixed or conditional.
Setup
# Python 3.11+ required
pip install langgraph langchain-openai langchain-core
# Set your API key
export OPENAI_API_KEY="sk-..."
This guide uses gpt-4o-mini — it's cheap, fast, and reliable at tool-calling. Swap in any model that supports function calling.
Step 1: Define Agent State
LangGraph passes a State object between every node. You define exactly what it holds.
from typing import Annotated
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage
from typing_extensions import TypedDict
class AgentState(TypedDict):
# add_messages appends new messages instead of overwriting
# This is critical — without it, each node would replace the list
messages: Annotated[list[BaseMessage], add_messages]
add_messages is a reducer. When a node returns {"messages": [new_msg]}, LangGraph appends new_msg to the existing list rather than replacing it. That's the whole message history mechanism.
Step 2: Define Tools
Tools are Python functions decorated with @tool. The docstring becomes the tool description the LLM reads to decide when to use it — write it like you're explaining to a smart intern.
from langchain_core.tools import tool
@tool
def calculate(expression: str) -> str:
"""
Evaluate a mathematical expression and return the result.
Use this for any arithmetic: addition, multiplication, percentages.
Example input: '(42 * 1.08) + 15'
"""
try:
# eval is safe here — restrict to math only in production
result = eval(expression, {"__builtins__": {}}, {})
return str(result)
except Exception as e:
return f"Error: {e}"
@tool
def get_word_count(text: str) -> str:
"""
Count the number of words in a given text string.
Use this when the user asks about word counts or text length.
"""
count = len(text.split())
return f"{count} words"
tools = [calculate, get_word_count]
Step 3: Bind Tools to the LLM
Binding tools sends their JSON schemas to the model on every call. The model responds with either a text message (final answer) or an AIMessage with tool_calls (wants to run something).
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# llm_with_tools sends tool schemas automatically on every invocation
llm_with_tools = llm.bind_tools(tools)
Step 4: Build the Graph Nodes
Two nodes: one for the LLM, one to execute tool calls.
from langchain_core.messages import SystemMessage
from langgraph.prebuilt import ToolNode
# Node 1: call the LLM
def call_model(state: AgentState) -> dict:
system = SystemMessage(
content="You are a helpful assistant. Use tools when they help answer accurately."
)
# Prepend system message; model sees full history every call
response = llm_with_tools.invoke([system] + state["messages"])
return {"messages": [response]}
# Node 2: execute whatever tool the LLM chose
# ToolNode handles tool dispatch, error catching, and result formatting
tool_node = ToolNode(tools)
ToolNode is LangGraph's built-in tool executor. It reads the tool_calls field from the last AIMessage, runs the matching function, and wraps the result in a ToolMessage. You don't need to write dispatch logic yourself.
Step 5: Add the Conditional Edge
This edge reads the last message and routes to either the tool node or END.
from langgraph.graph import END
from langchain_core.messages import AIMessage
def should_continue(state: AgentState) -> str:
last_message = state["messages"][-1]
# If the LLM returned tool_calls, run them
if isinstance(last_message, AIMessage) and last_message.tool_calls:
return "tools"
# Otherwise the LLM gave a final text answer
return END
Step 6: Assemble the Graph
from langgraph.graph import StateGraph
builder = StateGraph(AgentState)
# Register nodes
builder.add_node("agent", call_model)
builder.add_node("tools", tool_node)
# Entry point
builder.set_entry_point("agent")
# After the agent runs: check if we need tools or are done
builder.add_conditional_edges(
"agent",
should_continue,
{
"tools": "tools", # route to tool_node
END: END, # or finish
},
)
# After tools run: always go back to the agent
builder.add_edge("tools", "agent")
graph = builder.compile()
The graph now looks like this:
START
│
▼
agent ──(has tool_calls?)──YES──▶ tools
▲ │
└──────────────────────────────────┘
│
NO
│
▼
END
Step 7: Run It
from langchain_core.messages import HumanMessage
def run_agent(query: str) -> str:
result = graph.invoke({"messages": [HumanMessage(content=query)]})
return result["messages"][-1].content
# Test 1: needs calculator tool
print(run_agent("What is 15% tip on a $47.80 dinner?"))
# Test 2: needs word count tool
print(run_agent("How many words are in this sentence: The quick brown fox jumps over the lazy dog"))
# Test 3: no tool needed
print(run_agent("What is the capital of France?"))
Expected output (Test 1):
A 15% tip on a $47.80 dinner is $7.17, making your total $54.97.
Verification
Turn on LangGraph tracing to see every node execution and message:
# See every step the agent takes
for step in graph.stream(
{"messages": [HumanMessage(content="What is 12 * 8 + 100?")]},
stream_mode="values",
):
last = step["messages"][-1]
last.pretty_print()
You should see:
HumanMessagewith the queryAIMessagewith atool_callsblock targetingcalculateToolMessagewith196AIMessagewith the final answer:"12 × 8 + 100 = 196"
If the agent answers without using the calculator, your tool docstring isn't clear enough — rewrite it.
Adding More Tools
The loop doesn't change when you add tools. Just define the function and append to tools:
@tool
def get_current_date(timezone: str = "UTC") -> str:
"""
Return the current date and time for a given timezone.
Use this when the user asks about today's date or current time.
Accepts timezone strings like 'US/Eastern' or 'Europe/London'.
"""
from datetime import datetime
import zoneinfo
tz = zoneinfo.ZoneInfo(timezone)
now = datetime.now(tz)
return now.strftime("%A, %B %d, %Y at %H:%M %Z")
tools = [calculate, get_word_count, get_current_date]
# Re-bind and recompile — everything else stays the same
llm_with_tools = llm.bind_tools(tools)
tool_node = ToolNode(tools)
Recompile the graph after updating tool_node. The should_continue logic and state schema don't need to change.
Production Considerations
Loop limits. Add a step_count field to AgentState and terminate after N iterations. Without this, a hallucinating model can loop indefinitely.
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
step_count: int # increment in call_model, check in should_continue
Tool errors. ToolNode catches exceptions and returns them as ToolMessage content. The LLM sees the error and can retry or explain — but log these. Repeated tool errors usually mean your docstring is misleading the model.
Streaming. Use graph.astream() for async endpoints. Token-level streaming from the LLM requires stream_mode="messages" and an async node.
Observability. Set LANGCHAIN_TRACING_V2=true and LANGCHAIN_API_KEY to get full traces in LangSmith. Each graph run becomes a traceable session — you can see which tool was called, with what inputs, and how long it took.
What You Learned
- ReAct is a message-accumulation loop: reason → act → observe → repeat
add_messagesreducer is what makes the history persist across nodes- Tool docstrings are model instructions — precision matters more than length
ToolNodehandles dispatch;should_continuehandles routing- Adding tools requires no changes to the loop — only rebinding and recompiling
When not to use this pattern: If your task is a fixed pipeline with no branching decisions, a simple LangChain chain is faster and cheaper. ReAct is for tasks where the model needs to decide which tool and when — not for predetermined sequences.
Tested on LangGraph 0.2.x, LangChain Core 0.3.x, Python 3.12, gpt-4o-mini