Python & AI Tutorials Logo
LangChain & LangGraph

16. Prebuilt Components and Multi-Branch Routing

In Chapter 15 we assembled an agent graph by hand — a model node, a tool node, and a conditional edge that decides whether to keep looping or stop. This is essentially the standard structure for tool-calling agents, so LangChain and LangGraph ship it as prebuilt components you can use instead of writing the same scaffolding from scratch every time.

In the first half of this chapter, we'll rebuild the Chapter 15 agent using prebuilt components. We'll swap the tool-execution node and routing function for ToolNode and tools_condition, and finally replace the entire graph assembly with a single call to create_agent. You'll see that the behavior stays identical to Chapter 15, while the code shrinks considerably.

In the second half, we'll combine prebuilt components with the manual graph assembly approach from Chapter 15 to build a more complex agent. The agent we'll build routes each request to a different handler — complex consultations go to a high-performance model, while simple questions are answered by a cheaper, smaller model. This is a multi-branch structure where the processing path diverges based on the type of request.

16.1) Prebuilt Components and create_agent

In this section we'll replace the tool_node function and should_continue function from Chapter 15's graph with the prebuilt components ToolNode and tools_condition. After that, we'll skip the manual assembly altogether and create the entire graph with a single create_agent call. The thing to watch at each step is that the code gets shorter while the agent's behavior stays identical to Chapter 15.

16.1.1) ToolNode and tools_condition

ToolNode is a prebuilt node that handles tool execution for you. When the last message in the State (the AIMessage returned by the LLM) contains tool_calls, it runs the requested tools and adds the results to messages as ToolMessage objects. It does the same job as the tool_node function we wrote in Chapter 15. On top of that, when the LLM requests multiple tools at once, it runs them in parallel.

It also supports exception handling during tool execution. If you set ToolNode(tools, handle_tool_errors=True), the graph won't crash even when a tool raises an exception. The exception is converted into a ToolMessage carrying the error details, which gets passed to the LLM so it can see the failure and retry with corrected arguments.

You create a ToolNode by passing it a list of tools. We'll use the same two tools from Chapter 15.

python
from langgraph.prebuilt import ToolNode
from langchain.tools import tool
 
@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    fake_data = {"Tokyo": "18°C, cloudy", "Cairo": "31°C, sunny"}
    return fake_data.get(city, f"No weather data for {city}.")
 
@tool
def calculate(expression: str) -> str:
    """Evaluate a simple arithmetic expression. Example: '3 * 21'."""
    return str(eval(expression))  # Warning: eval() is a security risk. Do not use in production.
 
tools = [get_weather, calculate]
 
# Chapter 15's tool_map + tool_node function replaced by this single line
tool_node = ToolNode(tools)

ToolNode builds an internal name-to-tool mapping from the tool list, just like Chapter 15's tool_map. At runtime it looks up each tool by the name the LLM requested and calls it. In other words, the tool_map dictionary, the for loop over tool_calls, and the code that builds and collects ToolMessage objects — all of that now lives inside ToolNode.

tools_condition is the prebuilt routing function that replaces the should_continue function from Chapter 15. Let's look at Chapter 15's should_continue again.

python
def should_continue(state: AgentState) -> Literal["tool_node", "__end__"]:
    """Decide whether to execute tools or end the graph."""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tool_node"
    return END

It returned "tool_node" (our tool node's registered name) when the last message had tool_calls, and END otherwise. tools_condition works exactly the same way, with one difference in the name it returns. While should_continue was written to return "tool_node" — the name we registered in our graph — tools_condition is hardcoded to return "tools".

As we learned in Section 15.2.4, the value a routing function returns is the name of the next node to execute. If no node with that name exists in the graph, routing fails. So when using tools_condition, the tool node must be registered under the name "tools".

python
from langgraph.prebuilt import ToolNode, tools_condition
 
builder.add_node("tools", ToolNode(tools))                  # Register with the name "tools"
builder.add_conditional_edges("llm_call", tools_condition)  # Routes to "tools" or END

This way, when tools_condition returns "tools", it connects exactly to the ToolNode we just registered.

Now let's reassemble the complete graph from Chapter 15, including all the remaining pieces. The tools, State, and llm_call node are unchanged from Chapter 15.

python
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
 
@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    fake_data = {"Tokyo": "18°C, cloudy", "Cairo": "31°C, sunny"}
    return fake_data.get(city, f"No weather data for {city}.")
 
@tool
def calculate(expression: str) -> str:
    """Evaluate a simple arithmetic expression. Example: '3 * 21'."""
    return str(eval(expression))  # Warning: eval() is a security risk. Do not use in production.
 
tools = [get_weather, calculate]
 
class AgentState(MessagesState):
    llm_calls: int
 
llm = ChatOpenAI(model="gpt-5-mini")
model_with_tools = llm.bind_tools(tools)
 
def llm_call(state: AgentState):
    """Call the LLM and return its response."""
    response = model_with_tools.invoke(state["messages"])
    return {
        "messages": [response],
        "llm_calls": state.get("llm_calls", 0) + 1,
    }
 
builder = StateGraph(AgentState)
 
builder.add_node("llm_call", llm_call)
builder.add_node("tools", ToolNode(tools))  # ToolNode instead of Chapter 15's tool_node function
 
builder.add_edge(START, "llm_call")
builder.add_conditional_edges("llm_call", tools_condition)  # tools_condition instead of Chapter 15's should_continue
builder.add_edge("tools", "llm_call")
 
agent = builder.compile()

Compare this with the Chapter 15 code. The tool_map dictionary, the tool_node function, and the should_continue function are all gone. The work they did is now handled by ToolNode(tools) and tools_condition. The tool node is registered as "tools" to match the name that tools_condition routes to. Let's run it with the same question from Chapter 15.

python
result = agent.invoke({
    "messages": [HumanMessage(content="Get the temperature in Cairo, then multiply the number by 3.")],
    "llm_calls": 0,
})
 
print(result["messages"][-1].content)
print(f"\nTotal LLM calls: {result['llm_calls']}")

Output:

Current temperature in Cairo: 31°C. Multiplied by 3 = 93.
 
Total LLM calls: 3

The result is identical to Chapter 15. The agent looks up the weather, performs the calculation, and produces the final answer — the same behavior is preserved, while the code we need to write and maintain has shrunk.

What if you want to register the tool node under a name other than "tools"? In that case, pass a mapping dictionary as the third argument to add_conditional_edges, specifying which node each return value from tools_condition should connect to. Since tools_condition returns either "tools" or END, you use those as keys and map them to the target nodes. For example, if you register the tool node as "run_tools":

python
builder.add_node("run_tools", ToolNode(tools))
builder.add_conditional_edges(
    "llm_call",
    tools_condition,
    {"tools": "run_tools", END: END}   # "tools" return → run_tools node, END return → terminate
)

ToolNode and tools_condition replace individual parts of the graph — the tedious ones — but adding nodes and wiring them together is still on us. Could we hand off that assembly too? That's exactly what create_agent does.

16.1.2) create_agent

create_agent is a LangChain factory function that handles the entire graph assembly for a tool-calling agent. Pass it a model and a list of tools, and it builds a graph with the same structure we assembled in 16.1.1, already compiled and ready to run. Let's try it.

python
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_core.messages import HumanMessage
 
@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    fake_data = {"Tokyo": "18°C, cloudy", "Cairo": "31°C, sunny"}
    return fake_data.get(city, f"No weather data for {city}.")
 
@tool
def calculate(expression: str) -> str:
    """Evaluate a simple arithmetic expression. Example: '3 * 21'."""
    return str(eval(expression))  # Warning: eval() is a security risk. Do not use in production.
 
agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[get_weather, calculate],
    system_prompt="You are a helpful assistant.",
)
 
result = agent.invoke({
    "messages": [HumanMessage(content="Get the temperature in Cairo, then multiply the number by 3.")],
})
print(result["messages"][-1].content)

Output:

Current temperature in Cairo: 31°C. Multiplied by 3 = 93.

No State definition, no node functions, no add_node or add_edge. A single create_agent call did all of that, and the result is identical to 16.1.1.

What happens internally is exactly what we already know. create_agent creates an LLM call node from the model you pass in, builds a ToolNode from the tool list, and connects them with a tools_condition edge and a loop-back edge. The result is a loop-structured graph identical to what we assembled in 16.1.1.

tool_calls present

no tool_calls

START

LLM Call Node

ToolNode

END

Let's look at the parameters. create_agent isn't actually new to us — we used it briefly in Chapter 11 when building conversational RAG, but we didn't go into the parameters in detail. Let's go through them one by one.

  • model: The LLM the agent will use. The simplest approach is to pass a provider string like "openai:gpt-5-mini". If you need to configure model parameters directly, pass an initialized model instance like ChatOpenAI(model="gpt-5-mini"). Internally, the LLM call node uses this model.
  • tools: The list of tools the agent can use. A ToolNode is built from these internally.
  • system_prompt: Behavioral instructions for the agent. This is prepended as a system message to the message list on every LLM call.
  • checkpointer: Saves conversation state so the agent can remember previous turns. This is the same parameter we used with InMemorySaver() and thread_id in Chapter 11 to implement multi-turn conversations. We'll cover how it works in detail in Chapter 17.
  • response_format: Use this when you want the agent's final answer as structured output. Pass a Pydantic model (the same concept from Chapter 7), and the validated object will be available in result["structured_response"].
  • middleware: Registers functions to run at specific points in the agent's execution loop. This is the parameter we used to register trim_old_messages in Chapter 11. We'll explain it in detail below.

Let's see how response_format works in practice.

python
from pydantic import BaseModel
from langchain.agents import create_agent
 
class WeatherReport(BaseModel):
    city: str
    temperature: str
    condition: str
 
agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[get_weather, calculate],
    response_format=WeatherReport,
)
 
result = agent.invoke({
    "messages": [HumanMessage(content="What's the weather in Tokyo?")],
})
print(result["structured_response"])

Output:

city='Tokyo' temperature='18°C' condition='cloudy'

The agent called the get_weather tool, then organized the information into a WeatherReport object matching the schema.

middleware

The agent loop has distinct stages. It calls the LLM, executes tools, calls the LLM again — these stages repeat. Middleware lets you insert your own functions before or after these stages. You specify the timing with a decorator: @before_model means right before the LLM call, and @after_model means right after the LLM responds. Register the function in the middleware parameter, and it runs at the designated point every time.

We already used middleware in Chapter 11. We decorated a trim_old_messages function with @before_model and registered it — since it ran before every LLM call, it could trim the message list each time.

Let's build a middleware that prints the message count right before each LLM call, so we can see exactly when it runs.

python
from langchain.agents import create_agent, AgentState
from langchain.agents.middleware import before_model
 
@before_model
def log_llm_call(state: AgentState, runtime) -> None:
    """Print the message count right before each LLM call."""
    print(f"[before_model] About to call LLM, current messages: {len(state['messages'])}")
 
agent = create_agent(
    model="openai:gpt-5-mini",
    tools=[get_weather, calculate],
    middleware=[log_llm_call],
)
 
result = agent.invoke({
    "messages": [HumanMessage(content="Get the temperature in Cairo, then multiply the number by 3.")],
})
print(result["messages"][-1].content)

Output:

[before_model] About to call LLM, current messages: 1
[before_model] About to call LLM, current messages: 3
[before_model] About to call LLM, current messages: 5
Current temperature in Cairo: 31°C. Multiplied by 3 = 93.

The log_llm_call middleware ran three times. The LLM was called three times while processing the user's request, and the middleware ran right before each call. The message counts tell us the State at each point: before the first call there was only the user's question (HumanMessage) — 1 message. After each loop iteration, the AIMessage requesting a tool call and the ToolMessage with the result were added, growing to 3, then 5.

One thing to know: before LangChain 1.0, this role was filled by a function called create_react_agent on the LangGraph side, and it is now deprecated. If you see from langgraph.prebuilt import create_react_agent in older tutorials or blog posts, understand that it's a previous version of the create_agent you're learning now.

We've rebuilt the agent from Chapter 15 concisely using ToolNode, tools_condition, and create_agent. In the next section, we'll combine these prebuilt components with manual graph assembly to build a more complex agent.

16.2) Building a Multi-Branch Agent

The multi-branch agent we'll build in this section first determines what kind of request it's dealing with, then handles each kind with a different model or a different set of tools. We'll assemble the overall graph manually using the approach from Chapter 15, and use create_agent for the parts that need a tool-calling loop.

16.2.1) Requirements and Design

We'll build the customer-support agent mentioned in this chapter's introduction. Here are the requirements:

  • Simple inquiries ("What are your business hours?") → A low-cost small model answers directly.
  • Complex consultations ("My order arrived damaged — should I get an exchange or a refund?") → A high-performance model answers.
  • Order lookups ("What's the delivery status of order #12345?") → An agent with an order-lookup tool handles it.

Each inquiry type needs a different model and tool setup, so we need a graph that classifies each request first, then routes it to the right handler. Here's the structure:

simple inquiry

complex consultation

order lookup

START

classify

inquiry type?

simple_handler

complex_handler

order_agent

END

When a request comes in, the classify node determines which type of inquiry it is and records the result in the State. A conditional edge then reads the recorded type from the State and routes to the appropriate node. simple_handler handles simple inquiries, and complex_handler deals with complex consultations. order_agent uses an order-lookup tool to check delivery status and respond. Since order_agent is a standard tool-calling node, we'll build it with create_agent. Now let's build each piece in order.

16.2.2) The Classify Node and Routing Function

First, let's define the State. We'll add an intent field to store the classification result. The three types will be represented by the values "simple", "complex", and "order".

python
from langgraph.graph import MessagesState
 
class State(MessagesState):
    intent: str    # Classification result: "simple", "complex", "order"

Next is the classify node. It uses an LLM to determine which of the three types the user's request belongs to, and records the result in intent. We receive the classification result using structured output, which we learned in Chapter 7. When we declare the schema's intent field with a Literal type, the LLM's response is constrained to one of the declared values.

python
from typing import Literal
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
 
class IntentRoute(BaseModel):
    """Classification result for a customer inquiry."""
    intent: Literal["simple", "complex", "order"] = Field(
        description=(
            "simple: general questions like business hours or greetings. "
            "complex: consultations that need careful reasoning, like disputes or refunds. "
            "order: requests to look up a specific order."
        )
    )
 
classifier_llm = ChatOpenAI(model="gpt-5.4-nano").with_structured_output(IntentRoute)
 
def classify(state: State):
    """Classify the type of customer inquiry."""
    question = state["messages"][-1].content
    result = classifier_llm.invoke(
        f"Classify the customer's request.\n\nRequest: {question}"
    )
    return {"intent": result.intent}

We used the smallest model (gpt-5.4-nano) for classification. Deciding "which type is this inquiry?" is a simple task that doesn't need a high-performance model. And since the classify node is a gateway that every request passes through, a cheap and fast model is preferable.

Next is the routing function. It simply returns the classification result stored in the State.

python
def route_by_intent(state: State) -> Literal["simple", "complex", "order"]:
    """Determine the next node based on the classification result."""
    return state["intent"]

The classify node has already decided which node should run next and recorded it in intent, so the routing function just returns that value as-is.

16.2.3) Handler Nodes by Type

Now let's build the handlers for each of the three inquiry types.

The simple inquiry handler calls a small model once. In a real customer-support agent you'd apply RAG to search internal documents for answers, but we've kept the handler simple to stay focused on the topic of this chapter.

python
simple_llm = ChatOpenAI(model="gpt-5.4-mini")
 
def simple_handler(state: State):
    """Answer simple inquiries with a small model."""
    response = simple_llm.invoke(state["messages"])
    return {"messages": [response]}

The complex consultation handler uses a high-performance model. For the same reason as the simple inquiry handler, we've kept it simple — just generating a response.

python
complex_llm = ChatOpenAI(model="gpt-5.4")
 
def complex_handler(state: State):
    """Answer complex consultations with a high-performance model."""
    response = complex_llm.invoke(state["messages"])
    return {"messages": [response]}

The order lookup handler needs to use an order-lookup tool, which means it requires a tool-calling loop. Since its structure is identical to a standard tool-calling agent, we'll build it with create_agent.

python
from langchain.tools import tool
from langchain.agents import create_agent
 
@tool
def get_order_status(order_id: str) -> str:
    """Look up the delivery status of an order by order number."""
    fake_data = {"12345": "In transit, expected tomorrow", "67890": "Delivered"}
    return fake_data.get(order_id, f"Order {order_id} not found.")
 
order_agent = create_agent(
    model="openai:gpt-5.4-mini",
    tools=[get_order_status],
)

16.2.4) Assembling and Running the Graph

Let's connect all the nodes we've built into a graph. We'll place classify at the starting point, connect it to the three handlers via a conditional edge, and configure each handler to terminate after it finishes.

python
from langgraph.graph import StateGraph, START, END
 
builder = StateGraph(State)
 
builder.add_node("classify", classify)
builder.add_node("simple", simple_handler)
builder.add_node("complex", complex_handler)
builder.add_node("order", order_agent)      # Register the create_agent graph as a node
 
builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", route_by_intent)
builder.add_edge("simple", END)
builder.add_edge("complex", END)
builder.add_edge("order", END)
 
agent = builder.compile()

Let's run one inquiry of each type and see which handler processes it.

python
from langchain_core.messages import HumanMessage
 
for question in [
    "What are your business hours?",
    "My order arrived damaged. Should I get an exchange or a refund?",
    "What's the delivery status of order 12345?",
]:
    result = agent.invoke({"messages": [HumanMessage(content=question)]})
    print(f"Q: {question}")
    print(f"[{result['intent']}] A: {result['messages'][-1].content}\n")

Output:

Q: What are your business hours?
[simple] A: I don't have fixed business hours—I'm available 24/7.
...
 
Q: My order arrived damaged. Should I get an exchange or a refund?
[complex] A: If your order arrived damaged, you should generally be entitled to a **replacement/exchange or a full refund**.
...
 
Q: What's the delivery status of order 12345?
[order] A: Order 12345 is **in transit** and is **expected tomorrow**.

Each inquiry was processed through a different path. simple_handler answered the simple inquiry with a small model, complex_handler answered the complex consultation with a high-performance model, and order_agent called the get_order_status tool to answer the order lookup.