teachyou.ai academy
← All posts
LangChain

LangChain Agents Deep Dive: ReAct, Tool-Calling and Custom Agents

Ira Menon · Jul 3, 2026 · 15 min read

Why "agents" keep breaking in production

Every team that ships an LLM feature eventually hits the same wall: a single prompt-response call isn't enough. The model needs to look something up, run a calculation, hit an internal API, or decide between three different paths depending on what a user just asked. That's the moment "agents" enter the conversation, and it's also the moment most teams get confused, because the word "agent" gets used for at least three different architectures that behave very differently under load.

LangChain popularized the modern agent pattern in the Python and JavaScript ecosystems, and it's still one of the most common ways developers wire an LLM to tools. But there's a real difference between the original ReAct-style agent (the one that reasons in text and parses its own output), the newer tool-calling agents that lean on model-native function calling, and a fully custom agent loop you write yourself when neither prebuilt option fits. This article walks through all three, with working code, so you can pick the right one instead of copy-pasting whatever example you found first.

If you're building this for real, the mental model matters more than the API surface. An agent, at its core, is a loop: the model decides an action, code executes that action, the result goes back into context, and the model decides again. Everything else — parsers, memory, tool schemas, executors — exists to make that loop reliable.

The core agent loop, stripped down

Before touching LangChain's abstractions, it helps to see the loop with no framework at all. This is what every agent library is secretly doing underneath:

def naive_agent_loop(llm, tools, user_input, max_steps=6):
    messages = [{"role": "user", "content": user_input}]
    tool_map = {t.name: t for t in tools}

    for step in range(max_steps):
        response = llm.invoke(messages)

        if response.tool_calls:
            messages.append(response)
            for call in response.tool_calls:
                tool = tool_map[call["name"]]
                result = tool.invoke(call["args"])
                messages.append({
                    "role": "tool",
                    "tool_call_id": call["id"],
                    "content": str(result),
                })
            continue

        return response.content

    return "Max steps reached without a final answer."

That's it. No magic. The model gets messages, optionally asks for a tool, the tool runs, the result is appended, and the loop repeats until the model stops asking for tools. LangChain's agent abstractions are built to manage exactly this loop, but with schema validation, retries, tracing, and memory bolted on. Understanding the raw loop first makes every LangChain abstraction easier to reason about, because you'll recognize which part of this loop each class is standing in for.

ReAct agents: reasoning and acting in the same breath

ReAct (short for "Reasoning + Acting") was the original pattern LangChain built its agent module around. The idea, from the 2022 paper by Yao et al., is simple: instead of asking the model to just answer, you ask it to alternate between a Thought, an Action, and an Observation, in plain text, until it reaches a Final Answer.

A ReAct prompt looks roughly like this under the hood:

Answer the following question using the tools available.

Thought: I need to find the current exchange rate first.
Action: get_exchange_rate
Action Input: {"from": "USD", "to": "INR"}
Observation: 1 USD = 87.4 INR
Thought: Now I can calculate the total.
Action: calculator
Action Input: {"expression": "500 * 87.4"}
Observation: 43700
Thought: I now know the final answer.
Final Answer: 500 USD is approximately 43,700 INR.

The key detail: the model isn't calling a function directly. It's writing text that *looks like* a function call, and a parser on the LangChain side extracts the action name and input, executes it, and stuffs the observation back into the prompt. This matters because it means ReAct agents are somewhat model-agnostic — they work even with models that have no native tool-calling support — but they're also fragile, because the parser depends on the model formatting its output exactly right.

Here's a working ReAct agent using create_react_agent from langchain:

from langchain.agents import create_react_agent, AgentExecutor
from langchain_core.tools import tool
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

@tool
def get_exchange_rate(from_currency: str, to_currency: str) -> float:
    """Return a mock exchange rate between two currency codes."""
    rates = {("USD", "INR"): 87.4, ("EUR", "INR"): 94.1}
    return rates.get((from_currency, to_currency), 1.0)

@tool
def calculator(expression: str) -> float:
    """Evaluate a basic arithmetic expression."""
    return eval(expression, {"__builtins__": {}})

react_prompt = PromptTemplate.from_template("""
Answer the following question as best you can. You have access to these tools:

{tools}

Use this format:

Question: {input}
Thought: think about what to do
Action: the tool to use, one of [{tool_names}]
Action Input: the input to the tool
Observation: the tool's result
... (repeat Thought/Action/Observation as needed)
Thought: I now know the final answer
Final Answer: the final answer to the question

Question: {input}
{agent_scratchpad}
""")

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [get_exchange_rate, calculator]

agent = create_react_agent(llm, tools, react_prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True, max_iterations=6)

result = executor.invoke({"input": "How much is 500 USD in INR?"})
print(result["output"])

Notice AgentExecutor — this is the class actually running the loop from the previous section. It calls the LLM, parses the text output for an Action/Action Input pair, runs the matching tool, appends the Observation, and loops until it sees Final Answer. max_iterations is your safety valve against infinite loops, and verbose=True is invaluable for debugging because you see every Thought/Action/Observation cycle as it happens.

The weakness of this pattern is parsing brittleness. If the model outputs "Action: get_exchange_rate " with a trailing space, or wraps the action input in markdown, the parser can throw an OutputParserException. This is exactly the problem that pushed the ecosystem toward native tool-calling.

Tool-calling agents: letting the model emit structured calls

Modern LLM providers — OpenAI, Anthropic, and others — expose native "tool use" or "function calling" in their APIs. Instead of asking the model to write Action: tool_name as text, you send the model a JSON schema for each tool, and the model returns a structured tool-call object directly, no text parsing required.

LangChain's create_tool_calling_agent (and the newer create_agent in langchain 1.x) is built around this. It's dramatically more reliable because you're relying on the provider's own structured output guarantees instead of a regex-like parser.

from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import tool
from langchain_anthropic import ChatAnthropic

@tool
def search_docs(query: str) -> str:
    """Search internal documentation for a given query string."""
    fake_index = {
        "refund policy": "Refunds are processed within 5-7 business days.",
        "shipping": "Standard shipping takes 3-5 business days.",
    }
    for key, value in fake_index.items():
        if key in query.lower():
            return value
    return "No matching documentation found."

@tool
def create_ticket(summary: str, priority: str = "normal") -> str:
    """Create a support ticket with a summary and priority level."""
    return f"Ticket created: '{summary}' (priority={priority})"

llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0)
tools = [search_docs, create_ticket]

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a support assistant. Use tools when you need facts you don't know."),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

response = executor.invoke({
    "input": "A customer wants to know the refund policy and also wants a ticket opened."
})
print(response["output"])

A few things worth calling out here. First, @tool automatically builds a JSON schema from the function's type hints and docstring — that docstring is not decoration, it's what the model reads to decide when and how to call the tool, so treat it like an API contract. Second, tool-calling agents can request multiple tools in a single turn (the model might call search_docs and create_ticket together), which the ReAct text format struggles to express cleanly. Third, because the schema is strongly typed, you get validation errors immediately if the model tries to pass a string where a number was expected, instead of silently corrupting a parse downstream.

For anyone starting a new project today, tool-calling agents should be the default. ReAct is worth understanding because a lot of production systems and open-source agents still use it, and because it's genuinely useful with models or providers that don't support native function calling well.

Structured tools with Pydantic for real validation

Simple @tool functions are fine for demos, but production tools usually need richer input validation — optional fields, enums, nested objects. StructuredTool with a Pydantic schema handles that cleanly:

from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool
from typing import Literal

class TicketInput(BaseModel):
    summary: str = Field(description="One-line description of the issue")
    priority: Literal["low", "normal", "high", "urgent"] = Field(
        default="normal", description="Urgency of the ticket"
    )
    customer_email: str = Field(description="Email of the customer filing the ticket")

def _create_ticket(summary: str, priority: str, customer_email: str) -> str:
    return f"[{priority.upper()}] Ticket for {customer_email}: {summary}"

create_ticket_tool = StructuredTool.from_function(
    func=_create_ticket,
    name="create_ticket",
    description="Create a support ticket with priority and customer email.",
    args_schema=TicketInput,
)

The Literal type is doing real work here — it constrains the model's tool call to one of four valid priority values, and LangChain will surface a validation error back into the loop if the model tries anything else. This is the difference between a tool that "usually works" and one that fails loudly and specifically when something's wrong, which is what you want when a bad call could otherwise create garbage data downstream.

Building a custom agent when prebuilt executors don't fit

AgentExecutor covers most cases, but sometimes you need control it doesn't give you — custom retry logic, per-step logging to your own observability stack, early termination on a specific condition, or interleaving non-tool steps (like a human approval gate) into the loop. This is where LangGraph, LangChain's graph-based orchestration layer, becomes the better foundation, because it lets you define the agent loop as an explicit state graph instead of a hidden executor.

from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from langchain_core.messages import AnyMessage, HumanMessage
from typing import TypedDict, Annotated
import operator

class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]

def call_model(state: AgentState):
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

def should_continue(state: AgentState) -> str:
    last_message = state["messages"][-1]
    if getattr(last_message, "tool_calls", None):
        return "tools"
    return END

llm_with_tools = llm.bind_tools(tools)

graph = StateGraph(AgentState)
graph.add_node("agent", call_model)
graph.add_node("tools", ToolNode(tools))
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
graph.add_edge("tools", "agent")

app = graph.compile()

result = app.invoke({"messages": [HumanMessage(content="Open a high-priority ticket for a billing error")]})
for message in result["messages"]:
    print(type(message).__name__, "-", getattr(message, "content", message.tool_calls))

Compare this to the naive loop from the second section — it's the same shape, just expressed as nodes and edges instead of a for loop. call_model is your "ask the LLM" step, ToolNode is your "run the tool" step, and should_continue is the branching logic that decides whether to keep looping or stop. The payoff for this extra structure is that you can now insert arbitrary nodes anywhere: a human-approval node before high-risk tool calls, a summarization node when the message history gets too long, or a separate node that logs every tool call to your analytics pipeline. None of that is bolted onto an executor's internals — it's just another node in the graph.

This is also where custom agents earn their keep for anything approaching a real product. A support bot that can create tickets shouldn't be one bad prompt away from spamming your ticketing system; you want a node that checks priority before calling create_ticket, and a graph makes that a first-class step rather than a hack layered on top of a callback.

Memory: agents need to remember what they just did

An agent without memory re-derives its plan from scratch every request, which is both slow and inconsistent. LangChain's memory story has shifted meaningfully — older ConversationBufferMemory-style classes are being phased out in favor of just managing message lists directly, since that composes more naturally with LangGraph's state model.

from langgraph.checkpoint.memory import MemorySaver

checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "customer-482"}}

app.invoke({"messages": [HumanMessage(content="My order #482 hasn't arrived")]}, config=config)
app.invoke({"messages": [HumanMessage(content="Actually, can you check the refund policy for that?")]}, config=config)

The thread_id is the key idea: every invocation tagged with the same thread ID shares state, so the second call sees the full message history from the first, including any tool calls and observations. This is a far cleaner mental model than the older memory classes, because "memory" is just "state persisted by thread," and you can swap MemorySaver for a Postgres- or Redis-backed checkpointer without touching your graph logic at all.

Common failure modes and how to actually fix them

A few problems come up constantly once agents move past a demo:

  • Infinite tool-calling loops. The model keeps calling a tool, gets a result, and calls it again with slightly different arguments, never reaching a final answer. Fix this with a hard max_iterations or recursion_limit, and just as important, make sure your tool's docstring makes it obvious when the task is actually complete.
  • Wrong tool chosen. If you have ten tools with overlapping descriptions, the model will occasionally pick the wrong one. The fix isn't a smarter model — it's tighter, more distinct tool descriptions and, where possible, fewer tools per agent. An agent with three well-described tools outperforms one with fifteen vague ones almost every time.
  • Tool errors crashing the whole run. A tool that raises an unhandled exception should not take down the agent. Wrap tool bodies in try/except and return the error as a string observation — the model can often recover and try a different approach if it's told what went wrong, instead of the whole executor dying.
  • Context window bloat. Long-running agents accumulate huge message histories full of tool observations. Trim or summarize older messages before they get resent on every turn, especially for tools that return large JSON blobs.
  • Non-determinism in testing. Agents are hard to unit test because the model's tool choice can vary. Set temperature=0 for anything you're testing, and write tests that assert on the tool that was called (via mocked tools) rather than the exact wording of the final answer.
def _flaky_tool(query: str) -> str:
    try:
        return real_lookup(query)
    except Exception as exc:
        return f"Tool error: {exc}. Try rephrasing the query or using a different tool."

That last pattern — returning errors as observations instead of raising — is probably the single highest-leverage change you can make to an agent's reliability. Models are surprisingly good at recovering from a clearly stated failure; they're terrible at recovering from a stack trace that killed the process.

Choosing between ReAct, tool-calling, and a custom graph

There's no universally correct choice here, but a few rules of thumb hold up in practice. Reach for a ReAct agent when you're working with a model that has weak or no native function calling, or when you specifically want the model's reasoning to be visible as readable text for debugging or compliance reasons. Reach for a tool-calling agent (create_tool_calling_agent or the newer create_agent) as your default for anything built on Claude, GPT, or similar models with native tool support — it's more reliable, supports parallel tool calls, and fails more predictably. Reach for a custom LangGraph when you need conditional branching beyond "call a tool or don't," human-in-the-loop approval steps, multi-agent handoffs, or persistent memory across sessions.

In practice, most production systems end up as a hybrid: a tool-calling agent for the core reasoning loop, wrapped in a LangGraph that adds guardrails, logging, and approval steps around it. Starting with the simplest option and only adding graph complexity when you hit a concrete limitation will save you from over-engineering an agent that a plain AgentExecutor would have handled fine.

Testing agents before they touch production

Agent behavior is nondeterministic enough that "it worked when I tried it" isn't a real test strategy. A minimal but effective approach: mock the LLM's tool-call decisions and assert on which tools were invoked and with what arguments, independent of the final natural-language response.

from unittest.mock import MagicMock

def test_agent_calls_refund_tool_on_refund_question():
    mock_llm = MagicMock()
    mock_llm.invoke.return_value = MagicMock(
        tool_calls=[{"name": "search_docs", "args": {"query": "refund policy"}, "id": "1"}]
    )

    tool_calls_made = []
    def fake_search_docs(query: str) -> str:
        tool_calls_made.append(query)
        return "Refunds take 5-7 business days."

    result = fake_search_docs("refund policy")
    assert "refund policy" in tool_calls_made
    assert "5-7" in result

This is intentionally lightweight — the point is to decouple "did the agent pick the right tool" from "did the model phrase the final sentence nicely," because the second thing changes on every model update and isn't worth pinning down in a test suite.

Wrapping up

The core insight that makes all of this click is that a LangChain agent is not a magic reasoning box — it's a loop around a model, a set of tools with schemas, and a parser or native function-calling layer that turns model output into an executable action. ReAct agents make that loop explicit in text and are useful when you need transparency or are working with older models. Tool-calling agents lean on native structured output and are the right default for most new work. Custom LangGraph agents give you the loop as an explicit, editable graph, which is what you want the moment you need guardrails, approvals, or multi-agent coordination.

Pick the simplest version that solves your actual problem, add structured validation with Pydantic wherever a bad tool call would be expensive, and make error handling inside tools a first-class concern rather than an afterthought. If you want to go deeper — building multi-agent systems, wiring in retrieval, adding human-in-the-loop approval, and deploying agents that hold up under real traffic — that's exactly what we cover hands-on in the LangChain Tutorial 2026 course on teachyou.ai, with full working projects instead of toy examples.