Issue 08 | Building a Standard ReAct Agent Architecture

Updated on 4/14/2026

Implementing the classic "Thought-Observation-Decision" (ReAct) loop model via a cyclic edge.

Geeks, welcome back to our "LangGraph Multi-Agent Expert Course". I am your old friend.

In the previous 7 sessions, we laid a solid foundation for the "AI Content Agency": we defined state machines, mastered basic node routing, and even taught large models simple structured outputs. However, if you look closely at our previous architectures, you will find a fatal flaw—they are too "linear" and too "obedient".

Imagine if the Researcher in our agency was a purely linear thinker: if you asked him to look up "new products from the 2024 Apple Event", and he didn't have this knowledge in his head, what would he do? Previously, he would just start "talking nonsense" (hallucinating).

How does a truly senior researcher work? He would first search on Google (Act), look at the titles of the search results (Observe), think in his head, "Hmm, this link looks promising, I need to click it and take a look" (Reason), then call the browser tool to read the webpage (Act), find that the content is insufficient, search with another keyword... until he feels he has gathered enough information, and only then will he report to you.

This is the tough nut we are going to crack today: the ReAct (Reason + Act) architecture.

Today, we will use LangGraph's Cyclic Edges to inject true "thinking and exploration" capabilities into our AI agency, completely refactoring our Researcher agent. Grab your coffee, let's go!


🎯 Learning Objectives for this Session

  1. Master the underlying logic of ReAct: Understand how the "Thought-Act-Observe" loop breaks the capability ceiling of large models.
  2. Master LangGraph cyclic graph construction: Learn to use Conditional Edges to implement unpredictable dynamic routing.
  3. Practical refactoring of the Researcher: Build a super researcher for our AI Content Agency that can autonomously call tools and decide when to conclude an investigation.
  4. State and context management: Understand how MessagesState accumulates clues like memory in an infinite loop.

📖 Principle Analysis

1. What is ReAct?

ReAct is a paradigm proposed by Princeton University and Google in 2022 (paper: ReAct: Synergizing Reasoning and Acting in Language Models). Before this, large models either engaged in pure reasoning (Chain of Thought) or pure acting (directly calling APIs).

ReAct combines the two: letting the large model "think out loud" before taking action, and "assessing the current situation" after seeing the results of the action.

For our Researcher, its inner monologue goes like this:

  • Thought: The boss asked me to look up the latest features of LangGraph. I need to check with a search engine first.
  • Action: Call search_tool("LangGraph new features 2024")
  • Observation: The tool returned 5 search results.
  • Thought: The 2nd result looks the most relevant, I need to read its detailed content.
  • Action: Call scrape_web_tool("url_to_doc")
  • Observation: ...webpage text...
  • Thought: The information is sufficient, I can start summarizing and finish the task.

2. How to Map ReAct in LangGraph?

In LangGraph, we don't need to hardcode this loop. We only need to define two core nodes and one conditional edge:

  1. Agent Node (LLM Node): Responsible for reasoning and deciding whether to call a tool.
  2. Tool Node: Responsible for executing tools and returning results.
  3. Conditional Edge: Connects to the Agent Node. If the LLM decides to call a tool, it routes to the Tool Node; if the LLM directly outputs the final answer, it routes to END.

The beauty of this architecture is: the number of loop iterations is decided by the LLM itself. If it feels it has figured things out, the graph ends; if it feels it hasn't, the graph continues to loop.

3. Core Architecture Diagram (Mermaid)

Below is the ReAct workflow graph we are going to build for the Researcher today. Please look closely at the loop circuit in the diagram:

graph TD
    classDef start_end fill:#f96,stroke:#333,stroke-width:2px;
    classDef agent fill:#69b3a2,stroke:#333,stroke-width:2px;
    classDef tool fill:#ff9999,stroke:#333,stroke-width:2px;
    classDef condition fill:#e1d5e7,stroke:#333,stroke-width:2px;

    START((START)):::start_end
    END((END)):::start_end

    subgraph ReAct_Loop [ReAct Core Loop (Researcher)]
        Agent[🤖 Agent Node
Think and decide next step]:::agent Condition{💡 Routing Decision
Has Tool Call?}:::condition Tools[🛠️ Tool Node
Execute tool & record observation]:::tool end START --> Agent Agent --> Condition Condition -- "Yes (Go to Act)" --> Tools Tools -- "Return Observation" --> Agent Condition -- "No (Task Complete)" --> END

Instructor's Sharp Commentary: Do you see that line pointing from Tools back to Agent? This is where LangGraph is more powerful than traditional LangChain Chains. Chains are unidirectional, while Graphs are Turing complete. With loops, the Agent truly comes to "life".


💻 Practical Code Drill

Now, let's return to the "AI Content Agency" project. Our Planner has just issued a task: "Investigate the most popular AI coding assistant tools currently on the market, and provide a brief comparison report."

Our Researcher needs to take the order.

1. Preparation and Tool Definition

First, we need to equip the Researcher with "hands and eyes" (Tools). For demonstration purposes, we will use simulated search and web scraping tools.

import json
from typing import TypedDict, Annotated, Sequence
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

# ==========================================
# 1. Define Tools - The Researcher's Arsenal
# ==========================================

@tool
def search_web(query: str) -> str:
    """Use this tool when you need to search the internet for the latest information."""
    print(f"   [Tool Execution] 🔍 Searching for: {query}")
    # Simulate search engine returning results
    if "AI coding assistant" in query or "AI 编程助手" in query:
        return json.dumps([
            {"title": "Cursor: Revolutionizing the way we code", "url": "https://cursor.sh/about"},
            {"title": "GitHub Copilot Latest Features", "url": "https://github.com/copilot"}
        ])
    return "No relevant information found."

@tool
def scrape_web(url: str) -> str:
    """Use this tool when you need to read the detailed content of a specific webpage."""
    print(f"   [Tool Execution] 📄 Scraping webpage: {url}")
    # Simulate web scraping
    if "cursor" in url.lower():
        return "Cursor is an AI editor based on a VS Code fork, deeply integrated with Claude 3.5 Sonnet, supporting multi-file context refactoring."
    elif "copilot" in url.lower():
        return "GitHub Copilot integrates OpenAI's models, with advantages in seamless integration with the GitHub ecosystem and enterprise-grade security compliance."
    return "Webpage content cannot be read."

# Tool list
tools = [search_web, scrape_web]

2. Define State and Agent Node

In ReAct, State is extremely important. We need to record the entire history of "Thought-Act-Observe". The add_messages reducer provided by LangGraph is the perfect solution.

# ==========================================
# 2. Define State
# ==========================================
class AgentState(TypedDict):
    # Use add_messages to ensure messages are appended, not overwritten
    # This is the secret to how ReAct remembers previous rounds of search results!
    messages: Annotated[Sequence[BaseMessage], add_messages]

# ==========================================
# 3. Define LLM and Node Binding
# ==========================================
# Initialize LLM (assuming you have configured OPENAI_API_KEY)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# Crucial step: Bind tools to the LLM
# This is equivalent to telling the LLM: "If you need to, you can call these functions at any time"
llm_with_tools = llm.bind_tools(tools)

def researcher_agent_node(state: AgentState):
    """
    Agent Node: Responsible for reading the current context (including previous observations),
    reasoning, and deciding whether to output the final answer or continue calling tools.
    """
    print("\n[Agent Thinking...] 🧠 Analyzing current intelligence...")
    messages = state["messages"]
    
    # Inject system prompt to give the Researcher its persona
    sys_msg = SystemMessage(content="""
    You are a Senior Researcher at the AI Content Agency.
    Your goal is to gather accurate information using tools.
    Please follow these principles:
    1. Do not make things up! You must base your answers on facts returned by the tools.
    2. If the information is insufficient, continue searching or reading webpages.
    3. When you have gathered enough information, directly output the final investigation report.
    """)
    
    # Call the LLM
    response = llm_with_tools.invoke([sys_msg] + messages)
    
    # Return the new message (will be appended to the state)
    return {"messages": [response]}

3. Build Tool Node and Conditional Routing

This is the most core code of this session. We need to process the tool_calls returned by the LLM, execute them, and stuff the results back to the LLM as a ToolMessage.

(Note: LangGraph officially provides a ready-made ToolNode, but to let you thoroughly understand the underlying logic, we are hand-writing a minimalist version of the tool execution node.)

# ==========================================
# 4. Define Tool Execution Node (Tool Node)
# ==========================================
def tool_execution_node(state: AgentState):
    """
    Tool Node: Specifically responsible for intercepting the LLM's tool_calls, executing real Python functions,
    and wrapping the results as a ToolMessage to return.
    """
    messages = state["messages"]
    # Get the latest message from the LLM
    last_message = messages[-1]
    
    tool_responses = []
    # Iterate through all tools the LLM requested to call
    for tool_call in last_message.tool_calls:
        tool_name = tool_call["name"]
        tool_args = tool_call["args"]
        
        # Find the corresponding tool function and execute it
        tool_instance = next(t for t in tools if t.name == tool_name)
        try:
            result = tool_instance.invoke(tool_args)
        except Exception as e:
            result = f"Tool execution error: {str(e)}"
            
        # Must be wrapped as a ToolMessage, including the tool_call_id
        # This way the LLM knows which of its calls this result corresponds to
        tool_responses.append(
            ToolMessage(
                content=str(result),
                name=tool_name,
                tool_call_id=tool_call["id"]
            )
        )
        
    return {"messages": tool_responses}

# ==========================================
# 5. Define Conditional Routing (Conditional Edge)
# ==========================================
def should_continue(state: AgentState) -> str:
    """
    Routing logic: Decides whether the graph continues to loop or ends.
    """
    messages = state["messages"]
    last_message = messages[-1]
    
    # If the message returned by the LLM contains tool_calls, it means it wants to act
    if last_message.tool_calls:
        print("   [Routing Decision] 🔀 Tool call detected, heading to Tools node.")
        return "continue"
    
    # Otherwise, it means the LLM thinks the task is complete and output plain text
    print("   [Routing Decision] 🏁 Task complete, preparing to output report.")
    return "end"

4. Assemble and Run Our Agency Researcher

Let's assemble the nodes and edges!

# ==========================================
# 6. Build LangGraph
# ==========================================
workflow = StateGraph(AgentState)

# Add nodes
workflow.add_node("agent", researcher_agent_node)
workflow.add_node("tools", tool_execution_node)

# Set entry point: Always enter the agent node first to think
workflow.add_edge(START, "agent")

# Add conditional edges: Starting from agent, decide destination based on should_continue's return value
workflow.add_conditional_edges(
    "agent",
    should_continue,
    {
        "continue": "tools", # If it returns continue, go to tools node
        "end": END           # If it returns end, end the graph
    }
)

# Add cyclic edge: After tool execution, must return to agent node for "observation and next step reasoning"
workflow.add_edge("tools", "agent")

# Compile the graph
researcher_app = workflow.compile()

# ==========================================
# 7. Simulated Run (Demo)
# ==========================================
if __name__ == "__main__":
    from langchain_core.messages import SystemMessage # Complete the dependencies from above
    print("=== AI Content Agency: Researcher Started ===")
    
    # Task issued by Planner
    initial_task = "Please investigate the most popular AI coding assistant tools currently on the market (Cursor and Copilot), and provide a brief comparison report."
    
    inputs = {"messages": [HumanMessage(content=initial_task)]}
    
    # The stream method allows us to see the execution process step by step
    for output in researcher_app.stream(inputs, stream_mode="updates"):
        # Print the currently executing node
        for node_name, state_update in output.items():
            pass # Detailed logs are already printed inside the nodes
            
    # Print the final result
    final_state = researcher_app.get_state(inputs) # Get the final state
    # (Note: The get_state usage above requires thread_id in some versions,
    # the simplest way to get the final message is directly from the last stream output)
    print("\n==================================")
    print("📊 Researcher Final Report:")
    print("==================================")
    # Extract the content of the last message in the state
    print(output['agent']['messages'][-1].content)

🧠 Visualizing the Execution Process (The LLM's Inner Monologue)

When you run this code, you will see the console frantically outputting:

  1. [Agent Thinking...] -> The LLM feels the need to look up information.
  2. [Routing Decision] 🔀 Tool call detected... -> Triggers the conditional edge.
  3. [Tool Execution] 🔍 Searching for... -> Executes search_web.
  4. Returns to Agent, [Agent Thinking...] -> Sees the search results only have URLs, decides to dig deeper.
  5. [Routing Decision] 🔀 Tool call detected...
  6. [Tool Execution] 📄 Scraping... -> Executes scrape_web.
  7. Returns to Agent, [Agent Thinking...] -> Information gathering complete, starts writing the report.
  8. [Routing Decision] 🏁 Task complete... -> Routes to END.

This is true intelligence! It is no longer a rigid process, but an Agent with autonomous exploration capabilities.


Pitfalls and How to Avoid Them

As your instructor, I can't just teach you how to run a Demo; I also have to tell you how this architecture will "kill" you in a real production environment.

💣 Pitfall 1: Infinite Loop

Symptom: The LLM gets stuck in a loop. For example, if it can't find something, it searches with a different word, still can't find it, and keeps searching... Your API bill will explode overnight. How to Avoid: In LangGraph, you must set a Recursion Limit when compiling the graph. The default is 25, but it is recommended to set it explicitly.

# Limit the graph to a maximum of 10 steps
researcher_app.invoke(inputs, {"recursion_limit": 10})

If the limit is reached, LangGraph will throw a GraphRecursionError, which you can catch in the outer layer and gracefully degrade.

💣 Pitfall 2: Tool Errors Causing Graph Crashes

Symptom: scrape_web encounters anti-crawler blocking, throws a TimeoutError, and the entire Graph directly errors out and exits. How to Avoid: Never let a tool's Error bubble up to the graph level! Just like the try...except we wrote in tool_execution_node, you should convert the error message into a string, wrap it in a ToolMessage, and return it to the LLM. The LLM is very smart. If it sees "Error: 403 Forbidden" in the ToolMessage, it will figure out a workaround (like trying a different website, or stating "the website is inaccessible" in the report). Turning exceptions into Observations is the essence of the ReAct architecture.

💣 Pitfall 3: Context Window Explosion

Symptom: The Researcher checks 10 webpages, each with 5000 words. The messages list gets longer and longer, eventually exceeding GPT-4's 128k context limit, throwing a TokenLimitExceeded error. How to Avoid: In real projects, we cannot infinitely add_messages. We need to introduce State Trimming or a Summarize Node into the loop. When len(messages) exceeds a certain threshold, it triggers a small internal LLM to compress the preceding useless webpage content into a summary. (Spoiler: We will specifically expand on this advanced technique in Issue 12, "Long Context and Memory Management"!)


📝 Summary of this Session

Today, we completed a cognitive leap from "linear execution" to "dynamic loops":

  1. The ReAct architecture is not some mysterious magic; it is simply LLM Node + Tool Node + Cyclic Conditional Edge.
  2. We successfully built the Researcher role for the "AI Content Agency". It is no longer a nerd that only recites training data, but a true researcher that can use search engines, read webpages, and autonomously decide when to deliver the results.
  3. We mastered the accumulation mechanism of the messages state in LangGraph, which is the key to the LLM maintaining memory across multiple loop iterations.

Next Session Preview: Although the current Researcher is capable, it is fighting alone. In our Agency, how does the Planner (Supervisor) assign tasks to the Researcher, and how does the Researcher toss the report to the Writer (Lead Author) after finishing? In Issue 09, "Multi-Agent Collaboration: SubGraphs and Networked Organizations", we will integrate the Researcher we wrote today as a sub-node into our massive agency network.

Geeks, get today's code running, try adding a "weather query" tool to your Researcher, and see if it can tell you whether to bring an umbrella tomorrow. See you next time!