Conditional Routing & Subgraphs

9 min read Module 3 of 10 Topic 9 of 30

What you'll learn

  • Implement multi-way conditional routing with complex state-based decisions
  • Use Send() for dynamic fan-out to run parallel branches over variable-length lists
  • Define subgraphs and compose them as nodes in a parent graph
  • Apply the map-reduce pattern with LangGraph's parallel execution model
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Beyond Linear Workflows

Real agent workflows branch. A customer support agent might route to different handling logic based on issue type. A research agent might fan out to process multiple documents in parallel. A pipeline agent might have completely different sub-workflows for different task types.

LangGraph handles all of this with conditional edges, Send(), and subgraphs.


Multi-way Conditional Routing

The basic tools_condition is binary (tools vs END). Real production routing is more nuanced:

from langgraph.graph import StateGraph, END
from typing import Literal

class RouterState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    # intent is set by classify_intent and read by route_by_intent
    intent: str | None
    priority: Literal["low", "medium", "high"] | None
    requires_human: bool

def classify_intent(state: RouterState) -> dict:
    """LLM classifies the intent of the user's request."""
    prompt = f"""
Classify this customer request:
Message: {state['messages'][-1].content}

Return JSON with:
- intent: one of ["billing", "technical", "account", "general"]  
- priority: one of ["low", "medium", "high"]
- requires_human: true if the issue is complex or the customer is upset
"""
    result = llm.invoke([HumanMessage(content=prompt)])
    # json.loads parses the LLM's structured JSON output into a Python dict
    classification = json.loads(result.content)
    
    # partial state update: only these three fields are changed
    return {
        "intent": classification["intent"],
        "priority": classification["priority"],
        "requires_human": classification["requires_human"],
    }

def route_by_intent(state: RouterState) -> str:
    """Route to the appropriate handler based on intent and priority."""
    # requires_human overrides everything: escalate before checking intent
    if state["requires_human"]:
        return "human_handoff"
    
    # high priority issues go to a dedicated escalated path regardless of intent type
    if state["priority"] == "high":
        return "escalated_handler"
    
    # intent_map translates the LLM's classification into a graph node name
    intent_map = {
        "billing": "billing_agent",
        "technical": "tech_support_agent",
        "account": "account_agent",
        "general": "general_agent",
    }
    
    # dict.get with a default prevents KeyError if the LLM returns an unexpected intent
    return intent_map.get(state["intent"], "general_agent")

# Wire it up
builder.add_node("classify", classify_intent)
# The routing dict maps every possible return value of route_by_intent to a node name
# LangGraph validates at compile time that all returned values are in this dict
builder.add_conditional_edges(
    "classify",
    route_by_intent,
    {
        "billing_agent": "billing_agent",
        "tech_support_agent": "tech_support_agent",
        "account_agent": "account_agent",
        "general_agent": "general_agent",
        "escalated_handler": "escalated_handler",
        "human_handoff": "human_handoff",
    }
)

Dynamic Fan-out with Send()

Send() enables true parallelism over dynamic lists, the core of the map-reduce pattern:

from langgraph.types import Send
from typing import TypedDict, Annotated
import operator

class MapReduceState(TypedDict):
    documents: list[str]           # input documents
    # operator.add as reducer means each worker's list appends to the shared list
    # (map-reduce aggregation: all parallel summaries accumulate here)
    summaries: Annotated[list[str], operator.add]
    final_report: str

class DocumentState(TypedDict):
    # each parallel branch gets its own isolated DocumentState
    doc: str
    summary: str | None

# Map: fan out one branch per document
def fan_out_documents(state: MapReduceState) -> list[Send]:
    """Return one Send per document to process them in parallel."""
    # Send(node_name, state_for_that_branch): LangGraph spawns one execution per Send
    return [
        Send("summarize_document", {"doc": doc, "summary": None})
        for doc in state["documents"]
    ]

# Worker: each branch processes one document independently
def summarize_document(state: DocumentState) -> dict:
    """Summarize a single document."""
    # truncate to 3000 chars to avoid token overflow in long documents
    prompt = f"Summarize this in 2-3 sentences:\n\n{state['doc'][:3000]}"
    result = llm.invoke([HumanMessage(content=prompt)])
    # returning {"summary": ...}: the operator.add reducer in MapReduceState
    # will append this to the parent's summaries list when the branch completes
    return {"summary": result.content}  # goes back to parent via operator.add reducer

# Reduce: aggregate all summaries after all parallel branches complete
def create_final_report(state: MapReduceState) -> dict:
    """Combine all summaries into a final report."""
    # state["summaries"] contains one entry per document, in completion order
    combined = "\n\n".join([f"Document {i+1}: {s}" for i, s in enumerate(state["summaries"])])
    prompt = f"Create a comprehensive report from these document summaries:\n\n{combined}"
    result = llm.invoke([HumanMessage(content=prompt)])
    return {"final_report": result.content}

# Build the map-reduce graph
builder = StateGraph(MapReduceState)
builder.add_node("summarize_document", summarize_document)
builder.add_node("create_report", create_final_report)

builder.set_entry_point("fan_out")
# passthrough node: exists only to trigger the conditional edge for fan-out
builder.add_node("fan_out", lambda s: s)

# fan_out_documents returns a list of Send objects: LangGraph runs them in parallel
builder.add_conditional_edges("fan_out", fan_out_documents)
# LangGraph waits for ALL parallel summarize_document branches before advancing to create_report
builder.add_edge("summarize_document", "create_report")
builder.add_edge("create_report", END)

Subgraphs: Composable Agent Workflows

Subgraphs let you encapsulate a complete agent workflow and use it as a single node in a larger graph:

# Define a reusable research subgraph: it has its own isolated state schema
class ResearchState(TypedDict):
    query: str
    search_results: list[dict]
    summary: str

def search_phase(state: ResearchState) -> dict:
    # search_tool.invoke() runs the tool synchronously with the given query
    results = search_tool.invoke(state["query"])
    return {"search_results": results}

def summarize_phase(state: ResearchState) -> dict:
    # join all result content fields into a single string for the LLM to summarize
    docs = "\n\n".join([r["content"] for r in state["search_results"]])
    result = llm.invoke([HumanMessage(content=f"Summarize:\n{docs}")])
    return {"summary": result.content}

# Build and compile the subgraph: compile() makes it runnable as a standalone graph
research_builder = StateGraph(ResearchState)
research_builder.add_node("search", search_phase)
research_builder.add_node("summarize", summarize_phase)
research_builder.set_entry_point("search")
research_builder.add_edge("search", "summarize")
research_builder.add_edge("summarize", END)
# research_subgraph is now a compiled graph: it can be invoked independently or used as a node
research_subgraph = research_builder.compile()

# Use the subgraph as a node in a parent graph
class MainState(TypedDict):
    task: str
    research_query: str
    research_summary: str  # populated by subgraph
    final_report: str

def prepare_research_query(state: MainState) -> dict:
    """Extract a search query from the task."""
    result = llm.invoke([HumanMessage(content=f"Turn this task into a search query: {state['task']}")])
    return {"research_query": result.content.strip()}

def invoke_research_subgraph(state: MainState) -> dict:
    """Run the research subgraph and extract the summary."""
    # invoke the subgraph with its own state schema: it runs its full internal graph
    result = research_subgraph.invoke({"query": state["research_query"]})
    # extract only the field the parent graph needs from the subgraph's output
    return {"research_summary": result["summary"]}

def generate_report(state: MainState) -> dict:
    result = llm.invoke([HumanMessage(
        content=f"Write a report for '{state['task']}' based on: {state['research_summary']}"
    )])
    return {"final_report": result.content}

# Parent graph: invoke_research_subgraph wraps the entire subgraph as a single node
main_builder = StateGraph(MainState)
main_builder.add_node("prepare_query", prepare_research_query)
main_builder.add_node("research", invoke_research_subgraph)
main_builder.add_node("report", generate_report)
main_builder.set_entry_point("prepare_query")
main_builder.add_edge("prepare_query", "research")
main_builder.add_edge("research", "report")
main_builder.add_edge("report", END)

main_agent = main_builder.compile()

The Recursive Retry Pattern

A common pattern: retry a node with different parameters if it fails validation:

class ValidationState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    draft: str | None
    validation_errors: list[str]  # errors from the previous attempt
    retry_count: int

def generate_draft(state: ValidationState) -> dict:
    # inject previous validation errors into the prompt so the model can self-correct
    context = ""
    if state["validation_errors"]:
        context = f"\n\nPrevious attempt had errors:\n" + "\n".join(state["validation_errors"])
    
    result = llm.invoke(state["messages"] + [HumanMessage(content=context)])
    # increment retry_count so the routing function knows how many attempts have been made
    return {"draft": result.content, "retry_count": state["retry_count"] + 1}

def validate_draft(state: ValidationState) -> dict:
    # run_validation returns a list of error strings, or empty list on success
    errors = run_validation(state["draft"])
    return {"validation_errors": errors}

def should_retry_or_finish(state: ValidationState) -> str:
    # empty validation_errors means the draft passed all checks
    if not state["validation_errors"]:
        return "approved"
    # cap retries to avoid an infinite loop: force END even with errors
    if state["retry_count"] >= 3:
        return "failed"
    # errors remain and retry budget allows: loop back to regenerate
    return "retry"

builder.add_conditional_edges(
    "validate",
    should_retry_or_finish,
    # "retry" routes back to "generate": creating a controlled retry loop
    {"approved": END, "failed": END, "retry": "generate"}
)

These patterns: multi-way routing, fan-out with Send(), subgraph composition, and retry loops, are the building blocks of every complex production agent workflow you’ll encounter.

Knowledge Check

3 questions to test your understanding

1 What is the difference between add_conditional_edges with a dict vs a function that returns a list of node names?

2 Why use subgraphs instead of just adding all nodes to one flat graph?

3 What does Send() do in a LangGraph conditional edge?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan