第 11 章:事件流与前端折叠 —— 为什么流式聊天比 append token 难
DeerFlow 的前端既是 UI,也是一个事件投影器。它必须把不同时钟、不同持久级别的数据折叠成确定画面。
三条输入流
values 是权威快照,但到得更慢;messages-tuple 到得快,却可能是半个 tool-call JSON;历史完整却分页;乐观消息立即显示但没有后端 canonical ID。任何单一来源都不足以渲染正确对话。
消息身份比时间戳可靠
前端不把所有消息按 timestamp 重新排序。检查点压缩后可能只含“受保护早期输入 + 最近尾部”,时间排序无法表达中间缺口。系统使用 canonical message identity,把 checkpoint/live copy 覆盖到历史中的相同位置。
动态 context 会把用户消息 ID 从 X 换成 X__user,UI 只对 HumanMessage 规范化这个保留后缀,确保乐观输入和 checkpoint 回显仍是同一条可见消息。
为什么先来的 AI chunk 可能要后移
SSE 中 messages-tuple 可能先发 AI/tool step,values 稍后才带本轮 HumanMessage。若直接 append,界面会暂时出现“助手先回答,用户后提问”。提交时 hooks 记录 pre-submit identity baseline,只把本轮新出现的非 baseline 步骤移动到新用户消息之后,不重排旧历史。
这个 anchor 在 finish、stop、stream error 后仍保留,因为 SDK 的 settled frame 可能继续携带瞬态顺序;下一次提交或线程切换才重置。
Processing 与 Final 的区分
带 tool_calls 的 AIMessage 可能同时有可见文本,例如“刚才失败了,我换个方式”。它必须进入 processing group,而不能只显示工具卡。
更棘手的是,流式中的 content-only AIMessage 也暂时算 processing:provider 可能稍后给同一 message 追加 tool call。如果过早画成最终气泡,文本会在工具出现时跳回步骤面板。只有 run settle 后,最后的纯文本消息才稳定成为 final answer。
Reasoning 在 processing 和 final 两种组件里都放在答案上方,避免消息从一种渲染器切到另一种时顺序反转。
断线重放
StreamBridge 为事件分配 ID,浏览器在 sessionStorage 记录 thread 对应的 run_id。重连时通过 Last-Event-ID join。
如果请求的 ID 已早于 bridge 保留窗口,服务端发一个无 ID 的 gap 控制帧。上游 LangGraph SDK 不认识这个事件,会把它忽略并误判正常结束,因此 DeerFlow 在 API client 外包一层 lazy async iterable:
- 截获
gap; - 清理旧 reconnect metadata;
- 向 thread hook 发内部
stream_replay_gapcustom event; - 调
threads.getState()加载 durable values; - 从
latest_available_event_id之后重新 join; - 最多恢复五次,避免永久循环。
恢复期间不能 cancel 后端 run——gap 只说明客户端落后,不说明 Agent 停止。
子任务折叠
task_started/running/completed 是加法事件。core/tasks/lifecycle.ts 把它们归一化成 Partial<Subtask>,Context provider 用最新 ref 合并,避免异步历史 backfill 覆盖刚到的 SSE 更新。
步骤按 message_index 去重;累计 usage 取最大值;已完成任务的最后 AI answer 不在 steps 重复显示,因为它已经作为 card result 展示。
Artifact 状态的双层结构
ThreadState artifacts 是文件清单真相源。浏览器 sessionStorage 只保存 Artifact 面板是否打开、当前选中路径和刷新 bootstrap cache。初始空 stream value 不得在历史加载完成前清空已恢复 UI 状态。
流式 write_file/str_replace 参数可生成瞬态预览;run 完成后再正式刷新文件内容。自动打开产物要在 effect 中设 timer 并清理,不能在 render 期间安排副作用。
Stop 与最终一致性
Stop 会走 SDK 的 abort + 后端 cancel。前端立即 invalidates 当前线程、历史、token usage 和侧栏缓存,并再安排一次延迟 refetch,因为取消完成与 title/finalization 提交可能不在同一时刻。
这体现了前端的角色:它不假设一次 HTTP 返回就代表所有后端投影同步完成,而是为最终一致性设计刷新策略。
源码锚点
frontend/src/components/workspace/chats/use-thread-chat.tsfrontend/src/core/api/api-client.tsfrontend/src/core/messages/utils.tsfrontend/src/core/tasks
下一章看这些 durable state 到底如何存、如何分支、如何恢复。