☰
前端AI助手模块实战:SSE流式传输与性能优化全解
2026/10/9 7:22:09 网站建设 项目流程

1. AI助手模块是什么,为什么值得做

1.1 模块定位与核心价值

AI助手模块,这几年几乎成了前端绕不开的必修课。我这边接到的需求很直接:给内部管理系统加一个AI助手,员工登录后可以直接对话提问,查制度、查数据、甚至让它帮忙生成周报。听起来就是一个聊天窗口,可真到落地,从流式传输、渲染性能到会话恢复,每一环都藏着细节。这是前端系列第十七章,我把从零到一搭建AI助手模块的完整过程拆开讲,包括技术选型、核心代码、状态设计,以及那些常规文档里不会写的坑。项目用的是 Vue 3 + TypeScript,但设计思路对 React 同样适用。

先给这个模块画个像。AI助手模块不是简单贴一个第三方聊天框组件,它是一整套与用户操作流深度绑定的交互能力:用户点开助手面板,能基于当前页面上下文提问,系统返回流式回答,回答里可能带表格、代码、链接,整个对话过程还要支持历史会话的保存与恢复。

我见过很多团队一开始图省事,直接在页面上嵌一个 iframe 指向某个AI产品页面,或者拿别人封装好的开源聊天UI套一层。问题在于:第一,与业务数据完全隔离,用户问"这个月的销售额异常怎么回事",助手根本不知道上下文,答非所问;第二,消息格式无法定制,业务需要的卡片、审批链接、数据表格都塞不进去。所以自研一个贴合业务的AI助手模块,不是炫技,是业务场景逼出来的。

从价值角度看,这个模块解决三类问题:一是降低用户学习系统的成本,员工不懂怎么操作就开口问;二是把重复性的信息查找交给对话完成,比如查制度、查流程;三是作为数据入口,让用户用自然语言触达原本藏在报表里的信息。需求边界清楚之后,这个模块的ROI其实很容易算明白。

1.2 适用场景与边界确认

在动手之前,一定要和产品经理把边界聊透。AI助手模块和智能客服、AI搜索不是一回事,虽然技术上有交集,但产品逻辑差异很大:

形态核心诉求交互特点
AI助手(本文场景)围绕业务系统的问答、生成会话式、带上下文、可追问
智能客服解决标准问题、工单流转流程化、多轮引导
AI搜索从海量文档中找答案关键词匹配 + 摘要

场景决定了技术方案:如果只是客服,直接接第三方 SaaS 更划算;但要做业务助手,就必须自建模块,因为要对接权限体系、业务数据和页面上下文。这个判断我在项目启动会上就明确过,后面所有技术选型都是围绕"业务助手"这四个字展开的。

2. 整体架构与技术选型

2.1 为什么前端不能直接调大模型 API

这是很多新手第一课就会踩的坑。把 API Key 直接写在前端,等于把钱包密码贴在门口。浏览器里所有代码和配置都是公开的,任何用户打开控制台就能看到网络请求里的凭证,别人可以拿着它疯狂调用,账单直接爆炸。

所以我们的架构有一个强制规定:前端只与自己的后端通信,后端统一代理大模型 API。

浏览器 → 前端页面 → 后端代理服务 → 大模型 API

后端代理负责三件事:一是藏住密钥,二是做权限校验(哪些用户能用、能用多少),三是做统一的请求日志和限流。这个设计不是过度防护,而是所有正经上线的AI功能都必须有的底线。我甚至建议在后端加一个简单的按用户维度的每日调用次数统计,防止个别用户把助手当成无限生成器刷。这个需求并不复杂,但少了它,运营成本完全失控。

2.2 通信方案选型:SSE 是默认答案

AI返回内容是流式的,等全部生成完再统一返回,用户要干等十几秒,体验上基本废了。前端要实时拿到增量内容,主流方案有三个:

方案优点缺点适用
SSE实现简单、自动重连、文本流天然适合单向通信AI对话的绝大多数场景
WebSocket双向通信、低延迟需要维护连接状态、复杂度高需要主动推送、多端同步
轮询兼容性极好浪费资源、延迟高老项目兜底

我最终选了 SSE。理由很简单:AI 助手场景下数据流向是单向的——用户发消息,服务端持续返回生成内容,不存在服务端主动推送其他事件的需求。为一个当前不存在、短期内也不太会出现"服务端主动推送"需求的功能引入 WebSocket 的维护成本,不划算。等真出现"别人改了文档,你这边要实时收到通知"的需求时,再单独上 WebSocket 也不迟。

这里有个选型前必须确认的点:大模型 API 的标准接口基本都是 SSE,后端代理透传即可。但如果后端接的是某些云厂商的非标准接口,可能返回 JSON 数组而不是 SSE 格式,那就让后端统一转成 SSE,前端只按标准格式解析。前后端约定好数据协议,前端可以少踩很多坑。

2.3 前端模块的分层设计

AI助手模块的代码,我分成三层:

  • UI层:消息列表、输入框、功能按钮、卡片组件,只管展示和用户交互
  • 状态层:会话列表、当前会话、消息数组、生成状态,用 Pinia 管理
  • Service层:所有 HTTP/SSE 请求、数据解析、错误标准化

这样分层,核心原因是 AI 助手的逻辑和普通表单提交完全不同。普通接口是"发出去,等响应完事";AI 是"发出去,持续收到增量,随时可能出错,用户可能中途打断"。这些状态分散在各组件里会乱成一锅粥,必须集中管理。Service 层独立出来,后续要给模块加单元测试、或者换一个后端实现,UI 层都不用动。

具体到目录结构,大概是这个形态:

src/ ├── components/ │ ├── AiAssistant/ │ │ ├── ChatPanel.vue │ │ ├── MessageItem.vue │ │ └── MessageInput.vue ├── composables/ │ ├── useChat.ts │ └── useStream.ts ├── services/ │ └── aiChat.ts └── stores/ └── assistant.ts

页面里只放一个<ChatPanel />,其他逻辑全部收进 composable 和 store。这个结构后来被报表模块复用的时候,几乎没改一行业务代码,直接拿来用了。

3. 核心功能实现与代码实战

3.1 会话管理:从新建到恢复

会话管理是 AI 助手模块的地基。用户可能同时开好几个会话,聊到一半刷新页面,回来还想继续。这要求我们做两件事:会话列表的增删改查、会话内容的持久化。

先定义数据模型:

interface Message { id: string; role: 'user' | 'assistant'; content: string; reasoningContent?: string; // 思考过程,默认折叠 status: 'complete' | 'pending' | 'error' | 'stopped'; createdAt: number; } interface Conversation { id: string; title: string; messages: Message[]; createdAt: number; updatedAt: number; }

创建新会话时,注意两个细节。一是创建后立刻清空当前消息列表、聚焦输入框,给用户明确反馈;二是会话标题的自动生成。如果每条都叫"新对话",列表里全是"新对话",根本没法区分。我的做法是:第一条用户消息发出去后,自动取前 20 个字做标题,超出部分加省略号。这个逻辑在消息发送成功后执行,不额外调接口,纯前端处理。

持久化这里有个取舍。localStorage 本身有 5MB 左右的容量限制,AI 对话消息动辄几千字,很快会爆。我实测之后定下的方案是:

数据存储位置原因
会话列表(元信息)localStorage数据量小,读取快,启动时同步可用
消息完整内容IndexedDB存储上限高,异步读写,不阻塞主线程

恢复会话的流程同样要设计好。页面加载后,先从 localStorage 恢复会话列表,再根据当前会话 ID 从 IndexedDB 拉回完整消息。这两个操作建议用 Promise.all 并行执行,恢复期间给一个轻量 loading 态,不要让整个页面白屏。顺序如果搞反了,用户会看到左侧列表出来了、右侧消息要等半天。

3.2 流式接收:用 fetch 读懂 SSE

这是整个模块的心脏。我直接说结论:不要用 EventSource,用 fetch + ReadableStream。EventSource 只能发 GET 请求,无法自定义请求头,对鉴权和业务参数都受限;而且它对错误状态码的处理很别扭。fetch 配合流式读取,能拿到状态码、能带 token、能中断请求,是更稳的方案。

核心代码长这样:

async function streamChat( messages: ChatMessage[], onMessage: (content: string) => void, signal: AbortSignal ): Promise<void> { const res = await fetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}`, }, body: JSON.stringify({ messages, stream: true }), signal, }); if (!res.ok) { throw new Error(`请求失败:HTTP ${res.status}`); } const reader = res.body!.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE 协议按空行分隔事件,这里按行解析 const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const payload = trimmed.slice(5).trim(); if (payload === '[DONE]') return; try { const data = JSON.parse(payload); const delta = data.choices?.[0]?.delta?.content ?? ''; if (delta) { onMessage(delta); } } catch { // 单条数据解析失败,跳过继续,不要中断整个流 } } } } finally { reader.releaseLock(); } }

有几个点必须解释清楚。

第一,buffer 变量是干什么的。网络传输按 chunk 来,一个 chunk 可能包含半条 SSE 事件,也可能包含好几条。我们不能假设"每次 read 恰好拿到完整的一行",所以要把没解析完的尾部留到下一次循环。这个 buffer 处理是流式解析的通用套路,少了它,文本会时不时漏几句。

第二,TextDecoder 必须传{ stream: true }。多字节字符(比如中文)在 chunk 边界处被切开时,如果不加这个参数,会被解析成乱码。这个参数的意思是"当前可能是半个字符,先缓存,等下一段拼齐再输出"。我见过太多项目这里写漏了,结果中文回复随机出现"锟斤拷"之类的乱码,排查半天都找不到原因。

第三,[DONE]标记的处理。协议约定客户端读到这个标记才算一次流式响应正常结束。后面我会讲,这个标记对排查问题意义重大——如果流被网关非正常掐断,前端读到了done却没见到[DONE],就能判定为异常。

3.3 消息渲染:Markdown 与代码高亮

AI 生成的内容绝大多数是 Markdown。如果你直接把字符串塞进模板插值,用户看到的会是满屏的#和**,体验直接归零。渲染方案我用的 markdown-it + highlight.js,两个都是老牌库,稳定、文档多、社区案例丰富。

import MarkdownIt from 'markdown-it'; import hljs from 'highlight.js'; const md = new MarkdownIt({ html: false, // 必须关掉,防止 XSS linkify: true, highlight(code, lang) { if (lang && hljs.getLanguage(lang)) { try { return `<pre class="hljs"><code>${hljs.highlight(code, { language: lang }).value}</code></pre>`; } catch (_) { // 语言名不存在时走默认转义逻辑 } } return `<pre class="hljs"><code>${md.utils.escapeHtml(code)}</code></pre>`; }, });

markdown-it 配置里html: false是最重要的一行。AI 生成的内容不可控,如果它输出了一段<script>或者<img onerror=...>,而你又开着 html 渲染,页面就可能被注入。我实测下来,AI 确实会"好心"地在回答里拼接 HTML,尤其当用户问"给我一段表单代码"的时候。所以 html 必须关,用户内容里的 HTML 一律转义展示。安全线在这里不能松。

代码高亮这里有个性能坑。AI 回复动不动给出几百行代码,highlight.js 如果全量引入,打包体积和渲染时间都会上升。我建议按需注册语言:

import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; import typescript from 'highlight.js/lib/languages/typescript'; import bash from 'highlight.js/lib/languages/bash'; import xml from 'highlight.js/lib/languages/xml'; hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('bash', bash); hljs.registerLanguage('xml', xml);

覆盖前端最常见的 JS/TS/HTML/CSS/bash 就够用,打包体积能砍掉一大半。这个优化在低端机上体感明显,属于性价比很高的改动。

另外要注意,流式场景下不应该每收到一个 delta 就重新渲染整个 Markdown。后面第 4 节我会专门讲渲染性能,这里先记一个原则:内容渲染要做节流,不要"来一个字渲一次"。

3.4 停止生成与错误兜底

流式过程中,用户想打断 AI 说话是很常见的。停止生成的关键是拿到AbortController的控制权,点击停止时调用abort():

let currentController: AbortController | null = null; async function handleSend() { const text = input.value.trim(); if (!text || isGenerating.value) return; addUserMessage(text); const assistantMsg = addAssistantMessage(''); // 先占位 currentController = new AbortController(); isGenerating.value = true; try { await streamChat( buildMessages(), (delta) => appendDelta(assistantMsg.id, delta), currentController.signal ); saveConversation(); } catch (err: any) { if (err.name === 'AbortError') { // 用户主动停止,不是错误,但要把已生成的半截内容保留 updateAssistantStatus(assistantMsg.id, 'stopped'); } else { // 网络错误或服务端错误 updateAssistantStatus(assistantMsg.id, 'error'); showErrorTip(err.message); } } finally { isGenerating.value = false; currentController = null; } }

这里有一个非常实际的交互细节:AI 消息必须在发送时就插入消息列表占位,后续不断追加内容。不能等流式全部结束后再一次性插入——如果用户干等十几秒屏幕还是一片空白,他会以为系统坏了。占位后再 append,配合滚动跟随,体验完全不一样。

错误兜底还要考虑几件事:

  • 请求超时:流式接口如果在规定时间内没有任何数据返回,应该主动 abort 并提示用户稍后再试。我这边配置的是 60 秒无数据判定为超时。
  • 限流响应:后端返回 429/409 时要转成友好提示,别让用户看到裸状态码。
  • 流内错误字段:服务端返回的 SSE 事件里如果有error字段,前端要能识别并停止渲染。

还有一条经验:错误提示不要用弹出框打断用户。AI 助手是高频工具,弹窗会很烦。我采用的是在消息下方插入一行轻量错误提示,附一个"重新生成"按钮,用户可以选择重试或继续聊别的内容。

4. 踩坑实录与排查思路

4.1 文本缺失、乱码与截断

这三类问题在流式解析里最常见,而且表面症状很像,都是"内容不对"。排查顺序很重要,我总结了一套固定流程。

先看是不是乱码。偶尔出现几个字符错乱,大概率是 TextDecoder 没加{ stream: true }。加上了还乱,检查后端返回的 Content-Type 是否声明 UTF-8,以及后端代理有没有在转发时改了编码。

再看是不是内容缺少中间段落或者段落重复。这种多半是 buffer 处理不对:当前 chunk 尾部留到下一轮时,和下一轮拼接后又重复解析了。正确写法是每轮先 split,把最后一行pop()出来留作 buffer,解析完所有 lines 后,把 buffer 拼到下一轮的头部。

最后看截断。消息在某个词中间突然断了,但请求本身没报错。这种场景多半是后端或网关对 SSE 响应做了缓冲,数据到一定量才 flush,连接被切断时后半段丢失。解决办法是让后端在代理层关闭缓冲,比如 Nginx 配置里加proxy_buffering off,或者在 SSE 响应头里加X-Accel-Buffering: no。

这里我要特别强调[DONE]标记的排查价值。我上线第二天就遇到一次事故:用户反馈"助手说一半话就闭嘴了",查下来是后端网关的超时时间配得比前端短,SSE 连接被网关掐断,但前端拿到的是 200 状态码,压根没进 error 分支。后来我在前端加了一道逻辑——流结束时必须显式读到[DONE],如果读到了流的done却没见标记,就判定为异常,提示用户重新生成。这个设计强烈建议加上,它能兜住大量"半截话"事故。

4.2 消息列表渲染性能

AI 回复很长时,如果每次 onMessage 都触发整个消息列表重新渲染,很快就会卡。尤其 Vue 3 的响应式对数组是整体追踪的,几千字的消息每秒更新几十次,页面帧率根本撑不住。

我的处理策略是"节流 + 局部更新":

  • 在组件层用一个定时器,把频繁的 append 合并成每 100ms 一次的批量更新
  • 渲染结果做缓存,内容变化超过阈值才重新跑 markdown-it

还有一个容易忽略的性能点:滚动锁定。流式输出时,如果用户在翻看历史消息,你不应该把他拉到最底部;但如果用户本来就在底部,又要保证自动跟随。判定逻辑很简单——记录用户滚动位置是否在底部附近(距离底部小于 30px 算在底部),新消息到达时只有"在底部"才触发自动滚动。这个细节不做好,用户翻历史时会被反复拽到最下边,特别恼火。

另外,长消息渲染还有一个隐藏问题:复制代码时会把行号一并复制进去。如果用了生成行号的高亮插件,一定要额外存一份不带行号的纯文本,复制时取那份。这个坑不亲自踩一次很难意识到。

4.3 常见问题速查表

现象可能原因处理方式
请求发出后长时间无响应后端网关缓冲、大模型本身处理慢检查 Nginx 缓冲配置;前端设置 60s 无数据超时
中文偶尔乱码流式解码未处理多字节字符TextDecoder 加{ stream: true }
消息中间缺一块或重复SSE 解析 buffer 处理错误按行 split,最后一行留到下一轮
生成一半突然停止网关超时掐断连接、未读到 [DONE]前端校验 [DONE] 标记,异常时提示重试
刷新后会话丢失未做持久化或存储超限localStorage 存元信息 + IndexedDB 存完整消息
生成时页面明显卡顿消息列表频繁整体重渲染100ms 节流 + 局部更新
复制代码带行号高亮渲染把行号混进代码文本单独存一份无行号纯文本供复制
页面出现异常脚本markdown-it 开了 html 渲染必须设置html: false

这个表看着简单,每一条背后都是真实事故。尤其是 [DONE] 标记那条,我建议所有做 AI 前端的团队都把它写进协议约定。前后端各执一份,前端校验流完整性,后端保证正常结束时输出标记,能省下大量排查时间。

5. 经验沉淀:一些可以少走弯路的设计

5.1 内容格式与产品细节约定

写代码之前,先把 AI 返回的内容格式和产品细节聊清楚。很多团队忽略了一件事:大模型可能会输出"思考过程",这些内容不该和真正的回答混在一起。我这边约定,如果流式返回里有 reasoning_content 字段,前端单独存储、默认折叠,用户想看清楚可以展开。这样既保留了模型推理的透明度,又不打扰正常阅读。

"重新生成"和"编辑后重发"这两个能力也建议第一时间做进去。AI 对话不是一次性的,用户很可能对答案不满意。重新生成本质上是:删除当前 assistant 消息,用相同的用户消息重新调一次接口。操作实现不难,但对产品价值提升很大,能显著减少用户因为回答不满意而反复开新会话的流失感。

还有一个细节是消息时间戳。AI 对话可能会跨天,消息列表里加上日期分组,比一长串时间戳清爽得多。这些看起来是小优化,实际上直接影响到用户愿不愿意长期用这个模块。

5.2 模块复用的设计原则

最后是我反复跟团队强调的一点:AI 助手模块的代码要当成公共模块来维护,而不是某一个页面的一次性功能。它以后一定会被其他模块复用——比如报表页想加一个"帮我解读这张图"的入口,审批页想加一个"帮我检查这个表单"的按钮。所以会话管理、流式请求、消息渲染这些能力,从一开始就拆成独立的 composable(useChat、useStream),页面里只负责组装 UI。这样后面做功能扩展时,复用成本几乎为零。

我自己实测的体会是,把 useChat 抽出来之后,新增一个入口页面平均只要半天,而第一个页面当初花了一周。这几倍的差距,就是因为基础能力都是现成的,新页面只需要处理自己特殊的卡片类型和触发方式。

另外,AI 助手模块做完之后,建议立刻定三个衡量指标:首次响应时间(用户发消息到第一个字出现)、消息完整率(正常读到 [DONE] 结束的消息占比)、用户留存率(第二天还愿意使用助手的用户占比)。前两个对应流式链路质量和后端稳定性,前端可以直接优化;第三个需要产品和运营一起打磨。指标不一定多复杂,但没有指标,等于闭着眼睛上线,后面出了问题都不知道该从哪里入手查。

我做完这个模块最大的感受是:AI 助手不是"加个聊天框"那么简单,它本质上是在把一套非确定性的、长耗时的、并发的数据流,塞进原本"请求-响应"式的传统前端里。技术选型想清楚、数据流理顺了,比写完所有组件都重要。希望这篇第十七章的落地记录里,关于流式解析、渲染性能和踩坑排查的经验,能让你下次接到 AI 需求时,少走几步弯路。如果后续要往多模态、语音交互或者移动端适配扩展,这套分层和流式处理的基础依然能复用,剩下的就是在这个骨架上继续长肉了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询