Skip to content

Engineering & Code

LangChain Multi-Agent Part 2: Designing Subagents

11 min read AI · LLM · LangChain

Part 1 built a working subagents system. A main agent called two specialists as tools, and the numbers showed what that cost. The pattern was about ten lines of wrapper code.

Those ten lines hold every decision that decides whether the system works. This part goes through them one at a time. Each section names a choice, shows the code, and gives the measured result on a local model.

The setup is the same as Part 1. Ollama with qwen3:8b, langchain 1.3.16, and langgraph 1.2.11. The same two specialists answer the same question:

When can I reboot dfw-core-01?

The LangChain docs list five decisions inside the subagents pattern:

DecisionOptions
Sync or asyncBlock on the subagent, or start it in the background
Tool patternOne tool per agent, or one dispatch tool for all of them
Subagent specsHow the main agent learns what each specialist does
Subagent inputsSend the task only, or send more context
Subagent outputsReturn the last message, or return structured state

Two words appear throughout this post. Subagent names the role in the pattern, and the docs use it in every heading. Specialist names one concrete agent in the example, such as the inventory specialist. They describe the same thing at different zoom levels.

Names and Descriptions Decide Everything

The docs call names and descriptions “prompting levers”. That undersells it on a local model. They decide whether the model calls the tool at all.

Here is the test. One tool, two arguments, four variants. Only the name and the description change. Each variant ran ten times with the same question and the same system prompt:

cases = {
    "task / multi-line": ("task", "Launch a specialist for one task.\n\nAvailable agents:\n- inventory: device records.\n- change: maintenance windows."),
    "task / one-line": ("task", "Launch a specialist for one task. Agents: inventory (device records), change (maintenance windows)."),
    "delegate / multi-line": ("delegate", "Send one task to a specialist.\n\nAvailable agents:\n- inventory: device records.\n- change: maintenance windows."),
    "delegate / one-line": ("delegate", "Send one task to a specialist. Agents: inventory (device records), change (maintenance windows)."),
}

The result:

task / multi-line        called the tool in 0/10 runs
task / one-line          called the tool in 0/10 runs
delegate / multi-line    called the tool in 0/10 runs
delegate / one-line      called the tool in 10/10 runs

Three of the four variants never called the tool. The model returned an empty message, the framework treated it as the final answer, and the run ended with a blank reply.

The split is clean at the edges, which is what makes it worth reporting. Tool calling is stochastic, so a 0 against 3 result on three runs is also what a merely unlikely variant would produce. Ten runs each rules that reading out for these four variants on this model. A variant with a 20% call rate would almost never return 0 out of 10.

Two separate levers show up here. The name task failed with both descriptions. A bulleted, multi-line description failed even with a working name. Only the plain name and the single-line description worked, and that combination worked every time.

Do not read this as a rule about the words task and delegate. Read it as a warning about the failure mode. A specialist that the main agent never invokes produces no error, no exception, and no log line. It produces silence. When a multi-agent system returns an empty answer, suspect the tool name and the description before you suspect the model.

Write descriptions the way you would write them for a new colleague on their first day. Say what the specialist does, say what to send it, and keep it to one or two sentences.

One Tool Per Agent

Part 1 used one wrapper per specialist. That gives you a separate description, a separate input shape, and a separate return format for each one:

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

Use this when you want control over each specialist. You can shape the input for one and leave the other alone.

One Dispatch Tool

The alternative puts every specialist behind a single tool. The tool takes an agent name and a task description, then looks up the agent in a registry:

from enum import Enum

SUBAGENTS = {
    "inventory": create_agent(
        model=model, tools=[lookup_device], system_prompt=INVENTORY_POLICY
    ),
    "change": create_agent(
        model=model, tools=[change_window], system_prompt=CHANGE_POLICY
    ),
}


class AgentName(str, Enum):
    INVENTORY = "inventory"
    CHANGE = "change"


@tool(
    "delegate",
    description=(
        "Send one task to a specialist. "
        "Agents: inventory (device records, give it a hostname), "
        "change (maintenance windows, give it a site code)."
    ),
)
def delegate(agent_name: AgentName, description: str) -> str:
    agent = SUBAGENTS[agent_name.value]
    result = agent.invoke({"messages": [{"role": "user", "content": description}]})
    return result["messages"][-1].content

The Enum on agent_name puts the valid names in the tool schema, so the model cannot invent a specialist that does not exist.

New specialists join by adding a line to SUBAGENTS. The coordinator never changes. That is the reason to pick this shape: separate teams can ship agents without touching the main agent’s code.

It costs more. Same question, same model, both designs with the same specialist policies. Each design ran three times and returned identical numbers:

DesignModel callsPrompt charactersCorrect
One tool per agent75158yes
One dispatch tool96765yes

The dispatch run spent two extra calls on a wrong first guess:

  [dispatch] change <- 'Check maintenance window for dfw-core-01'
  [dispatch] inventory <- 'Get site code for dfw-core-01'
  [tool] lookup_device('dfw-core-01')
  [dispatch] change <- 'Check maintenance window for site code TX-ALPHA-3'
  [tool] change_window('TX-ALPHA-3')

The main agent sent a hostname to the change specialist first. That specialist’s policy says to refuse a hostname and ask for a site code, so it did. The main agent then went to inventory, got the site code, and came back.

That recovery is the system working correctly. A per-agent description that says “You must pass a site code, not a hostname” sits right next to the tool call and prevents the mistake. A shared dispatch description carries the same warning further from the point of use, and the model missed it.

Choose one tool per agent below about ten specialists. Choose the dispatch tool when the registry grows or when different teams own different agents.

Subagent Inputs: Watch for Policy Collisions

The wrapper decides what the specialist sees. In Part 1 it sent one string:

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

The obvious improvement is to send more. LangChain supports this with ToolRuntime, which gives the wrapper access to the main agent’s state:

from langchain.messages import HumanMessage
from langchain.tools import ToolRuntime, tool


def first_user_question(runtime: ToolRuntime) -> str:
    """Return the text of the first human message in the main conversation."""
    for message in runtime.state["messages"]:
        if isinstance(message, HumanMessage):
            return str(message.content)
    return ""


@tool("inventory_expert", description="Look up a network device by hostname.")
def call_inventory_expert(query: str, runtime: ToolRuntime) -> str:
    task = f"The user asked: {first_user_question(runtime)}\n\nYour task: {query}"
    result = inventory_agent.invoke({"messages": [{"role": "user", "content": task}]})
    return result["messages"][-1].content

That looks like a strict improvement. The specialist now knows why the main agent called it. Here is what it did to the results:

Subagent inputModel callsCorrect
Task only73 of 3
Task plus the user’s question30 of 3

The extra context broke the system on all three runs. The trace shows why:

  [subagent] inventory_expert <- 'The user asked: When can I reboot dfw-core-01?

Your task: dfw-core-01'

answer: The inventory_expert tool does not provide information about maintenance
windows or reboot schedules. To determine when you can reboot `dfw-core-01`,
please consult your maintenance window calendar or contact the appropriate team.

The inventory specialist read the word “reboot”, checked its own policy, and found this line: “Never quote a maintenance window. That is another team’s job.” So it declined. It was right to decline, and the main agent accepted the refusal and stopped.

The specialist answered the question it saw rather than the task it received. The extra context changed which of those two it treated as the job.

That leaves an obvious question. Does wider input hurt in general, or did it hurt here because it collided with one specific rule? A third script answers it. The wrapper still sends the user’s question, and the only change removes that one line from the inventory policy:

CONFLICT_LINE = "- Never quote a maintenance window. That is another team's job.\n"
TRIMMED_POLICY = INVENTORY_POLICY.replace(CONFLICT_LINE, "")
Subagent inputInventory policyModel callsCorrect
Task onlyFull73 of 3
Task plus the user’s questionFull30 of 3
Task plus the user’s questionOne line removed73 of 3

Deleting one sentence restored the system to a perfect score with the wider input unchanged. So the wider input is not harmful by itself. It became harmful because it carried a word that matched a rule the specialist had been given, and that rule fired.

The practical form of the lesson is narrower than “send less”. Every string you add to a subagent’s input can activate a rule in that subagent’s prompt. You are prompting the specialist whether you mean to or not. Reach for ToolRuntime when the task genuinely depends on earlier state, such as a file the user uploaded three turns ago. Read the specialist’s own prompt before you widen its input, and measure before and after.

Subagent Outputs: Return More Than Text

By default the wrapper returns one string, and everything else the specialist produced disappears. That is usually what you want. Sometimes the main agent needs a value it can use rather than a sentence it has to parse.

A Command return lets the wrapper update the main agent’s state alongside the tool message:

class NetOpsState(AgentState):
    """The main agent's state, plus the site code the inventory specialist found."""

    site_code: str


SITE_CODE_RE = re.compile(r"\b[A-Z]{2}-[A-Z]+-\d+\b")


@tool("inventory_expert", description="Look up a network device by hostname.")
def call_inventory_expert(
    query: str,
    tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
    result = inventory_agent.invoke({"messages": [{"role": "user", "content": query}]})
    answer = result["messages"][-1].content
    found = SITE_CODE_RE.search(answer)
    return Command(
        update={
            "site_code": found.group(0) if found else "",
            "messages": [ToolMessage(content=answer, tool_call_id=tool_call_id)],
        }
    )

The main agent gets the state key when you pass state_schema=NetOpsState to create_agent:

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

site_code in final state: 'TX-ALPHA-3'

Cost is unchanged at seven model calls. Your code now holds TX-ALPHA-3 as a value, so you can log it, validate it, or hand it to a function that never talks to a model.

There is a cheaper fix worth trying first. Tell the specialist what to return. The docs name a common failure where a subagent does good tool work and then leaves the results out of its final message. The main agent sees only that final message, so the work is lost. Two lines in the specialist’s prompt fix most of it. State that the supervisor sees only the final message. Then list what that message must contain.

Parallel Calls Cost No Extra Code

The main agent can call several specialists in one turn. Ask about two devices and watch the interleaving:

  [subagent] inventory_expert('dfw-core-01')
  [subagent] inventory_expert('iad-edge-02')
  [tool] lookup_device('iad-edge-02')
  [tool] lookup_device('dfw-core-01')
  [subagent] change_expert('TX-ALPHA-3')
  [subagent] change_expert('VA-BRAVO-9')
  [tool] change_window('TX-ALPHA-3')
  [tool] change_window('VA-BRAVO-9')

Both inventory lookups started before either finished, and iad-edge-02 returned first even though dfw-core-01 went out first. The second round did the same with both site codes. The whole two-device question took eleven model calls, four more than the single-device version.

You write nothing to get this. The main agent emits two tool calls in one message, and the framework runs them together. Specialists work in isolated context, so they cannot interfere with each other.

The docs draw one more distinction here. This concurrency is synchronous, because the main agent still waits for every specialist before it continues. Asynchronous means something different: the main agent starts a background job in another process and keeps talking to the user. Use that for work measured in minutes, such as a long document review. Accept that you now own a job system.

One Note on Memory

Subagents start with fresh state on every call. That is the default, and it is what makes them safe to run in parallel.

Two consequences follow. A subagent cannot remember an earlier invocation, so anything it needs must arrive in the task string or in the state you pass. LangGraph also cannot see inside a subagent that runs inside a tool function, so get_state with subgraphs will not show you subagent state. If you need to inspect that state during an interrupt, call the subagent from a graph node instead of a tool.

What Comes Next

You can now shape a subagents system with intent. Names and descriptions decide whether specialists get called. The tool pattern decides how the system grows. Inputs and outputs decide how much each specialist knows and how much it returns.

Part 3 leaves this pattern. It covers handoffs, where a tool call changes the agent’s own prompt and tools, the skills pattern, where one agent loads expertise on demand, and routers, which classify first and dispatch in parallel. It ends with the numbers that tell you which one to pick.