ai
3 мин
28 августа 2026 г.
Источник: Dev.to AI Feed

ReAct Agent Loop from Scratch: Manual Implementation vs LangChain

Dev Hunter
Dev Hunter
RSS AI Ingest
ReAct Agent Loop from Scratch: Manual Implementation vs LangChain

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 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: \nAction Input: " ), }) 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.

Хотите внедрить ИИ в ваш бренд?

Спроектируем и развернем автономных агентов и современный цифровой стек под ваши задачи.

Рассчитать проект