深入浅出 LangGraph —— 第9章:流式输出:实时响应用户

📖 本章学习目标

  • ✅ 理解为什么流式输出对 AI 应用体验至关重要
  • ✅ 掌握 stream() 的多种模式(values/updates/messages/custom)
  • ✅ 学会使用 streamEvents 获取细粒度执行事件
  • ✅ 实现 HTTP SSE 和 WebSocket 的流式集成
  • ✅ 掌握自定义流式数据写入(StreamWriter)的使用
  • ✅ 避免常见的流式陷阱和性能问题

一、为什么流式输出是刚需

1、等待的代价

ChatGPT 刚出来的时候,用户等待完整回复可能需要10-30秒。流式输出(Streaming) 改变了这一切。使用流式输出,用户能看到文字一个字一个字地出现,感觉上响应几乎是即时的。

对比体验:

方式 用户体验 感知延迟 适用场景
等待完整响应(invoke) 页面空白10秒 → 突然出现全文 ⭐⭐ 差 后台批处理
逐 Token 流式(stream) 立刻看到第一个字,逐渐补全 ⭐⭐⭐⭐⭐ 优 聊天界面
节点状态流式 看到 Agent 的每步执行进度 ⭐⭐⭐⭐ 好 复杂任务监控

流式输出
用户发送消息
立即显示第一个字
逐字显示...
完整回复

2、LangGraph 的流式层次

graph.stream()
values 模式

每步后完整 State
updates 模式

每步后 State 增量
messages 模式

逐 Token 消息流
custom 模式

自定义数据流
streamEvents 模式

完整执行事件

五种流式模式对比:

模式 数据粒度 数据量 适用场景
values 完整State 状态快照监控
updates 增量更新 节点执行追踪
messages 逐Token 聊天打字机效果
custom 自定义 灵活 进度条、分步展示
streamEvents 事件流 最大 调试和可视化

二、基础流式模式

1、values 模式:每步完整状态

步骤1:准备环境
typescript 复制代码
import * as dotenv from 'dotenv';
dotenv.config();

import { MessagesAnnotation, StateGraph, MemorySaver } from '@langchain/langgraph';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages';
步骤2:创建图
typescript 复制代码
const model = new ChatOpenAI({ model: 'gpt-4o-mini' });
const checkpointer = new MemorySaver();

const graph = new StateGraph(MessagesAnnotation)
  .addNode('chat', async (state) => {
    const response = await model.invoke(state.messages);
    return { messages: [response] };
  })
  .addEdge('__start__', 'chat')
  .addEdge('chat', '__end__')
  .compile({ checkpointer });
步骤3:使用values模式流式输出
typescript 复制代码
async function streamValues() {
  const config = { configurable: { thread_id: 'stream-demo' } };
  
  // stream() 返回异步迭代器
  for await (const chunk of graph.stream(
    { messages: [new HumanMessage('介绍一下流式输出的优点')] },
    { ...config, streamMode: 'values' }
  )) {
    // 每个节点执行后,输出完整的当前 State
    const msgs = chunk.messages;
    console.log(`[values] 消息数: ${msgs.length},最新: ${msgs[msgs.length-1].content.slice(0, 50)}...`);
  }
}

await streamValues();
// 输出:
// [values] 消息数: 2, 最新: 流式输出的优点包括: 1. 降低感知延迟...

代码解读:

  • graph.stream():返回 AsyncGenerator,可用 for await 遍历
  • streamMode: 'values':每个节点执行后,输出当前完整的 State
  • 适用于:需要知道每步执行后的完整状态,如进度条显示

优势:

  1. 可以看到每一步的完整状态
  2. 适合调试和监控
  3. 可以保存中间状态用于恢复

劣势:

  1. 数据量大,每次传输完整State
  2. 不适合高频更新场景

2、updates 模式:只看变化的部分

typescript 复制代码
async function streamUpdates() {
  const config = { configurable: { thread_id: 'stream-updates-demo' } };
  
  for await (const chunk of graph.stream(
    { messages: [new HumanMessage('你好')] },
    { ...config, streamMode: 'updates' }
  )) {
    // chunk 是 { 节点名: 该节点返回的状态增量 } 的对象
    for (const [nodeName, nodeUpdate] of Object.entries(chunk)) {
      console.log(`[updates] 节点 "${nodeName}" 完成,更新:`, 
        JSON.stringify(nodeUpdate).slice(0, 100));
    }
  }
}

await streamUpdates();
// 输出:
// [updates] 节点 "chat" 完成,更新: {"messages":[{"content":"你好!有什么可以帮助你的?"}]}

streamMode: 'updates'只输出每个节点更新了什么(增量),而非完整 State,比 values 模式数据量更小,适合需要精确追踪哪个节点改了什么的场景。

优势:

  1. 数据量小,只传输变化部分,高效精准
  2. 清晰看到每个节点的贡献
  3. 适合多节点工作流监控

3、messages 模式:逐 Token 输出

这是最重要的流式模式,让用户看到 LLM 逐字生成内容:

typescript 复制代码
async function streamMessages() {
  const config = { configurable: { thread_id: 'stream-msg-demo' } };
  
  process.stdout.write('AI: ');
  
  for await (const [message, metadata] of graph.stream(
    { messages: [new HumanMessage('写一首五言绝句')] },
    { ...config, streamMode: 'messages' }
  )) {
    // message 是每个生成的 Token(或消息片段)
    if (message.content) {
      process.stdout.write(message.content as string);
    }
  }
  
  console.log('\n[生成完毕]');
}

await streamMessages();
// 输出(逐字显示):
// AI: 春眠不觉晓,处处闻啼鸟。夜来风雨声,花落知多少。
// [生成完毕]

代码解读:

  • streamMode: 'messages':每生成一个 Token 就产生一个事件
  • 解构 [message, metadata]: message 是消息片段,metadata 包含节点信息
  • process.stdout.write:不换行地逐字输出,模拟打字机效果
  • 这是实现 ChatGPT 风格打字机效果的标准做法

工作原理:

  1. LLM开始生成第一个token
  2. 立即通过stream返回给客户端
  3. 客户端显示该token
  4. 重复直到生成完毕

优势:

  1. 极低的首字延迟(TTFT < 500ms)
  2. 用户体验极佳
  3. 适合长文本生成

实际应用:

  • ChatGPT网页版
  • Claude对话界面
  • 所有现代AI聊天应用

三种模式对比示例:

typescript 复制代码
// 假设图有3个节点: plan → execute → review

// values模式: 输出3次完整State
for await (const state of graph.stream(input, { streamMode: 'values' })) {
  console.log('完整State:', state); // 每次都包含所有字段
}

// updates模式: 输出3次增量
for await (const updates of graph.stream(input, { streamMode: 'updates' })) {
  console.log('增量更新:', updates); // 只包含变化的字段
}

// messages模式: 输出N次token(N=token数量)
for await (const [msg] of graph.stream(input, { streamMode: 'messages' })) {
  console.log('单个token:', msg.content); // 逐字输出
}

三、streamEvents:完整执行事件

1、获取细粒度事件流

streamEvents 提供图执行的完整事件序列,适合构建详细的执行监控:

typescript 复制代码
async function streamWithEvents() {
  const config = { configurable: { thread_id: 'events-demo' } };
  
  for await (const event of graph.streamEvents(
    { messages: [new HumanMessage('帮我规划一次旅行')] },
    { ...config, version: 'v2' }
  )) {
    switch (event.event) {
      case 'on_chain_start':
        console.log(`🚀 图开始执行`);
        break;
      case 'on_chain_stream':
        // 每个 Token 都触发此事件
        if (event.data.chunk?.content) {
          process.stdout.write(event.data.chunk.content);
        }
        break;
      case 'on_tool_start':
        console.log(`\n🔧 工具调用开始: ${event.name}`, event.data.input);
        break;
      case 'on_tool_end':
        console.log(`✅ 工具执行完成: ${event.name}`);
        break;
      case 'on_chain_end':
        console.log(`\n🏁 图执行完成`);
        break;
    }
  }
}

await streamWithEvents();

输出:

复制代码
🚀 图开始执行
正在规划旅行路线...
🔧 工具调用开始: search_flights { from: "北京", to: "上海" }
✅ 工具执行完成: search_flights
航班查询完成,继续规划...
🏁 图执行完成

代码解读:

  • streamEvents(input, { version: 'v2' }):使用 v2 事件格式(推荐),旧版本是v1,字段结构复杂
  • event.event: 事件类型字符串,常见类型如上
  • event.name: 触发事件的组件名称(节点名、工具名等)
  • event.data:事件相关数据(input/output/chunk 等)
  • 适合构建 Agent 执行可视化面板

常见事件类型:

  • on_chain_start/end: 图/节点开始/结束
  • on_chain_stream: 流式数据块
  • on_tool_start/end: 工具调用开始/结束
  • on_custom_event: 自定义事件

事件流示意图:
LLM LangGraph 应用程序 LLM LangGraph 应用程序 streamEvents(input) on_chain_start 调用LLM token 1 on_chain_stream(token 1) token 2 on_chain_stream(token 2) ...更多tokens on_chain_end 完成

2、事件过滤和聚合

typescript 复制代码
// 只关注特定事件
for await (const event of graph.streamEvents(input, config)) {
  // 只处理工具调用事件
  if (event.event.startsWith('on_tool_')) {
    console.log('工具事件:', event.name, event.event);
  }
  
  // 只处理特定节点的事件
  if (event.metadata?.langgraph_node === 'agent') {
    console.log('Agent节点事件:', event.event);
  }
}

四、自定义流式写入

1、在节点内主动推送数据

有时你想在节点执行过程中(而非节点完成后)实时推送中间结果:

typescript 复制代码
import { RunnableConfig } from '@langchain/core/runnables';
import { dispatchCustomEvent } from '@langchain/core/callbacks/dispatch';

async function longTaskNode(
  state: typeof MessagesAnnotation.State,
  config: RunnableConfig
) {
  const steps = ['分析需求', '制定方案', '生成代码', '验证结果'];
  
  for (let i = 0; i < steps.length; i++) {
    // 在节点内部主动推送进度事件
    await dispatchCustomEvent(
      'progress_update',
      { step: steps[i], progress: (i + 1) / steps.length * 100 },
      config
    );
    
    // 模拟耗时操作
    await new Promise(resolve => setTimeout(resolve, 500));
  }
  
  const response = await model.invoke(state.messages);
  return { messages: [response] };
}

代码解读:

  • dispatchCustomEvent(name, data, config):主动推送自定义事件
    • name是事件名,在 streamEvents 中通过 on_custom_event 接收
    • config 参数必须从节点函数的第二个参数获取并传入
    • 适用于:进度条、分步骤显示、长时间任务的实时状态更新
接收自定义事件
typescript 复制代码
// 接收自定义事件
for await (const event of graph.streamEvents(input, config)) {
  if (event.event === 'on_custom_event' && event.name === 'progress_update') {
    const { step, progress } = event.data as { step: string; progress: number };
    console.log(`进度: ${progress.toFixed(0)}% - ${step}`);
  }
}

// 输出:
// 进度: 25% - 分析需求
// 进度: 50% - 制定方案
// 进度: 75% - 生成代码
// 进度: 100% - 验证结果

五、HTTP SSE 集成

1、在 Express 中实现 SSE 流式接口

SSE: 查看MDN相关文档《Server-sent events》

步骤1:安装依赖
bash 复制代码
npm install express @types/express
步骤2:创建SSE端点
typescript 复制代码
import express from 'express';
import { HumanMessage } from '@langchain/core/messages';

const app = express();
app.use(express.json());

app.post('/chat/stream', async (req, res) => {
  // 设置 SSE 响应头
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // Nginx兼容
  res.flushHeaders();

  const { message, threadId } = req.body;
  const config = { configurable: { thread_id: threadId } };

  try {
    for await (const [msgChunk] of graph.stream(
      { messages: [new HumanMessage(message)] },
      { ...config, streamMode: 'messages' }
    )) {
      if (msgChunk.content) {
        // SSE 格式:data: xxx\n\n
        res.write(`data: ${JSON.stringify({ token: msgChunk.content })}\n\n`);
      }
    }
    // 发送完成标志
    res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
  } catch (error) {
    res.write(`data: ${JSON.stringify({ error: String(error) })}\n\n`);
  } finally {
    res.end();
  }
});

app.listen(3000, () => {
  console.log('SSE服务器运行在 http://localhost:3000');
});

代码解读:

  • Content-Type: text/event-stream:SSE 的固定 MIME 类型
  • Cache-Control: no-cache:防止代理缓存
  • Connection: keep-alive:保持连接打开
  • X-Accel-Buffering: no:Nginx兼容,禁用缓冲
  • res.flushHeaders():立即发送响应头,建立 SSE 连接
  • data: xxx\n\n :SSE 标准格式,每条消息以两个换行结尾
  • 前端用 EventSource API fetch + ReadableStream 消费

SSE格式说明:

  • 每条消息以"data: "开头
  • 以"\n\n"(双换行)结尾
  • 可以包含多个字段:event, id, retry等
步骤3:前端消费SSE
javascript 复制代码
// 前端JavaScript代码
const eventSource = new EventSource('/chat/stream', {
  headers: { 'Content-Type': 'application/json' },
});

let fullResponse = '';

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  
  if (data.token) {
    // 逐字追加到显示区域
    fullResponse += data.token;
    document.getElementById('response').textContent = fullResponse;
  } else if (data.done) {
    // 完成,关闭连接
    eventSource.close();
    console.log('生成完成');
  } else if (data.error) {
    // 错误处理
    console.error('错误:', data.error);
    eventSource.close();
  }
};

eventSource.onerror = (error) => {
  console.error('SSE连接错误:', error);
  eventSource.close();
};

SSE工作流程图:
LangGraph Express服务器 浏览器 LangGraph Express服务器 浏览器 loop [流式输出] POST /chat/stream 设置SSE响应头 响应头(200 OK) stream(messages模式) token data: {"token":"x"}\n\n 显示token data: {"done":true}\n\n 关闭EventSource

2、WebSocket 替代方案

对于双向通信场景,WebSocket 更合适:

typescript 复制代码
import WebSocket from 'ws';

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  ws.on('message', async (message) => {
    const { text, threadId } = JSON.parse(message.toString());
    
    for await (const [msgChunk] of graph.stream(
      { messages: [new HumanMessage(text)] },
      { configurable: { thread_id: threadId }, streamMode: 'messages' }
    )) {
      if (msgChunk.content) {
        ws.send(JSON.stringify({ type: 'token', content: msgChunk.content }));
      }
    }
    
    ws.send(JSON.stringify({ type: 'done' }));
  });
});

六、最佳实践和踩坑指南

💡 实践 1:根据场景选择合适的流式模式

场景 推荐模式 原因
聊天界面打字机效果 messages 逐 Token 展示,首字延迟低
多节点执行进度展示 updates 知道每个节点的完成情况
完整执行监控/调试 streamEvents 最细粒度的事件
长任务进度条 自定义事件 节点内部主动推送
状态快照保存 values 完整State便于恢复

💡 实践 2:流式输出中的错误处理

typescript 复制代码
async function safeStream(input: typeof MessagesAnnotation.State) {
  try {
    for await (const chunk of graph.stream(input, config)) {
      yield chunk; // 继续向上传递
    }
  } catch (error) {
    console.error('流式输出中断:', error);
    yield { error: String(error) }; // 推送错误事件
  }
}

原因:流式过程中任何错误都应该优雅处理,而不是直接崩溃。

💡 实践 3:控制流式频率

typescript 复制代码
// 批量发送tokens,减少网络请求
let buffer = '';
let count = 0;

for await (const [msgChunk] of graph.stream(input, config)) {
  if (msgChunk.content) {
    buffer += msgChunk.content;
    count++;
    
    // 每10个token或每100ms发送一次
    if (count >= 10) {
      sendToClient(buffer);
      buffer = '';
      count = 0;
    }
  }
}

// 发送剩余内容
if (buffer) {
  sendToClient(buffer);
}

原因:过于频繁的small packets会增加网络开销,适当批量化提升性能。

⚠️ 常见问题

问题 现象 解决方案
混用 invoke 和 stream 结果格式不一致 确定一种调用方式,统一接口
SSE 连接被代理截断 流式失效,等待完整响应 配置 Nginx 关闭缓冲:proxy_buffering off
客户端未处理 SSE 关闭 连接永久挂起 服务端发送 done 事件,客户端监听后关闭 EventSource
streamEvents v1/v2 混用 事件格式解析错误 统一使用 version: 'v2',字段结构更清晰
忘记设置CORS 前端无法连接SSE 添加Access-Control-Allow-Origin响应头
内存泄漏 长时间运行后内存增长 及时关闭未使用的stream,清理event listeners

📝 本章小结

核心知识点回顾

知识点 关键要点 应用场景
streamMode: 'values' 每步完整 State 状态快照监控
streamMode: 'updates' 每步增量更新 节点执行追踪
streamMode: 'messages' 逐 Token 输出 聊天打字机效果
streamEvents 完整执行事件流 调试和可视化
自定义事件 节点内主动推送 进度条、分步展示
HTTP SSE Server-Sent Events 单向流式通信
WebSocket 双向实时通信 交互式应用

🎯 动手练习

练习 1:打字机效果聊天界面

  • 目标:在命令行实现逐字显示的对话效果
  • 要求 :
    1. 使用 messages 模式
    2. 字符间有50ms延迟,模拟真实打字
    3. 支持多轮对话,保持上下文
  • 验收标准 :
    • AI 回复像真实打字一样逐字出现
    • 打字速度适中,不太快也不太慢
    • 多轮对话流畅,无卡顿

练习 2:多节点执行进度条

  • 目标:3节点工作流,实时显示每个节点的执行状态
  • 要求 :
    1. updates 模式
    2. 节点开始时显示"执行中⏳",完成后显示"✅"
    3. 显示总进度百分比
  • 验收标准 :
    • 控制台实时更新,无闪烁
    • 最终所有节点标记完成
    • 进度准确反映执行情况

练习 3:SSE 流式 API

  • 目标:实现一个 Express SSE 端点,前端 EventSource 消费
  • 要求 :
    1. 支持 thread_id,实现多轮流式对话
    2. 处理错误情况(网络断开、LLM错误等)
    3. 前端显示打字机效果
  • 验收标准 :
    • 浏览器能看到 AI 回复逐字出现
    • 刷新页面后对话历史保留
    • 错误情况下优雅降级

练习 4:自定义进度事件

  • 目标:长任务节点主动推送进度
  • 要求 :
    1. 创建耗时5秒的处理节点
    2. 每秒推送一次进度更新
    3. 前端显示进度条动画
  • 验收标准 :
    • 进度条平滑从0%到100%
    • 每步都有明确的状态提示
    • 任务完成后显示结果

📚 延伸阅读


下一章:第10章 ------ 记忆系统:短期与长期记忆

相关推荐
从零开始学习人工智能1 小时前
量化评估RAG效果:LLM答案自动评估脚本全解析
人工智能·多模态·rag
白熊1881 小时前
【大模型Agent】基于LangGraph搭建 多轮对话客户支持机器人 项目示例
人工智能·大模型·llm·agent·langgraph
love在水一方1 小时前
【Voxel-SLAM】Data Structures / 数据结构文档(二)
数据结构·人工智能·机器学习
ConardLi1 小时前
开源我的 GPT-Image2 生图 Skill,附大量玩法指南
前端·人工智能·后端
QYR_111 小时前
2026卷绕式扣式电池产业洞察:智能制造如何重塑微型储能格局?
人工智能·市场调研
白熊1881 小时前
【大模型Agent】LangGraph 深度科普:为智能体而生的“有状态”编排框架
人工智能·langchain·agent·langgraph
数智工坊1 小时前
【SIoU Loss论文阅读】:引入角度感知的框回归损失,让检测收敛更快更准
论文阅读·人工智能·深度学习·机器学习·数据挖掘·回归·cnn
bloglin999991 小时前
向量大模型升级可能改变向量空间(需要回归)
人工智能·数据挖掘·回归
AI技术增长1 小时前
Pytorch图像去噪实战(三):ResUNet图像去噪模型实战,解决UNet深层训练不稳定问题
人工智能·pytorch·深度学习