Skip to content

Engineering & Code

LangChain Multi-Agent Part 1: The Subagents Pattern

11 min read AI · LLM · LangChain

An agent is a model with tools and a loop. Give it three tools and it works well. Give it twenty and the quality drops. The model picks from a longer list, and every tool description sits in the context window on every call. Add a few conflicting rules to the system prompt and the failures get harder to explain.

The popular answer is to split the work into several agents. That answer is often right, and it is also easy to reach for too early. This series treats it as an engineering decision with a measurable cost. Every claim here comes from a script that ran on a laptop, and the numbers come with it.

This is the first of three parts. Part 1 covers the reasons to go multi-agent, the patterns LangChain documents, and the subagents pattern in working code. Part 2 covers the design decisions inside that pattern. Part 3 covers the other patterns and how to choose between them.

Every example runs locally with Ollama. No API keys, and no per-token billing. The versions used throughout are langchain 1.3.16, langchain-core 1.6.0, langchain-ollama 1.1.0, and langgraph 1.2.11.

Why Multi-Agent

The LangChain documentation opens the multi-agent guide with a warning worth repeating. Many complex tasks run fine on a single agent with the right tools and prompt. When developers ask for multi-agent, they usually want one of three things:

  • Context management. Give the model specialized knowledge without filling the context window with knowledge it does not need right now.
  • Distributed development. Let separate teams build and maintain capabilities behind clear boundaries, then compose them.
  • Parallelization. Run subtasks at the same time instead of one after another.

Three situations make the patterns worth the cost. A single agent has so many tools that it picks badly. A task needs deep domain context that would bloat one prompt. A workflow must enforce order, so some capability unlocks only after a precondition is met.

If none of those apply to your problem, add tools to one agent and move on.

The Five Patterns

LangChain documents five patterns. Part 3 builds the last four. Here is the map:

PatternHow it works
SubagentsA main agent calls subagents as tools. All routing goes through the main agent.
HandoffsA tool call updates a state variable. The system reads that variable and changes the prompt, the tools, or the active agent.
SkillsOne agent stays in control and loads specialized prompts on demand.
RouterA classification step sends the input to one or more specialized agents, then combines the results.
Custom workflowYou build the flow yourself in LangGraph and mix deterministic steps with agentic ones.

The patterns compose. A subagent can run a router. A router can dispatch to an agent that loads skills.

Setting Up

Ollama runs models locally and exposes an HTTP API on port 11434. Install it from ollama.com, then pull a model:

ollama pull qwen3:8b

Model choice matters more here than in a single-model chain. Every pattern in this series runs on tool calls. A model that writes a tool call as plain text breaks all of them. An 8B model handles this work. A 3B model does not, and the last section shows what that failure looks like.

Install the Python packages in a virtual environment:

python3 -m venv env
source env/bin/activate
pip install langchain langchain-ollama langgraph

The Shared Setup

Every script in this series imports the same tools and data. Two domains, one tool each:

INVENTORY = {
    "dfw-core-01": {"vendor": "Juniper", "site": "TX-ALPHA-3", "uplinks": 7},
    "iad-edge-02": {"vendor": "Arista", "site": "VA-BRAVO-9", "uplinks": 13},
}

CHANGE_WINDOWS = {
    "TX-ALPHA-3": "Tuesday 02:00 to 04:00 CST",
    "VA-BRAVO-9": "Sunday 23:00 to 03:00 EST",
}


def lookup_device(hostname: str) -> str:
    """Look up one network device by hostname. Returns vendor, site, and uplink count."""
    print(f"  [tool] lookup_device({hostname!r})")
    device = INVENTORY.get(hostname.strip().lower())
    if device is None:
        return f"No device named {hostname}."
    return (
        f"{hostname}: vendor={device['vendor']} "
        f"site={device['site']} uplinks={device['uplinks']}"
    )


def change_window(site: str) -> str:
    """Return the approved maintenance window for one site code, such as TX-ALPHA-3."""
    print(f"  [tool] change_window({site!r})")
    window = CHANGE_WINDOWS.get(site.strip().upper())
    if window is None:
        return f"No change window on file for site {site}."
    return f"{site}: approved window is {window}"

The site codes look nothing like the hostnames on purpose. A model can guess that dfw-core-01 sits in a site called DFW1. It cannot guess TX-ALPHA-3. Opaque values force a real tool call, and they let you tell a real answer from a confident invention.

The docstrings are not decoration. LangChain sends them to the model as tool descriptions, and the model picks tools by reading them.

One question drives every example. It needs both domains, and it needs them in order:

When can I reboot dfw-core-01?

The model must look up the device to get the site code, then look up the window for that site.

To compare designs fairly, every script counts model calls and prompt characters. A small wrapper around ChatOllama does the counting:

CALLS = {"model": 0, "prompt_chars": 0}


def make_model() -> ChatOllama:
    """Return a ChatOllama that counts every model call."""
    model = ChatOllama(model=MODEL_ID, temperature=0, reasoning=False)
    original = model._chat_params

    def counted(messages, stop=None, **kwargs):
        params = original(messages, stop=stop, **kwargs)
        CALLS["model"] += 1
        CALLS["prompt_chars"] += sum(
            len(str(m.get("content", ""))) for m in params["messages"]
        )
        return params

    model._chat_params = counted
    return model

reasoning=False turns off the thinking output that qwen3 produces by default. The traces stay readable without it.

The Baseline: One Agent

Measure the simple version first. One agent holds both tools:

from langchain.agents import create_agent

agent = create_agent(
    model=model,
    tools=[lookup_device, change_window],
    system_prompt=(
        "You answer network operations questions. "
        "Use the tools for every fact. "
        "Never state a fact you did not get from a tool."
    ),
)

result = agent.invoke({"messages": [{"role": "user", "content": QUESTION}]})

It works, and it chains the two tools without help:

  [tool] lookup_device('dfw-core-01')
  [tool] change_window('TX-ALPHA-3')

single agent on qwen3:8b: model_calls=3 prompt_chars=607 messages=6
answer: The device dfw-core-01 is located at site TX-ALPHA-3, which has an
approved maintenance window on Tuesday from 02:00 to 04:00 CST.

The baseline made three model calls and sent 607 prompt characters. It answered correctly on five runs out of five. Write those numbers down, because they are the bar every later design has to beat.

The Subagents Pattern

In the subagents pattern, a main agent coordinates specialists by calling them as tools. The main agent decides which specialist to invoke, what to send it, and how to combine the results. Subagents return their work to the main agent, and they never talk to the user directly.

Each subagent is an ordinary agent:

inventory_agent = create_agent(
    model=model,
    tools=[lookup_device],
    system_prompt=(
        "You look up network device records. "
        "Call lookup_device for every hostname. "
        "Report the vendor, the site code, and the uplink count. "
        "Never state a fact you did not get from the tool."
    ),
)

change_agent = create_agent(
    model=model,
    tools=[change_window],
    system_prompt=(
        "You report approved maintenance windows. "
        "Call change_window with a site code such as TX-ALPHA-3. "
        "If you do not have a site code, say so and ask for one."
    ),
)

The whole mechanism of the pattern is the wrapper. A subagent becomes a tool when you call it inside a @tool function:

from langchain.tools import tool


@tool(
    "inventory_expert",
    description=(
        "Look up a network device by hostname. "
        "Returns the vendor, the site code, and the uplink count. "
        "Use this first when you need a site code."
    ),
)
def call_inventory_expert(query: str) -> str:
    result = inventory_agent.invoke({"messages": [{"role": "user", "content": query}]})
    return result["messages"][-1].content


@tool(
    "change_expert",
    description=(
        "Return the approved maintenance window for a site code. "
        "You must pass a site code, not a hostname."
    ),
)
def call_change_expert(query: str) -> str:
    result = change_agent.invoke({"messages": [{"role": "user", "content": query}]})
    return result["messages"][-1].content

Read those five lines of body closely. The tool takes a query string, starts the subagent with that string as a fresh user message, and returns the subagent’s last message. The subagent sees nothing else. It has no memory of earlier turns, and it never sees the main agent’s conversation.

That isolation is the point of the pattern. Each subagent works in a clean context window, so a long specialist prompt never reaches the main thread.

The main agent then treats the specialists like any other tools:

main_agent = create_agent(
    model=model,
    tools=[call_inventory_expert, call_change_expert],
    system_prompt=(
        "You answer network operations questions. "
        "You have two specialists. "
        "Use inventory_expert for device records and site codes. "
        "Use change_expert for maintenance windows. "
        "Never state a fact you did not get from a specialist."
    ),
)

Reading the Trace

Here is the run. The subagent and tool prints come first, because they happen during .invoke():

  [subagent] inventory_expert('dfw-core-01')
  [tool] lookup_device('dfw-core-01')
  [subagent] change_expert('TX-ALPHA-3')
  [tool] change_window('TX-ALPHA-3')

subagents on qwen3:8b: model_calls=7 prompt_chars=1866 messages=6

The message list shows the two hops:

================================ Human Message =================================

When can I reboot dfw-core-01?
================================== Ai Message ==================================
Tool Calls:
  inventory_expert (50228c0d-0ca3-42e5-b302-bfb8f573fb77)
  Args:
    query: dfw-core-01
================================= Tool Message =================================
Name: inventory_expert

The device `dfw-core-01` is a Juniper device located at site `TX-ALPHA-3` with 7 uplinks.
================================== Ai Message ==================================
Tool Calls:
  change_expert (724fd886-2887-420e-bf1e-a4376473b442)
  Args:
    query: TX-ALPHA-3
================================= Tool Message =================================
Name: change_expert

The approved maintenance window for TX-ALPHA-3 is **Tuesday 02:00 to 04:00 CST**.
================================== Ai Message ==================================

You can reboot `dfw-core-01` during the approved maintenance window for site
`TX-ALPHA-3`, which is **Tuesday 02:00 to 04:00 CST**.

The main agent’s transcript holds six messages. It never sees lookup_device, change_window, or either specialist’s reasoning. It sees two tool results, and it works from those.

Note what the main agent passed to change_expert. It sent TX-ALPHA-3, the site code it learned from the first specialist. It carried a fact from one subagent to the next, which is what makes this a multi-hop system rather than two independent lookups.

What It Costs

Four scripts, same question, same model. The last two give each domain a realistic policy prompt of about 950 characters instead of one sentence. Every row ran three times, and the call count and the character count came back identical on all three:

DesignModel callsPrompt charactersCorrect
Single agent, short prompt3607yes
Subagents, short prompt71866yes
Single agent, long policies36472yes
Subagents, long policies75158yes

Two results matter here.

The subagents pattern always costs more model calls. Seven against three, every run, with no variation. The docs predict this, and the reason is structural. Every subagent invocation is a full agent loop, and its result flows back through the main agent for another turn. You pay for centralized control in round trips.

The context result flips. With one-sentence prompts, subagents send three times the prompt text, because each specialist repeats a full agent setup for very little work. With realistic domain policies, subagents send 20% less. The single agent carries both policies on every call it makes. Each specialist carries only its own.

That crossover is the decision. Count the size of your domain prompts. If both fit comfortably in one system prompt, one agent is cheaper and simpler. Once each domain needs its own page of rules, isolation starts paying for the extra calls.

The correctness column stayed clean across all four designs. This task is small enough that both designs get it right, so the numbers here separate cost and nothing else.

Read the correctness column as a floor rather than a verdict. This question is an unambiguous two-hop lookup, and the 8B model picks the right specialist every time. A harder task could separate the designs on accuracy as well as cost, and Part 3 shows one pattern that does fail this same question.

Where a Small Model Breaks

The examples above use an 8B model. Run the same script against llama3.2:3b and it fails the same way three times out of three:

  [subagent] inventory_expert('site code')
  [tool] lookup_device('site code')

subagents on llama3.2:3b: model_calls=7 prompt_chars=1723 messages=6
answer: I'll need more information to assist you. Can you please provide the
site code for the device dfw-core-01?

Look at what went wrong. The model picked the right specialist. It then filled the query argument with the string site code, which is a description of what it wanted rather than the hostname it had. The specialist looked up a device named “site code”, found nothing, and reported that. The main agent gave up and asked the user for the site code that the tool would have returned.

That is a routing success and an input failure. The 3B model understands which specialist to call, and it cannot work out what to send. Part 2 covers subagent inputs, and this is the failure that section exists to prevent.

The lesson for model choice is narrower than “use a bigger model”. Check that your model fills tool arguments correctly, not only that it picks the right tool. Every pattern in this series runs on tool calls, and a well-chosen tool with a garbage argument still returns nothing useful.

The opaque site codes matter for the same reason. A model that guesses DFW1 produces an answer that looks right until you check it.

One Note on create_supervisor

If you search for this topic you will find langgraph-supervisor and its create_supervisor function. Skip it for new work. The LangChain migration guide states that the package is no longer actively maintained, and it points to the subagents pattern above as the replacement. The guide maps each old option to its new equivalent, which helps if you have existing code to move.

What Comes Next

You now have a working subagents system and a measured reason to use it or skip it. The main agent routes, specialists work in isolated context, and the numbers say what that costs.

Part 2 goes inside the pattern. It covers the choice between one tool per agent and a single dispatch tool, how names and descriptions drive routing, what to send a subagent and what to send back, and how parallel subagent calls behave on a local model.