Skip to content

Commit a1c8ac2

Browse files
bloveclaude
andauthored
feat(website): add 'LangGraph Subgraphs: When to Split a Graph and When Not To' blog post (#839)
* docs: design spec for LangGraph subgraphs blog post Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: implementation plan for LangGraph subgraphs post Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(website): add 'LangGraph Subgraphs' blog post * docs: handoff for the GSC-driven blog sequence Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(website): correct attribution + streamSubgraphs claims in subgraphs post Applies blocking review findings: - Attribution is structural (namespace segment IS the tool call id), not a heuristic; the description ladder is unreachable in both shipped graphs. - streamSubgraphs is the LangGraph JS SDK's own option name, not a naming trap; re-aimed at the Python subgraphs=True kwarg. - Test asserts outcome 'interrupted', not phase. - Concede non-observability split reasons, then reframe. - Quote evidence in fences; add controlled-experiment framing for AG-UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): editorial pass on subgraphs post - Drop the false 'no independent checkpointing' cost: both subgraph children compile bare, while the flat AG-UI graph is the one with MemorySaver. - Handle the checkpoint objection honestly — child checkpointing rides the namespace under a parent checkpointer, which is the thesis again. - State that the AG-UI control group is itself a LangGraph StateGraph, so only one variable differs. - Answer the 'just emit custom events' objection. - Quote the rejected design-doc alternative in a fence. - Trim demonstrative tic (That's 13->7, Here's 7->3), restore Let's transitions, cut duplicated state-isolation and heuristic statements. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): account for the examples/ag-ui counterexample Spec review found a fifth graph the post's thesis didn't survive: examples/ag-ui compiles a child StateGraph on a transport that ALREADY emits subagent_activity events, so the split isn't buying observability. It splits because the child has its own ResearchState (topic + iterations) and its own agent->tools loop with an iteration cap. - Add 'So when does a split earn itself?' — two demos on one transport, one flat and one split, with child shape as the variable. - Stop claiming no such case exists in the closing. - Name cockpit/ag-ui/subagents so it isn't read as examples/ag-ui. - Last-resort rung accepts pending OR running (tracker:165-169). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): sharpen the split discriminator to control flow Re-review findings: - A custom child state schema doesn't discriminate: examples/chat and cockpit/chat/subagents both define child TypedDicts yet are single-node straight lines. Only control flow separates the earned split. - Drop hand-rolled streaming as a cost of flatness — examples/ag-ui pays it too, so it's a cost of the AG-UI transport, not of staying flat. - Reconcile the two conflicting 'one variable' claims. - 'neither graph we ship' was stale now that three tool-dispatch a child. - Closing no longer claims only two justifications exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): re-check subgraphs post against the landed cockpit example cockpit/langgraph/subgraphs (#838) now ships a conditional plain-node subgraph whose child state deliberately omits `messages`. - Fix the ladder-unreachability reason: it no longer holds for every graph by way of an empty message list — the plain-node child has no messages key at all. - Cite that example as first-hand evidence for 'isolation is something you design', which until now the post asserted without a concrete instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: refresh blog-sequence handoff for shipped post #1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f4c7b80 commit a1c8ac2

4 files changed

Lines changed: 529 additions & 0 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

Comments
 (0)