AI对话前端实战:SSE流式通信与状态管理完整指南
发布时间:2026/10/17 14:14:07 锦皓数字建站

我见过不少第一次做AI对话功能的前端同学第一版代码长得都差不多一个输入框、一个发送按钮、一个消息列表把用户的话POST给后端然后把返回的字符串append到列表里。这样确实能跑通demo但一进入真实业务就四处漏水——内容是一段一段吐出来的页面却等着全部返回才渲染用户想中途打断却发现根本没有取消入口回答里带着Markdown和代码块直接展示乱成一团聊了十几轮之后请求体大得连自己都看不下去。这篇从前端视角把从拿到一个模型接口到做出一个能上线的对话功能的完整链路拆一遍附带可以直接跑的最小实现。重点不是某一家模型服务商怎么用而是通信协议、状态管理、消息渲染、上下文裁剪这几块前端躲不开的硬活。适合正在做智能问答、AI助手、内容生成类页面的前端同学也适合想自己搭一个最小可用对话Demo的人。1. 先想清楚对话功能的前端到底要管哪几层1.1 对话功能不是“消息列表加接口调用”普通聊天功能里的消息是确定性的发出去一条对方回一条内容发出来那一刻就固定了。AI对话完全不是这样一条助手消息的生命周期可以拉得很长——请求发出去之后要等待生成过程是一点一点吐字的中途可能超时、报错、被用户主动打断生成完了还可能因为内容异常需要重试。这意味着前端不能照搬普通消息列表的写法。我们需要在消息列表中表达“生成中的消息”“被中断的消息”“失败了但可以重试的消息”甚至还要能区分“这条消息占用了多少上下文预算”。从数据模型开始对话功能就和普通IM不同。1.2 前端被默认要去管的三层我习惯把AI对话前端拆成三层来思考这也是跟后端划清职责边界的主要方式通信协议层负责把消息数组发给模型服务把流式返回的增量文本一帧帧接住处理中断、超时、错误码。状态管理层维护消息列表、当前请求状态、用户操作产生的事件比如点击停止、再次发送、切换会话。界面表现层输入框、气泡、Markdown渲染、代码高亮、复制按钮、加载动画以及移动端适配。一个很常见的失控现场就是把这三层全部揉进一个组件里聊天页面动不动上千行流式回调里直接操作DOM状态散落在一堆useState里到最后谁也不敢改。反过来只要把这三层拆开大部分问题其实都有相对标准的解法。1.3 先约定消息对象再谈实现无论后端怎么设计协议前端最好统一维护一个消息对象结构页面上所有逻辑都围绕它展开// 前端统一维护的消息结构 const message { id: msg_xxx, role: user, // user | assistant | system content: 你好帮我介绍一下流式输出, status: done, // pending | streaming | done | failed createdAt: Date.now(), tokenCount: 0 // 用于上下文裁剪正文里会再次提到 };有了这个统一结构消息列表渲染、流式更新、失败重试就都变成对单个消息对象的读写思路会清晰很多。协议方面前端只需要主动让后端确认几件事消息格式是不是上面这种role/content结构、流式返回走SSE还是普通JSON、错误码有哪些枚举值、有没有代表结束的专用标记。这些在动手写代码之前定好能省掉后面大量的联调口水。2. SSE流式消费前端真正要攻下的通信坎2.1 为什么第一版不该用WebSocket一提到“实时”很多人第一反应是WebSocket。但对于AI对话的常见场景WebSocket不是最优起点。原因很简单对话过程本质是“前端请求、后端持续返回”是单向推送SSE天然就是为这种模式设计的。SSE跑在HTTP上不需要额外维护长连接协议代理、网关、鉴权、日志都比较省心。SSE自带断线重连机制前端可以用标准事件来处理。WebSocket适合真正的双向实时交互比如协同编辑、在线白板我们的对话场景用SSE能把协议复杂度压到最低。如果你跟后端说“我想用SSE而不是WebSocket”后端同学通常也不会反对。2.2 用fetch消费流式响应最小可用代码很多人不知道fetch本身就支持读取流式响应。标准库EventSource虽然简单但只能用GET、不能自定义请求头也没法方便地做POST传参所以我更推荐用fetch加ReadableStream的方式。async function streamChat(messages, onMessage, signal) { const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), signal, }); if (!resp.ok || !resp.body) { throw new Error(请求失败${resp.status}); } const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; // 这里必须用 stream: true否则多字节中文字符可能被截断 buffer decoder.decode(value, { stream: true }); // SSE 数据通常按行分隔每行以 data: 开头 const lines buffer.split(\n); buffer lines.pop(); // 最后一行可能是不完整的半包留到下一轮 for (const line of lines) { const trimmed line.trim(); if (!trimmed.startsWith(data:)) continue; const data trimmed.slice(5).trim(); if (data [DONE]) { return; } try { const json JSON.parse(data); onMessage(json); // 把增量交给上层状态层 } catch (e) { // 半包JSON直接忽略等下一轮拼接完成 } } } }这段代码里最容易踩的坑是中文乱码和半包截断。TextDecoder第二参数必须传{ stream: true }否则一个中文拆在两个chunk里时会在边界处变成乱码buffer拆行时最后一行一定要留到下一轮继续拼否则可能出现一个JSON被切成两半、解析永远失败的情况。2.3 停止生成、超时与异常AbortController的正确用法很多前端只实现了“发消息”和“收消息”却忘了“停消息”。用户看到回答不满意或者开始胡说八道时第一反应就是立刻打断。如果前端没有取消机制后端就会继续生成白白消耗算力用户也干着急。class ChatClient { constructor(apiUrl) { this.apiUrl apiUrl; this.controller null; } async send(messages, handlers) { // 每次新请求都建一个新的 AbortController this.controller new AbortController(); const { onMessage, onDone, onAborted, onError } handlers; try { await streamChat(messages, onMessage, this.controller.signal); onDone(); } catch (err) { if (err.name AbortError) { // 用户主动停止属于正常结束分支 onAborted(); } else { onError(err); } } } stop() { // 停止时不仅前端停止读取也会给服务端发送断开信号 this.controller?.abort(); } }注意一个细节abort()会让fetch抛出一个AbortError这个分支要单独处理不要混进普通错误逻辑。停止生成后面端也会收到连接断开的信号从而中断生成任务——这里的语义是“前端告诉后端别再算了”而不只是“前端不管后续返回了”。提示停止生成时前端abort的目的一是停止本地读取二是给后端一个断开信号避免服务端继续白跑计算。千万不要只清空本地状态却让后端把答案算完。3. UI状态机Streaming对话不能靠两个布尔值撑住3.1 一张状态迁移表讲清对话生命周期继续说回页面。很多第一版代码用isLoading和isStreaming两个布尔值来管理结果就是发送中的消息和正在输出的消息分不清用户连续点了几次发送后多个请求抢同一块UI界面直接错乱。我用一个状态机来组织对话生命周期核心状态只有五个状态触发时机用户看到的效果可执行操作idle初始化或一轮对话结束输入框可编辑发送pending点击发送、等待首字节气泡出现、光标闪烁停止streaming开始收到增量文字逐段出现停止aborted用户点击停止保留半截内容、显示“已停止”标记重试或重新发送failed网络错误或接口错误气泡失败态、原因提示重试这些状态必须绑定到“单条消息”上而不是绑定到整个聊天页面。聊天页面同时会有多条消息每一条都有自己的命运你看到的那条正在生成另一条失败了还有一条早就完成——所以状态管理的最小单元是消息对象不是整个页面。3.2 停止、失败重试与首字延迟的处理有了状态机很多交互就能落到具体实现上。停止按钮的显示条件很简单当前消息状态为pending或streaming时展示点击后调用ChatClient.stop()把消息状态改为aborted并把“已停止”作为角标展示在气泡上避免用户误以为回答还在继续。失败重试时我倾向于复用同一条消息对象把status从failed改回pending再次调用send接口。千万不要另起一条消息否则聊天记录里会出现大量废弃气泡上下文也会变得混乱。还有一个体验细节容易被忽略首字延迟。点击发送之后用户往往最在意“模型到底有没有收到我的话”。如果后端处理比较慢前几秒毫无反馈用户会焦虑地狂点发送按钮。实践中可以在pending状态展示一个动态光标并配文案“正在思考…”如果超过10秒还没有任何增量就主动判定超时并报错。3.3 多会话并行与状态隔离产品一旦做起来就会引出多会话问题左侧会话列表右侧当前对话。切换会话时正在进行的流式请求怎么办我的做法是每次发送前把上一个会话中还在进行中的请求全部abort掉但保留已经收到的半截内容标记为aborted。这样既不让旧会话的更新污染新会话的视图也不丢失用户已经看到的文本。实现上的关键点是给每个请求绑定一个会话ID在流式回调里先判断当前会话ID是否匹配如果不匹配就直接丢弃增量数据。这种“会话隔离”在前端是一个经常被忽略的坑等产品做到多会话时再回头补就很痛苦。4. 让回答真正“可读”渲染管线与安全那道关4.1 大模型输出不能直接进DOM模型返回的原始文本通常是Markdown格式直接塞进页面会引来两个问题第一个是安全问题。大模型生成的内容本质上是不可信输入里面可能带HTML标签、图片地址、甚至恶意脚本。直接把原文塞进innerHTML等于把页面安全决定权交给了模型——这是绝对不能接受的。第二个是阅读体验问题。纯文本输出会让标题、列表、代码块、表格全部糊在一起用户根本没法看。前端需要一条完整的渲染管线把原始Markdown解析成受控的HTML再按设计稿处理成干净的气泡。4.2 Markdown解析、代码高亮与XSS过滤的分工我把渲染流程拆成三步function renderAssistantText(rawText) { // 第一步先转义 HTML避免原文里的 script 或事件属性直接生效 const escaped escapeHtml(rawText); // 第二步用 Markdown 解析器把转义后的文本解析为受控 HTML // 这里假设你引入了一个成熟的 Markdown 解析器marked 或 markdown-it 这一类的方案 const html parseMarkdown(escaped); // 第三步对最终 HTML 做一次白名单过滤只保留允许的标签和属性 return sanitizeHtml(html); }很多人会反过来问先转义再解析那Markdown语法会不会被破坏实际上不会因为Markdown解析器识别的是#、*、这些标记它们不会被转义影响会被转义的是、、这些HTML敏感字符模型原文里就算写了script转义之后也只是个普通文本节点不会被执行。代码高亮也是一条独立流程。大语言模型的回答里经常带代码块渲染时如果整块代码都是一种颜色可读性会很差。但代码高亮库体积不小我的建议是按需加载先检测到回答里有代码块再动态引入高亮方案避免首屏包袱太重。提示不要相信任何解析库的默认过滤输出先转义再解析是底线。解析库只是帮你把Markdown转成HTML它管不了模型有意构造的恶意内容。4.3 流式渲染的性能取舍流式过程中模型几乎每几十毫秒就吐一个词。如果每次收到增量就把整条消息重新解析一遍Markdown你会发现CPU占用率瞬间拉满页面开始掉帧、光标飘忽不定。我实测下来有几个经验不要逐token触发渲染。把增量缓冲起来按大约200ms的间隔节流一次渲染。整条消息生成期间可以不对内容做完整Markdown解析只做纯文本展示等生成结束后再一次性执行完整解析和高亮渲染。这样大多数用户不会察觉到区别性能却能省很多。长回答会拖垮DOM性能。超过一定长度的回答建议默认折叠中间大段内容只保留首尾点开再展开避免一次性创建几千个DOM节点。这三条结合起来流式输出才会又顺又稳。5. 多轮对话的上下文管理前端也得懂点token账5.1 为什么不能把全部历史都发给模型很多前端同学以为消息列表就是一切直接把最近100条对话原封不动传给后端。等请求发出去了才发现响应速度越来越慢甚至直接报“上下文超限”。原因在于对话模型一次能处理的token是有限的。历史消息越多留给模型思考新回答的空间就越小而且每次发送都把全部历史算一遍成本也会线性上涨。上下文管理不是后端一个人的事前端如果能在请求前把消息列表裁剪到合理长度对整个链路的稳定性和成本都有直接帮助。5.2 一个可落地的滑动窗口裁剪方案先需要一个粗略的token估算函数。精确计算token要靠模型分词器前端全量引入太重实际业务里用一个近似估算就够了function estimateTokens(text) { // 粗略估算中文按每个字约 1.5 token英文按每个字符约 0.3 token const cjkChars (text.match(/[\u4e00-\u9fff]/g) || []).length; const otherChars text.length - cjkChars; return Math.ceil(cjkChars * 1.5 otherChars * 0.3); }然后按滑动窗口裁剪系统指令必须保留最近的对话优先保留太旧的、太长的历史按比例截断。function trimMessages(messages, maxTokens 6000) { const system messages.filter(m m.role system); const rest messages.filter(m m.role ! system); const result [...system]; let used estimateTokens(result.map(m m.content).join()); // 从最新消息往前遍历保留最近的内容 for (let i rest.length - 1; i 0; i--) { const msg rest[i]; const cost estimateTokens(msg.content); if (used cost maxTokens) break; result.unshift(msg); used cost; } return result; }这个方案的好处是简单直观、不依赖服务端前端就能在发送前完成预算计算。它当然不是最聪明的策略因为不看每一条的具体内容但作为默认通用逻辑已经足够稳。真正在意效果的团队可以在这个基础上再加“摘要压缩旧对话”的环节那就是进阶话题了。5.3 role分离与指令注入的体验层防护消息结构里的role字段不是摆设。system、user、assistant三种角色的语义完全不同system是给模型的全局设定user是当前用户输入assistant是模型历史的回复。裁剪时一定要保持角色的相对顺序乱序会导致模型理解错乱甚至回复风格漂移。还有一个前端容易忽略的问题指令注入。用户可能在输入框里写“忽略以上所有设定”并试图通过这种方式操纵模型行为。前端可以做一层最基础的检测发现这类明显意图时提示用户但真正的防线必须在后端和模型侧完成——前端过滤只是体验层不能当成安全方案来依赖因为请求可以绕过页面直接构造。6. 上线前容易漏掉的暗坑鉴权、弱网与测试6.1 模型密钥绝不能出现在前端这是我最想强调的一条规定。有人为了Demo方便把模型服务的密钥直接写在前端请求头里。一旦页面被访问密钥就等于暴露给了全世界的用户被别人拿去调用产生的费用算谁的正确做法前端只请求自己的后端代理接口由后端持有模型密钥或临时凭证。前端带上自己的用户会话凭证后端校验通过后再转发给模型服务。代理层还可以做限流、审计、缓存甚至批量处理敏感词过滤这些都不应该放在前端。6.2 弱网和断流下的用户感知SSE有个特点是“连接中断很常见”。电梯里、地铁上、切后台回来长连接随时可能断。前端要做几件事如果流式过程中途断掉不能默默失败要把半截内容保留并明确提示“回答不完整”。给用户重试入口时重试语义要清晰。我推荐的做法是生成一条新请求但保留旧气泡作为历史避免覆盖造成内容丢失。如果服务端支持事件ID和续传可以尝试断点重连如果不支持就老老实实走人工重试。同时要注意幂等性问题网络超时后用户重试可能同一轮请求发了两次后端如果没有去重机制用户会看到两段几乎一样的回答。前端可以在重试前用一个本地标记记录“上次请求是否已成功送达”配合后端的请求ID去重才能把这个问题闭环。6.3 用可注入故障的Mock接口把UI跑稳上线前还有一个很实用的手段自己搭一个假的SSE接口。前端开发时不依赖真实模型服务而是用一个本地服务按预设节奏吐字还可以故意注入延迟、乱序、突然断流、超大回答等异常场景专门用来验证UI状态机。// 最小可用的 Node 模拟接口 import http from http; http.createServer((req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }); const demoText 你好这是一个模拟的流式回答。你可以看到它逐字出现。; let index 0; const timer setInterval(() { const chunk demoText.slice(index, index 2); index 2; if (index demoText.length) { // 每次推送一块增量 res.write(data: ${JSON.stringify({ content: chunk })}\n\n); } else { // 结束标记 res.write(data: [DONE]\n\n); clearInterval(timer); res.end(); } }, 50); }).listen(3000);有了这个模拟服务前端开发可以完全并行不需要等后端的真实接口就绪。在工程实践里我会进一步加参数控制按指定秒数后断开、返回400错误、返回超大文本等专门用来走查各种边界情况。从个人实际体会来说把一个对话Demo做成产品真正拉开差距的往往不是模型选得多强而是状态管理是否闭环、取消与重试是否可靠、长内容渲染是否流畅。把通信、状态、渲染、上下文这几层都理顺再换任何一个模型服务商前端骨架都不需要动——这也是从0到1做对话功能最值得投入的部分。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。