LangGraph — Multi-Agent Patterns¶
From a single tool-calling agent to supervisors and handoffs. Architecture-level trade-offs of multi-agent systems are covered in Agentic AI — Multi-Agent Architecture Patterns; this page is about building them with LangGraph.
What Is Current (LangGraph 1.x / LangChain 1.x)¶
| API | Status |
|---|---|
langchain.agents.create_agent |
Current prebuilt tool-calling agent; returns a compiled LangGraph graph |
langchain.agents.middleware.* |
Current way to customise the agent loop (HITL, limits, retries, summarisation, PII) |
langgraph.prebuilt.create_react_agent |
Deprecated since LangGraph 1.0, planned removal in 2.0 — still works, emits a deprecation warning |
langgraph.prebuilt.ToolNode, tools_condition |
Current; use them in hand-written graphs |
langgraph-supervisor, langgraph-swarm packages |
Maintained for existing users; the maintainers now recommend the tool-based supervisor pattern below for most cases |
Migration from create_react_agent:
create_react_agent(...) |
create_agent(...) |
|---|---|
prompt= |
system_prompt= (or @dynamic_prompt middleware) |
pre_model_hook / post_model_hook |
@before_model / @after_model middleware |
from langgraph.prebuilt.chat_agent_executor import AgentState |
from langchain.agents import AgentState |
interrupt_before=["tools"] |
HumanInTheLoopMiddleware(interrupt_on={...}) |
Node names agent, tools |
Node names model, tools |
Offline Model for the Examples¶
All examples below run without API keys using a scripted fake model. In real code pass a model instance or a "provider:model" string such as "anthropic:claude-sonnet-5". Fake models are explained in 05 Testing.
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
class ScriptedModel(GenericFakeChatModel):
"""Returns pre-defined messages in order; ignores bound tools."""
def bind_tools(self, tools, **kwargs):
return self
def scripted(*messages: AIMessage) -> ScriptedModel:
return ScriptedModel(messages=iter(messages))
def tool_call(name: str, args: dict, call_id: str) -> AIMessage:
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": call_id}])
Single Agent with create_agent¶
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
@tool
def get_order_status(order_id: str) -> str:
"""Return the delivery status of an order."""
return f"{order_id}: shipped"
support_agent = create_agent(
scripted(
tool_call("get_order_status", {"order_id": "ord-42"}, "call_1"),
AIMessage("Order ord-42 has shipped."),
),
tools=[get_order_status],
system_prompt="You are a support agent. Use tools, never guess order data.",
name="support",
)
result = support_agent.invoke({"messages": [HumanMessage("Where is ord-42?")]})
print([type(m).__name__ for m in result["messages"]])
# ['HumanMessage', 'AIMessage', 'ToolMessage', 'AIMessage']
print(list(support_agent.get_graph().nodes)) # ['__start__', 'model', 'tools', '__end__']
The result is a normal compiled graph: stream, checkpointers, get_state, subgraph usage and all tests from 05 apply. Other useful parameters: response_format= (structured final answer in result["structured_response"]), context_schema=, checkpointer=, store=.
Middleware¶
| Middleware | Guards against |
|---|---|
HumanInTheLoopMiddleware(interrupt_on={"tool_name": True}) |
Risky tool calls without approval |
ModelCallLimitMiddleware(run_limit=..., thread_limit=...) |
Runaway loops and cost |
ToolCallLimitMiddleware(tool_name=..., run_limit=...) |
One tool hammered in a loop |
ModelRetryMiddleware / ToolRetryMiddleware |
Transient provider / tool failures |
ModelFallbackMiddleware(first_model, ...) |
Provider outage |
SummarizationMiddleware(model, trigger=..., keep=...) |
Context window overflow |
PIIMiddleware("email", strategy="redact") |
PII in inputs, outputs or tool results |
@before_model, @after_model, @wrap_model_call, @wrap_tool_call, @dynamic_prompt |
Your own hooks |
Human approval of a tool call with HumanInTheLoopMiddleware:
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
guarded_agent = create_agent(
scripted(
tool_call("get_order_status", {"order_id": "ord-42"}, "call_1"),
AIMessage("Order ord-42 has shipped."),
),
tools=[get_order_status],
middleware=[HumanInTheLoopMiddleware(interrupt_on={"get_order_status": True})],
checkpointer=InMemorySaver(), # required for interrupts
)
config = {"configurable": {"thread_id": "support-1"}}
paused = guarded_agent.invoke({"messages": [HumanMessage("Where is ord-42?")]}, config)
request = paused["__interrupt__"][0].value
print(request["action_requests"][0]["name"], request["review_configs"][0]["allowed_decisions"])
# get_order_status ['approve', 'edit', 'reject', 'respond']
done = guarded_agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config)
print(done["messages"][-1].content) # Order ord-42 has shipped.
Choosing a Multi-Agent Pattern¶
| Pattern | Control flow | Good for | Watch out for |
|---|---|---|---|
| Supervisor with subagents as tools | Supervisor LLM calls subagents like tools; they return results to it | Most cases; clear ownership, easy context control | Supervisor is a bottleneck and pays tokens for every hop |
| Router graph (custom supervisor node) | A router node picks the next worker via Command |
Deterministic or classifier-based routing, compliance flows | You own the routing logic and its tests |
| Handoffs / swarm | The active agent transfers control with a handoff tool | Conversational flows where a specialist should talk to the user directly | Harder to trace "who is in charge"; needs loop limits |
| Hierarchical | Supervisors of supervisors (subgraphs) | Large systems with teams of agents | Latency, cost, debugging depth |
Start with one agent and good tools. Add agents only when a single prompt clearly cannot handle the tool set or the instructions.
Supervisor with Subagents as Tools¶
from langchain_core.tools import tool
research_agent = create_agent(
scripted(AIMessage("Top flaky test: test_checkout_timeout (12% failure rate).")),
tools=[],
system_prompt="You analyse CI history.",
name="research",
)
@tool
def ask_research(question: str) -> str:
"""Ask the research agent about CI history and flaky tests."""
result = research_agent.invoke({"messages": [HumanMessage(question)]})
return result["messages"][-1].content # only the final answer goes back
supervisor = create_agent(
scripted(
tool_call("ask_research", {"question": "Which test is the flakiest?"}, "call_r1"),
AIMessage("The flakiest test is test_checkout_timeout — quarantine it and open a ticket."),
),
tools=[ask_research],
system_prompt="You coordinate specialists. Delegate, then summarise.",
name="supervisor",
)
answer = supervisor.invoke({"messages": [HumanMessage("What should we fix first in CI?")]})
print(answer["messages"][-1].content)
- The tool wrapper is where you engineer context: send the subagent only what it needs, return only the final answer (not its whole transcript).
- Each subagent is testable on its own; the supervisor is testable with the subagent tool stubbed.
- Give tools precise docstrings — they are the supervisor's routing instructions.
Router Graph with Command¶
A router node returns Command(goto=...). Inject the decision function so production uses an LLM with structured output and tests use a stub.
from typing import Literal, TypedDict
from pydantic import BaseModel
from langchain_core.runnables import RunnableLambda
from langgraph.graph import START, StateGraph
from langgraph.types import Command
class Route(BaseModel):
next: Literal["ui_tester", "api_tester", "done"]
class QAState(TypedDict):
request: str
done: list[str]
def build_graph(router):
"""router: Runnable[QAState -> Route], e.g. model.with_structured_output(Route)."""
def supervisor(state: QAState) -> Command[Literal["ui_tester", "api_tester", "__end__"]]:
decision: Route = router.invoke(state)
return Command(goto="__end__" if decision.next == "done" else decision.next)
def ui_tester(state: QAState) -> Command[Literal["supervisor"]]:
return Command(update={"done": state["done"] + ["ui"]}, goto="supervisor")
def api_tester(state: QAState) -> Command[Literal["supervisor"]]:
return Command(update={"done": state["done"] + ["api"]}, goto="supervisor")
builder = StateGraph(QAState)
builder.add_node("supervisor", supervisor)
builder.add_node("ui_tester", ui_tester)
builder.add_node("api_tester", api_tester)
builder.add_edge(START, "supervisor")
return builder.compile()
def stub_router(state: QAState) -> Route: # deterministic stand-in for the LLM
if "api" not in state["done"]:
return Route(next="api_tester")
if "ui" not in state["done"]:
return Route(next="ui_tester")
return Route(next="done")
graph = build_graph(RunnableLambda(stub_router))
print(graph.invoke({"request": "Regression for release 1.4", "done": []}, {"recursion_limit": 20}))
# {'request': 'Regression for release 1.4', 'done': ['api', 'ui']}
In production: build_graph(ChatAnthropic(model=...).with_structured_output(Route)). The routing contract (Route) is the thing to test hardest — see 05.
Handoffs Between Agents¶
A handoff tool returns Command(goto=<agent>, graph=Command.PARENT): it exits the current agent's graph and jumps to a sibling node in the parent graph.
from langchain.tools import ToolRuntime
from langchain_core.messages import ToolMessage
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.types import Command
@tool
def transfer_to_billing(runtime: ToolRuntime) -> Command:
"""Hand the conversation to the billing agent."""
note = ToolMessage("Transferred to billing", tool_call_id=runtime.tool_call_id)
return Command(
goto="billing",
graph=Command.PARENT,
update={"messages": runtime.state["messages"] + [note]}, # pass the history along
)
triage = create_agent(scripted(tool_call("transfer_to_billing", {}, "h1")),
tools=[transfer_to_billing], name="triage")
billing = create_agent(scripted(AIMessage("Refund issued for invoice INV-9.")),
tools=[], name="billing")
builder = StateGraph(MessagesState)
builder.add_node("triage", triage, destinations=("billing",)) # destinations: for rendering
builder.add_node("billing", billing)
builder.add_edge(START, "triage")
desk = builder.compile()
result = desk.invoke({"messages": [HumanMessage("I was charged twice")]})
print([f"{type(m).__name__}: {m.content}" for m in result["messages"]])
# ['HumanMessage: I was charged twice', 'AIMessage: ', 'ToolMessage: Transferred to billing',
# 'AIMessage: Refund issued for invoice INV-9.']
ToolRuntimeinjectsstate,tool_call_id,context,storeinto a tool without exposing them to the model.- Include the
AIMessagewith the tool call and a matchingToolMessagein the handed-over history — otherwise the next model call fails on an invalid tool-call sequence. langgraph-swarm(create_swarm,create_handoff_tool) packages this pattern and remembers the active agent between turns.
Multi-Agent Checklist¶
- A single agent was tried first; each extra agent has a clear, testable responsibility
- Subagent inputs and outputs are minimal (context engineering), not full transcripts
- Routing decisions use structured output (
Routeschema), not free text parsing - Loop limits:
recursion_limit,ModelCallLimitMiddleware, delegation depth - Risky tools behind
HumanInTheLoopMiddlewareorinterrupt() - Traces tag the acting agent (
name=on each agent) for per-agent metrics - No imports from deprecated
langgraph.prebuilt.create_react_agentin new code
See also¶
- LangGraph — Stateful Agent Orchestration
- LangGraph — Testing LangGraph Apps
- LangChain — Agents & Tools
- Agentic AI — Multi-Agent Architecture Patterns
- Agentic AI — Tool Integration & Prompting