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.
By Ahmed R. · Last updated Aug 14, 2026 · LangGraph · Page #133
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
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.
# 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() 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
| Mode | Yields | Use for |
|---|---|---|
"values" | Full state after each graph step | Progress UI; showing state snapshots |
"updates" | Just the delta each node returned | Reactive UI; smaller payloads than values |
"messages" | (chunk, metadata) tuples — token-by-token LLM output | Chat UIs; streaming responses to browser |
"debug" | Verbose per-step debugging events | Development / diagnosing graph flow |
"custom" | Whatever you emit via StreamWriter | App-specific progress signals |
["updates", "messages"] | Multiple streams interleaved | Chat UIs with sidebar progress |
astream_events v2 event types (most useful)
| Event name | What it means |
|---|---|
on_chain_start / on_chain_end | A node started or finished |
on_chat_model_start / on_chat_model_end | LLM call started or finished (full response in end.output) |
on_chat_model_stream | Per-token LLM chunk (data.chunk is AIMessageChunk) |
on_tool_start / on_tool_end | Tool invocation start/end |
on_custom_event | Emitted via dispatch_custom_event |
on_retriever_start / on_retriever_end | Retriever ran (RAG graphs) |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 22%Wrong
stream_modefor the use case. Calledastreamwith default ("updates") and expected token chunks. Chat UIs need"messages". - 18%Missing
versiononastream_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 emitson_chat_model_stream. Nothing matches; console is empty. - 11%Using
astream_login 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.astreaminside; events don't propagate cleanly withoutwith_config({"tags": [...]}). - 8%Streaming from a non-streaming model. Model configured with
streaming=Falseor the provider client doesn't support SSE. Full response arrives as one chunk. - 8%Sync
streamwhere async is needed. Mixed async graph invoked via syncstream; either raises or silently blocks the event loop.
How to fix it
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.
# ✅ 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)
return StreamingResponse(chunk_generator(), media_type="text/event-stream"). Serialize chunks to JSON strings inside the generator; the browser reconstructs the message.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.
# ✅ 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
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.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.
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}")
Prevention checklist
- Always pass
version="v2"toastream_events. Never rely on v1 defaults. - Migrate off
astream_log— it's deprecated in favor ofastream_events v2. - Pick
stream_modedeliberately:"messages"for chat,"values"for progress,["updates", "messages"]for both. - Filter
astream_eventsby 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
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.
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.
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.
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.
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.