Streaming
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against langchain 1.4.3, langchain-core 1.6.5 and langgraph 1.2.12 on Python 3.12, with
FastAPI 0.141.1, Uvicorn 0.54.0 and HTTPX 0.28.1 for the server examples and @langchain/react 1.2.0, langchain
1.5.14 and TypeScript 7.0.2 for the browser examples, as verified on 2026-09-29. The running example is
DocsAssistant, which answers questions over this site’s documentation using Ollama’s llama3.1:8b chat model and
the nomic-embed-text embedding model over the pgvector store built in
Integrating with LangChain. Ollama was not available while
the snippets were checked, so every output shown here was produced by running the code against the real packages
with a small scripted fake chat model and a stub search_docs tool; the SSE output further down was captured from a
real Uvicorn process serving the endpoint over HTTP. A live model would phrase things differently and split its
chunks differently, but the shape of the events is the same.
A language model takes seconds to finish an answer and milliseconds to start one. Streaming closes that gap: the user sees the first words, the tool the agent is calling and the step it is on while the run is still in flight, which is the difference between "it is thinking" and "it is broken". The interaction design side — what to show, when to stay quiet, how to fail — is covered in Agent UX. This page is the mechanics: what LangChain and LangGraph can stream, how to push it over HTTP, and how a browser reads it.
Setup: a scripted model and a stub tool
Every example imports the same two small files. fake.py is a chat model that first asks for the search_docs tool
and then, once it sees the tool result, answers word by word, so the messages mode has real chunks to show:
"""A scripted, streaming fake chat model for the streaming examples."""
from collections.abc import Iterator
from typing import Any
from langchain_core.callbacks import CallbackManagerForLLMRun
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
class ScriptedChat(BaseChatModel):
"""Calls search_docs first, then answers with the tool result, streaming word by word."""
@property
def _llm_type(self) -> str:
return "scripted-fake"
def bind_tools(self, tools, **kwargs): # tools are ignored: the script decides
return self
def _reply(self, messages: list[BaseMessage]) -> AIMessage:
last = messages[-1]
if isinstance(last, ToolMessage):
return AIMessage(content=f"According to the docs: {last.content}")
return AIMessage(
content="",
tool_calls=[{"name": "search_docs", "args": {"query": "HNSW ef_search"}, "id": "call_1"}],
)
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
return ChatResult(generations=[ChatGeneration(message=self._reply(messages))])
def _stream(self, messages: list[BaseMessage], stop=None,
run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any) -> Iterator[ChatGenerationChunk]:
reply = self._reply(messages)
if reply.tool_calls:
yield ChatGenerationChunk(message=AIMessageChunk(content="", tool_call_chunks=[
{"name": "search_docs", "args": '{"query": "HNSW ef_search"}', "id": "call_1", "index": 0}]))
return
for word in reply.text.split(" "):
yield ChatGenerationChunk(message=AIMessageChunk(content=word + " "))
agent.py builds the agent with create_agent (see
Agents with create_agent). The tool announces its progress through
get_stream_writer, which is how a tool talks to the custom stream mode:
from langchain.agents import create_agent
from langchain.tools import tool
from langgraph.config import get_stream_writer
from fake import ScriptedChat
@tool
def search_docs(query: str) -> str:
"""Search the documentation and return the best matching passage."""
writer = get_stream_writer()
writer({"status": "searching", "query": query})
writer({"status": "found", "hits": 3})
return "ef_search is the query-time candidate list size (default 40)."
agent = create_agent(ScriptedChat(), tools=[search_docs], system_prompt="You are DocsAssistant.")
To run against Ollama, replace ScriptedChat() with ChatOllama(model="llama3.1:8b") from langchain_ollama; the
streaming code below does not change. See LangChain — Streaming for the agent-level view of the same API.
Stream modes
A compiled graph — and therefore any agent from create_agent — has stream and astream. Both take a
stream_mode, either one value or a list, that chooses what is emitted:
| Mode | What each chunk is | Reach for it to |
|---|---|---|
|
The full state after each step |
Render the whole conversation or state, and replace the UI with each snapshot |
|
Only the keys a node just changed, keyed by node name |
Show progress ("the model answered", "the tools ran") without re-sending state |
|
A |
Print the answer as it is generated |
|
Whatever a node or tool passes to |
Emit your own progress signals, such as "found 3 hits" |
LangGraph also has checkpoints, tasks and debug modes, aimed at tooling rather than end-user UIs (the
checkpoints and tasks modes require a checkpointer); they are described in the
LangGraph streaming guide.
One output shape: version="v2"
With the default output format a chunk’s shape depends on how many modes you asked for and whether you set
subgraphs=True. Passing version="v2" (LangGraph 1.1 or later) removes that: every chunk is a dictionary with
type (the mode), ns (the subgraph namespace, an empty tuple for the root graph) and data (the payload).
Everything on this page uses it, and it is the format the FastAPI endpoint below is built on.
values and updates
from agent import agent
inputs = {"messages": [{"role": "user", "content": "What does ef_search do?"}]}
for part in agent.stream(inputs, stream_mode="values", version="v2"):
print("values ", [type(m).__name__ for m in part["data"]["messages"]])
for part in agent.stream(inputs, stream_mode="updates", version="v2"):
for node, update in part["data"].items():
print("updates", node, "->", [type(m).__name__ for m in update["messages"]])
Output:
values ['HumanMessage']
values ['HumanMessage', 'AIMessage']
values ['HumanMessage', 'AIMessage', 'ToolMessage']
values ['HumanMessage', 'AIMessage', 'ToolMessage', 'AIMessage']
updates model -> ['AIMessage']
updates tools -> ['ToolMessage']
updates model -> ['AIMessage']
values emits the growing message list once per step, so the last chunk is the final state. updates names the
node that produced each change: the model node asked for the tool, the tools node ran it, the model node answered.
See LangGraph — Streaming.
messages and custom
messages is the mode behind "typing" animations. Its payload is the message chunk plus a metadata dictionary
saying which node and step produced it. It also carries the ToolMessage when a tool returns, and empty chunks
that hold only tool-call fragments, so a consumer that prints text filters on the type and on non-empty text:
from langchain_core.messages import AIMessageChunk
from agent import agent
inputs = {"messages": [{"role": "user", "content": "What does ef_search do?"}]}
for part in agent.stream(inputs, stream_mode=["messages", "custom"], version="v2"):
if part["type"] == "messages":
chunk, metadata = part["data"]
if isinstance(chunk, AIMessageChunk) and chunk.text:
print(chunk.text, end="|", flush=True)
else: # "custom": whatever a tool passed to get_stream_writer()
print("\ncustom:", part["data"])
print()
print(sorted(metadata))
Output (the | marks the boundary between chunks; the last line lists the metadata keys the installed version
attaches to each chunk):
custom: {'status': 'searching', 'query': 'HNSW ef_search'}
custom: {'status': 'found', 'hits': 3}
According |to |the |docs: |ef_search |is |the |query-time |candidate |list |size |(default |40). |
['checkpoint_ns', 'langgraph_checkpoint_ns', 'langgraph_node', 'langgraph_path', 'langgraph_step', 'langgraph_triggers', 'lc_versions', 'ls_integration', 'ls_model_type', 'ls_provider']
The metadata’s langgraph_node tells you which node emitted a chunk, which is how you stream only the answering
model and stay silent about a routing or grading model in the same graph. Two details matter in practice:
-
Subgraphs. If the agent is a node of a parent graph, the parent’s
messagesstream does not include the inner model’s tokens unless you passsubgraphs=True; thensfield then says where each chunk came from. -
Models that do not stream. Set
disable_streaming=Trueon a chat model (available on every chat model through the base class) and it emits one completeAIMessageinstead ofAIMessageChunkpieces. Checked with the fake model: the stream then contained one wholeAIMessageper model call. A filter written asisinstance(chunk, AIMessageChunk)silently drops that answer, so filter onAIMessage, which the chunk class extends.
get_stream_writer needs a LangGraph execution context, so a tool that calls it cannot be invoked directly outside a
graph, and on Python older than 3.11 it does not work in async code (pass a writer parameter instead). See
LangGraph — Streaming for custom mode.
Typed event streaming
Recent LangGraph and LangChain releases add a second, higher-level API that the current documentation recommends for
most application code: stream_events(…, version="v3"), and astream_events(…, version="v3") for async use. Instead
of branching on stream_mode chunks it returns a run object with one typed projection per concern — messages,
values, tool_calls, subgraphs, interrupts and a final output — that can be consumed independently:
import warnings
warnings.filterwarnings("ignore")
from agent import agent
inputs = {"messages": [{"role": "user", "content": "What does ef_search do?"}]}
stream = agent.stream_events(inputs, version="v3")
for kind, item in stream.interleave("messages", "tool_calls"):
if kind == "messages":
print("model:", str(item.text) or "(tool call)")
else:
print("tool: ", item.tool_name, item.input)
print("final:", stream.output["messages"][-1].text)
Output:
model: (tool call)
tool: search_docs {'query': 'HNSW ef_search'}
model: According to the docs: ef_search is the query-time candidate list size (default 40).
final: According to the docs: ef_search is the query-time candidate list size (default 40).
Each item of stream.messages is one model call, iterable for its text deltas; stream.tool_calls yields one item per
tool execution with its input, output and any error; stream.output is the final state. The first model: line is
the tool-call turn, which has no text. The installed langgraph 1.2.12 marks this protocol experimental — the call
raises a LangChainBetaWarning, which the example filters out — so treat its API as subject to change and pin your
versions. See LangChain — Event streaming and
LangGraph — Event streaming.
astream_events
astream_events is the older, Runnable-level events API, and it works on anything that is a Runnable — a single chat
model, an LCEL chain (see LCEL & Runnables) or a compiled graph. It emits a
flat sequence of dictionaries with an event name such as on_chat_model_stream, on_tool_start, on_tool_end or
on_chain_end, so it is the tool for "show me every step of every component", not only the tokens:
import asyncio
from agent import agent
async def main() -> None:
inputs = {"messages": [{"role": "user", "content": "What does ef_search do?"}]}
async for event in agent.astream_events(inputs, version="v2"):
kind = event["event"]
if kind == "on_tool_start":
print("tool start:", event["name"], event["data"]["input"])
elif kind == "on_tool_end":
print("tool end: ", event["name"], event["data"]["output"].content)
elif kind == "on_chat_model_stream" and event["data"]["chunk"].text:
print(event["data"]["chunk"].text, end="|", flush=True)
print()
asyncio.run(main())
Output:
tool start: search_docs {'query': 'HNSW ef_search'}
tool end: search_docs ef_search is the query-time candidate list size (default 40).
According |to |the |docs: |ef_search |is |the |query-time |candidate |list |size |(default |40). |
Pass version="v2" when you use this API on a Runnable or graph. Custom events dispatched with
adispatch_custom_event appear in the same stream as on_custom_event:
import asyncio
from langchain_core.callbacks.manager import adispatch_custom_event
from langchain_core.runnables import RunnableLambda
async def step(x: str) -> str:
await adispatch_custom_event("progress", {"stage": "retrieving"})
return x.upper()
async def main():
async for ev in RunnableLambda(step).astream_events("hi", version="v2"):
print(ev["event"], ev["name"], ev["data"])
asyncio.run(main())
Output:
on_chain_start step {'input': 'hi'}
on_custom_event progress {'stage': 'retrieving'}
on_chain_stream step {'chunk': 'HI'}
on_chain_end step {'output': 'HI'}
Choose between the three APIs like this:
| API | Strength | Cost |
|---|---|---|
|
Stable, small, and exactly what an HTTP endpoint needs |
You branch on the mode yourself |
|
Typed projections that several consumers read concurrently |
Marked experimental in the installed version |
|
Works on any Runnable and sees every component start and end |
Verbose; you filter the flood of events yourself |
See the astream_events reference and the adispatch_custom_event reference.
A FastAPI Server-Sent Events endpoint
A browser cannot render a JSON document that is still being written, so streams reach it as Server-Sent Events: a
plain HTTP response with Content-Type: text/event-stream whose body is a sequence of blocks such as event: token
and data: \{…}, each ended by a blank line. FastAPI 0.135 added first-class support: declare
response_class=EventSourceResponse, yield from the path operation and every yielded item is serialised into an
event. Yield a ServerSentEvent to control the event name. The endpoint below streams DocsAssistant over POST,
since the question and the thread ID travel in a body:
from collections.abc import AsyncIterable
from fastapi import FastAPI
from fastapi.sse import EventSourceResponse, ServerSentEvent
from langchain.agents import create_agent
from langchain_core.messages import AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from pydantic import BaseModel
from agent import search_docs
from fake import ScriptedChat # in production: ChatOllama(model="llama3.1:8b")
agent = create_agent(ScriptedChat(), tools=[search_docs], checkpointer=InMemorySaver())
app = FastAPI()
class ChatRequest(BaseModel):
message: str
thread_id: str
@app.post("/chat", response_class=EventSourceResponse)
async def chat(req: ChatRequest) -> AsyncIterable[ServerSentEvent]:
config = {"configurable": {"thread_id": req.thread_id}}
inputs = {"messages": [{"role": "user", "content": req.message}]}
try:
async for part in agent.astream(
inputs, config, stream_mode=["messages", "updates", "custom"], version="v2"
):
if part["type"] == "messages":
chunk, _meta = part["data"]
if isinstance(chunk, AIMessage) and chunk.text:
yield ServerSentEvent(event="token", data={"text": chunk.text})
elif part["type"] == "custom":
yield ServerSentEvent(event="status", data=part["data"])
elif part["type"] == "updates":
for node, update in part["data"].items():
yield ServerSentEvent(event="step", data={"node": node, "messages": len(update["messages"])})
yield ServerSentEvent(event="done", data={"thread_id": req.thread_id})
except Exception as exc: # tell the client, then close the stream cleanly
yield ServerSentEvent(event="error", data={"type": type(exc).__name__})
Four decisions in the handler deserve a comment:
-
One event name per concern.
tokenis the text to append,statusis a tool’s own progress signal,stepmarks a node finishing,donecloses the turn. The client switches on the name and never guesses from the payload. -
Filter on
AIMessagewith text. That skips the empty tool-call fragments and theToolMessage, and it also works when a model was set todisable_streaming=True. -
Errors become events. Once the first byte has gone out the HTTP status is already 200, so an exception cannot change it. The handler catches the exception, sends an
errorevent carrying only the exception type, and ends the stream; the details belong in the server log, not in the browser. -
The checkpointer and
thread_id. TheInMemorySaverkeeps each thread’s history so a follow-up turn continues the conversation (see Short-Term Memory & Checkpointers). A real service takes the thread ID from the authenticated session rather than trusting a value the client sends, and uses a durable checkpointer.
Serve it with uvicorn app:app and call it with curl -N, which turns off curl’s own buffering. This is what came
back from the running server:
event: step
data: {"node": "model", "messages": 1}
event: status
data: {"status": "searching", "query": "HNSW ef_search"}
event: status
data: {"status": "found", "hits": 3}
event: step
data: {"node": "tools", "messages": 1}
event: token
data: {"text": "According "}
event: token
data: {"text": "to "}
event: token
data: {"text": "the "}
event: token
data: {"text": "docs: "}
event: token
data: {"text": "ef_search "}
event: token
data: {"text": "is "}
event: token
data: {"text": "the "}
event: token
data: {"text": "query-time "}
event: token
data: {"text": "candidate "}
event: token
data: {"text": "list "}
event: token
data: {"text": "size "}
event: token
data: {"text": "(default "}
event: token
data: {"text": "40). "}
event: step
data: {"node": "model", "messages": 1}
event: done
data: {"thread_id": "t-1"}
The response also carried content-type: text/event-stream; charset=utf-8, cache-control: no-cache and
x-accel-buffering: no, the last of which tells an nginx reverse proxy not to buffer the stream. Any proxy or CDN in
front of the service must stream rather than buffer, or the tokens arrive in one lump at the end.
A Python client reads the same stream with HTTPX, which is also how you test the endpoint end to end:
import json
import httpx
answer = []
with httpx.stream(
"POST",
"http://localhost:8765/chat",
json={"message": "What does ef_search do?", "thread_id": "t-2"},
timeout=None,
) as response:
event = None
for line in response.iter_lines():
if line.startswith("event:"):
event = line.removeprefix("event:").strip()
elif line.startswith("data:"):
data = json.loads(line.removeprefix("data:"))
if event == "token":
answer.append(data["text"])
else:
print(f"{event:7} {data}")
print("answer:", "".join(answer))
Output:
step {'node': 'model', 'messages': 1}
status {'status': 'searching', 'query': 'HNSW ef_search'}
status {'status': 'found', 'hits': 3}
step {'node': 'tools', 'messages': 1}
step {'node': 'model', 'messages': 1}
done {'thread_id': 't-2'}
answer: According to the docs: ef_search is the query-time candidate list size (default 40).
When the model fails mid-stream, the client receives an error event instead of a broken connection. Making the
agent raise inside the handler and calling the endpoint through FastAPI’s TestClient returned:
event: error
data: {"type": "RuntimeError"}
See FastAPI — Server-Sent Events (SSE) and MDN — Using server-sent events for the wire format. Authentication, rate limits and cancellation on client disconnect are application code that this page does not exercise; deploying the agent behind a managed server is covered in Deployment.
Consuming the stream in the browser
The browser’s built-in EventSource can only issue GET requests, so a POST endpoint like this one is read with
fetch and a stream reader. This TypeScript helper turns the response into an async generator of typed events. It
type-checks under strict with tsc --noEmit, and running it under Node 24 against the live server printed the
sequence shown after it:
export type ChatEvent =
| { event: "token"; data: { text: string } }
| { event: "status"; data: Record<string, unknown> }
| { event: "step"; data: { node: string; messages: number } }
| { event: "done"; data: { thread_id: string } }
| { event: "error"; data: { type: string } };
/** POST to the FastAPI endpoint and yield its SSE events (EventSource cannot POST). */
export async function* chat(
message: string,
threadId: string,
signal?: AbortSignal,
): AsyncGenerator<ChatEvent> {
const res = await fetch("/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message, thread_id: threadId }),
signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) return;
buffer += value;
let end: number;
while ((end = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
let event = "message";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (data) yield { event, data: JSON.parse(data) } as ChatEvent;
}
}
}
step {"node":"model","messages":1}
status {"status":"searching","query":"HNSW ef_search"}
status {"status":"found","hits":3}
step {"node":"tools","messages":1}
step {"node":"model","messages":1}
done {"thread_id":"t-js"}
TEXT: According to the docs: ef_search is the query-time candidate list size (default 40).
A UI appends every token event’s text to the current answer, shows status events as a transient label, and
disables the input until done or error. Pass an AbortSignal to stop generation when the user presses "Stop". See
MDN — Using server-sent events.
useStream
LangChain also ships frontend SDKs whose hook, useStream, manages the whole conversation state for you: messages that
grow token by token, tool calls, interrupts, a loading flag and thread history. React, Vue and Svelte use useStream
from @langchain/react, @langchain/vue and @langchain/svelte; Angular has injectStream. The hook does not read
the hand-written endpoint above: it speaks the Agent Server streaming protocol, so its apiUrl is the URL of
langgraph dev locally or of a deployed Agent Server, and assistantId is a graph name from langgraph.json (see
Deployment). The component below was type-checked against @langchain/react
1.2.0 with tsc --noEmit:
import { useState } from "react";
import { useStream } from "@langchain/react";
import { AIMessage, HumanMessage, type BaseMessage } from "langchain";
interface AgentState {
messages: BaseMessage[];
}
export function Chat() {
const [draft, setDraft] = useState("");
const stream = useStream<AgentState>({
apiUrl: "http://localhost:2024", // `langgraph dev`; use your Agent Server URL in production
assistantId: "docs_assistant", // a graph name from langgraph.json
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
void stream.submit({ messages: [new HumanMessage(draft)] });
setDraft("");
}}
>
{stream.messages.map((msg) => (
<p key={msg.id}>
{HumanMessage.isInstance(msg) ? "You: " : AIMessage.isInstance(msg) ? "DocsAssistant: " : "tool: "}
{msg.text}
</p>
))}
{stream.error ? <p role="alert">Something went wrong. Try again.</p> : null}
<input value={draft} onChange={(e) => setDraft(e.target.value)} disabled={stream.isLoading} />
{stream.isLoading ? (
<button type="button" onClick={() => void stream.stop()}>Stop</button>
) : (
<button type="submit">Send</button>
)}
</form>
);
}
stream.messages updates on every token, stream.isLoading gates the input and toggles the Stop button, and
stream.error is shown as a generic message rather than the raw error. Because the type parameter describes the
graph state, stream.values, stream.toolCalls and the other reactive fields are typed as well. The component was
checked at the type level only; it was not run against a live Agent Server. See
LangChain — Frontend overview, and
LangChain.js for the JavaScript side of the framework.
Which of the two to use follows from where the agent runs:
| Approach | Use it when | You get |
|---|---|---|
|
The agent lives inside an existing FastAPI service and the UI is small |
Full control of the wire format and authentication, and no dependency on the Agent Server |
|
The agent is deployed with |
Threads, resumable streams, interrupts, tool-call state and branching without writing the protocol |
For approval flows that pause a run, see Human-in-the-Loop; resuming a paused
stream with Command(resume=…) is covered in
Human-in-the-Loop & Time Travel. To see each streamed step
in a trace, turn on tracing as described in Observability with
LangSmith.