What Is ReAct, and Why Should You Care?
ReAct (Reason + Act) is the pattern that powers virtually every modern AI agent. Instead of sending a single prompt to an LLM and hoping for the best, the agent loops:
- Thought — the LLM reasons about what to do next
- Action — it picks a tool to call
- Observation — the tool's result is fed back into the conversation
- Repeat — until the LLM produces a Final Answer
This loop is what separates a chatbot from an agent. A chatbot talks. An agent does things.
In this article, I'll walk you through a manual implementation of the ReAct loop in pure Python — about 170 lines — and then compare it with what LangChain gives you out of the box. You'll see exactly what the framework abstracts away, and where the abstraction leaks.
The Manual Implementation
The Tool System
Every agent needs tools. Here's a minimal but real tool registry:
from dataclasses import dataclass, field
from typing import Any, Callable
import json
@dataclass
class Tool:
"""A callable tool with metadata for the LLM."""
name: str
description: str
func: Callable
parameters: dict[str, Any] = field(default_factory=dict)
def run(self, **kwargs: Any) -> str:
result = self.func(**kwargs)
return result if isinstance(result, str) else json.dumps(result)
Each tool has a name, a description (this is what the LLM reads to decide when to use it), the actual function, and a parameter schema.
Real tools look like this:
import httpx
def get_weather(city: str) -> str:
"""Fetch current weather for a city using Open-Meteo (no API key needed)."""
geo = httpx.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1, "language": "en", "format": "json"},
timeout=10,
).json()
if not geo.get("results"):
return f"Could not find city: {city}"
loc = geo["results"][0]
lat, lon = loc["latitude"], loc["longitude"]
weather = httpx.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": lat, "longitude": lon, "current_weather": "true"},
timeout=10,
).json()
current = weather.get("current_weather", {})
return (
f"Weather in {loc['name']}, {loc.get('country', '')}: "
f"{current.get('temperature', 'N/A')}°C, "
f"wind {current.get('windspeed', 'N/A')} km/h"
)
No API keys. No auth. Just a plain function that returns a string. That's all a tool needs to be.
The ReAct System Prompt
The entire intelligence of the loop comes from the system prompt. Here's the one I use:
REACT_SYSTEM_PROMPT = """You are an autonomous AI agent that solves tasks step by step.
You have access to the following tools:
{tool_descriptions}
Use this exact format:
Thought: reason about what to do next
Action: the tool name to call (one of [{tool_names}])
Action Input: the JSON input for the tool
Observation: the result of the tool
... (repeat Thought/Action/Action Input/Observation as needed)
Thought: I now know the final answer
Final Answer: the final answer to the original question
Rules:
- Always think before acting.
- Call one tool at a time.
- If a tool returns an error, try a different approach.
- When you have enough information, give the Final Answer.
"""
The format is strict on purpose. We're going to parse the LLM's output line by line, so we need it to follow the structure.
The Agent Loop
Here's the core — the actual ReAct loop:
class ReActAgent:
def __init__(self, llm: str = "openai", tools=None, memory=None, max_steps: int = 8):
self.llm = llm
self.tools = tools or build_tool_registry()
self.memory = memory or ConversationBufferMemory()
self.max_steps = max_steps
@staticmethod
def _parse_action(text: str) -> tuple[str | None, str | None]:
"""Extract Action and Action Input from the LLM output."""
action = None
action_input = None
for line in text.splitlines():
line = line.strip()
if line.startswith("Action:"):
action = line.split("Action:", 1)[1].strip()
elif line.startswith("Action Input:"):
action_input = line.split("Action Input:", 1)[1].strip()
return action, action_input
def run(self, query: str) -> str:
tool_descriptions = "\n".join(
f"- {name}: {tool.description}" for name, tool in self.tools.items()
)
tool_names = ", ".join(self.tools.keys())
system = REACT_SYSTEM_PROMPT.format(
tool_descriptions=tool_descriptions, tool_names=tool_names
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": query},
]
for step in range(self.max_steps):
response = self._call_llm(messages)
# Check if the LLM is done
if "Final Answer:" in response:
final = response.split("Final Answer:", 1)[1].strip()
self.memory.add("assistant", final)
return final
# Parse the action
action, action_input = self._parse_action(response)
if not action or action not in self.tools:
# Nudge the model back into the correct format
messages.append({"role": "assistant", "content": response})
messages.append({
"role": "user",
"content": (
"Invalid format or unknown tool. Use the exact format:\n"
"Thought: ...\nAction: <tool_name>\nAction Input: <json>"
),
})
continue
# Execute the tool
try:
kwargs = json.loads(action_input or "{}")
except json.JSONDecodeError:
kwargs = {"input": action_input}
observation = self.tools[action].run(**kwargs)
# Feed the observation back into the conversation
messages.append({"role": "assistant", "content": response})
messages.append({"role": "user", "content": f"Observation: {observation}"})
self.memory.add("assistant", response)
self.memory.add("user", f"Observation: {observation}")
return "I could not complete the task within the step limit."
That's it. The entire agent is:
- Format the system prompt with tool descriptions
- Send messages to the LLM
- Check for "Final Answer:" — if found, return it
- Parse "Action:" and "Action Input:" from the response
- Execute the tool
- Append the observation as a user message
- Loop
What Happens at Runtime
When you run:
python main.py --query "What's the weather in Istanbul and what's 15 * 7?"
The conversation looks like this:
[Step 1]
Action: get_weather
Input: {"city": "Istanbul"}
Result: Weather in Istanbul, Turkey: 22°C, wind 14 km/h
[Step 2]
Action: calculator
Input: {"expression": "15 * 7"}
Result: 105
Final Answer: The weather in Istanbul is 22°C with 14 km/h wind, and 15 × 7 = 105.
The LLM reasoned: "I need two pieces of information. Let me get the weather first, then do the math, then combine."
The LLM Abstraction
One thing you might have noticed: _call_llm supports multiple backends:
def _call_llm(self, messages: list[dict[str, str]]) -> str:
if self.llm == "openai":
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o-mini", api_key=settings.openai_api_key)
return model.invoke(messages).content
if self.llm == "anthropic":
from langchain_anthropic import ChatAnthropic
model = ChatAnthropic(model="claude-3-5-sonnet-20241022", api_key=settings.anthropic_api_key)
return model.invoke(messages).content
if self.llm == "ollama":
from langchain_community.chat_models import ChatOllama
model = ChatOllama(model="llama3.1:8b", base_url="http://localhost:11434")
return model.invoke(messages).content
Here's the interesting part: we're using LangChain's LLM wrappers, but not LangChain's agent abstractions. We use LangChain for what it's good at — providing a unified interface to OpenAI, Anthropic, and Ollama — but we keep control of the agent loop ourselves.
Now Let's Compare: LangChain's Built-in Agent
Here's the same functionality using LangChain's create_tool_calling_agent:
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_openai import ChatOpenAI
from langchain.tools import Tool
# Define tools
weather_tool = Tool(name="get_weather", func=get_weather, description="Get current weather for a city.")
calc_tool = Tool(name="calculator", func=calculator, description="Evaluate a safe arithmetic expression.")
# Create the agent
llm = ChatOpenAI(model="gpt-4o-mini")
tools = [weather_tool, calc_tool]
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
result = executor.invoke({"input": "What's the weather in Istanbul and what's 15 * 7?"})
That's ~10 lines vs ~170. So why would you ever write the manual version?
The Trade-offs
Here's a side-by-side comparison:
Lines of code: Manual is ~170, LangChain is ~10.
Control over parsing: Manual gives you full visibility — you see every line. LangChain hides it behind AgentExecutor.
Error recovery: Manual lets you nudge the model back on parse failure. LangChain has its own default retry logic.
Memory: Manual uses your own ConversationBufferMemory or VectorMemory. LangChain has its own memory classes.
Debugging: Manual lets you print every step and see every message. LangChain's verbose=True gives you some output.
LLM backend swap: Manual — change one string ("openai" to "ollama"). LangChain — change the LLM class.
Prompt control: Manual — you own the system prompt 100%. LangChain — framework formats it for you.
Learning curve: Manual — read the code, understand every line. LangChain — read docs, trust the abstraction.
When to use the manual approach:
- You're learning how agents actually work
- You need custom parsing (e.g., your LLM doesn't follow the standard format)
- You need custom error recovery (e.g., retry with a different tool on failure)
- You want to swap LLMs freely without worrying about LangChain compatibility
- You're building a production system where you need full control over the loop
When to use LangChain's AgentExecutor:
- You're prototyping and need speed
- Your tools are simple and follow standard patterns
- You don't need to debug the loop itself
- You're already deep in the LangChain ecosystem (using its loaders, retrievers, etc.)
Memory: The Often-Overlooked Component
The manual implementation includes a simple but extensible memory system:
@dataclass
class ConversationBufferMemory:
"""Rolling window of recent conversation messages."""
max_messages: int = 20
messages: list[dict[str, str]] = field(default_factory=list)
def add(self, role: str, content: str) -> None:
self.messages.append({"role": role, "content": content})
if len(self.messages) > self.max_messages:
self.messages = self.messages[-self.max_messages:]
And for long-term semantic memory, there's a ChromaDB-backed option:
class VectorMemory:
"""Semantic long-term memory using ChromaDB."""
def __init__(self, collection_name="agent_memory", persist_dir="./vector_store"):
import chromadb
self.client = chromadb.PersistentClient(path=persist_dir)
self.collection = self.client.get_or_create_collection(name=collection_name)
def add(self, text: str, metadata=None, id=None):
self.collection.add(
documents=[text],
metadatas=[metadata or {}],
ids=[id or str(uuid.uuid4())],
)
def query(self, text: str, n_results: int = 5) -> list[str]:
results = self.collection.query(query_texts=[text], n_results=n_results)
return results.get("documents", [[]])[0]
This is something LangChain also provides, but again — when you write it yourself, you understand exactly when and how memory is injected into the prompt. With LangChain, it's a config parameter that might not behave the way you expect across different agent types.
Running It Locally with Ollama
One of the biggest advantages of controlling the loop yourself is how easy it is to swap to a local model:
# Install Ollama
ollama pull llama3.1:8b
# Run the agent with a local model — no API costs
python main.py --query "What's the weather in Istanbul?" --llm ollama
That's it. One flag. No LangChain agent type changes, no compatibility concerns. The loop doesn't care which LLM generates the text — it parses the output the same way.
The Full Picture
Here's what the complete kit includes beyond the ReAct loop:
- agent.py — the ReAct loop (what we covered above)
- tools.py — tool registry with weather, calculator, and web search
- memory.py — conversation buffer + vector memory (ChromaDB)
- rag_pipeline.py — RAG pipeline using LlamaIndex
- fine_tune.py — Unsloth fine-tuning script for local LLMs
- ollama_setup.py — local model helper
- notebooks/ — step-by-step Jupyter notebooks for each component
- guide/ — markdown guides on architecture, LangChain patterns, RAG, fine-tuning, and production deployment
I packaged all of this into a developer kit — it's the code I wish I had when I started building agents. If you want the full source code, notebooks, and guides, you can grab it here
Takeaway
LangChain is excellent for prototyping. But if you're building a production agent system — or if you simply want to understand how agents work under the hood — implementing the ReAct loop yourself is worth every line.
You get:
- Full control over parsing, error recovery, and memory
- Easy LLM swapping (OpenAI to Anthropic to Ollama with one string)
- Zero magic — every behavior is visible in your code
- A deeper understanding of what frameworks do for you (and what they hide)
The manual loop is 170 lines. Read it once, and you'll never look at AgentExecutor the same way again.
Top comments (0)