拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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_source=taotoken_aicg_blog_end ,API 地址是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数,配置时别抄错。

为什么流式 UI 特别需要统一通道?因为你的 reducer 是围绕“事件协议”写的。如果每换一个模型就要改一遍事件解析,那这套状态机根本没法维护。统一通道之后,客户端只面对一种流式事件结构,去重、排序、续传的逻辑才能稳定复用。

具体要准备三样东西,我称为“三件套”:

  • Base URL:https://taotoken.net/api
  • API Key:在控制台创建,形如sk-...
  • Model ID:比如gpt-4o-mini、claude-3-5-sonnet这类,按你控制台里可用的填

如果你用的是 Claude Code 这类编码工具,或者 Cline、Codex 这类带 MCP 的客户端,配置逻辑是一样的:Base URL 填 TaoToken 的 API 地址,Key 填你创建的 Key,Model 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、事件和 part:run 标识一次生成,事件负责排序与去重,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: Set<string>; buffer: Map<number, 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 后到时,如果你直接把游标推进到 8,7 就永久丢了。所以实验选择暂存未来事件,补齐缺口后一次性 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" } }

对应的请求代码,注意重连时要带上 cursor:

const 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,验证缓冲与 drain:

state = 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" 或 "CB"

4.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 都有稳定 ID;
  • event ID 去重,sequence 有缺口缓冲和上限;
  • 重连携带 cursor,过期时回退权威快照;
  • 断线、自然完成、失败和用户停止是不同终态;
  • 工具、审批、引用和错误使用 typed parts;
  • 历史消息入模前按当前工具与 data schema 验证;
  • stop 端点同时保存部分结果、取消生产者并清理活动流;
  • 高频事件批量提交,长会话做虚拟化;
  • 记录重复、乱序、重连和非法迁移指标;
  • Redis / 数据库过期、鉴权、多端并发和快照迁移有明确策略。

最后说验证边界:本文的 reducer 实验是框架无关的,没有调用真实模型,没有部署 Redis,也没有做多浏览器断线测试。正式接入时,以你锁定版本的 SDK 文档和真实基础设施结果为准。通道侧用 TaoToken 统一 Key 跑通全链路,前端侧把归并逻辑写扎实,剩下的就是按指标持续观测。想快速验证模型通道,可以从模型对话页开始;长期做编码和 Agent,Coding Plan 更省心。

返回列表