TypeScript AI 流式 UI:事件序号、幂等去重与断线恢复

📅 2026/8/9 5:31:39
TypeScript AI 流式 UI:事件序号、幂等去重与断线恢复
很多 AI 界面的第一版都是这样写的收到一个 token就把它拼到字符串尾部。网络稳定、只有文本时看不出问题一旦刷新页面、移动网络切换、工具调用插入、服务端重试同一段文字可能重复工具结果可能先于参数出现已经停止的回答甚至会继续增长。真正需要管理的不是“一个不断变长的字符串”而是一条可以重复接收、乱序到达、断线续传并最终收敛的事件流。本文基于 2026-08-08 读取的 AI SDK 官方流协议、消息持久化和 Resume Streams 文档再用一个通过 7 项测试的 TypeScript 最小实验拆开客户端 reducer 应负责的边界。一、只做字符串 append会把网络问题变成 UI 数据错误文本流最简单的语义确实是“分块到达再顺序拼接”。但官方 UI Message Stream 已经不只有文本它还包含 start、text start/delta/end、source、data、error、tool input、approval、tool output、finish 和 abort 等 typed parts并采用 SSE 格式承载。现场问题只拼字符串的结果需要的控制断线后重放最后几个 chunk句子重复一截event ID 去重与 resume cursor后到事件先抵达文本或工具状态错序sequence 缓冲与连续归并工具输出先于输入完成UI 展示不存在的执行结果typed part 状态迁移用户点击停止后仍有迟到包已停止回答继续增长显式终态保护新一轮请求复用旧连接两次回答串到一起run ID 隔离网络层的“至少一次到达”不能直接变成视图层的“至少追加一次”。UI 必须先归并事件再渲染状态。二、先把消息拆成 typed parts不要把文本、工具、引用和错误都塞进一个 Markdown 字符串。最小模型至少要区分 run、事件和 partrun 标识一次生成事件负责排序与去重part 表达可独立更新的 UI 单元。typeStreamEvent|{kind:text-start;partId:string;runId:string;eventId:string;sequence:number}|{kind:text-delta;partId:string;delta:string;runId:string;eventId:string;sequence:number}|{kind:text-end;partId:string;runId:string;eventId:string;sequence:number}|{kind:tool-input-ready;partId:string;input:unknown;runId:string;eventId:string;sequence:number}|{kind:tool-output-available;partId:string;output:unknown;runId:string;eventId:string;sequence:number}|{kind:finish|stop;runId:string;eventId:string;sequence:number}这里的类型不是照抄某个 SDK 内部实现而是从官方 typed stream parts 抽出的业务协议。生产环境可以增加 source、file、approval、error 等分支但不要退回到“所有内容都靠字符串约定”。三、归并顺序固定为 run、去重、终态、序号一个可恢复 reducer 的判断顺序应该稳定并可测试先拒绝其他 run再按 event ID 去重再保护终态最后处理 sequence。未来事件先进入缓冲区只有缺口补齐后才连续归并。if(event.runId!state.runId)returnignored(run-mismatch)if(seen(event.eventId))returnignored(duplicate)if(state.status!streaming)returnignored(terminal)if(event.sequencestate.nextSequence)returnignored(stale)if(event.sequencestate.nextSequence)returnbuffer(event)returnapplyAndDrainContiguousEvents(state,event)只保存lastSequence还不够因为序号 8 先到、序号 7 后到时直接把游标推进到 8 会永久丢掉 7。实验选择暂存未来事件补齐缺口后一次 drain生产环境还要限制缓冲数量和等待时间避免异常流耗尽内存。四、断线与停止必须是两种动作AI SDK 的 Resume Streams 文档明确区分了两件事刷新、关页或客户端stop()只会断开当前 HTTP 连接不应自动等价为取消底层生成真正的停止需要单独端点持久化部分回答、取消生产者并清理 active stream。动作服务端任务客户端下一步断线 / 刷新允许继续运行并持久化带 cursor 重连或读取权威快照用户明确停止取消生产者并写入停止终态保留部分结果不再自动重连流自然完成写入完整消息并清理活动流展示 completed可继续下一轮typeResumeCheckpoint{activeStreamId:string|nullmessageId:stringnextSequence:numberrunId:stringstatus:streaming|completed|stopped}这也是为什么“路由卸载时调用取消接口”很危险用户只是切页面却可能意外杀掉仍应继续的生成。五、重连不是再请求一次而是从游标续传重连请求至少要携带稳定的 chat/run 身份和已连续应用的 cursor。服务端若仍保留事件就返回 cursor 之后的增量若事件已经过期则返回权威消息快照让客户端替换而不是盲目追加。constcursorstate.nextSequence-1constresponseawaitfetch(/api/runs/${state.runId}/stream?after${cursor})forawait(consteventofreadEvents(response.body)){stateapplyStreamEvent(state,event).state}官方持久化指南还强调需要跨会话恢复时消息 ID 必须在存储前稳定包含工具、metadata 或自定义 data parts 的历史消息重新送给模型前需要按当前 schema 验证。客户端缓存不是权威数据库。六、工具 part 也要走显式状态迁移工具 UI 不能看到一个toolCallId就直接显示“执行成功”。最小顺序是 input streaming、input ready、approval、running、output/error。实验只实现 input ready 与 output available 两步已经能拒绝“工具尚不存在却先收到输出”的非法事件。casetool-output-available:{constpartparts.find(candidatecandidate.idevent.partId)if(part?.type!tool||part.status!input-ready){returnreject(invalid-transition)}returnupdate(part.id,{status:output-ready,output:event.output})}涉及支付、发布、删除或外发数据时approval 还应有独立 ID、参数摘要、影响范围和审计记录不能只在对话里问一句“是否继续”。七、最小实验验证了什么实验运行在 Bun 1.3.1、TypeScript 7.0.2、Biome 2.2.0。它不连接真实模型而是对 reducer 输入人工事件验证重复、乱序、续传、终态、工具迁移和 run 隔离。Biome: Checked 7 files. No fixes applied. TypeScript --noEmit: passed 7 pass, 0 fail, 23 expect() calls{cursor:2,duplicateReason:duplicate,finalStatus:completed,text:断线也不重字}测试证明的是纯状态归并语义同一 delta 重放不会重字序号 3 先到会等待序号 2断线后的续传结果与从头回放一致显式 stop 后迟到 delta 被拒绝。它不证明真实 SSE、Redis、浏览器或 SDK 已经稳定。八、接入 React 前再补三道门禁第一事件可以高频到达但 React 不必每个 token 都整页 render可以在 reducer 外按帧或短时间窗批量提交。第二长会话要虚拟化工具大结果和附件按需加载。第三服务端快照与客户端 part schema 要有版本升级后先迁移或降级展示不能把旧消息直接当成当前类型。constpending:StreamEvent[][]functionenqueue(event:StreamEvent){pending.push(event)scheduleOncePerFrame((){statepending.splice(0).reduce(applyStreamEvent,state)render(state)})}还要分别观测首事件时间、完整时间、重连次数、重复事件数、乱序缓冲深度、非法迁移数和 UI 提交次数。只有“模型耗时”一个指标解释不了用户看到的卡顿与错乱。九、上线前检查表与验证边界每个 run、message、part 和 event 都有稳定 IDevent ID 去重sequence 有缺口缓冲和上限重连携带 cursor过期时回退权威快照断线、自然完成、失败和用户停止是不同终态工具、审批、引用和错误使用 typed parts历史消息入模前按当前工具与 data schema 验证stop 端点同时保存部分结果、取消生产者并清理活动流高频事件批量提交长会话做虚拟化记录重复、乱序、重连和非法迁移指标Redis / 数据库过期、鉴权、多端并发和快照迁移有明确策略。官方来源Stream Protocols、Reading UI Message Streams、Chatbot Message Persistence、Chatbot Resume Streams。验证边界本文于 2026-08-08 读取官方文档并完成框架无关的 TypeScript reducer 实验没有调用真实模型没有部署 Redis没有建立线上 SSE 服务也没有执行 React 渲染性能或多浏览器断线测试。正式接入时应以锁定版本的 SDK 文档和真实基础设施结果为准。