You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx
+55-47Lines changed: 55 additions & 47 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,14 +10,15 @@ draft: false
10
10
11
11
Most people reach for a LangGraph subgraph expecting a _state_ boundary, and what they actually get is an _observable_ one.
12
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.
13
+
If your question is "single agent, approval loop, or multi-agent?", that is an architecture question and the [decision matrix](/docs/langgraph/concepts/agent-architecture) already answers it.
14
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
15
16
16
## What does a subgraph actually give you?
17
17
18
-
Nested execution and namespaced stream events. That's the honest list.
18
+
Nested execution and namespaced stream events.
19
+
That is the honest list.
19
20
20
-
Let's start with the canonical pattern, which is small.
21
+
Start with the canonical pattern, which is small.
21
22
Compile a child `StateGraph`, then add the compiled graph as a node in the parent:
22
23
23
24
```python
@@ -34,17 +35,17 @@ Two things change.
34
35
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
36
And LangGraph emits the child's stream events under a namespace, so a consumer can tell parent output from child output.
36
37
37
-
Here's the part I think gets assumed and shouldn't: state isolation isn't a third.
38
+
Here is the part I think gets assumed and should not be: state isolation is not a third.
38
39
39
40
If parent and child share `MessagesState`, the child appends to the same message list the parent is building.
40
41
Nothing about `add_node` fenced anything off.
41
42
42
43
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
+
That is a decision you make and maintain, not a property `compile()` hands you.
44
45
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
+
We ship one graph that does exactly that, and because it is a capability demo built to show the primitive, it is a clean look at the shape.
46
47
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
+
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 cannot read the transcript, and it cannot append to one.
48
49
49
50
That boundary is real, and none of it came from `compile()`.
50
51
It came from writing two `TypedDict`s and being deliberate about what they share.
@@ -61,19 +62,20 @@ Reuse across parents is a consequence of the child being a value you can referen
61
62
62
63
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
64
-
There is one more, and it's worth stating because it looks like a counterexample.
65
+
There is one more, and it is worth stating because it looks like a counterexample.
65
66
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
+
Notice that is the namespace again, doing a second job.
67
68
68
69
## Why do people really split?
69
70
70
71
In our own repo, the honest answer is: so the frontend can see the delegation.
71
72
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.
73
+
That is a claim about our own graphs, not a law of the framework — and one of them splits for a different reason entirely, which I will get to.
74
+
But it is a natural experiment rather than a portfolio.
75
+
Nobody wrote these to prove a point about subgraphs, and the constraint that drove them, a frontend that renders per-child progress, is not specific to us.
74
76
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
+
Start with what we wrote down at the time.
78
+
Here is the comment sitting above the research subagent in our canonical `examples/chat` graph:
77
79
78
80
```python
79
81
# Research subagent — a small compiled child graph the parent dispatches
@@ -83,9 +85,11 @@ Here's the comment sitting above the research subagent in our canonical `example
83
85
# SubagentTracker keys on to populate `agent.subagents()`.
84
86
```
85
87
86
-
That's not a state argument. It's a visibility argument.
88
+
That is not a state argument.
89
+
It is a visibility argument.
87
90
88
-
The design doc for that feature is blunter still. Here's the alternative it rejected:
91
+
The design doc for that feature is blunter still.
92
+
Here is the alternative it rejected:
89
93
90
94
```text
91
95
Plain `@tool` returning a synthesized "subagent" payload — Simpler graph
@@ -94,15 +98,15 @@ namespace events get emitted because no subgraph runs. The card would
94
98
render empty. Rejected.
95
99
```
96
100
97
-
Then there's the conversion.
101
+
Then there is the conversion.
98
102
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
103
A working feature was restructured so a UI card would appear.
100
104
101
105
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 carries the identity — an id the tracker can attribute the child's stream to, and a `subagent_type` to name it.
106
+
That is deliberate: the tool call carries the identity — an id the tracker can attribute the child's stream to, and a `subagent_type` to name it.
103
107
104
-
For a long time that was also the only way into the map: plain `add_node` subgraphs streamed under a namespace nobody claimed, so [our own docs](/docs/langgraph/guides/subgraphs) were blunt that they didn't show up at all.
105
-
That's no longer true.
108
+
For a long time that was also the only way into the map: plain `add_node` subgraphs streamed under a namespace nobody claimed, so [our own docs](/docs/langgraph/guides/subgraphs) were blunt that they did not show up at all.
109
+
That is no longer true.
106
110
The namespace segment is itself a workable identity — unique per invocation, prefixed with the node name — so a plain subgraph child now registers in `subagents()` under its namespace key the moment it first streams, named by its node.
107
111
The subgraph is what makes the events observable; the tool call upgrades that identity from a node name to a real delegation record, with arguments a UI can render.
108
112
@@ -116,7 +120,7 @@ Namespaced events — and nearly everything interesting downstream follows from
116
120
117
121
### What the wire looks like
118
122
119
-
Let's take it from the wire inward.
123
+
Take it from the wire inward.
120
124
The event type carries the namespace after a pipe, so the base type is the part before it:
121
125
122
126
```text
@@ -125,16 +129,16 @@ messages|tools:call-1 # child run dispatched by tool call "call-1"
125
129
```
126
130
127
131
Our transport requests those child streams by default — `streamSubgraphs` is `true` unless you turn it off.
128
-
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.
132
+
That is the LangGraph JS SDK's own option name, passed straight through, and worth knowing if you are coming from the Python API, where the in-process `graph.stream()` equivalent is the `subgraphs=True` kwarg.
129
133
130
134
### The terminal-event hazard
131
135
132
136
A child graph terminates before the parent does, and a child's terminal event looks an awful lot like the parent's.
133
137
134
138
Without a namespace guard, that child terminal marker gets read as "the run finished" and closes out the parent's still-streaming assistant message.
135
-
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.
139
+
We guard it by refusing namespaced events as top-level terminal evidence, and there is a test that feeds a namespaced terminal marker in and asserts the parent message settles with outcome `interrupted` rather than success.
136
140
137
-
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.
141
+
If you ever write a transport against this stream yourself, that is the bug you will hit, and it will look like truncation rather than a namespace bug.
138
142
139
143
### Where child text goes
140
144
@@ -145,12 +149,13 @@ Ours took that path for a long time: child tokens merged into `messages()` unles
145
149
146
150
What made that bug expensive is that it self-corrected.
147
151
The parent's final `values` event rewrites the message list from authoritative graph state, so the stray bubble disappeared on its own once the run settled.
148
-
Assert on the finished DOM and everything looks right; watch the streaming pass and you'd see the child's notes appear and then vanish.
152
+
Assert on the finished DOM and everything looks right.
153
+
Watch the streaming pass and you would see the child's notes appear and then vanish.
149
154
A final-state test cannot catch it — we found it by watching a live model with the DOM under a polling probe.
150
155
151
156
The fix was to stop making it a decision at all.
152
157
A namespaced event belongs to its child, structurally: it feeds that child's `messages()` on the subagent stream and never merges into the parent transcript.
153
-
The opt-out flag is gone because there's nothing left to opt out of.
158
+
The opt-out flag is gone because there is nothing left to opt out of.
154
159
What the transcript shows at settle is decided by state — a shared `messages` key delivers the child's message through the final `values` sync; an isolated child schema means it never arrives.
155
160
`transcriptNodeNames` still exists for the genuinely separate problem of *top-level* side-effect nodes, like routers and title generators.
156
161
@@ -164,23 +169,23 @@ Marking a child running and routing its messages need no matching at all.
164
169
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.
165
170
It only runs for children whose state opens with a human message, and none of the graphs we ship reach it.
166
171
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.
167
-
The one wired in as a plain node doesn't keep a `messages` key in child state at all.
172
+
The one wired in as a plain node does not keep a `messages` key in child state at all.
168
173
Treat that path as untested rather than as the mechanism.
169
174
170
-
The general point survives, though, and it's the one worth carrying to any protocol.
175
+
The general point survives, though, and it is the one worth carrying to any protocol.
171
176
A consumer mapping child runs onto delegations is doing string matching unless the protocol gives it an id.
172
177
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.
173
178
174
179
One limit, though: only the _first_`tools:` segment of a namespace is read.
175
180
A subagent that itself delegates will have its inner events attributed to the outer tool call.
176
-
Nothing in this repo exercises deeper nesting, so don't build on it.
181
+
Nothing in this repo exercises deeper nesting, so do not build on it.
177
182
178
183
## When should you not split?
179
184
180
-
When there's no observable boundary to draw and no genuinely divergent state.
185
+
When there is no observable boundary to draw and no genuinely divergent state.
181
186
182
-
The cleanest evidence I have is a control group we didn't set out to build.
183
-
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.
187
+
The cleanest evidence I have is a control group we did not set out to build.
188
+
Our `cockpit/ag-ui/subagents` capability ships the same three-subagent feature as the LangGraph one — and it is a LangGraph `StateGraph` too, same framework, same orchestrator-plus-`task`-tool shape, same three roles, same cards in the UI — with no subgraph anywhere.
184
189
Its module docstring says so outright:
185
190
186
191
```text
@@ -189,49 +194,52 @@ structure, but each dispatch emits `subagent_activity` CUSTOM events
189
194
```
190
195
191
196
The thing that differs is the transport: AG-UI's already carries a first-class delegation event.
192
-
So so the specialists stayed a flat `async` helper and progress reaches the frontend as a custom event dispatched from the tool body.
197
+
So the specialists stayed a flat `async` helper and progress reaches the frontend as a custom event dispatched from the tool body.
193
198
194
-
The subgraph was never required by the feature. It was required by the transport.
199
+
The subgraph was never required by the feature.
200
+
It was required by the transport.
195
201
196
202
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.
197
-
What namespaces buy is that you don't have to.
203
+
What namespaces buy is that you do not have to.
198
204
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.
199
205
200
-
Staying flat wasn't free.
201
-
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.
206
+
Staying flat was not free.
207
+
There is no separate state schema to isolate anything into, and no child step sequence — every specialist gets the parent's shape, one LLM call wide.
202
208
What it bought was one fewer graph for a feature that renders identically.
203
209
204
-
That's the test I'd apply.
205
-
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.
210
+
For me, that is the test.
211
+
If your transport already has a way to say "a child is working right now," or your UI does not 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.
206
212
207
-
And splitting because a region of the graph _feels_ like a separate concern isn't a reason on its own.
213
+
And splitting because a region of the graph _feels_ like a separate concern is not a reason on its own.
208
214
A node is already a unit.
209
215
210
216
### So when does a split earn itself?
211
217
212
218
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.
213
219
214
220
Our `examples/ag-ui` demo runs on that same AG-UI transport, and it emits the same `subagent_activity` events from the tool body.
215
-
So it isn't buying observability; it already had it.
221
+
So it is not buying observability.
222
+
It already had it.
216
223
It compiles a child graph anyway.
217
224
218
225
Look at what the child is, though.
219
226
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.
220
227
221
-
And here's the part that took me a second read to see.
222
-
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.
228
+
And here is the part that took me a second read to see.
229
+
A custom child state schema does not discriminate at all: the two graphs I just used as observability evidence _also_ define their own child `TypedDict`s.
223
230
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.
224
231
225
-
So it's the control flow, not the schema.
232
+
So it is the control flow, not the schema.
226
233
A child that carries a `topic` string is a function call wearing a graph costume.
227
-
A child that loops until it's satisfied is a graph.
234
+
A child that loops until it is satisfied is a graph.
228
235
229
236
## Conclusion
230
237
231
238
Split when something outside the graph needs to see the child run as its own thing — a card, a progress panel, per-child streaming.
232
-
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.
233
-
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.
239
+
Split when the child has its own control flow — a loop, a branch, a stopping condition the parent does not have — and you are willing to own the mapping at both edges.
240
+
Do not split for tidiness, and do not 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.
234
241
235
242
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.
236
243
237
-
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.
244
+
If you have split a graph for a third reason — not observability, and not a child that is genuinely its own graph — I would like to hear it.
245
+
Those are the two I have been able to justify, and I doubt they are the only two that exist.
0 commit comments