Skip to content

Engineering & Code

LangChain Multi-Agent Part 3: Handoffs, Skills, and Routers

10 min read AI · LLM · LangChain

Part 1 built the subagents pattern, and Part 2 tuned it. That pattern has one shape. A main agent holds specialists as tools, and every decision passes through it.

Three other patterns solve the same problem differently. This part builds each one against the same two tools and the same question, then puts every number in one table.

The setup does not change. Ollama with qwen3:8b, langchain 1.3.16, and langgraph 1.2.11. Two tools, lookup_device and change_window. One question that needs both:

When can I reboot dfw-core-01?

Handoffs: Change the Agent, Not the Caller

In the handoffs pattern a single agent changes its own behavior. A tool writes a state variable, and middleware reads that variable before every model call and swaps the system prompt and the tool list.

Start with the state. It carries the step and anything the later steps need:

class ChangeState(AgentState):
    """Track which step of the change request is active."""

    current_step: str
    site_code: str

A tool moves the workflow forward by returning a Command that writes the state:

@tool
def record_device(hostname: str, runtime: ToolRuntime) -> Command:
    """Record which device the change affects. Call this first."""
    device = INVENTORY.get(hostname.strip().lower())
    if device is None:
        return Command(update={"messages": [ToolMessage(
            content=f"No device named {hostname}.",
            tool_call_id=runtime.tool_call_id,
        )]})
    site = device["site"]
    return Command(
        update={
            "messages": [ToolMessage(
                content=f"Recorded {hostname} at site {site}.",
                tool_call_id=runtime.tool_call_id,
            )],
            "site_code": site,
            "current_step": "schedule",
        }
    )

Each step declares its own prompt and its own tools:

STEPS = {
    "identify": {
        "prompt": (
            "You are taking a network change request. "
            "You do not know which device the change affects yet. "
            "Call record_device with the hostname the user names."
        ),
        "tools": ["record_device"],
    },
    "schedule": {
        "prompt": (
            "You are scheduling a network change at site {site_code}. "
            "Call change_window with the site code {site_code}. "
            "Report the approved window to the user."
        ),
        "tools": ["change_window"],
    },
}

Middleware applies the right one before each model call:

@wrap_model_call
def apply_step_config(
    request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
) -> ModelResponse:
    """Set the prompt and the tool list from the current step."""
    step = request.state.get("current_step") or "identify"
    config = STEPS[step]
    request = request.override(
        system_prompt=config["prompt"].format(
            site_code=request.state.get("site_code") or ""
        ),
        tools=[TOOLS[name] for name in config["tools"]],
    )
    return handler(request)


agent = create_agent(
    model=model,
    tools=[record_device, change_window],
    state_schema=ChangeState,
    middleware=[apply_step_config],
    checkpointer=InMemorySaver(),
)

The checkpointer matters. State survives between turns only when the agent has one, and a thread_id in the config picks which conversation to resume.

Two turns on one thread show the machine working:

--- turn 1 ---
  [step] identify tools=['record_device']
  [state] site_code='TX-ALPHA-3' current_step='schedule'
  [step] schedule tools=['change_window']
answer: The device dfw-core-01 has been recorded at site TX-ALPHA-3.

--- turn 2 ---
  [step] schedule tools=['change_window']
  [tool] change_window('TX-ALPHA-3')
answer: The approved maintenance window for site TX-ALPHA-3 is Tuesday from
02:00 to 04:00 CST.

handoffs on qwen3:8b: model_calls=4 prompt_chars=1196
current_step='schedule' site_code='TX-ALPHA-3'

Read the middle of turn 1. The agent called record_device, the state flipped to schedule, and the very next model call already had the new prompt and the new tool list. The switch happened inside one turn with no extra plumbing.

Turn 2 never mentions a hostname. The user asked “When is that window?” and the agent had the site code in state, so it went straight to the tool. Four model calls covered both turns.

Two properties make this pattern different. The agent talks to the user directly at every step, so it can ask a clarifying question mid-workflow. The state persists, so a repeat request costs less than the first one. That combination fits customer support flows, intake forms, and any process where a capability should unlock only after a precondition is met.

Skills: Load the Rules on Demand

The skills pattern keeps one agent in control and moves the domain knowledge into loadable blocks. A tool returns a prompt instead of a fact:

SKILLS = {
    "device_records": INVENTORY_POLICY,
    "maintenance_windows": CHANGE_POLICY,
}


@tool(
    "load_skill",
    description=(
        "Load the rules for one area of work. "
        "Skills: device_records (looking up devices), "
        "maintenance_windows (approved change windows)."
    ),
)
def load_skill(skill_name: str) -> str:
    return SKILLS.get(skill_name, f"No skill named {skill_name}.")


agent = create_agent(
    model=model,
    tools=[load_skill, lookup_device, change_window],
    system_prompt=(
        "You answer network operations questions. "
        "You start with no domain rules loaded. "
        "You must call load_skill before you use any other tool. "
        "Load device_records before you look up a device. "
        "Load maintenance_windows before you quote a change window. "
        "Never state a fact you did not get from a tool."
    ),
)

On a single-domain question it does exactly what it promises:

  [skill] load_skill('device_records')
  [tool] lookup_device('dfw-core-01')

skills on qwen3:8b: model_calls=3 prompt_chars=2920 messages=6
answer: The vendor of dfw-core-01 is Juniper, and the site is TX-ALPHA-3.

Compare that to the single agent carrying both policies in its system prompt on the same question. That version used 2 model calls and 4,043 prompt characters. Skills used one more call and 28% less prompt text. The saving grows with the number of skills, because an agent with twenty skills still loads one.

The two-domain question is where it broke, the same way on all three runs:

  [skill] load_skill('maintenance_windows')
  [tool] change_window('DFW')

skills on qwen3:8b: model_calls=3 prompt_chars=2947 messages=6
answer: The site DFW has no approved change window on file. Please direct the
request to the change advisory board for further guidance.

Two things went wrong. The agent loaded the maintenance skill first, when it needed the device record first. It then passed DFW to change_window, a site code it invented from the hostname.

The second failure is the interesting one. The maintenance policy it had just loaded contains this rule: “If the request gives you a hostname instead of a site code, say that you need the site code first. Do not guess it from the hostname.” The agent read that rule into its own context and then broke it.

The likely reason is where the rule sits. A loaded skill is a tool result in the conversation, and the model weighs it like any other message. A subagent’s system prompt sits above the conversation and constrains the model more firmly. The subagents version of this same question worked, and the skills version did not.

Treat that explanation as a hypothesis rather than a result. These runs do not isolate prompt placement as the variable, because the two designs also differ in tool surface and message count. A controlled test would move only the rule, and this experiment did not do that.

The evidence has a second limit worth stating plainly. Every number in this series comes from one small local model, qwen3:8b, on one question. A model that follows instructions more closely might hold a rule it just read, which would make this a weakness of the model rather than a weakness of the pattern. Three identical runs raise that possibility and do not settle it.

What the runs do support is narrower and still useful. On this model, a rule delivered as a tool result did not hold, and the same rule delivered as a system prompt did. Use skills when the specializations are advisory and the cost of ignoring one is low. Coding assistants and knowledge bases fit well. Use subagents or handoffs when a rule must hold.

Routers: Classify First, Then Fan Out

A router does the routing in a separate step before any agent runs. It splits the question, sends each part to a specialist in parallel, then combines the results.

The classification step returns a plan rather than prose:

class Classification(TypedDict):
    """One sub-question and the agent that should answer it."""

    agent: str
    sub_question: str


class Plan(TypedDict):
    """The list of sub-questions the router produced."""

    items: list[Classification]


def classify(state: RouterState):
    """Split the question into sub-questions for the specialists."""
    planner = model.with_structured_output(Plan)
    plan = planner.invoke([
        {
            "role": "system",
            "content": (
                "Split the user's question into sub-questions. "
                "Use agent 'inventory' for device records, given a hostname. "
                "Use agent 'change' for maintenance windows, given a site code. "
                "Return one item per sub-question."
            ),
        },
        {"role": "user", "content": state["question"]},
    ])
    items = [i for i in plan["items"] if i["agent"] in AGENTS]
    return {"plan": items}

Send turns that plan into parallel work. It has to come from a conditional edge, because a node cannot return Send objects:

def fan_out(state: RouterState):
    """Start one run_agent task for every planned sub-question."""
    return [Send("run_agent", {"item": item}) for item in state["plan"]]


builder.add_edge(START, "classify")
builder.add_conditional_edges("classify", fan_out, ["run_agent"])
builder.add_edge("run_agent", "synthesize")
builder.add_edge("synthesize", END)

Each fan-out task appends to a reducer field, so the parallel results collect without a race:

class RouterState(TypedDict):
    question: str
    plan: list[Classification]
    findings: Annotated[list[str], operator.add]
    answer: str

Give it a question with two independent halves and both specialists run at once:

  [route] inventory <- 'What vendor is dfw-core-01?'
  [route] change <- 'What is the window for VA-BRAVO-9?'
  [tool] change_window('VA-BRAVO-9')
  [tool] lookup_device('dfw-core-01')

router on qwen3:8b: model_calls=6 prompt_chars=4596 findings=2
answer: The vendor of dfw-core-01 is Juniper. The maintenance window for
VA-BRAVO-9 is Sunday 23:00 to 03:00 EST.

The tool prints came back out of order. That is the fan-out running in parallel.

The question changed for this section, and the reason is the pattern’s real limit. A router classifies once, up front. It cannot learn a site code from the first specialist and feed it to the second, because both start at the same moment. Routers do not do multi-hop. Give a router work that splits cleanly into independent parts, such as searching three separate systems and merging the answers.

Custom Workflows

The fifth pattern is the escape hatch. Build the graph yourself in LangGraph, and put deterministic Python where you want determinism and an agent where you want judgment.

The router above is already a small example. It has three nodes, one conditional edge, and a reducer, and only two of its nodes call a model. Reach for a custom workflow when your process has real structure that a model should not be allowed to reinvent on every run. You can embed any of the other four patterns as a node inside it.

Choosing

The LangChain docs rate the patterns on four capabilities. This table restates that guidance:

CapabilitySubagentsHandoffsSkillsRouter
Separate teams own componentsstrongnonestrongfair
Parallel executionstrongnonefairstrong
Multi-hop workstrongstrongstrongnone
Agent talks to the user directlyweakstrongstrongfair

Here is what the same two-hop question actually cost on a laptop. Every row uses the same model, the same tools, and the same domain policies. Each row ran three times and returned identical numbers on all three:

DesignModel callsPrompt charactersCorrect
Single agent, all policies loaded36472yes
Subagents, one tool per agent75158yes
Subagents, one dispatch tool96765yes
Skills, loaded on demand32947no
Handoffs, two turns41196yes

Four things stand out.

The single agent is the cheapest correct answer at three model calls. Nothing in this series beats it on this problem, and this problem has two tools. That is the point Part 1 opened with, and the numbers still hold at the end.

Handoffs used the least prompt text by a wide margin, and that number covers two conversation turns rather than one. Each step loads one small prompt and one tool instead of the whole domain. The cost is that you now design a state machine, and the model can only do what the current step allows.

Subagents cost the most model calls in every configuration. You buy context isolation and clean team boundaries with round trips.

Skills was the cheapest by prompt text and the only design that got the answer wrong. Cheap context does not help when the agent ignores the context it loaded.

How to Decide

Start with one agent and every tool. Measure it. Most systems stop here, and the docs say so on the first screen of the multi-agent guide.

Move when a specific thing hurts. If one prompt cannot hold every rule, isolate the rules with subagents. If separate teams need to ship independently, give them subagents behind a dispatch tool. If the conversation has ordered stages and the agent must talk to the user between them, build handoffs. If the work splits into independent lookups, write a router. If the process has hard structure, write the graph yourself.

Whatever you pick, keep a script that counts model calls and prompt characters. Run it against the single-agent baseline. Every claim in these three posts came from that script, on one model and one question, so treat the numbers as a method rather than as constants. Run your own version against your own model. The patterns are all reasonable, and only measurement tells you which one your problem needs.