Every multi-agent framework demo runs one agent at a time. The blog posts show a linear DAG: planner → coder → reviewer → tester. Which is fine for a demo. In production, on a real feature, you want three coder agents chewing on three independent files at once, a reviewer already running on the first output while the second one finishes, and the tester waiting on a merged branch. Otherwise you're paying LLM latency N times in a row when you could pay it once.
LangGraph makes parallel fan-out cheap to declare and expensive to get right. The state model is the thing you get wrong first, and then the human approval gates are the thing you get wrong second. Here's what actually worked in DevSwarm, the multi-agent coding runtime I ship on.
LangGraph runs on a shared state object that flows between nodes. Every node reads a slice, writes
a slice, and returns. If you naively put every agent's output under one results field,
your parallel coders overwrite each other the moment they finish. The scheduler doesn't warn you.
The reviewer sees whichever writer landed last.
Two things fix this:
operator.add for lists, a custom merge for dicts). Parallel writes accumulate
into the list; nothing gets stomped.
from typing import Annotated, TypedDict
from operator import add
class AgentState(TypedDict):
# Reducer: append, do not overwrite. Every coder writes one entry keyed by task_id.
coder_outputs: Annotated[list[dict], add]
# Same idea for reviewer verdicts.
reviews: Annotated[list[dict], add]
# Non-parallel scalars are still last-write-wins; keep them out of parallel branches.
branch_name: str
LangGraph doesn't have a dedicated "spawn" primitive. You fan out by returning a list of
Send objects from a router node. Each Send targets a downstream node
with a task-specific input.
from langgraph.constants import Send
def route_to_coders(state: AgentState) -> list[Send]:
# planner has already produced state["tasks"], one per independent file/change
return [
Send("coder", {"task_id": t["id"], "spec": t["spec"], "context": t["context"]})
for t in state["tasks"]
]
Every Send becomes a parallel execution of the coder node. LangGraph
runs them concurrently, then waits for all of them at the next node boundary. That's your fan-in
barrier: put the reviewer downstream of the coder, and it waits for every coder to land before it
runs.
If you want the reviewer to start on the FIRST output rather than wait for all N, you use a different pattern: the reviewer is itself fanned out, one per task id, with the same task-keyed input. Then there's no barrier; each (task, coder, reviewer) triple runs as its own strand.
The seductive move is to add a human_gate node between reviewer and integrator,
and have it call some blocking await input() equivalent. This works for a demo. It
does not work when three coder strands are running in parallel and you want the human to approve
strand 2 while strand 1 is still being reviewed.
The pattern that works: gates are per-strand, and they emit an event, they don't block. The state
gets an awaiting_approval entry with the task id. A separate side channel (WebSocket
in DevSwarm's case, could be a Slack DM, could be a UI) notifies the human. The human's response
is a POST that mutates the shared store and triggers the graph to resume that specific strand via
LangGraph's checkpointer.
def human_gate(state: AgentState) -> dict:
task_id = state["current_task_id"]
return {
"awaiting_approval": [{"task_id": task_id, "diff": state["coder_outputs"][-1]["diff"]}],
"status": "paused",
}
# The graph is compiled with a checkpointer (Redis or Postgres).
# When the approval arrives, resume with .invoke(state, config={"configurable": {"thread_id": task_id}}).
The important word is checkpointer. Without one, a paused graph loses its state the moment the process cycles. With a Redis or Postgres checkpointer, you can pause a strand for a week and resume it clean. This is what turns the demo pattern into something you can run in production.
One coder agent will occasionally hang. The model returns a valid tool call to a shell command that runs forever (tail on a growing log, an interactive prompt). The graph waits for it. Every other strand finishes; the fan-in barrier never resolves.
Fix: every parallel node runs under a wall-clock timeout. If it exceeds, the node returns a
{"task_id": t, "status": "timeout", "diff": None} instead of the coder output. The
reducer accepts it; the reviewer downstream reads status and marks the task failed. The graph
keeps moving.
Ran the same diff through the reviewer twice and got two different verdicts within an hour.
Sampling temperature > 0 will do that. For gates that decide whether code merges, run the
reviewer at temperature=0 and, if you're paranoid, run it three times and take the
majority vote. Cost is a rounding error, disagreement is not.
Early bug: the integrator node was one-per-strand instead of a single node at the end. Each strand opened its own PR. Merged all 47 with the same title. Repo history looked like a horror movie.
Fix: the integrator sits after the fan-in barrier. It's a single node that receives every strand's output and opens one PR with N commits, or N sibling PRs on one branch, based on the config. The graph topology enforces the singleton, not the code inside the node.
Parallel isn't free. Three concurrent coder agents each holding a 30K-token context is 90K tokens of pre-fill happening simultaneously. Anthropic's rate limits will pause you. Provider timeouts get more aggressive at concurrency. And LangGraph's own scheduling overhead is real for graphs with 20+ nodes.
The math that actually holds:
Send fan-out. Reducer-channel state for anything a parallel node writes.This is the pattern I use on client builds. If your team is stuck on a multi-agent graph that runs sequentially when it shouldn't, or freezes when one agent hangs, that's a Pilot-sized engagement. The tools on my site demonstrate the same runtime, and the free 30-min audit is where we scope your specific graph.