一句话定义

.stream() 是 LangGraph 流式的底层入口,用 streamMode 这一个参数决定”你想看图执行的哪个切面”——values/updates 看状态、messages 看 LLM token、custom 看节点内部吐的任意数据、tools 看工具生命周期、debug 看全量调试信息。它们既能单独用,也能写成数组组合成 [mode, chunk] 元组流。

本篇以官方 streaming 文档为骨架,逐个 streamMode 讲透——官方代码大多只给片段、不给输出,本文给每个例子补上真实输出,并把官方语焉不详的反直觉点(values 首推缺字段、updates 的结构、多模式 messages 要二次解构等)展开。最后补一节官方完全没讲、但实战必踩的”并发写同字段冲突”。

如果你只是想快速把 LLM token 接到前端打字机,更推荐高层 API streamEvents(v3)——见本专栏第 6 篇。本篇聚焦底层 .stream(),适合需要直接控制 streamMode、或想理解 v3 脚下地基的场景。


全貌:streamMode 全家福

LangGraph 图编译后,会暴露一个 stream 方法,它返回一个异步迭代器:

for await (const chunk of await graph.stream(inputs, {
  streamMode: "updates",
})) {
  console.log(chunk);
}

关键就是 streamMode 参数。官方文档列出了这些取值:

Mode看到的内容推送粒度
values每步之后的完整 statesuperstep 级
updates每步之后的state 增量;同一步多个 update 分开发节点级
messagesLLM 调用的 token 二元组 (token, metadata)token 级
custom节点/工具通过 writer 吐出的任意数据自定义
tools工具调用的生命周期事件(start/event/end/error)工具事件级
debug图执行过程中的全量信息(含节点名+完整 state)全量

官方简介还提到 checkpoints / tasks 两个模式,但文档正文未展开,本篇聚焦上表这六个有详细说明的。

先记住一条粒度阶梯

从粗到细,大致是:

flowchart LR
    A["debug<br/>最全"] --> B["values<br/>完整 state"]
    B --> C["updates<br/>节点增量"]
    C --> D["messages / tools<br/>事件级"]
    D --> E["custom<br/>最自由"]

越靠右粒度越细、越自由,但也越需要你自己解析。理解这条阶梯,下面每一节就是在讲其中一个台阶。


一、Graph state:values vs updates

这是最常用、也最常被混淆的两个模式。先看官方定义:

  • updates:每步之后,推送该步对 state 的增量更新;同一步里有多个 update 会分别推送。
  • values:每步之后,推送 state 的完整值

一个贯穿全文的示例图

官方用了这样一个两节点图,本文复用它并给出完整输出:

import { StateGraph, StateSchema, START, END } from "@langchain/langgraph";
import { z } from "zod/v4";

const State = new StateSchema({
  topic: z.string(),
  joke: z.string(),
});

const graph = new StateGraph(State)
  .addNode("refineTopic", (state) => ({
    topic: state.topic + " and cats",     // 改 topic
  }))
  .addNode("generateJoke", (state) => ({
    joke: `This is a joke about ${state.topic}`,  // 改 joke
  }))
  .addEdge(START, "refineTopic")
  .addEdge("refineTopic", "generateJoke")
  .addEdge("generateJoke", END)
  .compile();

updates:增量,且 payload 是 { 节点名: 增量 }

for await (const chunk of await graph.stream(
  { topic: "ice cream" },
  { streamMode: "updates" }
)) {
  for (const [nodeName, state] of Object.entries(chunk)) {
    console.log(`Node ${nodeName} updated:`, state);
  }
}

⚠️ 官方代码用 Object.entries(chunk) 遍历,这其实暴露了 updates 的关键结构——chunk 不是裸 state,而是 { 节点名: 该节点的增量 } 官方没明说这点,很多人第一次看会困惑。下面是真实输出:

Node refineTopic updated: { topic: "ice cream and cats" }
Node generateJoke updated: { joke: "This is a joke about ice cream and cats" }

注意每条都只含该节点实际 return 的 key:refineTopic 只改了 topic,所以增量里没有 joke

values:完整 state 快照

for await (const chunk of await graph.stream(
  { topic: "ice cream" },
  { streamMode: "values" }
)) {
  console.log(`topic: ${chunk.topic}, joke: ${chunk.joke}`);
}

真实输出:

topic: ice cream and cats, joke: undefined
topic: ice cream and cats, joke: This is a joke about ice cream and cats

⚠️ 第一个反直觉点(官方完全没解释):values 第一次推送时 jokeundefined 因为 values 推的是”累加到当前的状态快照”——refineTopic 跑完、它的增量刚合并进 state 时,joke 字段在初始 state 里不存在,所以是 undefined不要假设每个 values chunk 都含所有 key,未出现过的 key 会缺失。

两者对照表

valuesupdates
推送时机每个 superstep 结束每个节点结束
payload完整 state(所有 key 的当前值)仅该节点 return 的增量
是否含节点名❌(裸 state)✅({ 节点名: 增量 }
并发节点合并成一次推送每个节点各推一次
适合UI 整体渲染、调试 state 演化日志、局部 diff、审计

选型直觉:想要”当前总状态”直接覆盖渲染 → values;想要”谁改了什么” → updates


二、LLM tokens:messages 模式与过滤

messages 模式推的是 LLM 的 token 流。官方定义:每条输出是一个二元组 [messageChunk, metadata],其中 messageChunk 是 token 片段,metadata 含图节点和 LLM 调用信息。

for await (const [messageChunk, metadata] of await graph.stream(input, {
  streamMode: "messages",
})) {
  console.log(messageChunk.content);      // 一个 token 片段
  console.log(metadata.langgraph_node);   // 这个 token 来自哪个节点
}

当一个图里有多个 LLM 调用时,你往往只想看其中某一个的 token。官方提供了三种过滤手段,但解释都偏简略,下面逐一展开——尤其是 tags,坑最多。

过滤方式一:用 tags 按 LLM 调用过滤

官方示例:给两个 LLM 打不同 tag,流式时按 tag 筛。

import { ChatOpenAI } from "@langchain/openai";

const model1 = new ChatOpenAI({
  model: "gpt-5.4-mini",
  tags: ["joke"],   // ← 打标签
});
const model2 = new ChatOpenAI({
  model: "gpt-5.4-mini",
  tags: ["poem"],
});

// 流式时按 tag 筛
for await (const [msg, metadata] of await graph.stream(
  { topic: "cats" },
  { streamMode: "messages" }
)) {
  if (metadata.tags?.includes("joke")) {
    console.log(msg.content + "|");
  }
}

⚠️ 这里官方完全没解释 tags 是什么——但它有四个层次必须分清,否则一用就错:

字段名 tags数组里的值 "joke"
谁定的LangChain RunnableConfig 标准字段,名字固定完全自定义的字符串
能不能改名❌ 改了不生效✅ 随便起
会去哪自动沿调用链传播,最终出现在 metadata.tags原样透传,供你 includes
  1. tags 是固定字段名,不是任意命名。 它是 LangChain(不是 LangGraph)RunnableConfig 接口的内置字段,所有 Runnable/ChatModel 构造函数都接受它。你能任意命名的是数组里的值'joke'/'poem'),不是字段名本身。
  2. 想要”任意 key 命名”的元数据,用 metadata 字段,不是 tags RunnableConfig 另有一个标准字段 metadata(key-value 对象),它才是 key/value 都任意的。但做”过滤流式 token”这种事,官方就是用 tags——因为 tags 会平铺到 streaming metadata 顶层,metadata 对象不一定。
  3. nostream 是个保留值,有魔法语义(见下节)。
  4. tags 会自动传播。 你给某个 LLM 实例挂了 tag,它每次调用都会带着,最终体现在 trace 和 streaming metadata 里。

过滤方式二:用 nostream 标签彻底排除某次调用

官方:用 nostream 标签把某个 LLM 调用的 token 从 messages 流里完全排除

const streamModel = new ChatAnthropic({ model: "claude-haiku-4-5-20251001" });
const internalModel = new ChatAnthropic({
  model: "claude-haiku-4-5-20251001",
}).withConfig({
  tags: ["nostream"],   // ← 保留值
});

⚠️ 关键区分:'joke' 这类普通标签是纯自定义,LangGraph 不管你、你自己消费;而 'nostream' 是 LangGraph 专门识别的暗号。 挂了它的 LLM 照常运行、照常产出(结果仍可用于内部处理,如结构化输出),但在 messages 模式里不会发射 token

类比:tags 是块白板,你爱写啥写啥;但其中有一个词 nostream 是暗号,门卫(LangGraph)看到就有固定动作(不放行),其他词门卫不管。适合”需要 LLM 结果但不想让它流到前端”的场景。

过滤方式三:按节点 langgraph_node 过滤

如果你只想看某个图节点里的 token(而不是按 LLM 实例分类),更直接的是过滤 metadata.langgraph_node

for await (const [msg, metadata] of await graph.stream(input, {
  streamMode: "messages",
})) {
  if (msg.content && metadata.langgraph_node === "writePoem") {
    console.log(msg.content + "|");
  }
}

tags vs langgraph_node 怎么选tags 适合”跨节点的逻辑分类”(比如所有”创意类” LLM,不管它在哪个节点被调),langgraph_node 适合”按图结构的位置筛”(我就要 writePoem 节点的东西)。两者也能组合。


三、Custom data:custom 模式与 writer

前面几个 mode 都是在”被动观察”图的状态/事件,而 custom 让你从节点内部主动往外推任意数据。官方两步走:

  1. 在节点/工具里用 LangGraphRunnableConfigwriter 参数推数据。
  2. 调用 .stream() 时设 streamMode: "custom" 接收。

在节点里推(官方 node 示例)

import { StateGraph, StateSchema, GraphNode, START, LangGraphRunnableConfig } from "@langchain/langgraph";
import * as z from "zod";

const State = new StateSchema({
  query: z.string(),
  answer: z.string(),
});

const node: GraphNode<typeof State> = async (state, config) => {
  config.writer({ custom_key: "Generating custom data inside node" });
  return { answer: "some data" };
};

const graph = new StateGraph(State)
  .addNode("node", node)
  .addEdge(START, "node")
  .compile();

for await (const chunk of await graph.stream(
  { query: "example" },
  { streamMode: "custom" }
)) {
  console.log(chunk);   // { custom_key: "Generating custom data inside node" }
}

在工具里推(官方 tool 示例)

import { tool } from "@langchain/core/tools";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import * as z from "zod";

const queryDatabase = tool(
  async (input, config: LangGraphRunnableConfig) => {
    config.writer({ data: "Retrieved 0/100 records", type: "progress" });
    // ...执行查询...
    config.writer({ data: "Retrieved 100/100 records", type: "progress" });
    return "some-answer";
  },
  {
    name: "query_database",
    schema: z.object({ query: z.string() }),
  }
);

为什么需要 custom?官方没明说,但价值在于三点: ① 你能推任意结构的数据(进度、中间结果、自定义事件),不受 state schema 约束;② 数据实时流出,不用等节点 return;③ 能从节点和工具两个地方推,而 messages 只覆盖 LLM 调用。最典型的应用是”接入任意 LLM API”——见下方 Advanced 节。


四、Tool progress:tools 模式的生命周期事件

tools 模式专门追踪工具调用的生命周期,适合做进度条、部分结果展示、错误提示。官方定义了四类事件:

事件何时触发payload
on_tool_start工具调用开始name, input, toolCallId
on_tool_event工具 yield 中间数据name, data, toolCallId
on_tool_end工具返回最终结果name, output, toolCallId
on_tool_error工具抛错name, error, toolCallId

要 yield 中间数据,工具得写成 async generator

官方:想发 on_tool_event,工具函数得是 async generatorasync function*),每次 yield 推一个中间数据,return 的值作为最终结果。

import { tool } from "@langchain/core/tools";
import { z } from "zod/v4";

const searchFlights = tool(
  async function* (input) {
    const airlines = ["United", "Delta", "American", "JetBlue"];
    const completed: string[] = [];

    for (let i = 0; i < airlines.length; i++) {
      await new Promise((r) => setTimeout(r, 500));
      completed.push(airlines[i]);
      // 每次 yield 都会发一个 on_tool_event
      yield {
        message: `Searching ${airlines[i]}...`,
        progress: (i + 1) / airlines.length,
        completed,
      };
    }
    // return 的值成为工具最终结果(ToolMessage.content)
    return JSON.stringify({
      flights: [
        { airline: "United", price: 450, duration: "5h 30m" },
        { airline: "Delta", price: 520, duration: "5h 15m" },
      ],
    });
  },
  {
    name: "search_flights",
    schema: z.object({ destination: z.string(), date: z.string() }),
  }
);

官方补充:现有的返回 Promise 的工具完全兼容——它们只会发 on_tool_starton_tool_end,没有 on_tool_event。所以升级老工具不用改代码,想加进度再改成 async generator。

服务端消费:按 event 分支

for await (const [mode, chunk] of await graph.stream(
  { messages: [{ role: "user", content: "Find flights to Tokyo" }] },
  { streamMode: ["updates", "tools"] }   // 注意这里用了数组模式,见后文
)) {
  if (mode === "tools") {
    switch (chunk.event) {
      case "on_tool_start":  console.log(`Tool started: ${chunk.name}`, chunk.input); break;
      case "on_tool_event":  console.log(`Tool progress: ${chunk.name}`, chunk.data); break;
      case "on_tool_end":    console.log(`Tool finished: ${chunk.name}`, chunk.output); break;
      case "on_tool_error":  console.error(`Tool failed: ${chunk.name}`, chunk.error); break;
    }
  }
}

React 前端:useStreamtoolProgress

官方给了 @langchain/langgraph-sdk/reactuseStream hook,开 "tools" 模式后自动得到一个 toolProgress 数组,每项追踪一个运行中工具的状态(starting/running/completed/error,以及 input/data/result/error)。前端直接 .filter(t => t.state === "running") 就能渲染进度卡片,无需自己拼事件流。(完整代码见官方 Extended example,此处不赘述。)

tools vs custom:都能推工具进度,怎么选

官方专门对比了这两个模式——这是决策难点,展开如下:

toolscustom
数据来源工具(async generator 的 yield)节点 + 工具(config.writer
数据结构固定的生命周期事件(4 类)完全自定义
前端集成useStream 自动给 toolProgress自己解析
适合工具进度条、状态机式 UI不映射到工具生命周期的自由数据

一句话:你的数据天然是”工具开始→进行→结束”这种生命周期 → 用 tools,省事且前端开箱即用;你的数据是自由格式、或来自节点而非工具 → 用 custom


五、Subgraph outputs:subgraphs: true

subgraphs: true 后,父图和子图的输出都会进入流。官方:输出变成 [namespace, data] 二元组,其中 namespace 是一个元组,表示”这条数据来自哪个子图路径”,如 ["parent_node:<task_id>", "child_node:<task_id>"]

for await (const chunk of await graph.stream(
  { foo: "foo" },
  { subgraphs: true, streamMode: "updates" }
)) {
  console.log(chunk);
}

官方扩展示例的输出(父图 node1 → node2 是个子图,子图内又有 subgraphNode1/2):

[[], { node1: { foo: "hi! foo" } }]
[["node2:dfddc4ba-..."], { subgraphNode1: { bar: "bar" } }]
[["node2:dfddc4ba-..."], { subgraphNode2: { foo: "hi! foobar" } }]
[[], { node2: { foo: "hi! foobar" } }]

怎么读 namespace 空数组 [] 表示来自父图;非空数组表示来自子图,数组元素是从父到子的节点路径。这样多 Agent 编排时,你能精确知道”这条更新是哪个子图产生的”。注意子图的输出(如 subgraphNode2 改的 foo)和父图最终的合并输出(最后一行 node2: { foo })会分别推送。


六、Debug:debug 模式

最暴力但也最全的模式——推送图执行过程中的所有可用信息,包括节点名和完整 state。官方代码很简单:

for await (const chunk of await graph.stream(
  { topic: "ice cream" },
  { streamMode: "debug" }
)) {
  console.log(chunk);
}

什么时候用:线上排查问题、想看完整 state 演进轨迹、或不确定哪个 mode 够用时,先用 debug 全量看一遍,再降级到具体的 values/updates。它是”开关全亮”的调试模式,信息量大但噪音也大,不建议生产长期开。


七、Multiple modes:数组组合与 [mode, chunk]

streamMode 可以传数组,比如 ["updates", "custom"]。官方定义:

The streamed outputs will be tuples of [mode, chunk] where mode is the name of the stream mode and chunk is the data streamed by that mode.

for await (const [mode, chunk] of await graph.stream(inputs, {
  streamMode: ["updates", "custom"],
})) {
  console.log(chunk);
}

⚠️ 关键(官方只说了一句,但有两个大坑):只要传数组,每条输出就统一变成 [mode, chunk] 元组,无论这个 mode 单独用时是什么形态。

坑一:messages 进了数组要多解构一次

messages 单独用时是 [messageChunk, metadata];但组合用时,它被包进了 [mode, chunk]chunk 槽位——也就是说 chunk 本身又是个 [messageChunk, metadata] 二元组,要解构两次

for await (const [mode, chunk] of await graph.stream(input, {
  streamMode: ["values", "messages"],
})) {
  if (mode === "values") {
    console.log("[values]", chunk);           // chunk 是完整 state 对象
  } else if (mode === "messages") {
    const [msg, metadata] = chunk;            // ← 第二次解构!
    console.log("[messages]", msg.content);
  }
}

忘了多剥一层,chunk.content 就是 undefined(因为 chunk 是个数组)。

坑二:不同 mode 的推送节奏差异巨大

values 是 superstep 级(少),messages 是 token 级(多),组合后你会看到大量 messages 夹杂少量 values,这是正常的:

[values]   { topic: "cats" }
[messages] Why   from writeJoke
[messages]  did  from writeJoke
[messages]  the  from writeJoke
[values]   { topic: "cats", joke: "Why did the..." }

所以组合模式务必用 mode 字段分流处理,别 console.log(chunk) 一把梭。


进阶补充:fan-out 并发写同字段的冲突与 reducer

这一节官方文档完全没讲,但在有 fan-out 并发的图里必踩。START 同时连向多个节点时,它们会被调度到同一个 superstep 并发执行

const graph = new StateGraph(State)
  .addNode("writeJoke", writeJoke)
  .addNode("writePoem", writePoem)
  // 官方注释明说 "write both the joke and the poem concurrently"
  .addEdge(START, "writeJoke")
  .addEdge(START, "writePoem")
  .compile();

两个反直觉点(也回答两个常见疑问):

  1. 同 superstep 的并发节点彼此看不到对方本轮输出,只能读上一轮 state。想让 writePoem 读到 writeJoke 的 joke?不能 fan-out,得改成串行 writeJoke → writePoem
  2. 节点无出边时自然终止,等价于连 END——所以上图没显式写 END 也完全合法,compile() 不报错。

如果两个并发节点写同一个字段呢?

取决于该字段有没有定义 reducer

channel 类型并发写同字段的行为
普通 channel(无 reducer)报错Can receive only one value per step
带 reducer 的 channel自动调用 reducer 合并

无 reducer 时直接报错——这是 LangGraph 的数据安全保护,防止并发写互相覆盖而你还毫无察觉:

At key 'result': Can receive only one value per step.
Use a custom reducer to combine multiple values.

给字段配 reducer 就能解决,reducer 会把多个值”两两归约”成一个:

const State = Annotation.Root({
  result: Annotation<string>({
    reducer: (prev, next) => prev + "\n" + next,   // 合并策略:拼接
    default: () => "",
  }),
});

常用的 reducer 模式:

场景reducer 写法适合
字符串拼接(prev, next) => prev + "\n" + next多节点输出合并成文本
数组累加(prev, next) => [...prev, ...next]多节点结果合成列表(最常见)
对象浅合并(prev, next) => ({ ...prev, ...next })合并字典

⚠️ 并发场景下 reducer 必须顺序无关。 因为归约顺序取决于内部调度,像 (prev, next) => next 这种”后写覆盖”在并发里”谁后”是不确定的,结果会随调度变化。需要”最后一个赢”说明你的依赖其实是串行的,那就别 fan-out,改成串行边。 这也是 MessagesAnnotation 不怕并发的原因——它预置了支持追加/去重的 reducer。


Advanced:接入任意 LLM API 与关闭流式

custom 接入任意 LLM API(streaming arbitrary chat model)

官方 Advanced 节最有价值的应用:custom 模式能接入任何 LLM API,哪怕它没实现 LangChain 的 chat model 接口。套路是——用原生客户端自己处理 SSE 流,每个 token 通过 config.writer 推出来:

async function* streamTokens(modelName: string, messages: any[]) {
  const response = await openaiClient.chat.completions.create({
    messages, model: modelName, stream: true,   // 原生流式
  });
  for await (const chunk of response) {
    const delta = chunk.choices[0]?.delta;
    if (delta?.content) {
      yield { role: delta.role, content: delta.content };
    }
  }
}

// 在 tool 里用原生流式客户端,边收边 writer 出去
const getItems = tool(
  async (input, config: LangGraphRunnableConfig) => {
    let response = "";
    for await (const msgChunk of streamTokens(modelName, [
      { role: "user", content: `List items in: '${input.place}'` },
    ])) {
      response += msgChunk.content;
      config.writer?.(msgChunk);    // ← 自定义数据流入 custom 流
    }
    return response;
  },
  { name: "get_items", schema: z.object({ place: z.string() }) }
);

这个套路的本质:你的 LLM 调用不必是 ChatOpenAI/ChatAnthropic 这些”正经”封装。只要它提供了某种流式接口(私有模型、第三方 SDK、自己包的 HTTP 客户端),都能用 writer 把输出统一灌进 LangGraph 的流式管线,外部用 custom 模式无差别接收。这让 LangGraph 成了流式协议适配层,而不只是”LangChain 模型的运行器”。

关闭特定模型的流式

混合使用支持/不支持流式的模型时,可以显式关闭。设 streaming: false

import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  model: "o1-preview",
  streaming: false,   // 关闭该模型的流式
});

官方补充:不是所有 chat model 集成都支持 streaming 参数。如果你的模型不支持,改用基类提供的 disableStreaming: true——这个参数所有 chat model 都有。


设计规则速查

把全文浓缩成一张决策表:

你想要用什么
当前完整状态做 UI 渲染values(每来一次直接覆盖渲染)
谁改了什么 / 日志审计 / 局部 diffupdates(payload 小、带节点名)
LLM token 打字机效果messages(配 tags/langgraph_node 过滤)
排除某次 LLM 调用的 tokenmessages + nostream tag
工具进度条 / 生命周期 UItools(async generator + useStream
节点/工具吐任意数据 / 接非 LangChain 模型custom + config.writer()
多 Agent 编排看子图输出subgraphs: true + 任意 mode
全量调试 / 排查问题debug
一次遍历同时拿多个视角数组 ["values", "messages"],输出 [mode, chunk]
更细的事件级粒度(子图、生命周期)放弃 .stream,用 streamEvents(v3)(见第 6 篇)

以及六条容易忘的硬规则:

  1. values 第一次推送可能字段不全——未出现的 key 会是 undefined,别假设每个 chunk 都齐全。
  2. updates 的 payload 是 { 节点名: 增量 }——所以官方代码用 Object.entries 遍历;增量只含该节点改动的 key。
  3. tags 字段名固定、值任意;metadata 才是任意 key;nostream 是保留值。
  4. 组合模式下 messages 要解构两次——[mode, chunk] 里的 chunk 本身又是 [msg, meta]
  5. fan-out 并发写同字段必须配 reducer,且 reducer 要顺序无关。
  6. 节点无出边 = 自然终止 = 等价连 ENDcompile() 不报错。

一句话总结

官方 streaming 文档的骨架就是六个 streamMode——values/updates 看状态、messages 看 token、custom 看自定义数据、tools 看工具生命周期、debug 看全量,外加数组组合成 [mode, chunk] 元组流。但文档代码普遍只给片段不给输出、对 values 首推缺字段 / updates 的结构 / 多模式 messages 二次解构 / 并发写冲突这些坑都语焉不详。记住这几个反直觉点,你就能在大多数场景里替代掉更重的 streamEvents(v3),直接用底层 stream() 把活干漂亮。