Getting Started with AI Agents — Building a ReAct Agent from Scratch

Getting Started with AI Agents — Making LLMs Act with the ReAct Pattern
Ask ChatGPT "What's the weather in Seoul right now?" and you get: "I don't have access to real-time information." But an Agent would call a weather API, interpret the result, and answer in natural language. This difference is what separates chatbots from agents.
In this post, we will understand the ReAct pattern — the most fundamental building block of agents — implement it from scratch in pure Python, and then explore why it evolved into Tool Calling.
Series: Part 1 (this post) | Part 2: LangGraph + Reflection | Part 3: MCP + Multi-Agent | Part 4: Production Deployment
Chatbot vs Agent: What's the Difference?
Most LLM applications are "chatbots." The user asks a question, and the model answers from its trained knowledge. Input to Output — that's it.
Agents are different. The LLM uses Tools to interact with the outside world. It searches, calculates, calls APIs, and queries databases. Then, based on those results, it decides the next action on its own.
Key Insight: Agent = LLM + Tools + Loop. The LLM decides "what to do," the Tool performs the "actual action," and the Loop repeats "until completion."
ReAct: Reasoning + Acting
The ReAct (Reasoning and Acting) paper by Yao et al. in 2022 laid the foundation for LLM agents. The core idea is simple: make the LLM alternate between Thinking (Thought) and Acting (Action) in a repeated cycle.
Thought → Action → Observation Loop
A ReAct agent repeats three steps:
- Thought — Reasons about "What should I do in this situation?"
- Action — Selects and executes the appropriate tool
- Observation — Receives the tool's result and uses it for the next decision
User: "Tell me Tesla's current stock price and market cap"
Thought: I need to search for Tesla's current stock price.
Action: search["Tesla current stock price"]
Observation: Tesla (TSLA) is currently trading at $248.50...
Thought: I found the stock price. Now I need to find the market cap.
Action: search["Tesla market cap 2024"]
Observation: Tesla's market capitalization is $792 billion...
Thought: I have both pieces of information, so I can provide the answer.
Final Answer: Tesla's current stock price is $248.50, and its market cap is approximately $792 billion.The reason this pattern is powerful is that the reasoning process is transparent. You can see exactly why the model took each action and what the intermediate results were.
Building a ReAct Agent from Scratch
Enough theory. Let's implement a ReAct Agent using pure Python and the OpenAI API.
Step 1: Define the Tools
First, we create the tools the Agent will use.
import math
import requests
def search(query: str) -> str:
"""Simulates a web search."""
# In practice, use Tavily, SerpAPI, etc.
responses = {
"Seoul weather": "Seoul current temperature 22°C, clear skies, humidity 45%",
"Python latest version": "Python 3.13.0 (released October 2024)",
}
return responses.get(query, f"No search results found for '{query}'.")
def calculate(expression: str) -> str:
"""Evaluates a mathematical expression."""
try:
result = eval(expression, {"__builtins__": {}}, {"math": math})
return str(result)
except Exception as e:
return f"Calculation error: {e}"
# Tool registry
TOOLS = {
"search": search,
"calculate": calculate,
}Step 2: System Prompt
The system prompt is the heart of the ReAct pattern. We need to clearly tell the LLM "in what format to think and act."
SYSTEM_PROMPT = """You are a helpful assistant that can use tools to answer questions.
Available tools:
- search[query]: Search the web for information
- calculate[expression]: Evaluate a mathematical expression
You MUST follow this exact format for each step:
Thought: <your reasoning about what to do next>
Action: <tool_name>[<argument>]
After receiving an Observation, continue with another Thought/Action,
or provide your final response:
Thought: <I now have enough information>
Final Answer: <your complete answer to the user>
Always think step by step. Use tools when you need external information."""Step 3: Parse the Response
We extract Actions from the LLM's text response using regular expressions.
import re
def parse_action(response_text: str) -> tuple[str, str] | None:
"""Extracts the Action: tool_name[argument] pattern from the LLM response."""
pattern = r"Action:\s*(\w+)\[(.+?)\]"
match = re.search(pattern, response_text)
if match:
tool_name = match.group(1)
argument = match.group(2)
return tool_name, argument
return None
def has_final_answer(response_text: str) -> str | None:
"""Checks whether a Final Answer is present."""
pattern = r"Final Answer:\s*(.+)"
match = re.search(pattern, response_text, re.DOTALL)
if match:
return match.group(1).strip()
return NoneStep 4: The Agent Loop
Putting all the pieces together completes our ReAct Agent.
from openai import OpenAI
client = OpenAI()
def react_agent(question: str, max_iterations: int = 5) -> str:
"""An Agent that operates using the ReAct pattern."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
for i in range(max_iterations):
# 1. Ask the LLM for its next action
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
temperature=0,
)
assistant_msg = response.choices[0].message.content
messages.append({"role": "assistant", "content": assistant_msg})
print(f"\n--- Step {i+1} ---")
print(assistant_msg)
# 2. If a Final Answer is present, terminate
final = has_final_answer(assistant_msg)
if final:
return final
# 3. Parse the Action and execute the tool
action = parse_action(assistant_msg)
if action:
tool_name, argument = action
if tool_name in TOOLS:
observation = TOOLS[tool_name](argument)
else:
observation = f"Error: Unknown tool '{tool_name}'"
# 4. Add the Observation to the conversation
messages.append({
"role": "user",
"content": f"Observation: {observation}"
})
print(f"Observation: {observation}")
else:
# If neither Action nor Final Answer is found, return the response as-is
return assistant_msg
return "Maximum number of iterations exceeded."Let's run it:
answer = react_agent("What's the weather in Seoul? And convert it to Fahrenheit.")
print(f"\nFinal answer: {answer}")--- Step 1 ---
Thought: I need to search for the current weather in Seoul.
Action: search[Seoul weather]
Observation: Seoul current temperature 22°C, clear skies, humidity 45%
--- Step 2 ---
Thought: The temperature in Seoul is 22°C. I'll convert it to Fahrenheit. Formula: F = C * 9/5 + 32
Action: calculate[22 * 9/5 + 32]
Observation: 71.6
--- Step 3 ---
Thought: I have all the information I need.
Final Answer: The current weather in Seoul is clear skies, with a temperature of 22°C (71.6°F) and humidity of 45%.
Final answer: The current weather in Seoul is clear skies, with a temperature of 22°C (71.6°F) and humidity of 45%.With just around 40 lines for the react_agent function, we have a working Agent that can search and calculate.
Limitations of ReAct → Evolution to Tool Calling
The implementation above works well, but it has critical weaknesses when it comes to production use.
Fragility of Regex Parsing
# Cases where this pattern breaks:
pattern = r"Action:\s*(\w+)\[(.+?)\]"
# 1. What if the LLM slightly changes the format?
"Action: search[ Seoul weather ]" # Whitespace issue
"action: search[Seoul weather]" # Case sensitivity
"Action : search[Seoul weather]" # Space after colon
# 2. What if the argument contains brackets?
"Action: calculate[len([1,2,3])]" # Nested brackets → parsing failure
# 3. What if the LLM completely ignores the format?
"Let me search for Seoul weather. search Seoul weather" # Pattern mismatchReimplementing with Tool Calling
Using OpenAI's Function Calling API and Pydantic, we can solve these problems cleanly.
from pydantic import BaseModel, Field
class SearchInput(BaseModel):
"""Input schema for the web search tool"""
query: str = Field(description="The question or keywords to search for")
class CalculateInput(BaseModel):
"""Input schema for the calculator tool"""
expression: str = Field(description="The math expression to evaluate (e.g., '2 + 3 * 4')")We convert the Pydantic models into JSON Schema that OpenAI understands:
def to_openai_tool(model: type[BaseModel], name: str, description: str) -> dict:
"""Converts a Pydantic model to OpenAI tool format."""
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": model.model_json_schema(),
}
}
tools = [
to_openai_tool(SearchInput, "search", "Searches the web for information"),
to_openai_tool(CalculateInput, "calculate", "Evaluates a mathematical expression"),
]Now the Agent loop becomes much cleaner:
import json
def tool_calling_agent(question: str, max_iterations: int = 5) -> str:
"""An Agent based on Tool Calling."""
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": question},
]
for i in range(max_iterations):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools, # Pass the list of tools
tool_choice="auto", # Model automatically decides whether to use a tool
)
msg = response.choices[0].message
messages.append(msg)
# If there are no tool calls, this is the final answer
if not msg.tool_calls:
return msg.content
# Execute tool calls
for tool_call in msg.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
# Validate arguments with Pydantic, then execute
if name == "search":
validated = SearchInput(**args)
result = search(validated.query)
elif name == "calculate":
validated = CalculateInput(**args)
result = calculate(validated.expression)
else:
result = f"Unknown tool: {name}"
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
return "Maximum number of iterations exceeded."The key difference: Regex parsing is gone. The model returns tool calls as structured JSON, and Pydantic validates the arguments. This is the approach you should use in production.
When Should You Use an Agent?
Agents are powerful, but they are not a silver bullet. Introducing an Agent where it is unnecessary only adds complexity.
When You Need an Agent
- When you need real-time information: Querying live data such as weather, stock prices, or news
- When multi-step reasoning is required: "Look up A, calculate B from the result, and compare it with C"
- When integrating with external systems: Database queries, API calls, file read/write
- When user intent varies widely: When a single interface needs to serve multiple capabilities
When You Don't Need an Agent
- Simple Q&A: Questions that can be answered sufficiently from training data
- Fixed pipelines: Workflows where input → processing → output is always the same
- When latency matters: Tool calls introduce additional latency
- When cost is a concern: Each loop iteration incurs API call costs
Decision flow when a question comes in:
Does it need external information? ─── No ──→ Standard LLM call
│
Yes
↓
Is a single API call enough? ─── Yes ──→ Simple function call
│
No
↓
Does it require multi-step reasoning? ─── No ──→ RAG pipeline
│
Yes
↓
Introduce an AgentFull Hands-on Practice in the Agent Cookbook
This post covered only the core concepts and code highlights. The full practice notebooks include everything you need for real-world use, such as Tavily search integration, error handling, and streaming output.
- Week 1: ReAct Pattern Full Notebook
- Week 1: Tool Calling Full Notebook
- Weekend Project: Building a Real-World Agent
Next Up
In Part 2, we cover LangGraph and the Reflection pattern. We will implement a Self-Critique architecture where the agent validates and improves its own output, and a Planning Agent that breaks complex tasks into step-by-step sub-tasks.