Skip to content

Commit a54cc61

Browse files
committed
style(website): langgraph-subgraphs-when-to-split into Brian's 2026 register
1 parent 2a7e375 commit a54cc61

1 file changed

Lines changed: 55 additions & 47 deletions

File tree

apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx

Lines changed: 55 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ draft: false
1010

1111
Most people reach for a LangGraph subgraph expecting a _state_ boundary, and what they actually get is an _observable_ one.
1212

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.
1414
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.
1515

1616
## What does a subgraph actually give you?
1717

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.
1920

20-
Let's start with the canonical pattern, which is small.
21+
Start with the canonical pattern, which is small.
2122
Compile a child `StateGraph`, then add the compiled graph as a node in the parent:
2223

2324
```python
@@ -34,17 +35,17 @@ Two things change.
3435
The child runs as its own graph, with its own nodes and its own step sequence rather than being flattened into the parent's.
3536
And LangGraph emits the child's stream events under a namespace, so a consumer can tell parent output from child output.
3637

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.
3839

3940
If parent and child share `MessagesState`, the child appends to the same message list the parent is building.
4041
Nothing about `add_node` fenced anything off.
4142

4243
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.
4445

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.
4647
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.
4849

4950
That boundary is real, and none of it came from `compile()`.
5051
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
6162

6263
You can have all three without ever compiling a child graph, and you can compile a child graph and get none of them.
6364

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.
6566
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.
6768

6869
## Why do people really split?
6970

7071
In our own repo, the honest answer is: so the frontend can see the delegation.
7172

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.
7476

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:
7779

7880
```python
7981
# 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
8385
# SubagentTracker keys on to populate `agent.subagents()`.
8486
```
8587

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.
8790

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:
8993

9094
```text
9195
Plain `@tool` returning a synthesized "subagent" payload — Simpler graph
@@ -94,15 +98,15 @@ namespace events get emitted because no subgraph runs. The card would
9498
render empty. Rejected.
9599
```
96100

97-
Then there's the conversion.
101+
Then there is the conversion.
98102
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.
99103
A working feature was restructured so a UI card would appear.
100104

101105
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.
103107

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.
106110
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.
107111
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.
108112

@@ -116,7 +120,7 @@ Namespaced events — and nearly everything interesting downstream follows from
116120

117121
### What the wire looks like
118122

119-
Let's take it from the wire inward.
123+
Take it from the wire inward.
120124
The event type carries the namespace after a pipe, so the base type is the part before it:
121125

122126
```text
@@ -125,16 +129,16 @@ messages|tools:call-1 # child run dispatched by tool call "call-1"
125129
```
126130

127131
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.
129133

130134
### The terminal-event hazard
131135

132136
A child graph terminates before the parent does, and a child's terminal event looks an awful lot like the parent's.
133137

134138
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.
136140

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.
138142

139143
### Where child text goes
140144

@@ -145,12 +149,13 @@ Ours took that path for a long time: child tokens merged into `messages()` unles
145149

146150
What made that bug expensive is that it self-corrected.
147151
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.
149154
A final-state test cannot catch it — we found it by watching a live model with the DOM under a polling probe.
150155

151156
The fix was to stop making it a decision at all.
152157
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.
154159
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.
155160
`transcriptNodeNames` still exists for the genuinely separate problem of *top-level* side-effect nodes, like routers and title generators.
156161

@@ -164,23 +169,23 @@ Marking a child running and routing its messages need no matching at all.
164169
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.
165170
It only runs for children whose state opens with a human message, and none of the graphs we ship reach it.
166171
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.
168173
Treat that path as untested rather than as the mechanism.
169174

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.
171176
A consumer mapping child runs onto delegations is doing string matching unless the protocol gives it an id.
172177
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.
173178

174179
One limit, though: only the _first_ `tools:` segment of a namespace is read.
175180
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.
177182

178183
## When should you not split?
179184

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.
181186

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.
184189
Its module docstring says so outright:
185190

186191
```text
@@ -189,49 +194,52 @@ structure, but each dispatch emits `subagent_activity` CUSTOM events
189194
```
190195

191196
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.
193198

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.
195201

196202
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.
198204
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.
199205

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.
202208
What it bought was one fewer graph for a feature that renders identically.
203209

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.
206212

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.
208214
A node is already a unit.
209215

210216
### So when does a split earn itself?
211217

212218
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.
213219

214220
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.
216223
It compiles a child graph anyway.
217224

218225
Look at what the child is, though.
219226
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.
220227

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.
223230
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.
224231

225-
So it's the control flow, not the schema.
232+
So it is the control flow, not the schema.
226233
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.
228235

229236
## Conclusion
230237

231238
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.
234241

235242
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.
236243

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

Comments
 (0)