Branching and Conditional Edges for Multi-Path Agents
Conditional edges turn state into control flow: after a node finishes, a router function inspects the shared state and chooses the next node (or END).
That is how LangGraph agents escalate, call tools, skip steps, or fork work without hiding branches inside one opaque function.
Summary
Write a pure router that returns a path key, map keys to node names with add_conditional_edges, keep path labels stable, and test every branch with fixture state before connecting expensive models or tools.
Recipe
- List the real product paths (for example: tools, escalate, answer, retry).
- Put the signals those paths need into state (
risk,tool_calls,attempts). - Implement a router with no I/O:
state -> str(or path key). - Register destination nodes; map router outputs to node names (or
END). - Prefer explicit path maps over magic strings scattered across files.
- Add a default / fallback path for unexpected values.
- Unit-test the router with table-driven cases; integration-test the compiled graph per path.
Working Example
from typing import Literal, TypedDict
from langgraph.graph import StateGraph, START, END
class SupportState(TypedDict):
message: str
risk: str # "low" | "high"
needs_tools: bool
outcome: str
def triage(state: SupportState) -> dict:
text = state["message"].lower()
risk = "high" if any(w in text for w in ("lawsuit", "fraud", "delete all")) else "low"
needs_tools = "order" in text or "tracking" in text
return {"risk": risk, "needs_tools": needs_tools}
def route_after_triage(
state: SupportState,
) -> Literal["tools", "escalate", "answer"]:
if state["risk"] == "high":
return "escalate"
if state["needs_tools"]:
return "tools"
return "answer"
def tools_node(state: SupportState) -> dict:
return {"outcome": f"looked up order context for: {state['message'][:60]}"}
def escalate_node(state: SupportState) -> dict:
return {"outcome": "handed to human queue"}
def answer_node(state: SupportState) -> dict:
return {"outcome": "auto-replied from FAQ"}
builder = StateGraph(SupportState)
builder.add_node("triage", triage)
builder.add_node("tools", tools_node)
builder.add_node("escalate", escalate_node)
builder.add_node("answer", answer_node)
builder.add_edge(START, "triage")
builder.add_conditional_edges(
"triage",
route_after_triage,
{
"tools": "tools",
"escalate": "escalate",
"answer": "answer",
},
)
builder.add_edge("tools", END)
builder.add_edge("escalate", END)
builder.add_edge("answer", END)
graph = builder.compile()
print(graph.invoke({
"message": "Where is my order?",
"risk": "",
"needs_tools": False,
"outcome": "",
})["outcome"])For tool-calling agents, the router often inspects the last AI message for tool_calls and returns "tools" vs END.
Same API; different signal.
Deep Dive
Path maps
add_conditional_edges(source, router, path_map) has two common styles:
- Dict map - router returns keys; map sends them to node names or
END. - List of targets - when the router returns node names directly (verify preferred style for your version).
Explicit maps catch typos at compile/review time.
Fan-out and fan-in
From START (or any node) you can attach multiple fixed edges so several nodes run in the same superstep.
Combine with reducers on shared list fields so parallel writers append instead of clobber.
For dynamic map-reduce, LangGraph's Send API can dispatch per-item tasks (advanced; verify at build).
Routers as policy modules
Treat routers as the smallest policy unit you can code-review.
If product says "never auto-refund over $X," put that check in the router or a dedicated gate node, not only in a prompt.
Command-based routing
Nodes can also return Command(goto=..., update=...) to update state and jump in one step (useful after interrupt approval).
Prefer ordinary conditional edges for the main happy path so topology stays visible; use Command when the node must choose after an external resume value.
Observability
Name nodes after business steps (escalate, not node_3).
In LangSmith, conditional paths show up as different node sequences; that is how you measure how often traffic hits the dangerous branch.
Gotchas
- Silent wrong path - router returns a key missing from
path_map; fail loud in tests. - Side effects in routers - I/O makes routing non-deterministic and hard to test.
- Stale state signals - routing on fields never updated by prior nodes.
- Boolean soup - nested ifs without named path enums; extract helpers.
- No fallback - unknown model labels should go to
escalateorENDwith error state, not crash mid-flight. - Parallel overwrites - fan-out without reducers loses writes.
- Prompt-only routing - asking the model to "choose a path" without validating the label against an allowlist.
Alternatives
| Approach | Strength | Weakness |
|---|---|---|
| Conditional edges | Explicit, testable topology | More graph wiring |
| Giant node with internal if/else | Fast draft | Opaque traces, hard HITL |
| LLM-as-router only | Flexible language | Unstable labels, cost |
| Rules engine outside graph | Shared enterprise policy | Two systems to sync |
| Always-linear pipeline | Simple | Cannot adapt mid-run |
FAQs
Should the model or code choose the path?
Use code for safety and compliance branches. Use the model for fuzzy classification that still lands on an allowlisted path key validated in the router.
Can one router return multiple next nodes?
Fan-out is usually expressed with multiple edges or Send, not a single string. Check your LangGraph version docs for multi-destination patterns.
How do I unit test a conditional edge?
Call the router with crafted state dicts and assert return values. Separately invoke the compiled graph with mocked node bodies if needed.
What goes wrong with stringly-typed paths?
Typos ("escallate") fail at runtime. Prefer Literal types and a single path_map constant shared by tests.
How does this differ from multi-agent handoff?
Branching chooses the next step in one graph. Handoff often transfers goals and context to another agent or subgraph. You can implement handoff as a branch into a subgraph node.
Where should I put retries?
Transient tool failures belong in retry policies or a retry path with an attempt counter, not infinite unconditional edges back to the same node.
Can I change routing without redeploying?
Only if path policy is data-driven (config/feature flags) and the graph already contains those nodes. New nodes still need a deploy.
Is END required on every branch?
Every run must eventually stop. Branches may rejoin a shared tail node that then edges to END, or each branch edges to END directly.
Related
Related: Building Your First Stateful Agent Graph
Related: LangChain & LangGraph Basics
Related: Human-in-the-Loop Approval Nodes
Related: The LangGraph Mental Model
Related: LangChain & LangGraph Best Practices
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.