|
| 1 | +--- |
| 2 | +title: 'LangGraph Subgraphs: When to Split a Graph and When Not To' |
| 3 | +description: 'On LangGraph, a subgraph buys you an observable boundary, not a state boundary. Why our own graphs got split, and what the frontend sees while a child runs.' |
| 4 | +date: 2026-08-27 |
| 5 | +tags: [langgraph, subgraphs, agents, streaming, angular] |
| 6 | +author: brian |
| 7 | +featured: false |
| 8 | +draft: false |
| 9 | +--- |
| 10 | + |
| 11 | +Most people reach for a LangGraph subgraph expecting a _state_ boundary, and what they actually get is an _observable_ one. |
| 12 | + |
| 13 | +If your question is "single agent, approval loop, or multi-agent?", that's an architecture question and the [decision matrix](/docs/langgraph/concepts/agent-architecture) already answers it. |
| 14 | +This post is about the layer underneath: what a subgraph actually changes at runtime, why our own graphs got split, and what the frontend sees while a child is running. |
| 15 | + |
| 16 | +## What does a subgraph actually give you? |
| 17 | + |
| 18 | +Nested execution and namespaced stream events. That's the honest list. |
| 19 | + |
| 20 | +Let's start with the canonical pattern, which is small. |
| 21 | +Compile a child `StateGraph`, then add the compiled graph as a node in the parent: |
| 22 | + |
| 23 | +```python |
| 24 | +research_builder = StateGraph(MessagesState) |
| 25 | +research_builder.add_node("search", search_web) |
| 26 | +research_builder.add_edge(START, "search") |
| 27 | +research_subgraph = research_builder.compile() |
| 28 | + |
| 29 | +builder = StateGraph(MessagesState) |
| 30 | +builder.add_node("research", research_subgraph) # a compiled graph, used as a node |
| 31 | +``` |
| 32 | + |
| 33 | +Two things change. |
| 34 | +The child runs as its own graph, with its own nodes and its own step sequence rather than being flattened into the parent's. |
| 35 | +And LangGraph emits the child's stream events under a namespace, so a consumer can tell parent output from child output. |
| 36 | + |
| 37 | +Here's the part I think gets assumed and shouldn't: state isolation isn't a third. |
| 38 | + |
| 39 | +If parent and child share `MessagesState`, the child appends to the same message list the parent is building. |
| 40 | +Nothing about `add_node` fenced anything off. |
| 41 | + |
| 42 | +Isolation is something you design — give the child its own state schema, then map in at the boundary and map the result back out. |
| 43 | +That's a decision you make and maintain, not a property `compile()` hands you. |
| 44 | + |
| 45 | +We ship one graph that does exactly that, and because it's a capability demo built to show the primitive, it's a clean look at the shape. |
| 46 | +Its child state schema has no `messages` key at all. |
| 47 | +Parent and child share exactly two keys, `research_topic` and `research_brief`, so the child is handed a topic and hands back a brief — it can't read the transcript, and it can't append to one. |
| 48 | + |
| 49 | +That boundary is real, and none of it came from `compile()`. |
| 50 | +It came from writing two `TypedDict`s and being deliberate about what they share. |
| 51 | + |
| 52 | +### What about context windows and error boundaries? |
| 53 | + |
| 54 | +Those are real reasons to split — our own docs lean on them. |
| 55 | +The [subgraphs guide](/docs/langgraph/guides/subgraphs) points at per-task context windows and failure containment as reasons to reach for subagents, and the docstring on our own research child's only node calls it "a focused contractor." |
| 56 | + |
| 57 | +But look at where each one actually comes from. |
| 58 | +A narrow context window is a consequence of what you pass into the child's `ainvoke` — you get it by handing over a topic instead of a transcript. |
| 59 | +An error boundary is a consequence of how the parent handles a failed child call, and a node-level retry wraps any node, plain function or compiled graph alike. |
| 60 | +Reuse across parents is a consequence of the child being a value you can reference twice. |
| 61 | + |
| 62 | +You can have all three without ever compiling a child graph, and you can compile a child graph and get none of them. |
| 63 | + |
| 64 | +There is one more, and it's worth stating because it looks like a counterexample. |
| 65 | +Wire the child in as a node under a parent that has a checkpointer, and the child's steps get checkpointed under its namespace — which is what lets you interrupt and resume at child granularity. |
| 66 | +Notice that's the namespace again, doing a second job. |
| 67 | + |
| 68 | +## Why do people really split? |
| 69 | + |
| 70 | +In our own repo, the honest answer is: so the frontend can see the delegation. |
| 71 | + |
| 72 | +That's a claim about our own graphs, not a law of the framework — and one of them splits for a different reason entirely, which I'll get to. |
| 73 | +But it's a natural experiment rather than a portfolio — nobody wrote these to prove a point about subgraphs, and the constraint that drove them, a frontend that renders per-child progress, isn't specific to us. |
| 74 | + |
| 75 | +Let's look at what we wrote down at the time. |
| 76 | +Here's the comment sitting above the research subagent in our canonical `examples/chat` graph: |
| 77 | + |
| 78 | +```python |
| 79 | +# Research subagent — a small compiled child graph the parent dispatches |
| 80 | +# via the `research` @tool. Running it as an actual subgraph (vs. inline |
| 81 | +# logic) is what causes LangGraph to emit stream events under namespace |
| 82 | +# prefix `tools:<id>` for the child run, which is what the @threadplane/langgraph |
| 83 | +# SubagentTracker keys on to populate `agent.subagents()`. |
| 84 | +``` |
| 85 | + |
| 86 | +That's not a state argument. It's a visibility argument. |
| 87 | + |
| 88 | +The design doc for that feature is blunter still. Here's the alternative it rejected: |
| 89 | + |
| 90 | +```text |
| 91 | +Plain `@tool` returning a synthesized "subagent" payload — Simpler graph |
| 92 | +code but does not exercise the SubagentTracker code path: no `tools:` |
| 93 | +namespace events get emitted because no subgraph runs. The card would |
| 94 | +render empty. Rejected. |
| 95 | +``` |
| 96 | + |
| 97 | +Then there's the conversion. |
| 98 | +Our `cockpit/chat/subagents` demo originally ran its three specialists as a flat in-process helper, and was rewritten to dispatch a real compiled child graph — because the flat version emitted no namespace events, so `subagents()` stayed empty and no card rendered. |
| 99 | +A working feature was restructured so a UI card would appear. |
| 100 | + |
| 101 | +In both of those graphs the compiled child is invoked from inside a `@tool` body, not wired in as a plain node. |
| 102 | +That's deliberate: the tool call is what the tracker registers, and our own docs are blunt that [plain subgraph nodes](/docs/langgraph/guides/subgraphs) don't show up in that map at all. |
| 103 | + |
| 104 | +Which cuts the other way from how it sounds — plain `add_node` subgraphs make the point sharper, not weaker. |
| 105 | +Those still get a namespace, so they're still observable in the raw stream. |
| 106 | +They just don't get a name, so nothing downstream can attribute them to anything. |
| 107 | +The subgraph is what makes the events observable; the tool call is what gives them an identity. |
| 108 | + |
| 109 | +## What does the frontend see while a child runs? |
| 110 | + |
| 111 | +Namespaced events — and nearly everything interesting downstream follows from that one fact. |
| 112 | + |
| 113 | +### What the wire looks like |
| 114 | + |
| 115 | +Let's take it from the wire inward. |
| 116 | +The event type carries the namespace after a pipe, so the base type is the part before it: |
| 117 | + |
| 118 | +```text |
| 119 | +messages # parent |
| 120 | +messages|tools:call-1 # child run dispatched by tool call "call-1" |
| 121 | +``` |
| 122 | + |
| 123 | +Our transport requests those child streams by default — `streamSubgraphs` is `true` unless you turn it off. |
| 124 | +That's the LangGraph JS SDK's own option name, passed straight through, and worth knowing if you're coming from the Python API, where the in-process `graph.stream()` equivalent is the `subgraphs=True` kwarg. |
| 125 | + |
| 126 | +### The terminal-event hazard |
| 127 | + |
| 128 | +A child graph terminates before the parent does, and a child's terminal event looks an awful lot like the parent's. |
| 129 | + |
| 130 | +Without a namespace guard, that child terminal marker gets read as "the run finished" and closes out the parent's still-streaming assistant message. |
| 131 | +We guard it by refusing namespaced events as top-level terminal evidence, and there's a test that feeds a namespaced terminal marker in and asserts the parent message settles with outcome `interrupted` rather than success. |
| 132 | + |
| 133 | +If you ever write a transport against this stream yourself, that's the bug you'll hit, and it will look like truncation rather than a namespace bug. |
| 134 | + |
| 135 | +### Where child text goes |
| 136 | + |
| 137 | +Into your main transcript, by default. |
| 138 | +Our `filterSubagentMessages` is off unless you set it, so a child's tokens flow into `messages()` alongside the parent's. |
| 139 | + |
| 140 | +That isn't a quirk of our config. |
| 141 | +Any consumer reading a namespaced stream has to decide what a child's tokens mean, and "append them like everything else" is the path of least resistance — so unless something opts out, child text lands in the parent transcript and the same content renders twice. |
| 142 | + |
| 143 | +### How does a child get attributed? |
| 144 | + |
| 145 | +By id — and this is the part I find well-designed: the namespace segment _is_ the identifier. |
| 146 | + |
| 147 | +`tools:<id>` carries the parent tool call id, so the tracker slices the prefix off and looks the id up directly against what it recorded when the tool call came through. |
| 148 | +Marking a child running and routing its messages need no matching at all. |
| 149 | + |
| 150 | +There is also a description-comparison ladder — exact match on the tool call's `description` argument, then substring either direction, then a last-resort fallback to any unmapped subagent still pending or running. |
| 151 | +It only runs for children whose state opens with a human message, and none of the graphs we ship reach it. |
| 152 | +The ones dispatched through a tool call invoke the child with an empty message list, so the first message in child state is the AI response. |
| 153 | +The one wired in as a plain node doesn't keep a `messages` key in child state at all. |
| 154 | +Treat that path as untested rather than as the mechanism. |
| 155 | + |
| 156 | +The general point survives, though, and it's the one worth carrying to any protocol. |
| 157 | +A consumer mapping child runs onto delegations is doing string matching unless the protocol gives it an id. |
| 158 | +LangGraph gives it an id — which is why the ladder is vestigial here and would be load-bearing in a fan-out graph with look-alike children. |
| 159 | + |
| 160 | +One limit, though: only the _first_ `tools:` segment of a namespace is read. |
| 161 | +A subagent that itself delegates will have its inner events attributed to the outer tool call. |
| 162 | +Nothing in this repo exercises deeper nesting, so don't build on it. |
| 163 | + |
| 164 | +## When should you not split? |
| 165 | + |
| 166 | +When there's no observable boundary to draw and no genuinely divergent state. |
| 167 | + |
| 168 | +The cleanest evidence I have is a control group we didn't set out to build. |
| 169 | +Our `cockpit/ag-ui/subagents` capability ships the same three-subagent feature as the LangGraph one — and it's a LangGraph `StateGraph` too, same framework, same orchestrator-plus-`task`-tool shape, same three roles, same cards in the UI — with no subgraph anywhere. |
| 170 | +Its module docstring says so outright: |
| 171 | + |
| 172 | +```text |
| 173 | +Mirrors cockpit/chat/subagents' orchestrator + `task` tool + `_run_subagent` |
| 174 | +structure, but each dispatch emits `subagent_activity` CUSTOM events |
| 175 | +``` |
| 176 | + |
| 177 | +The thing that differs is the transport: AG-UI's already carries a first-class delegation event. |
| 178 | +So so the specialists stayed a flat `async` helper and progress reaches the frontend as a custom event dispatched from the tool body. |
| 179 | + |
| 180 | +The subgraph was never required by the feature. It was required by the transport. |
| 181 | + |
| 182 | +You could dispatch custom events from the LangGraph graph too — nothing stops you, and `adispatch_custom_event` is a LangChain primitive, not an AG-UI one. |
| 183 | +What namespaces buy is that you don't have to. |
| 184 | +The boundary emits its own identity for free, and a transport that reads it works against any graph rather than any graph that remembered to instrument itself. |
| 185 | + |
| 186 | +Staying flat wasn't free. |
| 187 | +There's no separate state schema to isolate anything into, and no child step sequence — every specialist gets the parent's shape, one LLM call wide. |
| 188 | +What it bought was one fewer graph for a feature that renders identically. |
| 189 | + |
| 190 | +That's the test I'd apply. |
| 191 | +If your transport already has a way to say "a child is working right now," or your UI doesn't render per-child progress at all, then a subgraph is a boundary you now have to defend: an extra state schema, mapping at both edges, and one more place to look when a message goes missing. |
| 192 | + |
| 193 | +And splitting because a region of the graph _feels_ like a separate concern isn't a reason on its own. |
| 194 | +A node is already a unit. |
| 195 | + |
| 196 | +### So when does a split earn itself? |
| 197 | + |
| 198 | +When the child really is a different graph — and the repo has exactly one of those, which is the case I owe you after arguing the other side this whole time. |
| 199 | + |
| 200 | +Our `examples/ag-ui` demo runs on that same AG-UI transport, and it emits the same `subagent_activity` events from the tool body. |
| 201 | +So it isn't buying observability; it already had it. |
| 202 | +It compiles a child graph anyway. |
| 203 | + |
| 204 | +Look at what the child is, though. |
| 205 | +It has its own `agent → tools → agent` loop with conditional edges and an iteration cap — a different control flow from the parent's, not a slice of it. |
| 206 | + |
| 207 | +And here's the part that took me a second read to see. |
| 208 | +A custom child state schema doesn't discriminate at all: the two graphs I just used as observability evidence _also_ define their own child `TypedDict`s. |
| 209 | +But both of those children are one node and a straight line, so the schema is really just an argument list with a type on it. |
| 210 | + |
| 211 | +So it's the control flow, not the schema. |
| 212 | +A child that carries a `topic` string is a function call wearing a graph costume. |
| 213 | +A child that loops until it's satisfied is a graph. |
| 214 | + |
| 215 | +## Conclusion |
| 216 | + |
| 217 | +Split when something outside the graph needs to see the child run as its own thing — a card, a progress panel, per-child streaming. |
| 218 | +Split when the child has its own control flow — a loop, a branch, a stopping condition the parent doesn't have — and you're willing to own the mapping at both edges. |
| 219 | +Don't split for tidiness, and don't assume the split isolated state: wire a child in as a node on a shared `MessagesState` and it appends straight to the transcript the parent is building. |
| 220 | + |
| 221 | +The [architecture matrix](/docs/langgraph/concepts/agent-architecture) covers the tiering question, the [subgraphs guide](/docs/langgraph/guides/subgraphs) has the composition and `subagents()` wiring, and [What injectAgent() Actually Returns](/blog/what-inject-agent-returns) walks the signal surface those child streams land in. |
| 222 | + |
| 223 | +If you've split a graph for a third reason — not observability, and not a child that's genuinely its own graph — I'd like to hear it. Those are the two I've been able to justify; I doubt they're the only two that exist. |
0 commit comments