ARTICLE DETAIL

资讯详情

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

SSE实战:Spring Boot与Electron构建AI流式对话

SSE实战:Spring Boot与Electron构建AI流式对话 去年我在做一个 AI 助手类的桌面应用后端是 Spring Boot 3.x客户端是 Electron 搭 Vue 3。核心需求很直接用户输入一句话后端请求大模型接口再把回答一点一点吐回给界面而不是让用户干等十几秒看一个 loading。第一个版本用轮询第二个版本用 WebSocket最后真正跑得顺的反而是看起来最不起眼的 SSEServer-Sent Events。这篇就把我从 0 到 1 把 SSE 落到 Spring Boot 和 Electron 的完整过程写出来包括 SseEmitter 的用法、心跳与超时、abort 中断、Electron 主进程和渲染进程的职责划分以及几个坑到半夜的排查实录。如果你也在做 AI 对话、实时通知、日志实时输出这类需求可以直接照着抄。1. SSE 到底是门什么技术协议层的白话拆解1.1 一个普通 HTTP 响应怎么变成永远不完结SSE 全称 Server-Sent Events翻译过来就是服务端推送事件。它的实现思路特别朴素客户端发起一个普通的 HTTP 请求服务端收到后不急着断开连接而是把响应体的 Content-Type 设置成text/event-stream然后在这个连接上持续不断地输出内容直到服务端主动关闭或者客户端断开。你可以把它类比成听广播服务端是电台客户端是收音机广播一旦开始电台说什么你就听什么不需要你反复去问下一句是啥。跟你平时请求一个接口然后等完整 JSON 返回完全不同SSE 的处理方式是一次请求、持续响应客户端通过同一个 HTTP 连接不断读取数据块。关键点在于HTTP 底层还是那个 HTTP连接复用也好Nginx 转发也好都把它当普通请求处理。这对开发调试特别友好浏览器地址栏直接敲接口地址就能看到流式内容抓包工具也能逐条看到服务端发出的数据。我在最开始排查问题的时候基本全靠浏览器标签页。1.2 消息格式event / data / id 的约定SSE 的数据格式非常简单是纯文本协议每一行都有固定含义。最常见的两种类型是event:和data:。event:声明这条消息的事件名称data:就是消息内容。一条完整的消息以空行结束。举个例子服务端推送两段内容event: message data: {role:assistant,content:你好} event: message data: {role:assistant,content:有什么可以帮你}客户端在收到这两段后就能把消息内容拼接起来实现打字机一样的效果。除了 event 和 dataSSE 还支持id:、retry:等字段。id:用于断线重连时的续传标记retry:告诉客户端如果连接断了多少毫秒后自动重新连接。这个格式有一个明显的好处服务端可以非常自然地表达当前事件序列。大模型流式输出时每次迭代产生的文本片段就是一条data:而一条对话的结束可以用event: done来标记。客户端只要按约定解析就能把整个生命周期分得很清楚。我在 Spring Boot 端封装的时候固定往外面发送event: message和event: done两种事件前者负责内容片段后者负责收尾前端拿到done之后就知道整个流结束了。2. 技术选型为什么 AI 对话场景我最终选了 SSE2.1 三张方案对比表在敲定 SSE 之前我把轮询、WebSocket、SSE 三条路都认真试了一遍。直接看对比更直观方案通信方向实现复杂度断线重连调试友好度适用场景轮询客户端主动拉取最低天然重复请求高低频通知实时性要求不高WebSocket全双工高自己实现中等聊天室、协作编辑、实时游戏SSE服务端单向推送低浏览器内置支持高实时通知、AI 流式回答、日志流从协议上看WebSocket 是全双工客户端和服务端随时可以互发消息SSE 是单工只能服务端推给客户端。很多人在选型时会惯性地觉得 WebSocket 更高级但在 AI 对话这个场景里用户输入一次服务端返回一大段流这个交互模式天然就是单向的SSE 反而更贴合。2.2 SSE 的单工反而是优势如果你只是做 AI 对话、消息推送双向通信极大概率用不上。用户点一个按钮服务端开始生成回答整个过程就是一问一答的变体双向能力是殺鸡用牛刀。而且 WebSocket 需要自己处理心跳、重连、消息分帧、连接状态机SSE 却是浏览器和 HTTP 协议栈原生支持的断线后客户端会自动重连省掉一大半工程代码。还有一个非常重要的现实原因现在大模型服务的标准接口基本都是 SSE 格式。也就是说Spring Boot 后端在对接大模型 SDK 时拿到的是一个流式响应后端只需要把这个流原样转发给客户端。如果选 WebSocket你得先把大模型的 SSE 流解析一遍再转换成自己的 WebSocket 帧客户端收到后再解析一遍多了一层无意义的工作。SSE 对 SSE省去了大量格式转换。另外SSE 基于普通 HTTP在 Electron 客户端里用 fetch 就能读在浏览器里用 EventSource 也能读在 Node 环境里同样能解析。我后来把同一个后端接口同时接进了 Web 管理端和 Electron 桌面端完全不用为不同客户端写两套适配逻辑。选型这块我的结论很明确除非你有真实的双向实时交互需求否则优先考虑 SSE。3. Spring Boot 侧把 SSE 出口做成一个可靠的消息通道3.1 SseEmitter 的基础形态Spring Boot 对 SSE 的封装核心是org.springframework.web.servlet.mvc.method.annotation.SseEmitter。用起来非常简单Controller 里面声明一个方法返回类型写成SseEmitter然后把这个对象交给一个异步线程去发送数据。最基础的一段代码长这样RestController RequestMapping(/api/sse) public class SseController { GetMapping(value /chat, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(RequestParam String prompt) { SseEmitter emitter new SseEmitter(180_000L); ExecutorService executor Executors.newFixedThreadPool(8); executor.execute(() - { try { // 模拟大模型流式返回 String[] chunks {你好, , 我是, AI, 助手}; for (String chunk : chunks) { emitter.send(SseEmitter.event().name(message).data(chunk)); Thread.sleep(300); } emitter.send(SseEmitter.event().name(done).data()); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }需要注意两点第一produces MediaType.TEXT_EVENT_STREAM_VALUE是必须的它告诉 Spring 响应体类型是事件流第二SseEmitter构造函数里的超时时间单位是毫秒我设的是 180 秒因为大模型回答长的时候很容易超过 60 秒。不过如果每个请求都 new 一个线程池生产环境会把资源浪费得很严重。我后来把线程池抽出来统一管理用Configuration定义了一个共享的ExecutorService专门负责流式转发任务。核心思路是Servlet 线程在返回SseEmitter后立即释放真正向外写数据的动作全在异步线程池里执行这样 Tomcat 的线程池不会被长连接占死。3.2 流式转发大模型返回的完整代码真实场景里你不太可能自己模拟数据而是对接大模型 SDK。大多数 Java 版 SDK 会提供一个流式接口返回的是一个迭代器或者响应式流。我这边封装了一个统一调用的门面类对外返回IteratorString里面是模型吐出来的片段。转发逻辑就更接近实战了Service public class StreamChatService { public void forwardToClient(SseEmitter emitter, String prompt) { streamTaskExecutor.execute(() - { try (StreamResponse response llmClient.streamChat(prompt)) { IteratorChunk iterator response.iterator(); while (iterator.hasNext()) { Chunk chunk iterator.next(); emitter.send(SseEmitter.event() .name(message) .data(Map.of(text, chunk.getText()))); } emitter.send(SseEmitter.event().name(done).data()); emitter.complete(); } catch (Exception e) { try { emitter.send(SseEmitter.event() .name(error) .data(e.getMessage())); emitter.completeWithError(e); } catch (IOException ex) { // 此时客户端多半已断开连接状态由容器清理 } } }); } }这段代码里有几个细节是踩过坑之后才补上的。第一个event().data()里面传Map时Spring 会自动把 Map 序列化成 JSON 字符串前端直接用JSON.parse就能拿到结构化数据比手动拼 JSON 字符串省事得多。第二个response.iterator()在遍历过程中要确保不会阻塞太长时间否则客户端那边容易触发空闲超时这一点后面单独讲。第三个异常处理里要分别覆盖emitter.send抛出的IOException因为这种情况下连接基本已经断了再做后续发送只会叠加日志噪音。3.3 心跳、超时和客户端断连的处理SSE 有一个让人头疼的问题很多网关或中间件会对空闲连接做回收一旦连接在指定时间内没有数据流动就会被强制关闭。我遇到过的典型报错是stream disconnected before completion: idle timeout waiting for sse排查到最后发现是连接空闲时间太长导致的。解决办法是加心跳。就像两个人打电话每隔一阵子总要互相嗯一声证明电话还通着。服务端每隔 15 秒或者 20 秒往连接里发一条心跳消息连接就一直处于活跃状态网关不会认为它空闲。心跳消息本身前端可以直接忽略只要约定一个特殊事件名就行。ScheduledExecutorService heartBeatScheduler Executors.newSingleThreadScheduledExecutor(); heartBeatScheduler.scheduleAtFixedRate(() - { try { emitter.send(SseEmitter.event().name(heartbeat).data(ping)); } catch (IOException e) { // 连接已断开停止心跳 throw new RuntimeException(e); } }, 10, 20, TimeUnit.SECONDS);关于超时时间我在生产上把 SseEmitter 的存活时间设成和心跳周期联动如果大模型接口可能在 10 秒内没有第一个 tokenSseEmitter 默认的 30 秒超时很容易在模型思考阶段就把连接掐断。所以我通常把SseEmitter的超时设成 180 秒甚至更长同时保持每 20 秒一次心跳双管齐下。客户端断连的处理同样关键。SseEmitter提供了三个回调onCompletion、onTimeout、onError。我在初始化 emitter 的时候都会把它们挂上确保连接结束或异常时能清理资源emitter.onCompletion(() - { // 连接正常结束清理会话数据 clientRegistry.remove(sessionId); heartBeatScheduler.shutdownNow(); }); emitter.onTimeout(() - { // 服务端超时需要给客户端一个明确事件再结束 emitter.complete(); }); emitter.onError(ex - { // 连接异常打印关键错误 log.warn(SSE connection error: {}, ex.getMessage()); });3.4 abort 中断下后端如何优雅收场很多 AI 交互界面都会给用户一个停止生成的按钮前端点击后就取消当前请求。这个动作在 HTTP 层的表现是客户端断开连接或者发一个取消信号。对于 SSE 来说客户端断开连接后服务端向emitter.send数据时就会抛出异常onError回调被触发上面的代码会自动清理连接状态。但这里有个更隐蔽的问题大模型的流式调用还在后台运行着。如果只是把 emitter 清理掉后端线程还会继续跑白消耗 CPU 和 token。所以我在设计上给每次请求都绑定了一个取消标志客户端断开时onError回调里去调用大模型 SDK 的 cancel 方法。emitter.onError(ex - { // 通知模型调用取消 llmClient.cancel(requestId); clientRegistry.remove(sessionId); });如果你用的是响应式大模型 SDK通常有Disposable.dispose()如果是官方 HTTP 接口可以直接把底层的Call取消掉。这一步做没做直接影响后端在高并发下的表现。我在压测时发现如果不主动取消模型调用中断请求一多线程池很快会被无意义的任务占满后面的正常请求就要排队等线程。4. Electron 客户端从触发请求到流式渲染的完整链路4.1 渲染进程里直接用 fetch 消费流Electron 的渲染进程本质就是一个 Chromium 浏览器因此所有浏览器里可用的 Web API 在这里同样有效。看 SSE 流最直接的方式是用原生fetch读取response.body这个ReadableStream逐块解析明文数据。这里我推荐一个做法不要用EventSource。因为EventSource只能发起 GET 请求无法自定义请求头也没法在需要时带 Authorization 之类的鉴权信息。而 AI 对话场景请求头里基本都要带 tokenfetch是唯一能满足需求的原生方案。另外fetch配合AbortController可以实现取消EventSource虽然也有close()但在某些场景下取消不够即时。下面是一段可以在渲染进程里直接跑的解析函数async function readSseStream( url: string, token: string, onMessage: (data: string) void, signal: AbortSignal ) { const resp await fetch(url, { headers: { Authorization: Bearer ${token} }, signal, }); if (!resp.ok || !resp.body) { throw new Error(HTTP ${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; buffer decoder.decode(value, { stream: true }); // SSE 协议以空行分隔消息 const parts buffer.split(\n\n); buffer parts.pop() ?? ; for (const part of parts) { const dataLine part .split(\n) .find((line) line.startsWith(data: )); if (dataLine) { onMessage(dataLine.slice(6)); } } } }几个要点decoder.decode(value, { stream: true })是为了处理多字节字符被拆到两个 chunk 的情况否则 emoji 或中文会出现乱码。空行分隔的协议解析其实非常脆弱如果服务端用了\r\n而不是\n上面代码可能漏消息我在 Spring 端统一发的是\n所以这里只处理了\n。后来我把解析逻辑封装成了流式行迭代器因为缓冲区可能收到半条消息不能简单把整个 buffer 切掉。经验之谈buffer.split(\n\n)后最后一段无条件保留在 buffer 里等下一个数据块补全。4.2 主进程/渲染进程的分工IPC 封装与安全模型Electron 应用中主进程和渲染进程的职责经常让人纠结。对于 SSE 请求我最终的选择是请求放在渲染进程发起但所有需要访问 Node 能力或者持有机密信息的部分放主进程。为什么这么分因为大模型的密钥如果写在渲染进程代码里一旦打包出来的 asar 被解包密钥会直接暴露。所以我的架构是这样的渲染进程Vue负责发 prompt、接收流式消息、更新界面不持有任何密钥。主进程负责保存 API 密钥、调用大模型接口。渲染进程通过ipcRenderer.invoke告诉主进程帮我发起请求主进程随后向渲染进程发送多条流式 IPC 消息。实现上主进程用ipcMain.handle注册一个sse:start方法渲染进程调用后传入 prompt主进程返回一个requestId。随后主进程每收到大模型的一个 chunk就通过event.sender.send(sse:chunk, { requestId, data })把片段推给渲染进程。结束或出错时发送sse:done或sse:error。主进程核心代码import { ipcMain, BrowserWindow } from electron; ipcMain.handle(sse:start, async (event, prompt: string) { const requestId crypto.randomUUID(); const win BrowserWindow.fromWebContents(event.sender); llmClient.streamChat(prompt, { onChunk: (text) { win.webContents.send(sse:chunk, { requestId, text }); }, onDone: () { win.webContents.send(sse:done, { requestId }); }, onError: (err) { win.webContents.send(sse:error, { requestId, message: err.message }); } }); return requestId; }); ipcMain.handle(sse:abort, (event, requestId: string) { llmClient.cancel(requestId); });4.3 preload 桥接与 Vue 组合式封装Electron 安全模型要求开启contextIsolation: true和nodeIntegration: false渲染进程不能直接拿到 Node 能力必须通过 preload 里的contextBridge暴露一个白名单 API。我在 preload 里这样写import { contextBridge, ipcRenderer } from electron; contextBridge.exposeInMainWorld(sseApi, { start: (prompt: string) ipcRenderer.invoke(sse:start, prompt), abort: (requestId: string) ipcRenderer.invoke(sse:abort, requestId), onChunk: (callback: (data: { requestId: string; text: string }) void) { const listener (_: unknown, data: { requestId: string; text: string }) callback(data); ipcRenderer.on(sse:chunk, listener); return () ipcRenderer.removeListener(sse:chunk, listener); }, onDone: (callback: (data: { requestId: string }) void) { const listener (_: unknown, data: { requestId: string }) callback(data); ipcRenderer.on(sse:done, listener); return () ipcRenderer.removeListener(sse:done, listener); } });然后在 Vue 组件里用window.sseApi完成全部逻辑。我一般会封装一个组合式函数useChatStream对外暴露sendMessage、stop和answer状态。组件的模板只负责把answer渲染出来配合 CSS 里的光标闪烁效果就是很自然的 AI 打字机界面。在 Vue3 的onBeforeUnmount钩子里我会调用返回的清理函数把ipcRenderer的监听器移除掉防止页面切换后产生重复监听、重复渲染。这一点容易忽略Electron 里页面不一定销毁组件却可能频繁挂载卸载不做清理的后果是一次对话结束界面上出现两遍内容。4.4 用户点取消abort 与资源释放在 Electron 里做页面上的停止生成按钮需要同时触达两端渲染进程要停止接收和渲染主进程要取消大模型调用。在我用的 IPC 架构里前端只是调用window.sseApi.abort(requestId)主进程收到命令后做两件事第一调用llmClient.cancel(requestId)让大模型接口的底层连接尽快关闭释放后端线程和 token 配额。第二主动向渲染进程发送一条sse:done事件告诉 UI 这个流已经结束可以清理 loading 状态。如果走的是渲染进程直接fetch的路线abort 更简单AbortController.abort()会中断reader.read()此时后台的 tcp 连接断开Spring Boot 端自然触发onError从而执行后端的取消逻辑。有一点我要特别提醒Electron 渲染进程被直接关闭时如果还有未结束的 SSE 请求最好在before-quit或window-all-closed事件里给主进程发一个广播把当前所有requestId统一 abort 一遍否则后台的模型调用还会继续跑一段时间既不省钱也不环保。5. 我踩过的坑和排查实录5.1 流中途连着两次报 idle timeout上线第一天就收到用户反馈AI 回答在生成过程中经常突然中断报错信息正是stream disconnected before completion: idle timeout waiting for sse。我第一反应是后端超时设置太短把SseEmitter超时改成了 0表示不超时结果问题依旧。排查过程是这样走的先看 Spring Boot 日志发现onTimeout触发了再看网关层日志发现连接在 60 秒处被断开最后查到 Nginx 默认的 read timeout 是 60 秒而大模型在生成第一个 token 之前有一段思考时间加上我的首包没有用心跳填充连接空闲超过了 60 秒直接被网关切断。最终修复是在服务端启动一个ScheduledExecutorService每 20 秒给所有活跃连接发一条event: heartbeat。前端解析时遇到heartbeat事件直接忽略不触发 UI 更新。改完之后这个问题再没出现过。5.2 全局过滤器把 SSE 响应拦了一道项目里之前有个全局过滤器目的是给每个响应追加统一的请求 ID 和 CORS 头。SSE 上线后发现一个诡异现象前几条消息能正常到客户端后面的消息全部丢失而服务端明明在持续 send。定位到最后问题出在过滤器对响应做的包装。某个版本里我对响应体做了一层缓存包装目的本来是对上传文件做 XSS 过滤结果把流式输出也包装成了只能写一次的缓冲。SSE 要求响应必须边写边刷缓冲类包装一旦开启数据就卡在缓冲区里出不去。解决方法是把 SSE 请求单独排除在响应包装过滤器之外通过路径匹配或者判断Accept头是否为text/event-stream。这里也给了我一个教训SSE 是边写边刷的实时通道任何对响应体的二次包装、缓冲、压缩都可能是隐形杀手。如果项目里启用了 Gzip 压缩也要把 event-stream 排除掉否则数据会被压缩缓冲等缓冲满了一次性刷给客户端实时性直接没了。5.3 Electron 打包与 vue-tsc 构建的那些琐事Electron 客户端在本地开发时一切正常打包后就出现界面能发请求但收不到流的怪问题。后来发现是打包后请求的 baseURL 写错了渲染进程请求的是本地文件路径下的某个地址自然连不上 Spring Boot 服务。另外项目里用的vue-tsc版本是1.8.27TypeScript 是5.3.3每次执行vue-tsc --noEmit都会报一堆跟 Electron 类型声明相关的错。我最后是在tsconfig.json里把 Electron 相关类型声明单独抽了一个tsconfig.electron.json构建主进程时才引入Vue 渲染进程的 tsconfig 不加载 Electron 类型两边类型检查互不污染。这个做法实践下来很管用。Electron 打包时我还关注过内存占用问题。打包出来的应用跑一段时间内存持续上涨GC 好像不积极。后来给主进程启动命令加了--expose-gc参数并在主进程里定时手动触发global.gc()每次触发前先统计当前内存占用如果超过预设阈值再执行回收。实测下来峰值内存能压下去不少。app.commandLine.appendSwitch(js-flags, --expose-gc);import { app } from electron; const memoryTimer setInterval(() { const mem process.memoryUsage(); if (mem.heapUsed 512 * 1024 * 1024 global.gc) { global.gc(); console.log(manual GC triggered, heapUsed after:, process.memoryUsage().heapUsed); } }, 30_000); app.on(before-quit, () { clearInterval(memoryTimer); });这种操作只建议在应用层真的需要长时间运行时用日常开发时不建议开因为会干扰性能分析。5.4 值得反复检查的连接生命周期清单每次联调 SSE 出问题我都会从头过一遍连接生命周期这里整理成一个清单建议直接收藏Spring Boot 的SseEmitter超时时间是否足够长模型思考阶段是否可能超过超时上限是否有定时心跳填充空闲连接网关层是否被中间代理或 Nginx 掐断是否启用了对 event-stream 的缓冲、压缩、二次包装客户端是否用了EventSource是否需要带请求头的fetchAbortController的signal是否正确传入了fetch点击停止后是否真的断开了连接Electron 渲染进程的ipcRenderer监听器是否在组件销毁时移除主进程是否处理了窗口关闭时未结束的请求。这个清单帮我在后来几次新需求上线时省了大量排查时间基本照着过一遍就能定位 90% 的问题。最后再补一句我个人的体会SSE 这套方案看起来简单但简单建立在协议本身的可靠和客户端生态的天然支持上。真正复杂的地方不在于写几行 SseEmitter 的代码而在于把超时、心跳、中断、断连、IPC 生命周期这些边角料全部收拾干净。只要能把这根流式通道的每一个环节都弄明白它在你手里就是个非常趁手的实时推送工具。后面如果要做多人协作或者双向交互再上 WebSocket 也不迟。
返回列表