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:

fake.py — a scripted, streaming chat model
"""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:

agent.py — DocsAssistant with a stub search tool
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:

Table 1. The stream modes used on this page
Mode What each chunk is Reach for it to

values

The full state after each step

Render the whole conversation or state, and replace the UI with each snapshot

updates

Only the keys a node just changed, keyed by node name

Show progress ("the model answered", "the tools ran") without re-sending state

messages

A (message chunk, metadata) pair for every token from any chat model in the graph

Print the answer as it is generated

custom

Whatever a node or tool passes to get_stream_writer()

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

Full-state snapshots versus per-node deltas
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:

Printing tokens and progress signals together
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 messages stream does not include the inner model’s tokens unless you pass subgraphs=True; the ns field then says where each chunk came from.

  • Models that do not stream. Set disable_streaming=True on a chat model (available on every chat model through the base class) and it emits one complete AIMessage instead of AIMessageChunk pieces. Checked with the fake model: the stream then contained one whole AIMessage per model call. A filter written as isinstance(chunk, AIMessageChunk) silently drops that answer, so filter on AIMessage, 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.

Several modes at once

Passing a list, as the previous snippet did, interleaves the modes in arrival order and part["type"] says which is which. That is the recipe for a UI: messages for tokens, updates for step boundaries, custom for tool progress. It is exactly what the endpoint below does.

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:

Event streaming with interleaved projections
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:

Tokens and tool boundaries from astream_events
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:

A custom event from inside a Runnable
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:

Table 2. Choosing a streaming API
API Strength Cost

stream / astream with stream_mode

Stable, small, and exactly what an HTTP endpoint needs

You branch on the mode yourself

stream_events(version="v3")

Typed projections that several consumers read concurrently

Marked experimental in the installed version

astream_events(version="v2")

Works on any Runnable and sees every component start and end

Verbose; you filter the flood of events yourself

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:

flowchart LR B[Browser] -->|"POST /chat"| F[FastAPI] F -->|"astream, version v2"| A[create_agent graph] A -->|"messages, updates, custom"| F F -->|"token, status, step, done"| B
app.py — a streaming chat endpoint
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. token is the text to append, status is a tool’s own progress signal, step marks a node finishing, done closes the turn. The client switches on the name and never guesses from the payload.

  • Filter on AIMessage with text. That skips the empty tool-call fragments and the ToolMessage, and it also works when a model was set to disable_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 error event 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. The InMemorySaver keeps 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:

Real SSE output from the endpoint
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:

client.py — consuming the stream with HTTPX
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:

sse.ts — reading the endpoint with fetch
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;
    }
  }
}
Result of iterating chat() against the running endpoint
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:

Chat.tsx — a chat component with useStream
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:

Table 3. fetch reader versus useStream
Approach Use it when You get

fetch reader on your own endpoint

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

useStream against an Agent Server

The agent is deployed with langgraph.json

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.