资讯详情

资讯详情

TypeScript AI 流式 UI 实战:事件序号、幂等去重与断线恢复,用 TaoToken 统一 Key 跑通全链路

1. 为什么“收到就拼字符串”在 AI 流式 UI 里一定会翻车先说结论TypeScript 做 AI 流式 UI真正要管的不是“一个不断变长的字符串”而是一条可以重复接收、乱序到达、断线续传并最终收敛的事件流。如果你现在还在用setText(prev prev delta)这种写法网络一抖、页面一刷新、服务端一重试用户看到的回答就会重复一截、工具结果先于参数出现甚至点了停止还在继续长。我见过太多第一版 AI 聊天界面都是这么写的SSE 收到一个 token就 append 到字符串尾部。本地开发、网络稳定、只有纯文本时它看起来完美。但真实场景里AI 流式输出早就不只是文本了——它包含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 隔离核心矛盾在于网络层是“至少一次到达”视图层却要求“至少追加一次”。这两者之间必须有一个归并层也就是 reducer。它负责把不可靠的事件流收敛成可靠的 UI 状态。这篇就按这个思路从类型定义、归并顺序、断线恢复状态机一路写到用 TaoToken 统一 Key 跑通全链路最后用断网重连和重复事件注入做验证。适合谁看正在用 TypeScript 写 AI 聊天界面、Copilot 类工具面板、Agent 执行流可视化的前端同学以及被“重复渲染”“断线重连丢字”“工具卡片状态乱跳”折磨过的工程师。你不需要先懂 SSE 协议细节我会把每一步都写成可复制的代码。2. TaoToken 前置一个 Key 打通流式链路省掉多模型切换的胶水代码在写 reducer 之前先把“事件从哪来”这件事解决掉。流式 UI 的调试成本一大半其实花在模型通道上今天试这个模型、明天换那个模型每个都要单独配 Key、单独改 Base URL、单独处理不同的流式格式。你还没开始写去重逻辑光切换通道就耗掉半天。我的做法是用 TaoToken 做统一入口。它提供 OpenAI 兼容的 API 通道一个 Key 就能跑通对话、流式输出、工具调用这些链路前端只需要认一套 SSE 事件格式。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end API 地址是 https://taotoken.net/api 注意 API 地址不带 UTM 参数配置时别抄错。为什么流式 UI 特别需要统一通道因为你的 reducer 是围绕“事件协议”写的。如果每换一个模型就要改一遍事件解析那这套状态机根本没法维护。统一通道之后客户端只面对一种流式事件结构去重、排序、续传的逻辑才能稳定复用。具体要准备三样东西我称为“三件套”Base URLhttps://taotoken.net/apiAPI Key在控制台创建形如sk-...Model ID比如gpt-4o-mini、claude-3-5-sonnet这类按你控制台里可用的填如果你用的是 Claude Code 这类编码工具或者 Cline、Codex 这类带 MCP 的客户端配置逻辑是一样的Base URL 填 TaoToken 的 API 地址Key 填你创建的 KeyModel ID 填具体模型。三者缺一不可很多人报 401 就是因为只填了 Key 没填对 Base URL或者 Model ID 写了个不存在的名字。拿 Key 的入口在这里https://taotoken.net/api-keys 控制台在 https://taotoken.net/console 。如果你只是想先验证模型通不通可以用模型对话页面快速试一条https://taotoken.net/model-chat 。长期做编码和 Agent 的话Coding Plan 更划算https://taotoken.net/coding-plan 。这里要提醒一句TaoToken 是统一 API 通道不是让你绕过什么也不是替代你的编辑器。它的价值在于把多模型、多协议的差异收敛到一个入口让你的前端 reducer 只需要处理一种事件流。这一点对后面的幂等去重和断线恢复特别关键——协议稳定状态机才稳定。配置好之后先别急着写复杂逻辑。用一条最简单的流式请求确认通道是通的再往上叠 reducer。下一节我给一份可直接复制的配置和请求代码。3. 可复制配置typed parts 类型 reducer 归并顺序 请求片段这一节是全文的核心全部是可复制的代码。我按“类型定义 → 归并顺序 → 请求配置”三步走你照着贴进项目就能跑。3.1 先把消息拆成 typed parts不要把文本、工具、引用、错误都塞进一个 Markdown 字符串。最小模型要区分 run、事件和 partrun 标识一次生成事件负责排序与去重part 表达可独立更新的 UI 单元。type StreamEvent | { 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 }; type Part | { id: string; type: text; text: string; status: streaming | done } | { id: string; type: tool; status: input-ready | output-ready; input?: unknown; output?: unknown }; type StreamState { runId: string; status: streaming | completed | stopped; nextSequence: number; seenEventIds: Setstring; buffer: Mapnumber, StreamEvent; parts: Part[]; };这里的类型不是照抄某个 SDK 内部实现而是从官方 typed stream parts 抽出的业务协议。生产环境可以增加source、file、approval、error等分支但不要退回到“所有内容都靠字符串约定”。3.2 归并顺序固定为 run、去重、终态、序号一个可恢复 reducer 的判断顺序必须稳定且可测试。顺序错了去重就会失效。正确顺序是先拒绝其他 run再按 event ID 去重再保护终态最后处理 sequence。function applyStreamEvent(state: StreamState, event: StreamEvent) { if (event.runId ! state.runId) return ignored(run-mismatch); if (state.seenEventIds.has(event.eventId)) return ignored(duplicate); if (state.status ! streaming) return ignored(terminal); if (event.sequence state.nextSequence) return ignored(stale); if (event.sequence state.nextSequence) return buffer(event); return applyAndDrainContiguousEvents(state, event); }只保存lastSequence是不够的。序号 8 先到、序号 7 后到时如果你直接把游标推进到 87 就永久丢了。所以实验选择暂存未来事件补齐缺口后一次性 drain。生产环境还要限制缓冲数量和等待时间避免异常流把内存吃光。function applyAndDrainContiguousEvents(state: StreamState, event: StreamEvent) { let next applyOne(state, event); next.seenEventIds.add(event.eventId); next.nextSequence event.sequence 1; while (next.buffer.has(next.nextSequence)) { const buffered next.buffer.get(next.nextSequence)!; next.buffer.delete(next.nextSequence); next applyOne(next, buffered); next.seenEventIds.add(buffered.eventId); next.nextSequence buffered.sequence 1; } return { state: next, applied: true }; }3.3 请求配置Base URL Key Model ID 三件套前端发起流式请求时把三件套放进配置。下面这份 JSON 可以直接作为你项目的默认配置模板{ baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, modelId: gpt-4o-mini, stream: true, resume: { enabled: true, cursorField: after } }对应的请求代码注意重连时要带上 cursorconst cursor state.nextSequence - 1; const response await fetch( https://taotoken.net/api/chat/completions?after${cursor}, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${config.apiKey}, }, body: JSON.stringify({ model: config.modelId, stream: true, messages: history, }), } ); for await (const event of readEvents(response.body)) { state applyStreamEvent(state, event).state; }如果你用 TOML 管理配置比如某些 CLI 工具等价写法是[provider] base_url https://taotoken.net/api api_key sk-你的Key model_id gpt-4o-mini stream true三件套里最容易错的是 Base URL。记住 API 地址是https://taotoken.net/api不带任何查询参数。Key 在 https://taotoken.net/api-keys 创建Model ID 按控制台可用列表填。配置对了通道就通了接下来才是 reducer 的活。4. 验证请求断网重连 重复事件注入看状态是否收敛写完 reducer 不能只靠“看起来对”要用人工事件注入验证。我跑的最小实验环境是 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 已经稳定但能证明你的归并逻辑是对的。4.1 重复事件注入模拟服务端重试把同一个eventId发两次const delta { kind: text-delta, partId: p1, delta: 断线也不重字, runId: run-1, eventId: evt-2, sequence: 2, }; let state initState(run-1); state applyStreamEvent(state, delta).state; state applyStreamEvent(state, delta).state; // 重复注入 console.log(state.parts[0].text); // 断线也不重字不会变成两遍4.2 乱序到达先发序号 3再发序号 2验证缓冲与 drainstate applyStreamEvent(state, { ...delta, eventId: evt-3, sequence: 3, delta: C }).state; state applyStreamEvent(state, { ...delta, eventId: evt-2, sequence: 2, delta: B }).state; // 最终 text 应为 BC而不是 C 或 CB4.3 断线重连断线后从 cursor 续传结果应与从头回放一致const cursor state.nextSequence - 1; // 服务端返回 cursor 之后的事件 const resumed await fetchResume(state.runId, cursor); for await (const event of readEvents(resumed.body)) { state applyStreamEvent(state, event).state; } // 断言resumed 后的 text 与不中断跑完的 text 完全相等4.4 工具 part 状态迁移工具 UI 不能看到一个toolCallId就显示“执行成功”。最小顺序是 input streaming、input ready、approval、running、output/error。实验只实现 input ready 与 output available 两步已经能拒绝“工具尚不存在却先收到输出”的非法事件case tool-output-available: { const part parts.find((candidate) candidate.id event.partId); if (part?.type ! tool || part.status ! input-ready) { return reject(invalid-transition); } return update(part.id, { status: output-ready, output: event.output }); }涉及支付、发布、删除或外发数据时approval 还应有独立 ID、参数摘要、影响范围和审计记录不能只在对话里问一句“是否继续”。4.5 断线与停止是两种动作这一点特别容易搞错。刷新、关页或客户端stop()只断开当前 HTTP 连接不应自动等价为取消底层生成真正的停止需要单独端点持久化部分回答、取消生产者并清理 active stream。type ResumeCheckpoint { activeStreamId: string | null; messageId: string; nextSequence: number; runId: string; status: streaming | completed | stopped; };这也是为什么“路由卸载时调用取消接口”很危险用户只是切页面却可能意外杀掉仍应继续的生成。断线要允许继续运行并持久化带 cursor 重连或读取权威快照用户明确停止才取消生产者并写入停止终态。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth流式 UI 的报错往往不在 reducer而在通道和配置。我把最常见的几类对照真实报错列出来你按顺序排查。401 Unauthorized九成是 Key 或 Base URL 的问题。先确认 Key 是从 https://taotoken.net/api-keys 创建的没有多余空格再确认 Base URL 是https://taotoken.net/api不是首页地址也没带 UTM 参数。三件套里 Base URL、Key、Model ID 任何一个错都会 401 或 404。local proxy failed / connection refused这类通常是本地代理或端口配置问题。检查你的请求是否被本地某个代理拦截或者localhost端口写错。如果你在容器里跑注意localhost指向的是容器本身不是宿主机。reading choices / cannot read property of undefined这是解析响应时字段对不上。流式响应里choices[0].delta可能为空非流式才有message。你的解析代码要区分delta和message并且对空数组做保护const choice chunk.choices?.[0]; if (!choice) continue; const delta choice.delta?.content ?? ;OAuth / token expired如果你用的是带 OAuth 的客户端比如某些 CLI 工具token 过期后需要重新授权。注意 OAuth 流程和 API Key 是两套东西别混用。用 TaoToken 统一 Key 的好处就是大部分场景只需要一个 API Key不用折腾多套鉴权。重复渲染 / 状态乱跳回到 reducer 的归并顺序检查。最常见的是把去重放在了 run 判断之前导致跨 run 的 eventId 冲突或者没有终态保护stop 之后迟到的事件还在 apply。断线后丢字检查你是否只保存了lastSequence而没有缓冲未来事件。序号 8 先到、7 后到时直接推进游标会丢 7。必须用 buffer drain。工具卡片状态错乱检查是否做了显式状态迁移。看到tool-output-available就直接渲染成功是典型的非法迁移。必须先确认 part 处于input-ready。排查顺序建议先确认通道401/404→ 再确认解析reading choices→ 再确认归并重复/乱序→ 最后确认终态stop 后增长。大部分问题在前两步就能定位。6. 接入 React 前再补三道门禁以及语义一致的收尾reducer 跑通不等于 UI 流畅。接入 React 之前我建议再补三道门禁。第一事件可以高频到达但 React 不必每个 token 都整页 render。可以在 reducer 外按帧或短时间窗批量提交const pending: StreamEvent[] []; function enqueue(event: StreamEvent) { pending.push(event); scheduleOncePerFrame(() { state pending.splice(0).reduce(applyStreamEvent, state); render(state); }); }第二长会话要虚拟化工具大结果和附件按需加载。第三服务端快照与客户端 part schema 要有版本升级后先迁移或降级展示不能把旧消息直接当成当前类型。还要分别观测首事件时间、完整时间、重连次数、重复事件数、乱序缓冲深度、非法迁移数和 UI 提交次数。只有“模型耗时”一个指标解释不了用户看到的卡顿与错乱。上线前对照这份检查表每个 run、message、part 和 event 都有稳定 IDevent ID 去重sequence 有缺口缓冲和上限重连携带 cursor过期时回退权威快照断线、自然完成、失败和用户停止是不同终态工具、审批、引用和错误使用 typed parts历史消息入模前按当前工具与 data schema 验证stop 端点同时保存部分结果、取消生产者并清理活动流高频事件批量提交长会话做虚拟化记录重复、乱序、重连和非法迁移指标Redis / 数据库过期、鉴权、多端并发和快照迁移有明确策略。最后说验证边界本文的 reducer 实验是框架无关的没有调用真实模型没有部署 Redis也没有做多浏览器断线测试。正式接入时以你锁定版本的 SDK 文档和真实基础设施结果为准。通道侧用 TaoToken 统一 Key 跑通全链路前端侧把归并逻辑写扎实剩下的就是按指标持续观测。想快速验证模型通道可以从模型对话页开始长期做编码和 AgentCoding Plan 更省心。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →