Building a ReAct Agent with LangGraph + Ollama

Chapter “LangGraph 1.0 Fundamentals” built graphs that did not do much. This chapter uses the same graph primitives to build the standard workhorse of applied LLM development: a ReAct agent, a program that alternates between “let the model think” and “run a tool the model asked for” until the model decides it has a final answer.

The name comes from the 2022 ReAct paper, short for Reason and Act, but at this point the pattern is more folklore than research. Ninety percent of the “AI agents” you read about are some variant of ReAct with two or three custom tools bolted on.

We are going to build the same agent twice. The first version uses langchain.agents.create_agent, which is what I reach for in most real projects. The second version constructs the same graph explicitly using the primitives from Chapter “LangGraph 1.0 Fundamentals”. Seeing the two side by side is the fastest way I know to understand what the prebuilt factory is doing on your behalf and when it is worth dropping down to the manual version.

A naming note, since you will see both in the wild: create_agent is the current home for this factory, in the langchain package rather than langgraph.prebuilt. Older code and tutorials call the same kind of factory create_react_agent. That name still exists in langgraph.prebuilt for backward compatibility, but langchain.agents.create_agent is the one actively developed and the one this book uses.

The shape of a ReAct loop

Before any code, the pattern in five bullets:

  1. The user sends a message.
  2. The model reads the transcript and produces either plain text (it is done) or one or more tool calls (it wants to use a tool).
  3. If the model produced tool calls, each one is executed and the results are appended to the transcript as ToolMessages.
  4. The updated transcript goes back to the model. Go to step 2.
  5. Eventually the model returns plain text with no tool calls. That is the final answer.

The “LangGraph 1.0 Fundamentals” chapter’s conditional edges are exactly the mechanism for “step 2 asks a question, step 3 or 5 depending on the answer.” A ReAct agent is a two-node graph:

1 START -> model
2 model -> tools   (if the model's last message has tool_calls)
3 model -> END     (otherwise)
4 tools -> model   (always loop back)

That is the whole thing.

The tools the agent will use

Both example scripts share source-code/langgraph_react_agent/_tools.py:

 1 from langchain_core.tools import tool
 2 
 3 
 4 @tool
 5 def multiply(a: int, b: int) -> int:
 6     """Multiply two integers and return their product."""
 7     return a * b
 8 
 9 
10 @tool
11 def web_search(query: str) -> str:
12     """Search DuckDuckGo for a query and return the top three text results."""
13     from ddgs import DDGS
14 
15     try:
16         results = list(DDGS().text(query, max_results=3))
17     except Exception as exc:
18         return f"Search failed: {exc}"
19 
20     if not results:
21         return "No results."
22 
23     return "\n\n".join(
24         f"- {r.get('title', '')}\n  {r.get('body', '')}" for r in results
25     )
26 
27 
28 TOOLS = [multiply, web_search]

Two tools, chosen for contrast. multiply is deterministic and instant. web_search is nondeterministic and network-bound. Together they let us pose questions of the form “look something up, then compute something with it” that require the loop to run for real.

Both are plain Python functions decorated with @tool. The decorator uses the docstring as the tool description the model sees and the type annotations as the argument schema. Docstring quality directly affects tool-selection accuracy, so it is worth taking seriously.

Version 1: create_agent

source-code/langgraph_react_agent/01_prebuilt_agent.py:

 1 """A ReAct agent using the prebuilt factory.
 2 
 3 `create_agent(model, tools)` builds and compiles the exact graph we
 4 construct by hand in `02_react_from_scratch.py`. Use this when the standard
 5 ReAct shape is all you need; drop down to the from-scratch version when you
 6 need to customize routing, add nodes, or change the state schema.
 7 """
 8 
 9 from langchain_core.messages import HumanMessage
10 from langchain_ollama import ChatOllama
11 from langchain.agents import create_agent
12 
13 from _tools import TOOLS
14 
15 model = ChatOllama(model="qwen3.5:4b", temperature=0)
16 agent = create_agent(model, TOOLS)
17 
18 result = agent.invoke(
19     {"messages": [HumanMessage(content="What is 137 times 24?")]}
20 )
21 
22 for m in result["messages"]:
23     print(f"--- {type(m).__name__} ---")
24     if getattr(m, "tool_calls", None):
25         for call in m.tool_calls:
26             print(f"  tool_call: {call['name']}({call['args']})")
27     if m.content:
28         print(m.content)

create_agent(model, tools) returns a compiled StateGraph, the same kind of object you get from graph.compile() in Chapter “LangGraph 1.0 Fundamentals”. It handles bind_tools on the model, wraps the tools in a ToolNode, and wires the two-node graph shown above. It also accepts, as optional keyword arguments, several things later chapters build by hand on top of the manual graph (checkpointer, interrupt_before/interrupt_after, response_format), all worth knowing about even though this chapter does not use any of them yet.

A representative run:

1 $ uv run 01_prebuilt_agent.py
2 --- HumanMessage ---
3 What is 137 times 24?
4 --- AIMessage ---
5   tool_call: multiply({'a': 137, 'b': 24})
6 --- ToolMessage ---
7 3288
8 --- AIMessage ---
9 137 times 24 equals **3,288**.

Four messages: the user’s question, the model’s tool call, the tool’s result, the model’s final answer. That is one full turn through the ReAct loop.

If the standard ReAct shape is all you need (one model, a list of tools, a normal chat transcript), create_agent is what to reach for. You do not need to see the graph plumbing.

Version 2: the same agent, built explicitly

Now here is what create_agent actually did. source-code/langgraph_react_agent/02_react_from_scratch.py:

 1 from typing import Annotated, TypedDict
 2 
 3 from langchain_core.messages import BaseMessage, HumanMessage
 4 from langchain_ollama import ChatOllama
 5 from langgraph.graph import END, START, StateGraph
 6 from langgraph.graph.message import add_messages
 7 from langgraph.prebuilt import ToolNode
 8 
 9 from _tools import TOOLS
10 
11 
12 class State(TypedDict):
13     messages: Annotated[list[BaseMessage], add_messages]
14 
15 
16 model = ChatOllama(model="qwen3.5:4b", temperature=0, thinking=False).bind_tools(TOOLS)
17 tool_node = ToolNode(TOOLS)
18 
19 
20 def call_model(state: State) -> dict:
21     reply = model.invoke(state["messages"])
22     return {"messages": [reply]}
23 
24 
25 def route_after_model(state: State) -> str:
26     last = state["messages"][-1]
27     if getattr(last, "tool_calls", None):
28         return "tools"
29     return END
30 
31 
32 graph = StateGraph(State)
33 graph.add_node("model", call_model)
34 graph.add_node("tools", tool_node)
35 
36 graph.add_edge(START, "model")
37 graph.add_conditional_edges(
38     "model", route_after_model, {"tools": "tools", END: END}
39 )
40 graph.add_edge("tools", "model")
41 
42 agent = graph.compile()
43 
44 
45 if __name__ == "__main__":
46     result = agent.invoke(
47         {"messages": [HumanMessage(content="What is 137 times 24?")]}
48     )
49 
50     for m in result["messages"]:
51         print(f"--- {type(m).__name__} ---")
52         if getattr(m, "tool_calls", None):
53             for call in m.tool_calls:
54                 print(f"  tool_call: {call['name']}({call['args']})")
55         if m.content:
56             print(m.content)

Let’s walk through the code:

The state. A single messages field reduced by add_messages, exactly like the last example of Chapter “LangGraph 1.0 Fundamentals”. Every message the graph produces, the model’s replies and the tool results, gets appended here.

The model. ChatOllama(...).bind_tools(TOOLS) gives the model the list of callable tools. When invoked, the model may respond with .tool_calls populated instead of .content.

ToolNode. From langgraph.prebuilt. Given a list of tools, ToolNode reads the last message on the transcript, executes each tool call in that message against the matching tool, and returns a list of ToolMessage objects containing the results. You could write this yourself in about a dozen lines (reading state["messages"][-1].tool_calls, dispatching each one, wrapping results in ToolMessage(content=..., tool_call_id=...)), but there is no reason to.

The two node functions. call_model invokes the model on the transcript and returns its reply. route_after_model is not a node, it is a router: a plain function from state to a string. If the model’s last reply has tool calls, we route to "tools"; otherwise we route to END.

The wiring. Three edges. Start goes to the model. The model is followed by a conditional edge that either goes to the tool node or ends. After tools run, we always loop back to the model.

Running it gives the same output as the version 1 example code. That is the point: the prebuilt factory is not magic, it is version 2 of the example code compressed.

When to reach for which version

  • Use create_agent if you have a model and a flat list of tools and you want the standard behavior. It is one line of graph construction and it stays in sync with best practices as the LangChain team refines the pattern.
  • Drop down to the manual StateGraph if you need any of the following: extra state fields beyond messages (retrieval context, user profile, running scratchpad), extra nodes (a planner before the model, a validator after the tools, a memory writer at the end), custom routing (route based on which tool was called, not just whether one was called), or extra edges (parallel tool execution, human-in-the-loop interrupts), or if create_agent’s built-in middleware, interrupt_before/interrupt_after, and checkpointer arguments do not cover what you need to customize.

In my own projects I probably start with create_agent about half the time and drop to the manual graph the other half. The nice thing about LangGraph 1.0 is that the migration is mechanical when you need it: the primitives are the same.

Watching each step with .stream()

The compiled agent is a Runnable, so it supports .stream(). Each yielded item is a dict of the form {node_name: partial_state_update_the_node_produced}. That is an easy way to understand what an agent is actually doing.

The example source-code/langgraph_react_agent/03_streaming_agent.py reconstructs the same manual graph and streams a two-tool question:

 1 question = (
 2     "Search for the current population of Canada, then multiply it by 2."
 3 )
 4 
 5 for step in agent.stream({"messages": [HumanMessage(content=question)]}):
 6     for node_name, node_output in step.items():
 7         print(f"=== node: {node_name} ===")
 8         for m in node_output["messages"]:
 9             if getattr(m, "tool_calls", None):
10                 for call in m.tool_calls:
11                     print(f"  tool_call: {call['name']}({call['args']})")
12             if m.content:
13                 snippet = m.content if len(m.content) < 400 else m.content[:400] + "..."
14                 print(f"  {type(m).__name__}: {snippet}")
15         print()

A representative session (your model’s tool choices and the DuckDuckGo results will vary):

 1 $ uv run 03_streaming_agent.py
 2 USER: Search for the current population of Canada, then multiply it by 2.
 3 
 4 === node: model ===
 5   tool_call: web_search({'query': 'current population of Canada'})
 6 
 7 === node: tools ===
 8   ToolMessage: - Population of Canada - Wikipedia
 9   Canada population density map (2014) Top left: The Quebec City–Windsor Corridor is the most densely inhabited and heavily industrialized region... Canada ranks 37th by population among countries of the world, comprising about 0.5% of the world's total, with about 41.5 million Canadians as of Q1 2026....
10 
11 === node: model ===
12   tool_call: multiply({'a': 41500000, 'b': 2})
13 
14 === node: tools ===
15   ToolMessage: 83000000
16 
17 === node: model ===
18   AIMessage: Based on my search results, Canada's current population is approximately **41.5 million** (as of Q1 2026).
19 
20 When multiplied by 2:
21 **41,500,000 × 2 = 83,000,000**
22 
23 So the result is **83 million**.

Five node executions. Two model invocations that produced tool calls, one that produced the final answer. Two tools invocations that fed data back into the transcript. .stream() makes this legible in a way that .invoke(), which only returns the final state, cannot.

I use .stream() for essentially every agent I write, and I usually convert it to .invoke() only after I am satisfied with the behavior. In practice, an agent that streams sensibly almost always invokes sensibly, and streaming while iterating catches bugs early.

Two failure modes worth knowing

Often (too often) things don’t wrk as we expect. Here are two failure modes you will likely encounter:

  • The model does not call the tool at all. If bind_tools is pointed at a model that does not support tool calling (most 1-3 B parameter models, and some larger models that were not fine-tuned for it), the model will respond in prose describing what it would do instead of returning .tool_calls. The router will then send the response straight to END and the agent will terminate with a plausible-sounding but wrong answer. If you see the model narrating its plan instead of executing it, you probably have the wrong model. As of mid-2026 the models I use for tool-calling work in this book are qwen3.5:4b, llama3.2:3b, gemma3:12b-it-qat, and mistral-small.
  • The agent loops forever. With a bad system prompt or a weak model, the ReAct loop can call the same tool over and over. LangGraph does not enforce a step limit by default.

For the second failure more try setting a recursion_limit when invoking:

1 agent.invoke({"messages": [...]}, config={"recursion_limit": 25})

Twenty-five is a reasonable number: enough for a real multi-step task, low enough to bail out before the LLM bill or the wall clock runs away.

What we covered

  • The ReAct loop is a two-node graph with one conditional edge, precisely the pattern you would write with the primitives from Chapter “LangGraph 1.0 Fundamentals”.
  • create_agent is a factory that builds and compiles that graph for you. Use it unless you need custom state, extra nodes, or unusual routing.
  • Building the graph explicitly is not much more code and unlocks all of the “LangGraph 1.0 Fundamentals” chapter’s flexibility.
  • .stream() on the compiled agent is essential for debugging and iteration.

Chapter “Durable, Restart-Safe Agents” keeps the same agent but adds a checkpointer, which is what turns it from a script that runs once into a service that can pause, resume, and survive a process restart without losing conversation state.