Free lesson · GenAI Agent Engineering
Build a multi-agent orchestrator
You can build a puppeteer-style orchestrator, implement a DAG-based agent network with topological ordering, build an RL-trained agent coordinator (epsilon-greedy), pick from multi-agent design patterns (research/analysis/sequence-routing), handle circular dependencies, weigh DAG vs linear pipelines, distinguish static composition vs dynamic orchestration, and apply DAG-network composition patterns.
Course: GenAI Agent Engineering · Chapter 37 · The Subgraph (Composition)
Free to read — no subscription required.
Introduction
When you need to coordinate multiple specialized agents — each handling its own slice of a workflow — a single monolithic graph quickly becomes hard to maintain and extend. A puppeteer-style orchestrator solves this by acting as a top-level controller that delegates tasks to self-contained subgraphs and threads shared state through each one in sequence. By the end of this lesson, you will know how to wire a puppeteer orchestrator in LangGraph, attach compiled subgraphs as its nodes, and route state between them so the composition behaves as one cohesive pipeline.
Key Terminology
- Puppeteer Orchestrator — a top-level
StateGraphthat controls sequencing and state routing across multiple subgraphs, delegating all internal processing to each subgraph while remaining unaware of their internal nodes. - Subgraph — an independently built and compiled
StateGraphthat encapsulates its own nodes and edges, and is registered as an opaque node inside a parent orchestrator graph viaadd_node. - Shared State Schema — a single
TypedDict(herePipelineState) that the orchestrator and every subgraph agree on, allowing state fields written by one subgraph to be read directly by the next without manual field-copying. - Compiled Subgraph — the result of calling
.compile()on aStateGraphbuilder, which produces a callable that LangGraph treats as a single node; the orchestrator sees only the subgraph's entry and exit, not its internal structure. - Sequential Subgraph Routing — the use of
add_edgein the orchestrator to specify the order in which compiled subgraphs execute, so that state flows from one subgraph's output directly into the next subgraph's input.
Concepts
The Puppeteer Pattern: Orchestration vs. Internal Choreography
A monolithic graph that grows to handle research, summarization, validation, and formatting quickly becomes an interconnected tangle where every node is aware of every other. The puppeteer pattern separates two distinct concerns: who runs next (the orchestrator's responsibility) and how a unit of work is done (each subgraph's responsibility). The orchestrator acts like a conductor — it sets the sequence and passes the baton, but it has no knowledge of what happens inside each section of the orchestra.
This separation means you can reason about, test, and swap individual subgraphs without touching the orchestrator wiring, and you can redesign the orchestration order without touching the subgraph logic. The compiled subgraph is the boundary — once compiled, it is opaque to the outside world.
Shared State as the Communication Bus
For the puppeteer pattern to work, every participant must speak the same language. In LangGraph this is achieved by declaring a single shared TypedDict — PipelineState in the Code Walkthrough — and passing it to every StateGraph builder. Each subgraph reads the fields it needs and writes back only the fields it produces. When research_subgraph writes research_result, that updated dictionary is the exact object handed to summary_subgraph next — no adapter, no translation layer.
This design creates a clear data contract: the schema is the interface. Adding a new field to PipelineState (say, validation_result) costs one line in the TypedDict; the existing subgraphs that don't use it simply ignore it. The orchestrator never needs to inspect field values to route correctly when the pipeline is sequential — the edges encode the order, and the state carries the data.
Compiled Subgraphs as Opaque Nodes
When you call research_builder.compile(), LangGraph produces a runnable that looks, from the orchestrator's perspective, exactly like any other node function — it receives state in, returns a state delta out (see Code Walkthrough). The orchestrator registers it with add_node("research_agent", research_subgraph) and then edges manage the rest.
This opacity is a feature, not a limitation. The orchestrator's edges remain stable whether the research subgraph has one internal node or ten. Adding a third stage to the pipeline is additive: one add_node, two add_edge calls. None of the existing subgraphs require modification. This is what allows the pattern to scale cleanly — growth is always local to either a subgraph's internals or the orchestrator's routing table, never both at once.
Code Walkthrough
Now that you understand how subgraphs encapsulate their own nodes and state transitions, you can compose them under a single controlling graph — the puppeteer orchestrator — that decides which subgraph runs and in what order.
The pattern has three moving parts: a shared state schema that every subgraph and the orchestrator agree on, the subgraphs themselves compiled independently, and the orchestrator graph that adds those compiled subgraphs as ordinary nodes and routes between them.
Code snippetpython
1from typing import TypedDict 2from langgraph.graph import StateGraph, END 3 4class PipelineState(TypedDict): 5 topic: str 6 research_result: str 7 summary: str 8 9# --- Research subgraph --- 10def research_node(state: PipelineState) -> dict: 11 return {"research_result": f"Key facts about: {state['topic']}"} 12 13research_builder = StateGraph(PipelineState) 14research_builder.add_node("research", research_node) 15research_builder.set_entry_point("research") 16research_builder.add_edge("research", END) 17research_subgraph = research_builder.compile() 18 19# --- Summary subgraph --- 20def summarize_node(state: PipelineState) -> dict: 21 return {"summary": f"Summary — {state['research_result'][:80]}"} 22 23summary_builder = StateGraph(PipelineState) 24summary_builder.add_node("summarize", summarize_node) 25summary_builder.set_entry_point("summarize") 26summary_builder.add_edge("summarize", END) 27summary_subgraph = summary_builder.compile() 28 29# --- Puppeteer orchestrator --- 30orchestrator = StateGraph(PipelineState) 31orchestrator.add_node("research_agent", research_subgraph) 32orchestrator.add_node("summary_agent", summary_subgraph) 33orchestrator.set_entry_point("research_agent") 34orchestrator.add_edge("research_agent", "summary_agent") 35orchestrator.add_edge("summary_agent", END) 36pipeline = orchestrator.compile() 37 38result = pipeline.invoke({ 39 "topic": "LangGraph subgraph composition", 40 "research_result": "", 41 "summary": "", 42}) 43print(result["summary"])
The orchestrator invokes research_subgraph first, which populates research_result in the shared state, then passes that updated state directly to summary_subgraph. Each subgraph reads from and writes to the same PipelineState dictionary, so the output of one subgraph automatically becomes the input for the next without any manual field-copying between their internal nodes.
Because each subgraph is compiled before being registered as a node, LangGraph treats it as an opaque callable. The orchestrator sees only the subgraph's entry and exit — not its internal nodes. This is the puppeteer relationship: the orchestrator controls sequencing and state flow, while each subgraph manages its own internal choreography independently.
Adding a third subgraph later requires only a new add_node call and an updated edge — the existing subgraphs need no modification. That isolation is what makes the pattern scale cleanly as the pipeline grows.
Confirm that result["summary"] contains text derived from the topic string you passed in, and that swapping out one subgraph for a different compiled graph leaves the orchestrator wiring unchanged.
Do's and Don'ts
Having walked through building puppeteer-style orchestrator above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do share a single
TypedDictschema (PipelineState) across the orchestrator and every subgraph — all three graphs read from and write to the same dictionary, soresearch_resultproduced byresearch_subgraphis immediately visible tosummary_subgraphwithout any manual field-copying. - ✓Do compile each subgraph with
.compile()before passing it toorchestrator.add_node()— LangGraph treats the compiled graph as an opaque callable, which is what lets the orchestrator control sequencing while each subgraph manages its own internal node choreography independently. - ✓Do add new subgraphs with only a new
add_nodecall and an updatedadd_edge— because each subgraph is self-contained, extending the pipeline to a third or fourth stage never requires modifying existing subgraphs, only the orchestrator's wiring.
Don'ts
- ✗Don't register an uncompiled
StateGraphbuilder as an orchestrator node — only the.compile()result is a callable that LangGraph can invoke as a node; passing the builder object directly will fail at runtime because the orchestrator expects an opaque callable, not a graph-under-construction. - ✗Don't use mismatched state schemas between the orchestrator and a subgraph — if
summary_subgraphexpects aresearch_resultkey that the orchestrator's state schema omits, the field will be absent whensummarize_nodereadsstate['research_result'], producing silent empty output or aKeyErrorwith no obvious cross-graph error message. - ✗Don't copy state fields manually between subgraph calls — the puppeteer pattern relies on the orchestrator passing the same
PipelineStatedict through each node in sequence; introducing intermediate field-remapping logic breaks the clean handoff and creates a second source of truth that diverges when subgraph outputs change.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →