← Back to Home

LangGraph Streaming: Three Entry Points Tested

LangGraphStreamingTroubleshooting

TL;DR: LangGraph hands you three streaming entry points, and they are not three spellings of the same thing. stream() and astream() take a state-machine view — they emit graph-runtime data according to the stream_mode you picked. astream_events() takes a callback view — it forwards raw LangChain callback events (on_chain_start, on_chat_model_stream, on_chain_end, …). I tested this on langgraph 1.2.16 with langchain-core 1.6.12, and the single most counter-intuitive finding is this: the token-level output of stream_mode="messages" is not the graph streaming — it is a StreamMessagesHandler riding on the callback chain. That one fact explains three separate traps: why a typo in the mode name silently yields zero chunks, why tokens inside a subgraph do not bubble up by default, and why the nostream tag blocks tokens but not the final message.

1. Getting the three entry points straight

The official Streaming page opens with a line that is easy to skip:

"For new applications, we recommend event streaming—the typed-projection API introduced in LangGraph v1.2."

So LangChain positions stream(mode=...) as the interface for when you "need direct access to graph-runtime events or specific stream-mode output", and points new applications at the event streaming API added in v1.2. That is not a deprecation notice — most live projects still use stream modes — but it does mean you should know both roads exist before you pick one.

The semantic split can be summarized like this:

Entry pointViewUnit emittedWhat you receive
stream()State machineset by stream_modevalues / updates / checkpoints / tasks / debug / messages / custom
astream()State machine (async)samesame, without blocking the event loop
astream_events()CallbackLangChain eventson_chain_ / on_chat_model_ event dicts

astream() is not "stream, but inside async". The stream_mode semantics are identical — I ran the same graph with the same mode and got 13 chunks from both stream() and astream(), with no difference at all. The real difference is *how nodes run*: a synchronous def node gets handed to a thread pool by LangGraph, while an async def node actually runs on the event loop. There are numbers for that in the Troubleshooting section below.

2. What the seven stream modes emit (measured locally)

Every row below comes from the same graph (START → chat → END) and the same input; only stream_mode changed. The test model is a hand-written BaseChatModel subclass that slices a 45-character string into 4-character pieces, which makes the token count predictable: ceil(45/4) = 12 slices.

class FakeChatModel(BaseChatModel):
    text: str = "LangGraph streams tokens one chunk at a time."  # 45 chars
    per: int = 4
    def _generate(self, messages, stop=None, run_manager=None, **kwargs):
        return ChatResult(generations=[ChatGeneration(message=AIMessage(content=self.text))])
    def _stream(self, messages, stop=None, run_manager=None, **kwargs):
        for i in range(0, len(self.text), self.per):
            yield ChatGenerationChunk(message=AIMessageChunk(content=self.text[i:i+self.per]))
stream_modechunkschunk typemeaning
values2dictfull state snapshot after each step
updates1dictdelta for each step (keyed by node name)
checkpoints0—⚠️ silently returns 0 with no checkpointer
tasks2dicttask start/finish events, with error and result
debug2dictkeys payload / step / timestamp / type
messages13tuple(message_chunk, metadata) 2-tuple
custom0—nothing unless a node calls get_stream_writer()

That checkpoints row deserves its own paragraph. The official docs say "Requires a checkpointer" — but the code does not check, does not raise, does not warn. Pass stream_mode="checkpoints" to a graph compiled without one and you get zero chunks and a loop that finishes normally. Add compile(checkpointer=MemorySaver()) and it jumps to 3 chunks, with keys config / metadata / next / parent_config / tasks / values.

3. The version flag: v1 tuple length changes with the combination

Both stream() and astream() take a version argument that defaults to 'v1'. The official migration table spells out the difference, but if you have only ever read single-mode examples, the first combinatorial run is confusing:

Scenariov1 (default)v2
Single moderaw data (dict)StreamPart dict: type / ns / data
Multiple modes(mode, data) 2-tuplesame dict, branch on chunk["type"]
Subgraphs(namespace, data) 2-tuplesame dict, inspect chunk["ns"]
Multiple modes + subgraphs(namespace, mode, data) 3-tuplesame dict

I confirmed the last row locally: with a parent graph wrapping a subgraph, stream_mode=["updates", "messages"], subgraphs=True produces tuples whose length set is {3} — ("agent:078e6684-...",), then "messages", then (AIMessageChunk, {...}). With subgraphs=False the length set is {2}, and the chunk count jumps from 2 to 15 when subgraphs are switched on.

Which means this "generic-looking" unpacking silently breaks the moment you add a subgraph or a second mode:

# correct only for "single mode + no subgraphs"
for chunk in graph.stream(inputs, stream_mode="messages"):
    msg, meta = chunk        # with subgraphs on, chunk is a 3-tuple -> ValueError

Switching to v2 removes the branching entirely:

for part in graph.stream(inputs, stream_mode=["updates", "messages"],
                         subgraphs=True, version="v2"):
    if part["type"] == "messages":
        msg, meta = part["data"]
        print(msg.content, end="", flush=True)

4. The truth about token-level output: the callback streams, not the graph

This is the core of the article.

Open langgraph/pregel/_messages.py and you find that stream_mode="messages" is implemented as a callback handler:

class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
    """A callback handler that implements stream_mode=messages."""

And the registration in main.py is blunt about it:

if "messages" in stream_modes:
    run_manager.inheritable_handlers.append(
        messages_handler_cls(stream.put, subgraphs, parent_ns=...)
    )

It is registered on inheritable_handlers — which is what propagates it to every LangChain call inside the graph. That is exactly why the docs can say something that looks odd at first:

> "Note that message events are emitted even when the LLM is run using .invoke rather than .stream"

My run: the node calls model.invoke(...), which returns the whole string at once, yet stream_mode="messages" still yielded 13 chunks — 12 AIMessageChunk pieces ('Lang', 'Grap', 'h st', …, '.') plus one trailing chunk with content=''. Concatenating the 12 slices reproduces all 45 characters exactly. Non-empty chunks account for 12/13 = 92.3%. That trailing empty chunk is an end-of-stream signal, not dirty data — but it will shift any progress bar driven by chunk count by one notch.

Correspondingly, the same mechanism explains two more easy-to-hit behaviours.

Subgraph tokens do not bubble up by default. The docs put it as "Without subgraphs=True, stream_mode='messages' on the parent graph will not emit token chunks from the inner agent's LLM calls". Measured: with a parent wrapping a subgraph, the parent-side token chunk count is 0 when subgraphs=False and 13 when subgraphs=True. The docs also add a very practical note — create_agent returns a compiled graph, so adding it as a node turns it into a subgraph, and "this often shows up only after wrapping".

The nostream tag blocks tokens, not messages. The docs say invocations tagged nostream "still run and produce output; their tokens are simply not emitted in messages mode". I set the model to .with_config({"tags": ["nostream"]}) and watched the chunk count drop from 13 to 1 — and that single chunk is an AIMessage whose content is the complete 45-character string. In other words: it suppresses the 12 intermediate tokens, but the complete message the node returns still enters the stream. If your frontend renders incrementally *and* consumes the final message, this is where duplicate output comes from.

Four rules of thumb

Compressing the sections above into four lines you can act on:

1. Tokens only: prefer astream_events(version="v2") filtered on on_chat_model_stream, or stream_mode="messages". If the graph has subgraphs you must add subgraphs=True, or you will receive zero tokens.

2. State only: updates for deltas, values for snapshots. Do not reach for debug out of convenience — every chunk carries the full payload, and the measured 2 chunks are far heavier than the other two.

3. Several modes at once: go straight to version="v2" and skip the tuple-length branching.

4. When debugging, count chunks first: zero chunks does not mean the graph did not run. Nine times out of ten it is a misspelled mode name or a missing checkpointer — and neither one raises.

Troubleshooting: 6 real errors

All six were reproduced on this machine; error text is quoted verbatim. There are no affiliate links in this article.

Error 1: a typo in stream_mode silently yields 0 chunks

for c in graph.stream(inputs, stream_mode="tokens"):   # meant "messages"
    ...
# no exception, no warning, loop body never runs

I tried five wrong spellings — "tokens", "message" (missing s), "Messages" (wrong case), "update", "values " (trailing space) — and all five returned 0 chunks without raising anything. An unknown mode simply matches none of the if mode in stream_modes branches. When debugging, print(len(list(...))) first; do not blame the model.

Error 2: checkpoints mode without a checkpointer silently yields 0 chunks

Same root cause as Error 1, but easier to miss because the docs *do* mention the requirement while the code never enforces it. The test is simply whether you passed a checkpointer to compile().

Error 3: TypeError: tuple indices must be integers or slices, not str

for chunk in graph.stream(inputs, stream_mode="messages"):
    print(chunk["content"])     # wrong: chunk is (message_chunk, metadata)

Messages-mode chunks are 2-tuples, not dicts. The correct form is msg, meta = chunk. And as noted in section 3, this degrades further into an unpacking-count mismatch once subgraphs=True is added.

Error 4: TypeError: 'async_generator' object is not iterable

for e in graph.astream_events(inputs, version="v2"):    # wrong: for, not async for
    ...

astream_events is an async generator and can only be consumed with async for. In synchronous code, reach for stream(..., stream_mode=...) instead.

Error 5: TypeError: 'async for' requires an object with __aiter__ method, got coroutine

async for e in graph.astream_events(inputs, version="v3"):   # wrong: missing await
    ...
# TypeError: 'async for' requires an object with __aiter__ method, got coroutine

version="v3" returns a coroutine, not an async generator. The signature states it plainly: -> "AsyncIterator[StreamEvent] | Awaitable[Any]". The correct form awaits once first:

stream = await graph.astream_events(inputs, version="v3")
async for e in stream:
    ...

Measured: after the await you get an AsyncGraphRunStream producing 18 events, along with LangChainBetaWarning: The v3 streaming protocol on Pregel is experimental. — v3 is an experimental protocol today, so do not build production paths on it.

Error 6: the event loop freezes on time.sleep() inside async def

There is no exception here, only a UI that stops updating. I ran a 0.60-second sleep against a 50-millisecond heartbeat task and counted the heartbeats:

Node formheartbeatsverdict
def node + time.sleep(0.6)10not blocked (LangGraph uses a thread pool)
async def node + time.sleep(0.6)2blocked
async def node + await asyncio.sleep(0.6)10not blocked

(The unblocked expectation is about 12; the two runs landing at 10 is scheduling overhead.) async def only lets a function enter the event loop — it does not make the blocking calls inside it asynchronous. A time.sleep in an async node freezes the whole loop, and streaming stops with it. Use await for I/O, and hand CPU-bound work to a thread pool explicitly.

Where this article stops

Related reading

👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference

👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation

📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews

🔗 Recommended Tools

These are carefully selected tools. Using our affiliate links supports us to keep producing quality content:

☁️ DigitalOcean Cloud ⚡ Vultr VPS 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books 🤖 Xiaomi MiMo Platform
← Back to Home