tl;dr. two scaling moves. subgraphs:
a compiled graph is a node, so a whole team mounts inside a bigger graph
as one box — team state stays private, shared keys flow through.
fan-out: Send spawns n copies of a worker
node at runtime — map-reduce over agents, with reducers doing the
reduce. between them you get org charts and worker pools without new
concepts.
teams as black boxes
// a team is a graph. a company of teams is a graph of graphs.
// compile the lesson-four team once — mount it as one node.
const engineering = buildSupervisorTeam(engWorkers).compile();
const content = buildSupervisorTeam(contentWorkers).compile();
const org = new StateGraph(OrgState)
.addNode("triage", triage)
.addNode("engineering", engineering) // an entire team, one node
.addNode("content", content)
.addEdge(START, "triage")
.addConditionalEdges("triage", routeToTeam, ["engineering", "content"])
.addEdge("engineering", END)
.addEdge("content", END)
.compile();
// shared state keys (messages) flow in and out automatically.
// the team's internal keys — draft, revisionCount, whose turn —
// stay private to the subgraph. teams hide their process
// and expose their results. organizations should be so lucky. this is the composition rule that makes everything before it scale. the supervisor team from lesson four, the swarm from lesson five — once compiled, each is indistinguishable from a plain node. the parent graph routes between teams; each team routes within itself; neither layer can see the other's internals.
the state boundary is the useful part. parent and subgraph communicate
through the keys they share (usually messages, or a typed
result field); everything else in the team's annotation — the working
draft, the revision counter, whose turn it is — is invisible outside.
that's team topologies applied to
software you can compile: teams hide their process and expose their
interface, and cross-team coupling has to be declared in the shared
keys, where you can see it in review.
fan-out with Send
import { Send } from "@langchain/langgraph";
// fan-out: one worker definition, n dynamic instances.
// Send() creates a task per item — the graph doesn't know n
// until runtime.
function fanOut(state: typeof ReviewState.State) {
return state.files.map(
(file) => new Send("reviewFile", { file, pr: state.pr }),
);
}
async function reviewFile(input: { file: string; pr: string }) {
const verdict = await reviewer.invoke(fileContext(input));
return { findings: verdict.findings }; // reducer concatenates
}
const review = new StateGraph(ReviewState)
.addNode("plan", plan)
.addNode("reviewFile", reviewFile)
.addNode("summarize", summarize)
.addEdge(START, "plan")
.addConditionalEdges("plan", fanOut, ["reviewFile"])
.addEdge("reviewFile", "summarize") // implicit barrier: summarize
.addEdge("summarize", END) // waits for ALL branches
.compile(); - Send breaks the static-graph rule, on purpose.
normal edges are known at compile time.
Sendcreates tasks at runtime — one per file, ten files or two hundred — each with its own private input instead of the shared state. - the reducer is the reduce step.
every parallel branch returns
findings; the concat reducer from lesson two merges them. no reducer on a fanned-out field is the lost-update bug at n-times the frequency. - the next node is the barrier.
summarizeruns once, after every branch completes — langgraph's superstep model gives you the join for free. design around it: one slow branch holds the whole tier.
Send per gate, not a queue.
parallelism has a bill
fan-out multiplies everything by n: tokens, cost, and rate-limit
pressure. three habits keep it honest. size the worker's context to the
item — the file reviewer gets the file and the pr description, not the
whole conversation; Send's private input exists precisely
so parallel workers don't each drag the full state through the api.
match the model to the task — two hundred small reviews on the fast
model with one strong-model synthesis at the barrier beats two hundred
strong-model calls on both cost and, usually, quality. and cap n — a
pr with 400 changed files should fan out over the 30 that matter and
say so, not silently review everything. silent truncation is worse than
a visible cap.
when it breaks
- nesting for its own sake. subgraphs inside subgraphs inside subgraphs, because composition is cheap. every layer is a debugging layer — a trace that reads "org → engineering → review → reviewFile" is fine at four levels and a maze at seven. flatten until it hurts, then nest once.
- shared-key collisions.
parent and subgraph both writing
messageswith different conventions — one appends summaries, the other full transcripts — merge into a log nobody can read. decide what crosses the boundary, and keep team chatter inside the team. - one poisoned branch. 199 review branches succeed, one throws, the barrier never resolves. decide the policy per fan-out — retry, skip-and-log, or fail the run — and encode it in the worker, not in hope. the error-shape discipline applies to branches too.
next: human gates — the pause button. interrupts, checkpoints, and approval as a node in the graph instead of a hope in the process.