📖 本章学习目标
- ✅ 理解为什么流式输出对 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- 适用于:需要知道每步执行后的完整状态,如进度条显示
优势:
- 可以看到每一步的完整状态
- 适合调试和监控
- 可以保存中间状态用于恢复
劣势:
- 数据量大,每次传输完整State
- 不适合高频更新场景
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 模式数据量更小,适合需要精确追踪哪个节点改了什么的场景。
优势:
- 数据量小,只传输变化部分,高效精准
- 清晰看到每个节点的贡献
- 适合多节点工作流监控
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 风格打字机效果的标准做法
工作原理:
- LLM开始生成第一个token
- 立即通过stream返回给客户端
- 客户端显示该token
- 重复直到生成完毕
优势:
- 极低的首字延迟(TTFT < 500ms)
- 用户体验极佳
- 适合长文本生成
实际应用:
- 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:打字机效果聊天界面
- 目标:在命令行实现逐字显示的对话效果
- 要求 :
- 使用
messages模式 - 字符间有50ms延迟,模拟真实打字
- 支持多轮对话,保持上下文
- 使用
- 验收标准 :
- AI 回复像真实打字一样逐字出现
- 打字速度适中,不太快也不太慢
- 多轮对话流畅,无卡顿
练习 2:多节点执行进度条
- 目标:3节点工作流,实时显示每个节点的执行状态
- 要求 :
- 用
updates模式 - 节点开始时显示"执行中⏳",完成后显示"✅"
- 显示总进度百分比
- 用
- 验收标准 :
- 控制台实时更新,无闪烁
- 最终所有节点标记完成
- 进度准确反映执行情况
练习 3:SSE 流式 API
- 目标:实现一个 Express SSE 端点,前端 EventSource 消费
- 要求 :
- 支持 thread_id,实现多轮流式对话
- 处理错误情况(网络断开、LLM错误等)
- 前端显示打字机效果
- 验收标准 :
- 浏览器能看到 AI 回复逐字出现
- 刷新页面后对话历史保留
- 错误情况下优雅降级
练习 4:自定义进度事件
- 目标:长任务节点主动推送进度
- 要求 :
- 创建耗时5秒的处理节点
- 每秒推送一次进度更新
- 前端显示进度条动画
- 验收标准 :
- 进度条平滑从0%到100%
- 每步都有明确的状态提示
- 任务完成后显示结果
📚 延伸阅读
下一章:第10章 ------ 记忆系统:短期与长期记忆