
1. 为什么是 EventSource实时 AI 聊天的消息通道选型做 AI 聊天相关的功能第一步要解决的就是“消息怎么从服务端推到浏览器”。早期大家习惯用轮询前端每隔两三秒发一次请求问服务端“有没有新消息”。这种方式在传统业务里够用但放到 AI 流式输出这种场景下就非常尴尬——大模型生成一段回答往往需要几秒到几十秒轮询太频繁浪费请求轮询太稀疏又会让用户觉得“怎么半天没反应”。我自己最早做类似功能时用过 WebSocket后来换成 EventSource才发现很多看似复杂的问题其实可以更轻量地解决。1.1 实时交互的三种主流方案对比我把主流方案放在一起对比过它们的本质区别在于“连接模型”和“数据格式”轮询Polling前端定时发起 HTTP 请求服务端不管有没有新数据都返回一次响应。实现最简单但实时性差请求浪费严重。适合低频、容忍延迟的场景比如消息列表的自动刷新。WebSocket全双工通信客户端和服务端可以互相主动推送。功能最强大但协议相对复杂需要处理握手、心跳、断线重连、二进制帧等一堆细节。对于“AI 对话”这种“客户端说一句、服务端流式返回一串”的模式WebSocket 的能力其实有一半用不上。EventSourceSSE基于 HTTP 的单向流式通信服务端可以持续向客户端推送数据。浏览器原生支持自动重连协议简单用起来就像监听一个普通事件一样。对于 AI 聊天悬浮窗这个场景核心需求是“把大模型生成的内容以流式方式实时展示给用户”本质上是一条单向通道服务端 → 客户端。用户的问题通过普通 POST 或 GET 请求发送即可不需要服务端主动往客户端以外的方向推送其他东西。所以 EventSource 在架构上是更贴合需求的选择。提示EventSource 的一个硬性限制是只能通过 GET 方法发送请求所以携带参数通常要拼在 URL 上。如果必须用 POST那就得走 fetch ReadableStream 的方案或者直接上 WebSocket。这点在项目初期就要想清楚后面我会讲我的处理方式。1.2 EventSource 的核心机制与使用边界EventSource 本质上就是浏览器对“服务器推送”的一种标准化封装。服务端只要把 Content-Type 设置为text/event-stream然后按照固定的格式持续输出数据浏览器端的onmessage就会不断被触发。它的几个特性在实战中非常关键自动重连当网络抖动或者服务端主动断开连接时浏览器会自动重新发起连接不用自己写重连逻辑这一点实测非常省心。事件类型除了默认的 message 事件还可以通过event:字段自定义事件类型比如event: ping、event: error前端可以用addEventListener分别监听。断点续传可以通过Last-Event-ID头告诉服务端“我上次收到的消息 ID 是 xx”服务端可以从这个位置继续推送避免重复或丢失数据。AI 聊天场景下这个特性用得不多但自由聊天类产品可以借此实现消息归档。连接数量限制HTTP/1.1 下浏览器对同一个域名的并发连接数有上限一般是 6 个EventSource 会占一个常驻连接。如果是 H2 或者多域名部署这个限制基本可以忽略。使用边界也很清楚EventSource 是一次“长连接”如果用户切后台太久浏览器可能会自动断开重连之后需要自己判断上下文状态。另外服务端如果一段时间没有数据输出中间最好发心跳包保持连接存活否则代理层可能会把空闲连接掐断。2. 项目整体设计与准备这个实战项目我假设的场景是页面右下角有一个悬浮按钮点击后展开一个迷你聊天窗口用户输入问题后由后端转发给大模型接口大模型流式返回内容前端通过 EventSource 实时渲染在窗口里。2.1 功能清单我最终实现的功能包括悬浮球 展开/折叠聊天窗支持拖拽移动位置在 localStorage 中记忆用户输入问题流式显示 AI 回复流式返回过程中“停止生成”按钮连接状态指示连接中/已断开/重连中消息历史按会话维度保存在内存中刷新页面后保留当前会话这套功能覆盖了 AI 聊天悬浮窗的绝大部分核心诉求背后涉及的逻辑可以复用到其他实时推送场景比如通知中心、工单状态提醒、数据监控大屏等。2.2 技术栈与项目结构前端我用 Vue 3 Vite组合式 API script setup语法组件通信用 Pinia 管理会话状态。后端为了演示方便用 Node.js Express 写了一个模拟 SSE 接口内部用定时器模拟流式吐字方便没有大模型 API Key 的朋友也能直接跑起来看效果。如果你有真实的模型接口把后端这段换成对模型 API 的转发即可。项目结构大体如下├── src │ ├── api │ │ └── sse.ts # SSE 客户端封装 │ ├── components │ │ ├── ChatFab.vue # 悬浮球 │ │ ├── ChatWindow.vue # 聊天窗口主体 │ │ └── MessageItem.vue # 单条消息组件 │ ├── stores │ │ └── chat.ts # Pinia 会话状态 │ └── App.vue ├── server │ └── index.js # 后端 SSE 模拟接口 └── index.html2.3 后端 SSE 接口快速实现后端核心代码其实很短关键在于“持续输出但不关闭响应”。我直接用 Express 实现const express require(express); const app express(); app.get(/api/chat-stream, (req, res) { // 必须设置这些响应头否则 EventSource 无法正常工作 res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no // 关掉 Nginx 缓冲否则流式会卡顿 }); const question req.query.message || ; // 模拟一段拼好的回答真实场景这里应该是真实模型的流式输出 const answer 你刚才问的是${question}\n这是一条模拟的流式回复。; const chunks answer.split(); // 每 80ms 输出一个字模拟真实模型的逐字产出效果 let index 0; const timer setInterval(() { if (index chunks.length) { // 用 [DONE] 标记流式输出结束前端据此关闭 loading res.write(data: [DONE]\n\n); clearInterval(timer); res.end(); return; } // SSE 协议约定的数据格式data: 内容 两个换行符 res.write(data: ${JSON.stringify({ content: chunks[index] })}\n\n); index; }, 80); // 客户端断开连接时清理定时器避免服务端资源泄漏 req.on(close, () { clearInterval(timer); res.end(); }); }); app.listen(3000, () { console.log(SSE server running at http://localhost:3000); });这里有两个细节值得注意。第一SSE 的数据格式非常严格每一行必须以data:开头数据结束必须以两个换行符\n\n作为终止标记。这是协议层面的要求缺失任何一个换行符都有可能导致浏览器端事件不触发。第二我在响应头里加了X-Accel-Buffering: no。如果生产环境用 Nginx 做代理默认 Nginx 会对响应做缓冲导致 EventSource 变成“等到全部数据到齐才一口气发给前端”流式效果直接失效。这一行可以关掉 Nginx 的代理缓冲。这是非常典型的“开发环境好好的上线就变成一次性输出”的坑。3. 核心代码实现EventSource 客户端封装与悬浮窗组件3.1 封装一个可复用的 SSE 客户端直接在每个组件里 new EventSource 也能跑但几轮写下来会发现问题多个地方要监听不同事件、处理重连状态、清理连接代码会越来越散。所以我建议单独封装一个工具类。核心设计思路是把“建立连接”“接收消息”“错误处理”“关闭连接”这几件事收敛到一个类里对外提供简单的回调方法。同时兼容两种用法——一种是通过onmessage统一处理数据另一种是支持自定义事件监听。// src/api/sse.ts type EventSourceOptions { url: string; onMessage?: (data: any) void; onError?: (err: Event) void; onOpen?: () void; onDone?: () void; }; class SSEConnection { private es: EventSource | null null; private options: EventSourceOptions; constructor(options: EventSourceOptions) { this.options options; } connect() { this.close(); this.es new EventSource(this.options.url); this.es.onopen () { this.options.onOpen?.(); }; this.es.onmessage (event) { // 判断是否结束 if (event.data [DONE]) { this.options.onDone?.(); this.close(); return; } try { const parsed JSON.parse(event.data); this.options.onMessage?.(parsed); } catch (e) { console.error(SSE 数据解析失败, e); } }; this.es.onerror (err) { // EventSource 内部会自动重连这里主要做状态上报 this.options.onError?.(err); }; } close() { if (this.es) { this.es.close(); this.es null; } } } export default SSEConnection;这里有个点需要特别说明onerror回调触发时EventSource 不一定会彻底死掉它大概率会进入自动重连流程。所以不要在 onerror 里做太重的清理操作否则会导致“重连还没开始就被你手动 close 掉”的情况。State 管理我放在 Pinia 里主要存会话消息列表和连接状态。每一次点击“发送”就 create 一个SSEConnection实例把当前会话的 id 传给后端后端根据会话 id 拼出上下文然后开始流式返回。这个过程的整体逻辑是// stores/chat.ts 核心逻辑节选 const messages refMessage[]([]); const connectionStatus refidle | connecting | streaming | done(idle); let currentConnection: SSEConnection | null null; function sendMessage(content: string) { // 先把用户消息推入列表 messages.value.push({ role: user, content }); // AI 回复的消息先占位后续流式内容持续追加 const aiMessage: Message { role: assistant, content: }; messages.value.push(aiMessage); connectionStatus.value connecting; const query encodeURIComponent(content); currentConnection new SSEConnection({ url: http://localhost:3000/api/chat-stream?message${query}, onOpen: () { connectionStatus.value streaming; }, onMessage: (data) { aiMessage.content data.content; }, onDone: () { connectionStatus.value done; }, onError: () { connectionStatus.value error; } }); currentConnection.connect(); }注意我用了encodeURIComponent处理 message 参数。如果用户输入包含中文、特殊符号或者空格直接拼到 URL 上会出问题甚至导致请求 400。这是实战中很容易踩的坑很多人写完接口用英文测一切正常一输入中文就废了。3.2 悬浮窗组件结构悬浮窗拆成三个组件是合理的ChatFab悬浮球、ChatWindow聊天窗、MessageItem单条消息。三个组件通过父组件的状态协同工作。ChatWindow 固定挂载在右下角用 Vue 的Transition做展开收起动画。!-- ChatWindow.vue 结构节选 -- template Transition namechat-pop div v-ifvisible classchat-window div classchat-header spanAI 助手/span div span classstatus-dot :classconnectionStatus/span button click$emit(close)收起/button /div /div div classchat-body refbodyRef MessageItem v-formsg in messages :keymsg.id :messagemsg / /div div classchat-footer textarea v-modeldraft keydown.enter.exact.preventhandleSend placeholder输入你的问题Enter 发送 / button clickhandleSend :disabledsending发送/button /div /div /Transition /template这里有一个交互细节当 AI 的流式内容不断追加时聊天框需要自动滚动到底部否则用户看到的是内容“被挤压”在上方视觉效果很糟糕。我封装了一个scrollToBottom方法放在 watch 里监听最后一条消息的 content 变化时触发。watch( () messages.value[messages.value.length - 1]?.content, () { nextTick(() { if (bodyRef.value) { bodyRef.value.scrollTop bodyRef.value.scrollHeight; } }); } );有些朋友会直接用scroll-behavior: smooth但在高频追加内容的场景下平滑滚动反而会导致滚动跟不上出现来回“抽搐”的感觉。实测用默认的瞬时滚动体验更好。3.3 消息流式渲染与打字机效果流式数据的渲染分两步先把每个 chunk 追加到当前 AI 消息对象里触发 Vue 的响应式更新然后配合固定时间间隔的 CSS 闪烁光标就可以做出“打字机”效果。在 MessageItem 组件里AI 回复内容展示时如果正在生成光标会一直闪烁。这个实现不复杂不需要额外引入打字机库template div classmessage-item :classmessage.role div classbubble span{{ message.content }}/span span v-ifisStreaming classcursor/span /div /div /template script setup import { computed } from vue; const props defineProps({ message: Object, isLast: Boolean }); const isStreaming computed( () props.isLast props.message.role assistant ); /script style scoped .cursor { display: inline-block; width: 8px; height: 16px; background: #4f7cff; margin-left: 2px; animation: blink 0.8s step-end infinite; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } /style很多人纠结要不要用第三方打字机库实测下来完全没必要。真实场景下 AI 模型的输出本身就不是均匀的——有时快有时慢前端只要做到“来一个 chunk 渲染一个 chunk”自然会产生打字机效果。强行用setInterval去控制显示速度反而会造成内容堆积体验不自然。4. 悬浮窗的交互细节打磨功能跑通之后真正影响用户体验的反而是那些交互细节。悬浮窗不同于普通页面组件它常驻在页面上方做不好会打扰用户做得顺手则会变成“日常习惯的一部分”。4.1 拖拽与位置记忆拖拽我用原生 pointer 事件实现避免引入拖拽库。核心思路是指针按下时记录初始位置指针移动时计算偏移更新悬浮球的位置指针抬起时把最终位置写入 localStorage。function onPointerDown(e: PointerEvent) { dragging true; startX e.clientX - posX; startY e.clientY - posY; window.addEventListener(pointermove, onPointerMove); window.addEventListener(pointerup, onPointerUp); } function onPointerMove(e: PointerEvent) { if (!dragging) return; posX Math.min( window.innerWidth - fabSize, Math.max(0, e.clientX - startX) ); posY Math.min( window.innerHeight - fabSize, Math.max(0, e.clientY - startY) ); } function onPointerUp() { dragging false; localStorage.setItem(chatFabPos, JSON.stringify({ x: posX, y: posY })); window.removeEventListener(pointermove, onPointerMove); window.removeEventListener(pointerup, onPointerUp); }这里有两个坑值得说一下。第一Math.max和Math.min的边界判断不能少不然悬浮球可以被拖到屏幕外拖回来后发现找不到了。第二pointerup需要在window上监听而不是在元素上否则鼠标移动过快导致指针移出悬浮球时抬起事件会丢失拖拽状态就卡住了。4.2 折叠与展开悬浮球与聊天窗的切换我用一个v-model:visible来控制。用户点击悬浮球展开聊天窗点击聊天窗头部“收起”按钮或点击悬浮球则收起。展开后是否需要自动聚焦输入框这个细节很重要——用户点击悬浮球的目的通常就是提问自动聚焦能减少一步操作。script setup import { watch, nextTick, ref } from vue; const visible defineModel(visible, { type: Boolean, default: false }); const inputRef refHTMLTextAreaElement | null(null); watch(visible, async (val) { if (val) { await nextTick(); inputRef.value?.focus(); } }); /script自动聚焦可以做得更精细一点如果上次对话还没结束不要强行聚焦避免打断用户正在阅读流式输出的注意力。4.3 自动弹出策略有些运营场景希望用户进入页面后自动弹出聊天窗。这里我的建议是“克制”只在满足特定条件时弹出比如新用户首次访问、或者用户停留超过 30 秒。我用 localStorage 记录弹出次数最多每 24 小时自动弹出一次避免用户每刷新一页都被骚扰。function shouldAutoPopup(): boolean { const last localStorage.getItem(chatAutoPopupDate); if (!last) { localStorage.setItem(chatAutoPopupDate, new Date().toDateString()); return true; } return last ! new Date().toDateString(); }一句话总结自动弹出功能要做成“引导”而不是“打扰”。最理想的效果是用户没有注意到它是自动出现的只是刚好想咨询时发现它就在那里。5. 常见问题与排查技巧实录实战中踩过的坑整理成一张速查表方便大家直接对照排查现象可能原因解决方案EventSource 收不到任何消息响应头 Content-Type 不是 text/event-stream检查后端响应头设置消息一次性全部出现没有流式效果Nginx 缓冲未关闭设置X-Accel-Buffering: no连接 1 分钟左右自动断开代理层空闲超时服务端增加定时心跳刷新页面后消息丢失未持久化消息历史接入 localStorage 或 IndexedDB请求 URL 带中文直接报错未对参数编码使用 encodeURIComponent输入框回车后页面刷新缺少.prevent修饰符使用keydown.enter.exact.prevent用户反馈聊天卡顿/掉字网络拥塞时数据缓存堆积可考虑用fetch ReadableStream替代5.1 EventSource 连接被意外断开EventSource 的自动重连机制在实际使用中有一个隐藏问题它默认的重连间隔是 3 秒但服务端如果彻底不可用它会一直以 3 秒的间隔反复尝试给后端造成压力。可以通过服务端返回自定义重连时间来控制SSE 协议支持发送retry:字段来设定重连间隔。// 服务端在响应流中发送 retry 字段 res.write(retry: 5000\n\n);这样 EventSource 在收到 retry 字段后会自动使用 5 秒作为重连间隔直到连接建立成功为止。5.2 组件卸载后连接未关闭Vue 组件的onUnmounted里必须调用currentConnection.close()。如果不做这一步会出现一个非常诡异的现象组件已经卸载了但 devtools 里 Network 面板还挂着一条 pending 状态的 EventSource 请求而且消息还在继续推送。这是因为 EventSource 实例是独立于组件存在的它只跟 JS 运行环境生命周期挂钩不跟随组件销毁而销毁。onUnmounted(() { currentConnection?.close(); });5.3 请求带不上参数或鉴权头EventSource 天然不支持自定义 headers如果服务端需要 token 鉴权方案有这么几个把 token 放在 URL query 上简单粗暴但有 token 暴露风险使用 cookie 方案推荐浏览器会自动携带同源 cookie服务端把鉴权信息编码在短时有效的 token 参数中配合服务端校验我倾向于采用“短时有效 token 拼在 query 上”的方式先通过普通 POST 接口获取一个有效期为 1 分钟的临时 token然后 EventSource 的 URL 上带上这个 token。这样比把长期 token 暴露在 URL 里安全很多。5.4 页面长时间挂机连接失效如果用户打开页面去忙别的事半小时后回来发现悬浮窗显示断线这通常是因为笔记本电脑合盖或者网络切换导致连接被系统断开。EventSource 的自动重连在这种情况下可能也失效了因为浏览器可能没有触发 onerror。处理方式是添加一个定时器每 30 秒检测一次 connection 状态如果显示 non-connecting 且用户当前有未完成的消息就手动 close 掉重建连接。6. 从“能跑”到“好用”几个拿得出手的小细节说实话很多人按教程写完这段代码功能是能用的但和“体验好”之间还有一段距离。这里分享几个我做同类项目时总结出来的细节处理可以显著提升完成度。6.1 会话上下文管理真实的 AI 聊天悬浮窗用户的对话往往不是孤立的单轮内容而是连续的多轮对话。如果后端只是简单地把当前问题发给模型模型回答会缺乏上下文显得“答非所问”。我在 Pinia 里维护了一个上下文数组只保留最近 10 条消息避免 token 超限发送时拼接为 Prompt 的一部分。这里有一个度的问题上下文带得越长模型回答质量越高但首字延迟也会更明显。从实际经验看10 条以内是最佳平衡点。6.2 停止生成大模型生成一个很长回答时用户难免觉得“已经够了”。我在输入框旁边加了一个“停止”按钮实现方式是点击后调用currentConnection.close()同时在消息末尾追加一条“已停止生成”的灰色提示。这个逻辑非常简单但如果没有这个功能用户只能等模型把话说完体验会差很多。6.3 消息持久化刷新页面后聊天记录消失对用户来说是一个毁体验的行为。我用 localStorage 做了简单持久化key 为当前页面路径value 是最近 50 条消息。刷新后自动读取并恢复。考虑到聊天消息可能包含敏感信息我在实现时也做了清理策略页面关闭后超过 24 小时自动清空记录。说个更实际的场景很多后台管理系统的“操作指引”浮窗如果能记住用户上次问过什么下次打开时直接显示在对话历史里这种“记忆感”会让用户觉得系统真的很懂他而不只是摆设。6.4 移动端适配悬浮窗在 PC 端和移动端的布局策略完全不同。PC 端我限制聊天窗宽度为 360px、高度为 480px移动端则直接将聊天窗铺满全屏悬浮球尺寸也缩小。判断机型我用一个简单的window.innerWidth判定不需要引入额外的响应式库。const isMobile window.innerWidth 768;移动端的输入法弹出会挤压页面高度我建议聊天窗用height: 100dvh而不是100vh这样能自适应浏览器地址栏的收起和展开避免出现底部按钮被键盘顶出屏幕的问题。6.5 无消息时的空状态一个很小的细节但体现完成度首次打开聊天窗时显示一句“您好我是智能助手有什么可以帮您的”比一个空白列表友好得多。这个空状态还可以兼做“推荐问题”的入口放两三个快捷提问按钮用户点一下就能快速试玩转化率会有明显提升。这个设计思路对降低试用门槛非常有帮助。7. 最后想说的话EventSource 这套方案在“AI 对话”场景下用很小的成本解决了 80% 的核心问题。它不是万能的——如果你需要客户端和服务端双向高频互推WebSocket 仍然是更合适的选择。但对于“消息推送 实时展示”这个窄场景EventSource 的轻量性和浏览器原生支持会带来更高的开发效率维护成本也更低。我在实际开发中还遇到过一些奇奇怪怪的环境相关问题比如开发环境跑得好好的打包部署到生产环境后 EventSource 请求 404。排查到最后发现是静态服务器没有配置/api的代理转发规则白白浪费了半天时间。使用这种方案时建议把“检查 Nginx 或网关代理配置”列入上线 checklist可以少走不少弯路。这个项目后续还可以继续扩展比如接入用户头像体系、对接真实大模型接口、支持 Markdown 渲染逻辑上不会有太大改动组件化的好处就在这些地方体现出来了。如果你正准备做一个 AI 助手类的前端交互完全可以从悬浮窗这个小切口开始按这套思路逐步完善踩坑会少很多。