LangGraph astream_events vs astream_log Stream Mode Errors — Fix Guide (2026)
Streaming · astream_events & modes Severity: Medium

LangGraph astream_events vs astream_log Stream Mode Errors

LangGraph has five stream modes and two event APIs, and picking the wrong combination silently drops the events you actually wanted. Here's the map of what each API emits, when to use it, and the three bugs that account for most streaming pain.

TL;DRUse astream(stream_mode="messages") when you want token-by-token LLM chunks; use astream(stream_mode="values") for full state after each step; use astream_events(version="v2") when you need the fine-grained event stream (chain/tool starts, chunks, ends). astream_log is deprecated — migrate to astream_events v2.

Real error messages you'll see

Version required — astream_events without version
Version required — astream_events without version
ValueError: astream_events requires an explicit `version='v1'` or `version='v2'` argument.
  at Pregel.astream_events()
# LangGraph refuses to guess; pass version="v2" for current apps.
No token chunks — wrong stream_mode
No token chunks — wrong stream_mode
# app.astream({...}) yields one big dict per node, no partial tokens
async for chunk in app.astream({"messages": [...]}, {"configurable": {"thread_id": "x"}}):
    print(chunk)     # {"agent": {"messages": [AIMessage(complete)]}}
# Fix: pass stream_mode="messages" to get token-by-token LLM output.
DeprecationWarning — astream_log going away
DeprecationWarning — astream_log going away
DeprecationWarning: astream_log() is deprecated and will be removed in a future release. Migrate to astream_events(version="v2") which supersedes it.
  at Pregel.astream_log()
# Rewrite the consumer to filter on events like "on_chat_model_stream" instead of RunLogPatch.

stream_mode reference

ModeYieldsUse for
"values"Full state after each graph stepProgress UI; showing state snapshots
"updates"Just the delta each node returnedReactive UI; smaller payloads than values
"messages"(chunk, metadata) tuples — token-by-token LLM outputChat UIs; streaming responses to browser
"debug"Verbose per-step debugging eventsDevelopment / diagnosing graph flow
"custom"Whatever you emit via StreamWriterApp-specific progress signals
["updates", "messages"]Multiple streams interleavedChat UIs with sidebar progress

astream_events v2 event types (most useful)

Event nameWhat it means
on_chain_start / on_chain_endA node started or finished
on_chat_model_start / on_chat_model_endLLM call started or finished (full response in end.output)
on_chat_model_streamPer-token LLM chunk (data.chunk is AIMessageChunk)
on_tool_start / on_tool_endTool invocation start/end
on_custom_eventEmitted via dispatch_custom_event
on_retriever_start / on_retriever_endRetriever ran (RAG graphs)

Root causes (ranked by frequency)

Based on LangGraph developer reports; percentages sum to 100%.

  • 22%
    Wrong stream_mode for the use case. Called astream with default ("updates") and expected token chunks. Chat UIs need "messages".
  • 18%
    Missing version on astream_events. LangGraph refuses to guess between v1 and v2 event shapes.
  • 14%
    Filtering by wrong event name. Filter for on_llm_stream (LangChain classic) but LangGraph emits on_chat_model_stream. Nothing matches; console is empty.
  • 11%
    Using astream_log in new code. Deprecated API; the event shapes are legacy RunLogPatch and the docs increasingly assume v2.
  • 10%
    Consumer backpressure. The client can't keep up with token chunks; buffers fill and the graph blocks. Add rate-limiting or drop mid-stream chunks intentionally.
  • 9%
    Nested runnables not tagged. Custom node has an llm.astream inside; events don't propagate cleanly without with_config({"tags": [...]}).
  • 8%
    Streaming from a non-streaming model. Model configured with streaming=False or the provider client doesn't support SSE. Full response arrives as one chunk.
  • 8%
    Sync stream where async is needed. Mixed async graph invoked via sync stream; either raises or silently blocks the event loop.

How to fix it

Fix #1

Pick the right stream_mode for your UI

Fixes "no token chunks" and "too much data".

The default stream_mode="updates" emits one dict per node completion — good for progress bars, useless for token-by-token chat. Use "messages" for chat UIs, "values" when you need full snapshots, or pass a list to get multiple streams interleaved for a chat + progress UI.

stream_modes.pypython
# ✅ TOKEN STREAMING for chat UIs — stream_mode="messages"
config = {"configurable": {"thread_id": "chat-1"}}

async for chunk, metadata in app.astream(
    {"messages": [("user", "Explain LangGraph in one paragraph.")]},
    config,
    stream_mode="messages",       # <-- token-by-token
):
    # chunk is an AIMessageChunk; metadata identifies the source node
    if chunk.content:
        print(chunk.content, end="", flush=True)


# ✅ FULL STATE after each step — stream_mode="values"
async for state in app.astream(
    {"messages": [("user", "Hi")]},
    config,
    stream_mode="values",
):
    # state is the full State dict after each step
    print(f"messages so far: {len(state['messages'])}")


# ✅ DELTA UPDATES — stream_mode="updates" (the default)
async for update in app.astream(
    {"messages": [("user", "Hi")]},
    config,
    # stream_mode="updates" (implicit)
):
    # update is {node_name: partial_state_returned_by_that_node}
    for node_name, delta in update.items():
        print(f"{node_name} returned:", delta)


# ✅ MULTIPLE MODES interleaved — pass a list
async for mode, data in app.astream(
    {"messages": [("user", "Hi")]},
    config,
    stream_mode=["updates", "messages"],
):
    if mode == "messages":
        chunk, meta = data
        # forward token to browser
    elif mode == "updates":
        # forward progress event to sidebar
        for node_name, delta in data.items():
            print(f"[progress] {node_name}")


# ✅ CUSTOM events — emit progress signals from inside a node
from langgraph.config import get_stream_writer

async def slow_node(state):
    writer = get_stream_writer()
    writer({"step": "starting", "detail": "loading corpus"})
    # ... slow work ...
    writer({"step": "midway", "progress": 0.5})
    # ... more work ...
    return {"messages": [...]}

# Consumer picks these up with stream_mode="custom"
async for evt in app.astream(inp, config, stream_mode="custom"):
    print("custom:", evt)
Note: For SSE responses in FastAPI, pipe the async generator directly: return StreamingResponse(chunk_generator(), media_type="text/event-stream"). Serialize chunks to JSON strings inside the generator; the browser reconstructs the message.
Fix #2

Use astream_events(version="v2") for fine-grained events

Fixes deprecated astream_log and gives full observability.

astream_events is the modern successor to astream_log. It emits typed events for every runnable — chain starts, model streams, tool calls, retrievers. Always pass version="v2". Filter by event["event"] to catch only what you need.

astream_events_v2.pypython
# ✅ Full event stream — filter for what you care about
config = {"configurable": {"thread_id": "chat-1"}}

async for event in app.astream_events(
    {"messages": [("user", "Search LangGraph and summarize")]},
    config,
    version="v2",                # <-- REQUIRED in current LangGraph
):
    kind = event["event"]
    name = event.get("name")

    if kind == "on_chat_model_stream":
        # Token-by-token LLM output — the chunk is an AIMessageChunk
        chunk = event["data"]["chunk"]
        if chunk.content:
            print(chunk.content, end="", flush=True)

    elif kind == "on_tool_start":
        print(f"\n[tool {name} starting: {event['data'].get('input')}]")

    elif kind == "on_tool_end":
        # event["data"]["output"] is the tool return value
        print(f"[tool {name} done]")

    elif kind == "on_chain_start" and event.get("metadata", {}).get("langgraph_node"):
        print(f"\n>> node {event['metadata']['langgraph_node']} started")


# ✅ Filter by tags — cleaner than event-type switches for large graphs
from langchain_core.runnables import RunnableConfig

async def responder(state, config: RunnableConfig):
    # Tag the LLM call so events are easy to isolate
    tagged_llm = model.with_config({"tags": ["main_response"]})
    reply = await tagged_llm.ainvoke(state["messages"], config)
    return {"messages": [reply]}


# Consumer filters by tag — only main_response events reach the client
async for event in app.astream_events(inp, config, version="v2"):
    if "main_response" in event.get("tags", []):
        if event["event"] == "on_chat_model_stream":
            yield event["data"]["chunk"].content


# ✅ Custom events — dispatch_custom_event from inside any node
from langchain_core.callbacks import adispatch_custom_event

async def researcher(state, config):
    docs = await search.ainvoke(state["query"])
    await adispatch_custom_event(
        "docs_retrieved",
        {"count": len(docs), "sample_titles": [d.title for d in docs[:3]]},
        config=config,
    )
    return {"docs": docs}

# Consumer sees these as on_custom_event
async for event in app.astream_events(inp, config, version="v2"):
    if event["event"] == "on_custom_event" and event["name"] == "docs_retrieved":
        print("retrieved:", event["data"])


# ❌ DEPRECATED — don't use in new code
# async for log_patch in app.astream_log(inp, config):
#     print(log_patch.ops)         # RunLogPatch — legacy shape
Note: v1 and v2 event shapes are not compatible. If you're migrating from astream_log or v1, filter events by name and rebuild the parsing — the payload shapes changed. See the LangGraph migration guide for the exact mappings.
Fix #3

Handle consumer backpressure — never buffer unbounded

Prevents the graph from blocking or the process from OOMing on slow clients.

When your consumer (a browser over WebSocket, a downstream service, a file writer) can't keep up with the event stream, chunks pile up in memory. LangGraph itself doesn't apply backpressure — you own that. Two patterns work: (a) drop mid-stream chunks that don't matter, or (b) use a bounded queue and let the graph pause naturally.

backpressure.pypython
import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json


api = FastAPI()


# ✅ PATTERN 1 — bounded queue, backpressure via asyncio.Queue
@api.post("/chat-stream/{thread_id}")
async def chat_stream(thread_id: str, message: str):
    config = {"configurable": {"thread_id": thread_id}}

    # Bounded queue — producer blocks when queue is full
    queue: asyncio.Queue = asyncio.Queue(maxsize=100)

    async def produce():
        try:
            async for chunk, meta in api.state.graph.astream(
                {"messages": [("user", message)]}, config, stream_mode="messages"
            ):
                if chunk.content:
                    await queue.put(chunk.content)   # blocks when queue full
        finally:
            await queue.put(None)                    # sentinel: done

    async def consume():
        producer_task = asyncio.create_task(produce())
        try:
            while True:
                item = await queue.get()
                if item is None:
                    break
                yield f"data: {json.dumps({'chunk': item})}\n\n"
        finally:
            producer_task.cancel()

    return StreamingResponse(consume(), media_type="text/event-stream")


# ✅ PATTERN 2 — drop mid-stream chunks under load
async def rate_limited_stream(app, inp, config, max_chunks_per_sec: int = 30):
    """Emit at most N chunks per second; drop intermediate ones."""
    import time
    last = 0.0
    min_interval = 1.0 / max_chunks_per_sec

    async for chunk, meta in app.astream(inp, config, stream_mode="messages"):
        now = time.perf_counter()
        if now - last < min_interval:
            continue                        # drop this chunk
        last = now
        yield chunk


# ✅ PATTERN 3 — coalesce chunks for lower per-message overhead
async def coalesced_stream(app, inp, config, flush_ms: int = 100):
    """Buffer chunks for up to flush_ms and emit the joined content."""
    buffer: list[str] = []
    last_flush = 0.0

    import time
    async for chunk, meta in app.astream(inp, config, stream_mode="messages"):
        if chunk.content:
            buffer.append(chunk.content)
        now = time.perf_counter() * 1000
        if now - last_flush >= flush_ms and buffer:
            yield "".join(buffer)
            buffer.clear()
            last_flush = now

    if buffer:
        yield "".join(buffer)


# ✅ WebSocket disconnect — cancel the producer
from fastapi import WebSocket, WebSocketDisconnect

@api.websocket("/ws/chat/{thread_id}")
async def ws_chat(websocket: WebSocket, thread_id: str):
    await websocket.accept()
    config = {"configurable": {"thread_id": thread_id}}
    while True:
        try:
            msg = await websocket.receive_text()
        except WebSocketDisconnect:
            return

        task = asyncio.create_task(_stream_reply(websocket, msg, config))
        try:
            await task
        except asyncio.CancelledError:
            pass


async def _stream_reply(ws, msg, config):
    try:
        async for chunk, meta in api.state.graph.astream(
            {"messages": [("user", msg)]}, config, stream_mode="messages"
        ):
            if chunk.content:
                await ws.send_text(chunk.content)
    except Exception as e:
        await ws.send_text(f"[error] {e}")
Note: For chat UIs, the coalesce pattern (Pattern 3) noticeably improves perceived smoothness — 100ms flushes are visually indistinguishable from per-token streaming and cut network round-trips 10-30×.

Prevention checklist

  • Always pass version="v2" to astream_events. Never rely on v1 defaults.
  • Migrate off astream_log — it's deprecated in favor of astream_events v2.
  • Pick stream_mode deliberately: "messages" for chat, "values" for progress, ["updates", "messages"] for both.
  • Filter astream_events by event name and metadata — don't process every event.
  • Tag your model calls with .with_config({"tags": [...]}) so consumers can filter by intent.
  • Apply backpressure at the consumer — bounded queues or explicit drops. LangGraph doesn't do this for you.
  • Coalesce token chunks into ~100ms batches for chat UIs — smoother and cheaper than raw per-token.

Frequently asked questions

Should I use astream, astream_events, or astream_log?

astream for graph-level output (values, updates, message chunks). astream_events v2 when you need per-runnable events (tool starts, custom events, chain lifecycle). astream_log is deprecated — do not use in new code. Most chat apps only need astream(stream_mode="messages"); add astream_events when you build observability or fine-grained UI.

Why do I get one big chunk instead of streaming tokens?

Three checks. First: are you using stream_mode="messages" (or astream_events with on_chat_model_stream)? Default is "updates", which emits one dict per node. Second: is the model configured with streaming=True? Some providers require it explicitly. Third: does the provider support SSE? A few small models buffer server-side and only emit the completed response.

Can I stream from inside a tool?

Tools return synchronously in the current LangGraph API — the tool completes, then the ToolMessage flows into state. If you need intermediate updates during a long tool run, dispatch custom events with adispatch_custom_event from inside the tool, and consume them via astream_events. That gives you streaming progress without changing the tool's return contract.

Do stream chunks land in state?

The full final message lands in state via add_messages; the intermediate chunks do not. Streaming is a view into what the model is producing right now; state carries the completed result. If you need to persist partial output (say, for a "cancel mid-response" feature), buffer chunks in your consumer and write to state manually via update_state.

Is astream_events safe to use in production?

Yes — it's the recommended API. v2 has been stable since LangGraph 0.2 and is what LangSmith's tracing consumes. The main gotcha is the volume: a graph with many nodes emits many events, so filter early and don't log every event blindly. Also, custom events dispatched from inside tools show up here; use consistent naming so consumers can filter reliably.

Related errors