TL;DR: LangGraph 的 stream_mode 有七种,前端打字机效果用的是 messages;「子图 token 推不出来」要开 subgraphs=True;进度条用 custom 模式自己推。前端侧用官方 SDK 的 useStream,断线靠按 run 重连。2026 年的新项目还应该知道 1.1 引入的类型化流式事件。
先看一次典型的联调排查
假设这样一个联调现场:后端在本地跑 demo,终端里 token 一个一个往外蹦,一切正常。接上前端之后,打字机效果时有时无:简单问题有流式,复杂问题整个转圈,十几秒后一次性吐出全文。前端怀疑后端,后端怀疑前端,两边抓包都说自己没问题。
这种「时有时无」几乎从来不是网络问题,而是三个机制没对齐。第一,流式模式选错了:你开的模式根本不产出 token 级事件,前端自然等不到东西。第二,子图没有透传:主图里 LLM 的 token 推出来了,子图里 LLM 的 token 被黑盒挡住,于是走子图的请求没流式,不走的请求有,看起来就像随机故障。第三,某次内部 LLM 调用被标签挡掉了:设计如此,但前端不知道,误以为丢了事件。
在逐个拆这三个机制之前,把流式的全貌画出来是有用的。一次「打字机效果」要经过四段:服务端的 LLM 产出增量、LangGraph 运行时把增量包装成事件、HTTP 接口把事件推给浏览器、前端解析事件并渲染。任何一段断了,现象都是同一个:「没有流式」。所以排查的第一步不是抓包,而是先确认你在服务端订阅的模式确实产出你想要的那个事件类型,这也是本篇的主线:先把七种模式各自的产出搞清楚,剩下的联调问题大多会自动消失。
最后把机制说直白:LangGraph 的执行是一个 superstep 接一个 superstep 跑的(第 02 篇讲过这个概念),每个 superstep 里有节点在跑,节点里有 LLM 在吐 token,节点执行完状态做一次合并和存档。stream() 做的事,是把执行过程里发生的各种事件按你订阅的模式分流推出来。所以模式不是七种不同的流,更像是同一个执行过程的七种视图。你该开哪个,取决于前端画的是什么:打字机、流程进度、业务事件,还是调试面板。选模式的错误大多源于把「流式」当成一个开关,而它其实是一组各管一段的订阅。
顺手对齐三个贯穿全文的名词。thread 是会话,一次对话一个,跨多轮存活(第 03 篇的持久化挂在它下面);run 是一次执行,用户发一句话,运行时在某个 thread 下发起一个 run;stream 是这个 run 的事件通道,run 开始它打开,run 结束它关闭。后面讲断线重连、按 run 订阅、历史回放,全部落在这三个概念上,现在分清,后面不混。
这类问题的排查还牵涉前后端分工,先把责任边界划清,联调时才不会互相等:服务端负责模式选择、节点过滤、标签屏蔽,保证「该出的事件确实在发」;基础设施负责缓冲策略,保证「发了的事件能及时到达」;前端负责解析、聚合、重连,保证「到了的事件正确渲染,断了的连接正确恢复」。三方各自拿出证据(服务端事件日志、代理时间差、前端解析日志),十分钟就能定位断在哪一段;混在一起吵「谁的问题」,一周都定不了案。
七种模式,先分清要什么
| stream_mode | 推给你什么 | 典型用途 |
|---|---|---|
values |
每个 superstep 后的完整状态 | 调试看全局 |
updates |
每个节点的增量更新 | 看哪个节点改了什么 |
messages |
(消息块, 元数据) 元组 | 前端打字机效果 |
custom |
你自己写的任意事件 | 进度条、业务事件 |
checkpoints |
快照元数据 | 深度调试持久化 |
tasks |
任务开始/结束事件 | 追踪并行任务进度 |
debug |
运行时最详细事件 | 框架级排查 |
这七种不是并列的选项,它们对应着执行层级的不同深度,对着看就清楚了:values 和 updates 在 superstep 层,回答「状态现在是什么、这一步改了什么」;tasks 在并行任务层,回答「哪些分支在跑、跑完没有」;messages 在 LLM 调用层,回答「模型正在输出什么字」;custom 在你的业务层,推什么完全由你定义;checkpoints 在持久化层;debug 则把下面各层全部打开。前端要什么,就订阅到对应的层,订浅了拿不到细节,订深了被事件量淹没。
最常混用的是 values 和 updates,值得把区别说透。假设一个两节点的图:draft 节点写出一篇文章放进状态,review 节点给文章加一个评分字段。开 values,你在 draft 之后收到 {"article": "..."},在 review 之后收到 {"article": "...", "score": 8},每次都是截至当时的完整状态;开 updates,你收到的是 {"draft": {"article": "..."}} 和 {"review": {"score": 8}},每次只有这一步改了什么。补充一个细节:updates 里每个节点给出的增量是「节点返回值经过 reducer 合并之后」的结果,所以并行分支在同一 superstep 的写入会合在一起出现(合并规则见第 02 篇和第 08 篇),调试并行合并问题时它比 values 更能说明「谁写了什么」。
两者的用途由此决定:UI 上展示「流程跑到哪个节点」用 updates,一条事件对应一个节点动作,前端画步骤条正合适;要把完整执行记录导出存档或做对比,用 values,它天然是执行状态的时间线,第 15 篇的回归测试就是拿历史 values 做逐字段对比。顺带回答一个常被问的问题:流式期间前端要不要自己攒状态?大多数情况不用,values 事件本身就是攒好的最新状态,前端存最后一条就是当前全貌;自己再攒一遍,等于把 reducer 逻辑在前端复刻一份,和第 02 篇讲的合并规则一旦不一致,界面和服务端就对不上了。
剩下几种使用频率低得多,但各有明确位置:checkpoints 推快照元数据,排查持久化问题时有用,第 13 篇的表膨胀事故靠的就是这类数据看清写入频率;tasks 推任务级开始结束事件,在并行分支多的图里(第 08 篇的 Send 场景)看进度比 updates 更直观,因为它按任务而不是按节点聚合;debug 是全量事件,一秒几千条,只在框架级排查时短开,绝不要默认挂着。
给开发期一个实用建议:本地调试时把模式开成列表(比如 ["messages", "updates", "custom"]),一次执行同时看到三层视图,节点状态变化和 token 产出对得上时间线,很多「数据不对」的问题当场就能定位。这套配置只在开发环境用,上生产前收回到业务需要的最小集合,两套配置分别放在环境判断后面,不要靠人记得改。
还有一个边界要知道:一次 stream() 订阅的是一次 run 的事件,run 结束流就关了。想拿「会话最终状态」,去读 thread 的持久化状态(第 03 篇),那是 run 之外的事实来源;流只在 run 存活期间有增量。把这两个尺度分开,很多「流关了之后我该信谁」的困惑就不存在了:流负责过程,状态负责结论。
messages 模式:打字机效果的三个细节
for chunk in app.stream(inputs, config, stream_mode="messages"):
msg, metadata = chunk # 元组,不是裸消息
if msg.content and metadata["langgraph_node"] == "writer":
print(msg.content, end="", flush=True)
细节一:输出是元组,不是裸消息。第二个元素 metadata 标明了这条消息块来自哪个节点(langgraph_node),还有步数等来源信息。为什么设计成元组?因为所有 LLM 调用(不管在哪个节点、哪层子图)的输出都汇进同一条消息流,不带来源,前端就没法区分「这是给用户看的」还是「这是内部思考」。单 agent 应用可以先无视它;多 agent 应用必须用它,因为内部节点(审核、改写、摘要)的输出也在同一条流里,不过滤,用户就会在打字机里看到不该看到的中间思考,甚至把审核意见当成客服回答发出去。过滤就写在上面代码判断的位置,这是多 agent 应用流式的第一道闸门。哪些节点允许出流,建议收进一个白名单常量集中维护,而不是在每处循环里散着写;这个白名单和第 16 篇讲的工具权限是同一种思维:对内可见和对外可见是两个集合,混在一个集合里早晚会漏。
细节二:嵌套子图默认不透传。这是「时有时无」的第一嫌疑人。机制是:事件流默认只在主图层级产生,主图节点里直接调 LLM,token 能推出来;主图节点本身是个子图时,子图内部变成黑盒,主图只会在子图整体结束后推一次状态。现象就变成:不走子图的请求有流式,走子图的请求没有。加 subgraphs=True 让子图内部事件也冒出来:
app.stream(inputs, config, subgraphs=True, stream_mode="messages")
开了之后,事件带着子图命名空间一起出来,前端的过滤逻辑要跟着升级,不然子图里的内部调用会漏给用户。举个具体的搭配:主图里 support 节点是个子图,内部有 retrieve 和 compose 两个节点,你只想把 compose 的输出给用户看。白名单就要同时匹配命名空间和节点名,只判 langgraph_node 会把 retrieve 也放进来。第 07 篇从子图视角讲同一件事,包括流式不透传之外的其他隔离行为,两篇对照着看更完整。
细节三:不想暴露的内部 LLM 调用可以打 nostream 标签,运行时会跳过它的事件。典型用途是上下文压缩摘要:这次调用对用户没有信息量,推出去只会让打字机「倒带」,把已经显示过的内容重打一遍。用法是在模型调用上挂标签,比如用 with_config(tags=["nostream"]) 包一次再调。注意它和细节二是反向操作:一个让事件流出来,一个让事件不流出来。排查「前端少了内容」时,先确认图里有没有人配置过这类标签,再怀疑框架。
还有一个容易忽略的点:messages 模式推的是模型产出的 chunk,chunk 的粒度和内容形态取决于模型和 SDK 的实现,有的一次一个词,有的一次几个字,还可能夹带角色头部信息。前端不要假设「一个 chunk 等于一个字」或者「chunk 拼起来就是全文」,中文场景下分词粒度的抖动尤其明显。合帧渲染(见后面权衡部分)能同时解决粒度不均和渲染压力两个问题。
前端拼装消息时按 chunk 上的消息 id 聚合,同一 id 的 chunk 依次追加,而不是按到达顺序往界面尾部怼。差别在并行分支和人审恢复时显现:多支路同时吐 token 时按 id 聚合才不会把两条消息的字混在一起;interrupt 恢复后 run 重新开始吐消息,按 id 才能判断是「接着这条消息续写」还是「开了一条新消息」。这个 id 聚合逻辑写在前端一个纯函数里,单独测它,比整条链路联调时再排查便宜得多。
custom 模式:进度不只能靠 token
长任务里,token 流是个很差的进度信号。比如批量检索 50 个网页再做汇总:token 流只在每次模型调用时才有,检索的几十秒里一片安静,用户以为卡死了,刷新,任务中断,全部重来。用户真正想看的是业务进度:处理到第几个、当前在做什么、还剩多久。custom 模式就是给这个用的:节点里拿一个 writer,想推什么推什么:
from langgraph.config import get_stream_writer
def crawl(state: dict) -> dict:
writer = get_stream_writer()
for i, url in enumerate(state["urls"]):
writer({"progress": f"{i + 1}/{len(state['urls'])}", "url": url})
do_fetch(url)
return {"done": True}
writer 要在节点执行期间拿,它和这一次 run 绑定,推出去的事件由运行时路由给订阅了 custom 模式的客户端。事件内容完全由你定义,stream_mode="custom" 时这些字典原样出现在客户端。设计 payload 时建议自带业务字段:当前步骤、总数、对象标识。意义在断线重连时显现:前端拿着最后一条 custom 事件就知道流程停在哪一步,恢复后从哪继续展示,比只推一个裸数字有用得多。
还有一个属性要清楚:custom 事件走的是事件流,不进 state。它适合推「展示用」的信息,不适合推任何需要留痕的东西。比如「已处理 30/50」推给前端没问题,但「这 30 个里哪几个失败了」这种要参与后续流程或要追责的信息,必须写进 state 让 checkpointer 存档,光推流,进程一重启就什么都没了。一条简单的分界线:会过期的进流,要留底的进 state。
阶段级通知也适合走 custom:节点开头推「开始汇总」,结束时推「汇总完成」,配合 messages 的 token 流,前端能画出完整的进度感。并行场景(第 08 篇的 Send 展开多个分支)它尤其好用:让每个分支在事件里带上自己的任务标识,前端按标识分组展示「50 个子任务各自到了哪一步」,这是 token 流和节点级 updates 都给不了的粒度。它和 messages 模式可以一起开:stream_mode=["messages", "custom"]。多模式同开有个小变化必须知道:事件不再是裸数据,而是带模式名的二元组,前端先按模式名分发,再按各自格式解析。这是多模式联调的第一个坑:代码还按单模式写,收到元组解析报错,误以为框架坏了。
最后划一条边界:custom 事件的进度只覆盖单个 run。你要展示「这个用户今天跑过的所有任务」这种跨 run 的进度,事件流帮不上,那要么轮询 thread 列表,要么把任务登记进持久层。别在流上硬做全局视图,流天生是「一次执行」的尺度。事件频率也讲纪律:处理 1 万个文件的循环里逐个推事件,和不开进度条一样糟糕,攒批推(每完成 50 个或每 2 秒推一次)是这类场景的常规做法,阈值放进配置而不是写死。
选模式有个 30 秒版本:要打字机,messages;要步骤条,updates;要导出执行记录,values;要业务进度,custom;排查并行,tasks;排查持久化,checkpoints;框架级疑难杂症,debug。拿不准就先开 updates 加 messages,覆盖大多数前端需求,再按缺口加。
前端联调的现成方案
不用从裸连接撸起。官方 JS/TS SDK(@langchain/langgraph-sdk)提供 useStream React hook,接管了协议细节:消息流的解析与拼装、加载与错误状态、interrupt 的呈现(第 06 篇的人审 payload 怎么展示、用户提交的恢复值怎么送回去,hook 里都有对应接口)、以及按 run 重连。团队用它的实际收益不只是省代码:协议升级(比如迁到 v2 类型化事件)会随 SDK 更新带来,前端自己的解析层不用跟着改,把「流式协议」从你要维护的东西变成你依赖的依赖。配合 Agent Chat UI(开源 Next.js 项目)可以更快拿到完整参考实现。建议的路径是先把它跑通,再往自己的前端里搬需要的部分,而不是照着协议文档从零写:流式协议里的细节(元组、命名空间、interrupt 字段)自己拼很容易漏,漏一个就是一次「时有时无」。
部署形态对前端是透明的:无论是自部署的 Agent Server 还是托管服务,流式协议是同一套,前端代码不用为换部署方式重写。这意味着联调可以全程在本地做,第 17 篇讲的部署选型不会反过来绑架前端方案。
真正没有全家桶的是断线重连。长流程跑几分钟,手机锁屏、切应用、网络闪断,流断了,但任务还在服务端跑。这是流式 UI 最容易被漏测的场景,也是上线后投诉最集中的场景。处理思路可以固定成一个手册:
- 发起 run 时把 run_id 存下来,存哪(内存、sessionStorage 还是服务端会话)按你的产品形态定,刷新页面后还要能用就别只存内存。
- 流断了(连接关闭、组件重挂载、页面恢复),先拿着 run_id 查这次 run 的状态。
- run 还在跑:用
joinStream按 run_id 重新挂上事件流,把最后一条 custom 事件的进度作为续接点渲染。 - run 已结束:直接拉最终状态,不再订阅(对已结束的 run 订阅只会拿到空流)。
- 验证两个不变量:事件不丢(最后渲染的结果和 state 一致)、界面不重复渲染(重连不从头开始画)。
这一块值得在联调阶段写成自动化测试或至少一个手测清单,每个前端发版都过一遍。流式的 bug 几乎全部藏在这种「正常路径之外」的分支里。
断线重连之外,还有两件事和 thread 的生命周期挂钩。一是历史渲染:用户打开一个已有会话,第一屏的消息历史不该从流里等,直接按 thread_id 从服务端拉已持久化的状态来渲染(第 03 篇的 thread 概念),流只负责这个会话里新发生的增量。把「历史」和「增量」分成两条数据通路,重连、刷新、多端同步都变成拉状态的小事,而不是重放事件的难题。二是 interrupt 的联动:人审触发时,事件流里会出现 interrupt 信号(v1 从状态字段挖,v2 读属性),人提交恢复值之后,运行时从断点继续,流上会出现新的事件。前端要把「展示审批面板」和「提交后继续消费同一个 thread 的流」当成一个闭环来处理,第 06 篇的六个坑里有几个就炸在这一环。
另一个经常被忽略的「没有流式」原因在基础设施层:反向代理默认会缓冲上游响应,攒够一块再往浏览器推,LangGraph 这边事件正常产生,到用户面前就成了一坨。如果服务端日志里事件在正常发、浏览器里却整段出现,先查代理的缓冲配置(比如 nginx 默认开启的响应缓冲),对流式接口关掉它,再看别的。抓包抓不到这类问题,因为它发生在服务器自己身上,要对比「服务端发出时刻」和「浏览器收到时刻」才能确认。
把本篇的排查点收成一张清单,下次「没有流式」按序过一遍,多数十分钟内出结论:
- 服务端确认:订阅的模式里有没有产出目标事件的那个(打字机找
messages,进度找custom)。 - 子图确认:事件是否产生在子图内部而没开
subgraphs=True。 - 标签确认:目标调用是否被
nostream类标签有意挡掉。 - 基础设施确认:代理是否在缓冲响应,服务端发出时刻和浏览器收到时刻差多少。
- 前端确认:过滤白名单是否把节点错杀,chunk 是否按消息 id 正确聚合,元组解包是否按多模式格式写的。
- 环境确认:跑的是不是这次的 run(重连时 run 已结束,订阅只会拿到空流)。
1.1 之后:类型化事件
传统流式的输出是无 schema 的字典,前端解析全靠约定。最疼的是 interrupt:人审触发时,前端要从状态里挖 __interrupt__ 这个约定字段,字段名拼错没有任何报错,只有「人审面板永远不弹」这种静默失败。1.1 起可以 stream(..., version="v2") 拿到类型化的 StreamPart 事件,invoke 返回 GraphOutput(.value / .interrupts),挖字段变成读属性,前端可以基于类型定义生成解析代码,字段改了会直接在编译期报错。官方还推出了更激进的内容块中心事件流(v3,beta),围绕内容块而不是节点组织事件,方向是为多模态和多角色输出铺路。
迁移判断:存量代码没必要急着迁,无 schema 字典还能跑,迁移收益是工程性而不是功能性;新项目直接从 v2 起步,省掉一次事件格式迁移。中间状态(老项目加新功能)建议新功能用 v2,但别在一个前端里长期维护两套解析逻辑,分批迁完比永远双轨便宜。
补一条容易被忽视的工程判断:流式事件事实上是你们系统对前端的一层公开接口,前端类型跟着它写,它一变前端就变。所以事件里的字段(尤其 custom 模式自己定义的 payload)要当公共 API 对待:命名定下来就别随手改,要改就加新字段、旧字段过渡一个版本。类型化事件让这个约束显性了,但约束本身从第一天就存在,v1 的字典时代大家靠默契,v2 之后靠类型,别因为有了类型就把「接口会变」当成无成本的事。
权衡
流式的真实成本在服务端事件量和前端消费逻辑,不在「开个流式」这个动作上。成本结构也不对称:开发时多开一个模式几乎免费,出事时它推给你的每条事件都在消耗带宽、电量和渲染时间,而发现问题往往要等用户在真机上卡了才知道。所以默认集合要按「业务需要的最小订阅」来定,不是按「开发时顺手全开」来定。全模式全开(debug 加 subgraphs=True)在多 agent 应用里每秒能产生几千个事件,前端渲染会先于网络崩掉。三条具体建议:
- 生产只开业务需要的模式,
debug和checkpoints留给排查,排查完关掉。事件量要进监控,和接口耗时放同一块面板。 - 前端对 token 做合帧:每 50ms 把攒到的 chunk 合并渲染一次。打字机观感几乎不变,渲染压力降一个数量级,chunk 粒度不均的问题同时消失。
- 把「事件太多」当成和「响应太慢」同级的性能问题对待,有指标(每秒事件数、前端丢帧率)再看它,而不是等用户投诉卡。
指标上建议盯三个:首 token 时间(用户感知的「快不快」主要是它,不是总耗时)、事件吞吐(服务端每秒发出多少事件,异常上涨往往意味着模式开多了或子图透传范围过大)、渲染帧率(前端合帧是否生效)。移动端弱网再多一条:重连要有预算(最多几次、间隔多久),超预算就降级到轮询,别让一个打不开的流把页面拖死。另外给流式准备一条降级路径:流建立失败或中途异常时,前端退回轮询拿状态,体验降级但功能不丢。流式链路的部件(网络、代理、运行时、前端解析)比普通请求多,出故障的概率也按部件数放大,降级路径是给这条链路买的保险,成本不高,值得在第一版就带上。错误发生后给用户看什么、哪些错误该自动重试哪些该上报,直接沿用第 12 篇的四类错误分类,前端文案跟着错误类别走,而不是一律「出错了请重试」。
多租户场景多一条安全提醒:流是按 run 订阅的,joinStream 拿着 run_id 就能挂上来,所以 run_id 的可猜解性和接口鉴权要在设计期一起考虑,确保用户只能订阅自己的 run。这件事和第 16 篇的权限分级、第 17 篇的部署形态都相关,放到最后补往往会漏。
最后一个判断:不是所有应用都需要打字机。内部工具、批处理、用户不盯着屏幕的场景,轮询状态或直接拿最终结果就够了。流式是一整套要前后端一起维护的协议(模式选择、过滤规则、断线重连、合帧),值得为它付钱的是「用户盯着屏幕等结果」的场景。反过来说,只要你的产品是这个形态,这套机制就该在第一个版本进设计,后补的流式几乎都要把前后端接口翻一遍。
延伸阅读
- Streaming 文档:七种模式与 v2 事件
- LangGraph SDK (JS):useStream 与 joinStream
- Agent Chat UI:流式 + interrupt 的完整前端参考