深入学LangChain官方文档(二十):Frontend 会话交互基础——消息队列、断线恢复与会话分支

深入学LangChain官方文档(二十):Frontend 会话交互基础——消息队列、断线恢复与会话分支

本篇对应的官方文档

  • Frontend overview:说明前端 SDK 怎样把 Agent 的持久状态和运行过程交给 UI。
  • Message queues:说明运行期间的连续提交怎样进入同一 thread 的队列。
  • Join & rejoin streams:说明客户端断开后怎样保留服务端运行,并用同一threadId重新加入。
  • Branching chat:说明编辑或重新生成怎样从父 checkpoint 创建新分支。

本篇讲解范围
本篇只建立前端会话的四个基础对象:stream、thread、queue 和 checkpoint。工具卡片、审批面板与生成式 UI 将在后续文章展开。

客服用户提交“帮我查订单为什么还没发货”,Agent 开始查询仓储和物流。运行期间,用户又补充订单号;随后手机网络切换,页面暂时离开;回来后,他发现最初写错了订单号,希望从那条消息重新生成,而不是删掉整段历史。

如果前端只有一个消息数组和“正在生成”布尔值,这四个动作会互相冲突:补充消息可能抢占当前运行,断线可能被误认为任务停止,重新生成可能覆盖原历史,页面刷新后也不知道应该接回哪一次运行。真正的 Agent 前端需要管理的不是一串气泡,而是一条可持续、可排队、可恢复、可分支的运行时间线。


LangChain 官方 Frontend 文档把这条边界说得很清楚:后端createAgent生成可流式运行的 LangGraph 图,前端 SDK 通过 stream API 获得响应式状态。useStream不只返回 token,还暴露消息、工具调用、中断、状态值、checkpoint 和 thread 元数据。UI 因而可以成为 Agent 运行的控制面,而不只是打字机效果。

图中浏览器只持有连接与状态投影,真正的 run 和持久 thread 位于服务端。因此,页面离开不能直接推导出任务结束,这也是后续队列、重连和分支机制的共同前提。

一、四个运行对象不能混用

前端最容易出现的错误,是用一个isLoading解释所有状态。用户看到“加载中”,却不知道它表示浏览器正在接收数据、服务端 Agent 正在运行、队列里还有请求,还是 UI 只是在恢复历史。

在进入具体 API 前,先固定四个对象:

  • message是某个 checkpoint 下的对话内容;
  • stream是当前前端与运行状态之间的响应式连接;
  • run是服务端正在执行的一次 Agent 任务;
  • thread是多次 run 和 checkpoint 共同所属的持久会话身份。

这四者不是同义词。stream 可以断开而 run 继续;同一个 thread 可以先后包含多次 run;编辑旧消息会从旧 checkpoint 创建新路径,但仍可保留在同一 thread 的历史中。只有先分开这些对象,按钮文案和错误处理才不会混乱。

短问答产品可以只呈现消息;一旦 Agent 会调用长工具、等待审批、允许连续输入或恢复历史,前端就必须显式呈现“正在运行”“已断开”“待处理”“已取消”和“当前分支”等状态。

可以把 UI 状态看成两条相交但不重合的轴。连接轴回答“客户端是否正在接收更新”,运行轴回答“服务端任务是否仍在执行”。连接且运行表示实时接收;断开且运行表示任务在后台继续;连接且结束表示已经拿到最终状态;断开且结束表示客户端离开期间任务已经完成,重新加入后应立即恢复结果。用这四种组合设计状态条,比一个isLoading更接近真实运行。

消息展示也应区分“已被服务端接受”和“仅存在于本地输入框”。在网络不稳定时,乐观插入一条气泡可能让用户以为 submission 已经进入 thread。更稳的界面会为本地发送、服务端确认、进入队列和开始运行分别建立状态,失败时允许用户明确重试,而不是悄悄再次提交。

二、threadId 是恢复会话的稳定坐标

第 9 篇讲过 checkpointer 如何保存 Agent 状态。在前端,持久状态最终通过threadId变成用户能感知的连续会话。页面刷新、组件重挂载或设备切换时,只要客户端仍能获得正确的threadId,就可以指向同一条服务端会话。

这解释了为什么把消息数组存进浏览器不够。本地数组只能回答“页面曾经显示过什么”,不能回答:

  • 服务端是否仍有 run 在执行;
  • 哪些消息属于待处理队列;
  • 当前 UI 位于哪个 checkpoint 分支;
  • 某个工具调用是完成、失败还是等待审批;
  • 重新连接后遗漏了哪些状态更新。

onThreadId的职责是接收服务端创建或确认的 thread 身份,应用再把它保存到适合的本地状态或业务会话记录。演示可以使用sessionStorage,生产系统还要考虑用户身份、跨设备同步、过期策略和服务端访问控制。知道一个threadId不应自动获得查看该会话的权限。

thread 也不是浏览器标签页的同义词。一个标签页可以切换多个 thread,一个 thread 也可能被不同客户端重新加入。UI 必须明确当前绑定的是哪条会话,避免把 A 客户的历史恢复到 B 客户界面。

业务系统通常还需要一个比threadId更稳定的会话目录。threadId负责指向 Agent Server 的持久状态,业务数据库则记录它属于哪个用户、订单或工单,以及何时创建、是否归档。前端从业务接口取得获准的 thread,再交给useStream,而不是任意接受 URL 中的 ID。这样恢复能力和访问控制才不会混在一起。

新建会话、切换会话和恢复会话也应有不同动作。新建会话清空当前绑定并让服务端创建新 thread;切换会话先保存当前 UI 草稿,再绑定另一个已授权 ID;恢复会话则保留原 ID 并重新获取状态。若把三者都实现成“清空消息数组”,服务端历史与页面显示迟早会分离。

三、消息队列让连续输入按顺序生效

用户在 Agent 查询物流时补充“订单号是 2026-001”,这是正常交互,不应简单禁用输入框。但如果第二条消息立即启动一个并发 run,两次运行可能同时读取和修改同一 thread,最终顺序与用户预期不一致。


multitaskStrategy: "enqueue"的语义是:当前 run 不被打断,新的 submission 进入当前 thread 的待处理队列;当前 run 结束后,下一项自动开始。它同时保留两件事——用户可以继续表达,服务端状态仍按明确顺序推进。

队列不是输入框下面的一组临时气泡。当前官方 SDK 通过各框架对应的 submission queue helper 暴露队列状态。以 React 为例,useSubmissionQueue(stream)可以读取queue.entriesqueue.size,并用queue.cancel(id)取消尚未开始的单项,或用queue.clear()清空所有待处理项。


队列项包含自己的 ID、提交值、选项和创建时间。UI 应把这些状态明确显示出来,让用户知道“订单号补充”仍在等待,而不是已经影响当前回答。取消队列项也不等于取消正在运行的任务:前者只移除未开始的 submission,后者需要stream.stop()或服务端取消接口。

顺序处理不代表后续消息一定仍然适用。第一轮可能已经查到订单并完成答复,排在第二位的“我还想补充订单号”到达时就失去上下文意义。因此,队列除了保证执行顺序,还需要产品层的有效性判断。可以在队列卡片上展示它将接在哪个任务之后,允许用户在开始前编辑或取消;服务端真正执行时,再检查依赖对象是否仍存在。

也不要把所有连续输入都排队。用户点击“停止”、批准高风险操作或回答 interrupt,通常对应当前运行的控制信号,而不是下一次普通 submission。不同意图必须进入不同通道:普通追问可以 enqueue,取消调用 stop,审批则恢复指定 interrupt。统一塞进消息队列,会让紧急控制动作等到当前任务结束后才生效。

四、用 stream 串起队列与连接

下面的代码集中展示本篇前三个对象:useStream绑定 Agent 与 thread,useSubmissionQueue读取待处理项,submit使用enqueuedisconnect只离开当前连接。示例省略具体 UI 样式,重点观察状态和副作用。

import { useCallback, useState } from "react"; import { useStream, useSubmissionQueue } from "@langchain/react"; const THREAD_STORAGE_KEY = "supportThreadId"; export function SupportChat() { const [threadId, setThreadId] = useState<string | null>( sessionStorage.getItem(THREAD_STORAGE_KEY), ); const [connected, setConnected] = useState(true); const [mountKey, setMountKey] = useState(0); const stream = useStream<typeof supportAgent>({ apiUrl: "http://localhost:2024", assistantId: "support_agent", threadId, onThreadId(id) { setThreadId(id); if (id) { sessionStorage.setItem(THREAD_STORAGE_KEY, id); } }, }); const queue = useSubmissionQueue(stream); // 把运行期间的新消息放入同一 thread 的待处理队列。 const submitFollowUp = useCallback( (text: string) => { stream.submit( { messages: [{ type: "human", content: text }] }, { multitaskStrategy: "enqueue" }, ); }, [stream], ); // 只断开当前客户端,不取消服务端正在执行的 run。 const disconnect = useCallback(() => { void stream.disconnect(); setConnected(false); }, [stream]); // 使用已经保存的 threadId 重新挂载 stream consumer。 const rejoin = useCallback(() => { setMountKey((value) => value + 1); setConnected(true); }, []); return ( <main key={mountKey}> <ConnectionStatus connected={connected} /> <MessageList messages={stream.messages} /> <QueueList entries={queue.entries} onCancel={(id) => void queue.cancel(id)} onClear={() => void queue.clear()} /> <ChatInput onSubmit={submitFollowUp} /> <button onClick={disconnect}>暂时离开</button> <button onClick={rejoin} disabled={connected || !threadId}> 重新连接 </button> </main> ); }

这段代码中,threadId是服务端会话坐标,stream是响应式连接,queue是同一 thread 的待处理 submission 投影。mountKey只是 React 示例中触发重新挂载的方式,不是通用协议字段;Vue、Svelte 和 Angular 使用各自的重新挂载或条件渲染机制。


还要注意运行条件:官方 Message queues 模式依赖 LangGraph Agent Server。若后端没有提供对应的持久 thread 与队列能力,在浏览器里维护一个本地数组只能改善显示,不能获得跨连接的可靠顺序、服务端取消和恢复语义。

图中的后续消息始终先进入服务端队列,再按顺序形成新的 run;它不会反向修改已经执行中的节点。因此,队列解决的是“何时生效”,不是“怎样抢占”。

五、disconnect 与 stop 的区别是服务端是否继续

移动网络切换、页面跳转或应用进入后台时,客户端可能暂时不需要实时更新,但服务端长任务通常应该继续完成。此时应调用stream.disconnect()。当前官方文档说明,它等价于stop({ cancel: false }):客户端离开 stream,服务端 run 继续执行。


断开后,前端不再接收新消息,stream.isLoading会变为false,但这不能被显示成“任务完成”。应用应维护独立的连接状态,例如“已断开,服务端可能仍在运行”。重新加入时,用已保存的threadId重新挂载 stream consumer:断开期间产生的消息会补回;若 run 仍在执行,实时更新继续;若已经完成,则直接得到最终状态。

用户点击“停止生成”是另一种意图。stream.stop()默认会断开客户端并取消服务端 run;应用也可以调用服务端运行取消接口。停止按钮应明确告诉用户任务会被取消,不能和“稍后回来”共用同一个处理函数。


最短判断是:离开页面、切到后台或短暂丢网,用 disconnect;用户明确要求终止执行,用 stop。两者都可能让浏览器不再收到数据,但服务端副作用完全不同。

重连也不是“重新发送最后一条消息”。重新发送会创建新的 run,可能重复调用收费工具、重复写入工单或重复执行交易。正确恢复依赖 thread 与服务端持久状态,而不是前端猜测上次执行到哪里。

实际重连过程还需要失败出口。保存的 thread 可能已过期、被归档、无权访问或后端暂时不可用。前端应先保留用户当前看到的快照,再显示恢复状态;超过合理时间后,可以退回读取 thread 历史,而不是无限旋转。服务端确认 thread 不存在时,再清理本地 ID,并明确询问是否开始新会话,不能静默创建一个看似连续的新 thread。

多标签页同时连接同一 thread 时,也要考虑竞争。一个标签页提交新消息,另一个标签页可能仍显示旧队列。最安全的假设是服务端状态为准,客户端本地状态只是投影;恢复焦点或收到版本冲突时,重新同步 thread,而不是强行用本地数组覆盖。

六、checkpoint 让历史可以分支而不被覆盖

用户把“订单 2026-001”写成“2026-011”,如果直接修改本地消息数组,UI 看起来正确,服务端状态却仍基于旧输入。Branching chat 的做法是从目标消息之前的 checkpoint 启动一条新执行路径,原路径继续保留。


当前官方接口不是把 UI “切换到某个分支名称”,而是读取消息元数据中的parentCheckpointId,再在stream.submit的第二个参数中传入forkFrom: { checkpointId }。编辑用户消息时提交新文本;重新生成 AI 响应时可以不提交新输入,只从该响应的父 checkpoint 再运行一次。

把图中的节点映射到下面代码:消息组件先取得父 checkpoint,编辑动作把新文本和该 ID 一起交给submitEditedBranch,重新生成则只把父 checkpoint 交给regenerateResponse。两条路径都会创建新 run,而不是覆盖原消息。

type StreamHandle = ReturnType<typeof useStream>; // 从用户消息之前的 checkpoint 提交编辑后的新分支。 function submitEditedBranch( stream: StreamHandle, parentCheckpointId: string | undefined, editedText: string, ) { if (!parentCheckpointId || stream.isLoading) { return; } stream.submit( { messages: [{ type: "human", content: editedText }] }, { forkFrom: { checkpointId: parentCheckpointId } }, ); } // 从 AI 消息之前的 checkpoint 重新运行,不修改原用户输入。 function regenerateResponse( stream: StreamHandle, parentCheckpointId: string | undefined, ) { if (!parentCheckpointId || stream.isLoading) { return; } stream.submit(undefined, { forkFrom: { checkpointId: parentCheckpointId }, }); }

消息组件应在顶层调用useMessageMetadata(stream, message.id)读取parentCheckpointId,事件处理器再把这个值交给上面的函数。React Hook 不能放进普通事件函数中条件调用。这个区别很重要:官方示例把元数据读取放在消息组件中,点击编辑或重新生成时再执行submit

分支之后,原路径不会被覆盖。若要构建独立时间线视图,可以按需读取 thread 的 checkpoint 历史;日常消息列表不需要每次渲染都加载完整树。编辑和重新生成按钮也应在 streaming 时禁用,避免当前状态仍在变化时从不稳定位置分叉。

分支 UI 还要让用户知道“当前正在看哪一条路径”。只把新回答追加到原列表,会让两个互斥条件下的结果看起来像同一对话中的连续结论。可以在被编辑的消息旁标记分叉点,为同级回答提供切换控件,并在继续提问时明确新消息将追加到当前分支。分支选择是导航状态,不应靠删除其他消息来实现。

对于有副作用的 Agent,历史分支尤其需要谨慎。从旧 checkpoint 重新运行,不等于外部世界也回到了过去。第一次路径可能已经发送邮件或创建工单,新分支再次运行可能重复执行。checkpoint 恢复的是 Agent state,不会自动回滚数据库和第三方系统。工具必须保持幂等,或在重新执行前要求用户确认副作用。

七、useStream 投影的是 Agent 状态,不只是文本

前面的队列、连接和分支都通过同一个 stream 暴露给 UI。下面这张图进一步展开它能投影的对象,避免把不同状态重新压回一个消息字符串。


同一个 stream 可以提供消息、工具调用生命周期、interrupt、checkpoint 历史、typed state values 和 thread 元数据。前端应该为不同运行对象设计不同组件:

  • 消息内容进入对话气泡;
  • pending、completed、failed 的工具调用进入工具卡片;
  • interrupt 进入审批或补充信息面板;
  • 自定义 state 中的表格、文件和指标进入结构化结果区;
  • queue entries 进入待处理列表;
  • connection 与 run 状态进入独立状态条。

如果把所有状态重新压成模型文本,再用正则解析“正在查询订单”,就失去了 SDK 提供的运行语义。用户也无法区分模型只是说“我将查询”,还是工具确实已经开始。

类型推断的价值也在这里。前端使用与后端图状态对应的类型后,values中有哪些字段、消息和工具状态怎样呈现,都可以在编译期获得约束。它不是为了让聊天组件更复杂,而是避免把业务对象退化成不可验证的字符串。

这种对象映射也决定了组件边界。消息列表不应负责取消 run,队列列表不应修改 checkpoint,连接状态条不应猜测工具结果。每个组件只读取对应的 stream 投影,并通过明确操作回写。这样发生异常时,团队能沿对象找到责任:是 queue helper 没刷新、thread 绑定错误、checkpoint 元数据缺失,还是工具状态根本没有从后端发出。

八、失败处理要沿时间线定位

前端会话失败不能统一显示“网络错误”。至少要拆成以下几类:

提交重复。用户双击发送,或断线后客户端错误地重放 submission。应用应给提交建立稳定 ID、禁用重复事件,并让服务端对有副作用的工具保持幂等。

队列过期。用户的第二条补充只对当前任务有效,但轮到它执行时条件已经变化。UI 应允许取消待处理项,必要时在服务端再次验证前置条件。

断线误判。浏览器离开后把isLoading=false显示成“已完成”,用户以为结果已经确定。连接状态、run 状态和最终状态必须分开呈现。

错误取消。页面卸载时调用stop(),导致服务端长任务被取消;或用户点击“停止”却只调用disconnect(),任务仍在后台产生费用和副作用。生命周期事件与用户操作应使用不同函数。

恢复错 thread。本地保存了过期或其他用户的threadId。服务端必须重新做身份与授权校验;前端在 thread 不可用时清理本地坐标并明确开始新会话。

分叉点错误。编辑消息时从消息之后的 checkpoint 开始,旧输入仍留在状态中;或在 streaming 未完成时允许分叉。应使用消息元数据中的父 checkpoint,并在运行期间禁用相关操作。

历史树膨胀。用户频繁编辑和重新生成,checkpoint 分支越来越深。时间线视图应按需加载、清楚标记当前路径,并测试深树下的渲染性能。

这些失败都可以沿同一条记录定位:threadId → run → queue entry → stream connection → checkpoint → branch。只保存最终消息,会让团队无法判断问题是重复提交、服务端仍在运行、重连失败,还是从错误 checkpoint 产生了新分支。

总结:前端连接的是可持续运行,不是一段 token

最后把一次连续会话按时间顺序复原:thread 提供身份,queue 接住新输入,stream 传递实时状态,checkpoint 保存可恢复位置,分支从过去的明确节点重新执行。


现在重新回答开头的客服场景。用户在运行期间补充订单号,使用enqueue进入同一 thread 的队列;客户端暂时离开,使用disconnect()让服务端继续;回来后用持久化的threadId重新挂载;发现旧消息错误,则读取它的parentCheckpointId,通过forkFrom创建新路径。原历史仍然保留,新的执行也有明确起点。

最短记法是:

queue 决定新输入何时生效,disconnect 决定客户端离开时服务端是否继续,checkpoint 决定修改过去从哪里重新计算,threadId 把这一切连接成同一条可恢复会话。

只会追加 token 的聊天框无法承担长运行 Agent。先把消息、连接、运行和会话分开,后续的工具卡片、审批交互和结构化结果才有稳定的状态基础。