The most surprising thing about ReAct prompting is that it doesn’t just make LLMs smarter, it makes them more honest about what they don’t know.
Imagine you’re building a system that needs to answer questions about user data, but the LLM itself can’t directly access that data. You give it a tool, say, a getUserInfo(userId) function.
Here’s how ReAct works, step-by-step, with a concrete example. Let’s say a user asks: "What’s John Doe’s email address?"
The LLM, using ReAct, goes through this cycle:
- Thought: I need to find John Doe’s email address. The available tool
getUserInfo(userId)seems relevant. To use it, I need theuserIdfor "John Doe". I don’t have that information directly. - Action: Call
searchUser(name="John Doe")to find the user ID. - Observation: The
searchUsertool returns{"userId": "user123"}. - Thought: Now I have the
userIdfor John Doe, which is "user123". I can use this to get his email address with thegetUserInfotool. - Action: Call
getUserInfo(userId="user123"). - Observation: The
getUserInfotool returns{"email": "john.doe@example.com"}. - Thought: I have the email address: "john.doe@example.com". I can now answer the user’s question.
- Answer: John Doe’s email address is john.doe@example.com.
This isn’t just a sequence of API calls; it’s a reasoning process. The "Thought" steps are crucial. They represent the LLM’s internal monologue, deciding what to do next. This explicit reasoning allows the LLM to:
- Break down complex problems: It can decompose a user query into smaller, manageable steps.
- Select the right tools: It can choose which tool is most appropriate for each sub-problem.
- Handle missing information: If a tool returns insufficient data, the LLM can reason about what else it needs and plan a new action.
- Self-correct: If an action fails or yields unexpected results, the LLM can use its thought process to try a different approach.
Let’s look at the configuration. You’d typically define your tools and then provide them to the LLM. For example, in a Python environment using a framework like LangChain:
from langchain.agents import AgentExecutor, Tool, initialize_agent, AgentType
from langchain.llms import OpenAI
from pydantic import BaseModel, Field
# Define tool schemas
class SearchUserInput(BaseModel):
name: str = Field(description="The name of the user to search for.")
class GetUserInfoInput(BaseModel):
userId: str = Field(description="The ID of the user whose information to retrieve.")
# Mock tool implementations
def search_user(name: str) -> dict:
print(f"--- Tool Called: search_user(name='{name}') ---")
# In a real app, this would query a database or API
if name == "John Doe":
return {"userId": "user123"}
return {"userId": None}
def get_user_info(userId: str) -> dict:
print(f"--- Tool Called: get_user_info(userId='{userId}') ---")
# In a real app, this would query a database or API
if userId == "user123":
return {"email": "john.doe@example.com", "name": "John Doe"}
return {"email": None, "name": None}
# Define the tools for the LLM
tools = [
Tool(
name="SearchUser",
func=search_user,
description="Searches for a user by their name and returns their user ID. Input should be a JSON object with a 'name' field.",
args_schema=SearchUserInput
),
Tool(
name="GetUserInfo",
func=get_user_info,
description="Retrieves detailed information for a user given their user ID. Input should be a JSON object with a 'userId' field.",
args_schema=GetUserInfoInput
)
]
# Initialize the LLM
llm = OpenAI(temperature=0)
# Initialize the ReAct agent
agent = initialize_agent(
tools,
llm,
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
verbose=True # This is what shows the Thought/Action/Observation steps
)
# Run a query
query = "What's John Doe's email address?"
agent.run(query)
When you run this agent.run(query), the verbose=True flag will show you the internal "Thought", "Action", and "Observation" steps printed to your console, just like in the example above. The LLM is trained to output these steps in a specific format that the AgentExecutor can parse to call the correct tools and feed their results back.
The "description" field in the Tool object is critical. This is what the LLM reads to understand what a tool does and when to use it. If these descriptions are vague, the LLM will struggle. For instance, a description like "gets user data" is far less useful than "Searches for a user by their name and returns their user ID."
The AgentType.ZERO_SHOT_REACT_DESCRIPTION is a common choice for ReAct agents. It means the agent can use tool descriptions to figure out how to use them, even if it hasn’t been explicitly fine-tuned on that specific tool usage pattern before.
One of the most powerful aspects of ReAct is its ability to gracefully fail when it cannot find an answer. If search_user returned {"userId": None}, the LLM’s thought process might look like this:
- Thought: The
searchUsertool did not return a user ID for "John Doe". This means John Doe is not in the system. I cannot find his email address. - Answer: I’m sorry, but I couldn’t find a user named John Doe in our system.
This explicit acknowledgment of failure, rather than hallucinating an email address, is a massive win for reliability.
The next hurdle you’ll often face is managing the complexity of tool descriptions, ensuring they are precise enough for the LLM to interpret correctly, especially when dealing with many tools or intricate argument structures.