去年我在做一个 AI 助手类的桌面应用,后端是 Spring Boot 3.x,客户端是 Electron 搭 Vue 3。核心需求很直接:用户输入一句话,后端请求大模型接口,再把回答一点一点吐回给界面,而不是让用户干等十几秒看一个 loading。第一个版本用轮询,第二个版本用 WebSocket,最后真正跑得顺的,反而是看起来最不起眼的 SSE(Server-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 和 data,SSE 还支持id:、retry:等字段。id:用于断线重连时的续传标记,retry:告诉客户端如果连接断了,多少毫秒后自动重新连接。
这个格式有一个明显的好处:服务端可以非常自然地表达"当前事件序列"。大模型流式输出时,每次迭代产生的文本片段就是一条data:,而一条对话的结束可以用event: done来标记。客户端只要按约定解析,就能把整个生命周期分得很清楚。
我在 Spring Boot 端封装的时候,固定往外面发送event: message和event: done两种事件,前者负责内容片段,后者负责收尾,前端拿到done之后就知道整个流结束了。
2. 技术选型:为什么 AI 对话场景我最终选了 SSE
2.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 会提供一个流式接口,返回的是一个迭代器或者响应式流。我这边封装了一个统一调用的门面类,对外返回Iterator<String>,里面是模型吐出来的片段。转发逻辑就更接近实战了:
@Service public class StreamChatService { public void forwardToClient(SseEmitter emitter, String prompt) { streamTaskExecutor.execute(() -> { try (StreamResponse response = llmClient.streamChat(prompt)) { Iterator<Chunk> 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 秒内没有第一个 token,SseEmitter 默认的 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 对话场景,请求头里基本都要带 token,fetch是唯一能满足需求的原生方案。另外,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.27,TypeScript 是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,是否需要带请求头的fetch; AbortController的signal是否正确传入了fetch,点击停止后是否真的断开了连接;- Electron 渲染进程的
ipcRenderer监听器是否在组件销毁时移除,主进程是否处理了窗口关闭时未结束的请求。
这个清单帮我在后来几次新需求上线时省了大量排查时间,基本照着过一遍就能定位 90% 的问题。
最后再补一句我个人的体会:SSE 这套方案看起来简单,但"简单"建立在协议本身的可靠和客户端生态的天然支持上。真正复杂的地方不在于写几行 SseEmitter 的代码,而在于把超时、心跳、中断、断连、IPC 生命周期这些边角料全部收拾干净。只要能把这根流式通道的每一个环节都弄明白,它在你手里就是个非常趁手的实时推送工具。后面如果要做多人协作或者双向交互,再上 WebSocket 也不迟。