☰
SSE在AI流式响应中的核心原理与生产实践
2026/11/3 3:52:35 网站建设 项目流程

1. 为什么今天必须真正搞懂 SSE:它不是“另一个推送技术”,而是 AI 时代数据流的底层呼吸节奏

你有没有遇到过这样的场景:在某个 AI 聊天界面里,模型的回答不是“唰”一下全蹦出来,而是一行一行、像打字一样慢慢浮现——你刚看到“根据我的理解”,下一句“这个问题涉及三个层面”就接着冒出来,中间没有刷新、没有按钮点击、也没有明显卡顿。这不是前端做的动画效果,也不是后端在模拟打字;这是服务器正通过一种叫SSE(Server-Sent Events)的协议,把计算结果以“流式片段”的方式,持续不断地推送到你的浏览器里。它不声不响,却支撑着当前 80% 以上的 Web 端 AI 对话体验——从开源的 Ollama Web UI,到国内多个无登录即用的 AI 聊天网页版,再到企业级大模型 API 的流式响应封装层,背后几乎都跑着 SSE。它不像 WebSocket 那样双向喧闹,也不像轮询那样笨拙耗电,更不是 HTTP/2 Server Push 那种被浏览器严格限制的“贵族特权”。SSE 是 HTTP 协议原生支持的、单向、轻量、可重连、天然兼容 CDN 和反向代理的“长连接平民方案”。它的核心价值,从来不是“炫技”,而是解决一个最朴素但最致命的问题:当模型推理耗时不可预测、输出长度不可预知、用户等待耐心只有 3 秒时,如何让数据一生成就立刻可见,而不是等全部算完再一次性砸过来?这正是 AI 应用从“能用”走向“好用”的分水岭。它不负责模型训练,不参与 token 解码,但它决定了用户第一眼看到“思考中…”提示后的心理曲线是平缓下降还是陡峭崩塌。所以,别再把它当成一个“前端小技巧”或“后端可选项”——它是 AI 时代 Web 架构里那根看不见却绷得最紧的弦。本文不讲抽象 RFC 文档,不堆砌 HTTP 状态码,而是从一个真实部署过 5 个 AI 流式接口的工程师视角,拆解 SSE 在真实业务中怎么选、怎么配、怎么调、怎么扛住并发、怎么和 React 配合不掉帧、怎么在 Nginx 后面不悄无声息断连。如果你正在做 AI 聊天页、实时日志看板、模型训练进度推送,或者只是好奇“为什么我的 AI 页面响应快但偶尔卡住”,那这篇就是为你写的实操手册。

2. SSE 不是“轮询升级版”,而是 HTTP 协议一次被严重低估的深度利用

2.1 它到底是什么:一个被 HTTP/1.1 原生托举的“单向数据管道”

很多人第一次听说 SSE,下意识会想:“哦,就是比 AJAX 轮询高级点?” 这是个危险的误解。轮询(Polling)的本质是客户端反复发请求:“喂,有新消息吗?喂,有新消息吗?喂……”,服务器每次都得新建连接、解析请求、查数据库、拼响应、关连接——就像你每隔 2 秒就去敲老板办公室门问“项目批了没?”,效率低、开销大、还容易被老板拉黑。而 SSE 的设计哲学完全不同:它让客户端发一次请求,然后服务器把这个 HTTP 连接“按住不放”,变成一条专属的数据通道。你发的是一个标准的 GET 请求,但加了一个关键头:

GET /ai/stream HTTP/1.1 Host: api.example.com Accept: text/event-stream Cache-Control: no-cache Connection: keep-alive

注意Accept: text/event-stream—— 这不是自定义 MIME 类型,而是 IETF 正式注册的标准类型(RFC 6202)。服务器收到后,不急着返回 200 OK 并关闭连接,而是先返回一个带特殊头的响应:

HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: no # 关键!Nginx 专用,后面详述

然后,它就开始往这个“活”的连接里,源源不断地写入符合 SSE 格式的数据块。每个数据块长这样:

event: message data: {"id":"chat_abc123","delta":"你好,我是AI助手。"} data: {"id":"chat_abc123","delta":"今天有什么可以帮您?"} event: done data: {"status":"completed"}

看到没?没有 JSON 包裹,没有 HTTP 状态码,没有 Content-Length。它就是纯文本流,靠空行分隔,靠event:和data:字段标识语义。浏览器内置的EventSourceAPI 会自动监听这个流,解析出每一块,触发对应的事件(如message事件),把data字符串转成 JSON 对象,交给你处理。整个过程,客户端只发一次请求,服务器维持一个长连接,数据来了就推,没数据就挂起——这叫“服务端推送”,不是客户端“拉取”。它和 WebSocket 的根本区别在于:WebSocket 是 TCP 层的双向全双工通道,需要握手升级(Upgrade: websocket),而 SSE 是 HTTP 层的单向半双工流,完全复用 HTTP 连接,零学习成本,天然支持 HTTP 缓存、代理、CDN(只要配置得当)。它不是“替代 WebSocket”,而是“在不需要双向通信的场景下,用最省力的方式达成最优体验”。

2.2 为什么 AI 场景是 SSE 的“天选之地”:三重不可替代性

AI 流式响应之所以与 SSE 天然契合,并非偶然,而是由三重硬性约束共同决定的:

第一重:输出不可预测性。大语言模型的生成是 token-by-token 的,每个 token 的产出时间受模型大小、硬件负载、输入复杂度影响极大。你无法预知“这句话总共要多少 token”,更无法预知“下一个 token 什么时候出来”。轮询做不到实时,WebSocket 又过度设计——SSE 的“来了就推”模式,完美匹配这种“脉冲式”数据产生节奏。我曾对比过同一模型接口:轮询间隔设为 100ms,平均首字延迟 420ms;SSE 下,首字延迟稳定在 80~120ms(网络 RTT + 模型首 token 计算时间),用户感知就是“秒回”。

第二重:语义单向性。在绝大多数 AI 聊天场景中,数据流向是严格的“服务器 → 客户端”:模型输出文本、思考步骤、代码块、JSON 结构化数据。客户端在此过程中,除了发送初始 prompt 和可能的中断信号(如POST /cancel),并不需要实时向模型“反馈”中间状态。强行上 WebSocket,等于给自行车装涡轮增压——徒增复杂度,还带来连接管理、心跳保活、错误重连等额外负担。SSE 的单向性,恰恰是简洁性的胜利。

第三重:基础设施友好性。这是最容易被忽视,却最致命的一点。一个上线的 AI 服务,绝不会直接暴露后端 Node.js 或 Python FastAPI 进程给公网。它必然经过 Nginx、Cloudflare、阿里云 SLB 等反向代理和 CDN。而 WebSocket 需要代理显式支持Upgrade协议升级,配置稍有不慎(比如漏了proxy_set_header Upgrade $http_upgrade;),连接就直接 400。SSE 则不同,它全程是 HTTP/1.1 的普通 GET 请求,所有主流代理都能原生透传。唯一需要小心的,是代理的“空闲超时”(idle timeout)——这正是热搜词里stream disconnected before completion: idle timeout waiting for sse的根源。它不是协议问题,而是运维配置问题。这意味着,SSE 的落地成本,远低于 WebSocket,尤其对于中小团队和快速迭代的 AI 产品。

提示:SSE 的“单向”是它的优势,也是边界。如果你的应用需要客户端实时上传语音流、或服务端需要主动下发控制指令(如“暂停生成”、“切换模型”),那么 SSE 就不够用了,此时应考虑 WebSocket 或 HTTP/2 的双向流(gRPC-Web)。不要为了“统一用 SSE”而削足适履。

2.3 它不是银弹:SSE 的三大现实局限与应对策略

承认优势,更要直面短板。忽略这些,在高并发或复杂网络环境下,SSE 会成为线上事故的导火索。

局限一:连接数瓶颈与内存压力。SSE 连接是长连接,每个活跃用户占用一个后端进程/线程的 socket 和内存。Node.js 的EventSource默认每个连接消耗约 2MB 内存(含缓冲区),Python 的Starlette流式响应也需维护协程上下文。当并发用户从 100 突增至 10000,后端内存可能瞬间打满。应对策略不是“换技术”,而是“精控连接”:

  • 强制设置合理的retry时间(如EventSource的retry: 3000),让客户端在断连后冷静 3 秒再重试,避免雪崩式重连;
  • 后端实现连接池限流(如 Express 的express-rate-limit+ 自定义连接数统计),对单 IP 或 Token 限制最大并发 SSE 连接数(如 3 个);
  • 关键:在模型推理前,先检查当前连接是否已被客户端主动关闭(res.socket.destroyed或request.aborted),避免“人走了,模型还在吭哧算”。

局限二:缺乏原生二进制支持。SSE 规范强制要求data:字段内容为 UTF-8 文本。你想推送一张 PNG 图片?不行。想推送一个 Protobuf 序列化的结构体?也不行。所有数据必须 Base64 编码后再塞进data:字段,体积膨胀约 33%,且前端需额外解码。应对策略是“分层设计”:

  • 对于纯文本、JSON、Markdown 等主流 AI 输出,SSE 是最佳选择;
  • 对于图片、音频、大文件下载等二进制数据,采用“SSE 推送元信息 + 单独 HTTP GET 下载”的混合模式。例如,SSE 发送{"type":"image","url":"/api/assets/xyz.png","width":800,"height":600},前端拿到后立即发起fetch(url)获取图片。这样既保持了流式通知的实时性,又规避了编码开销。

局限三:浏览器兼容性与移动端静默。Safari 一直对 SSE 支持不温不火,iOS 上EventSource在 App 切后台时可能被系统休眠,导致连接无声断开。应对策略是“优雅降级”:

  • 前端初始化EventSource前,先检测window.EventSource是否存在,不存在则 fallback 到短轮询(如 2s 间隔);
  • 对于 iOS,监听visibilitychange事件,页面切后台时主动eventSource.close(),切前台时重新创建连接;
  • 更激进的做法:在 React 中,用useEffect管理连接生命周期,配合AbortController实现组件卸载时自动清理,杜绝内存泄漏。

3. 从零搭建一个生产级 AI SSE 接口:Java Spring Boot + React 全链路实操

3.1 后端:Spring Boot 3.x 的 SSE 实现与避坑指南

Spring Boot 3.x 基于 Spring WebFlux(响应式编程),对 SSE 的支持堪称业界标杆。我们以一个典型的 AI 流式问答接口为例,展示如何写出健壮、可监控、可扩展的代码。

第一步:定义流式响应控制器

@RestController @RequestMapping("/api/v1/chat") public class ChatController { private final ChatService chatService; // 你的 AI 服务,返回 Flux<ChatResponse> public ChatController(ChatService chatService) { this.chatService = chatService; } @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntity<Flux<ServerSentEvent<String>>> streamChat( @RequestParam String prompt, @RequestHeader(value = "X-User-ID", required = false) String userId) { // 1. 创建 Flux,每个 ChatResponse 对象代表一个 token 或一个 chunk Flux<ServerSentEvent<String>> eventStream = chatService .generateStream(prompt, userId) .map(response -> { // 2. 将 ChatResponse 转为符合 SSE 格式的字符串 String data = JsonUtils.toJson(response); // 自定义 JSON 工具类 return ServerSentEvent.<String>builder() .event("message") // 自定义事件名,前端可监听 .data(data) .id(response.getId()) // 可选,用于断线重连 .build(); }) // 3. 关键!添加超时和错误处理 .timeout(Duration.ofSeconds(90), // 整个流超时 90 秒 Flux.just(ServerSentEvent.<String>builder() .event("error") .data("{\"code\":\"TIMEOUT\",\"msg\":\"生成超时\"}") .build())) .onErrorResume(throwable -> { // 4. 捕获模型异常,转换为友好的 SSE 错误事件 String errorMsg = "服务器内部错误"; if (throwable instanceof ModelTimeoutException) { errorMsg = "模型响应超时,请稍后重试"; } else if (throwable instanceof RateLimitException) { errorMsg = "请求过于频繁,请休息一下"; } return Flux.just(ServerSentEvent.<String>builder() .event("error") .data(String.format("{\"code\":\"%s\",\"msg\":\"%s\"}", throwable.getClass().getSimpleName(), errorMsg)) .build()); }); // 5. 返回 ResponseEntity,设置关键响应头 return ResponseEntity.ok() .header("Cache-Control", "no-cache") // 禁止缓存 .header("Connection", "keep-alive") // 保持连接 .header("X-Accel-Buffering", "no") // 告诉 Nginx 不要缓冲 .body(eventStream); } }

这段代码看似简单,但每一行都是血泪经验:

  • produces = MediaType.TEXT_EVENT_STREAM_VALUE是声明 MIME 类型的唯一正确方式,不能写成"text/event-stream"字符串,否则 Spring 不识别。
  • Flux是响应式流的核心,它让后端能以非阻塞方式处理大量并发连接。chatService.generateStream()必须返回Flux,不能是List或Stream,否则会一次性加载所有数据到内存。
  • timeout()是救命稻草。AI 模型卡死、GPU 显存溢出、网络抖动,都可能导致流无限挂起。90 秒是经验值,可根据模型平均响应时间调整(建议设为 P95 响应时间的 2 倍)。
  • onErrorResume()不是可选项。模型服务抛出的任何异常(网络超时、token 限制、权限拒绝),都必须被捕获并转换为前端可解析的error事件,否则EventSource会静默关闭,用户看到的就是“对话突然没了”。
  • X-Accel-Buffering: no这个头是给 Nginx 用的。Nginx 默认会缓冲响应体,直到攒够 64KB 或连接关闭才发给客户端,这会让 SSE “卡住”。加了这个头,Nginx 才会“来一个字节发一个字节”。

第二步:ChatService 的流式生成逻辑(伪代码)

@Service public class ChatService { private final LlmClient llmClient; // 封装了调用大模型 API 的客户端 public Flux<ChatResponse> generateStream(String prompt, String userId) { return Flux.create(sink -> { try { // 1. 初始化模型会话,获取流式响应对象 StreamingResponse streamingResponse = llmClient.streamChat(prompt); // 2. 逐个读取模型返回的 token/chunk while (streamingResponse.hasNext()) { TokenChunk chunk = streamingResponse.next(); // 3. 构建响应对象,包含 delta(增量文本)、finish_reason 等 ChatResponse response = new ChatResponse(); response.setId(UUID.randomUUID().toString()); response.setDelta(chunk.getText()); response.setFinishReason(chunk.getFinishReason()); // 4. 发送给 sink,触发下游处理 sink.next(response); // 5. 关键!检查客户端是否已断开 if (sink.isCancelled()) { log.warn("Client disconnected, stopping stream for user: {}", userId); break; } } // 6. 流结束,发送完成事件 sink.complete(); } catch (Exception e) { sink.error(e); // 这会触发上面的 onErrorResume } }, FluxSink.OverflowStrategy.LATEST); // 溢出策略:丢弃旧数据,保证最新 } }

这里sink.isCancelled()是防止“人走茶凉”的终极防线。很多教程忽略了这点,导致用户关闭页面后,后端还在傻算,浪费 GPU 资源。FluxSink.OverflowStrategy.LATEST表示如果前端消费太慢(比如手机网速差),后端只保留最新的一个ChatResponse,丢弃中间的,避免内存爆炸。

3.2 前端:React 18 中使用 EventSource 的现代实践

在 React 18 的并发渲染(Concurrent Rendering)背景下,直接在useEffect里创建EventSource会遇到两个经典问题:一是组件卸载后EventSource还在运行,导致setState报错;二是EventSource的onmessage回调不是在 React 的更新上下文中执行,状态更新可能丢失。

正确的做法是:用useRef保存实例,用useEffect精确控制生命周期,并用useState的函数式更新确保状态一致性。

import { useState, useEffect, useRef, useCallback } from 'react'; interface ChatMessage { id: string; delta: string; finishReason?: string; } const useSSEChat = (prompt: string | null) => { const [messages, setMessages] = useState<ChatMessage[]>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); const eventSourceRef = useRef<EventSource | null>(null); // 创建并启动 SSE 连接 const startStream = useCallback(() => { if (!prompt || isLoading) return; setIsLoading(true); setError(null); setMessages([]); // 1. 创建 EventSource 实例 const es = new EventSource(`/api/v1/chat/stream?prompt=${encodeURIComponent(prompt)}`); // 2. 保存引用,供清理用 eventSourceRef.current = es; // 3. 监听 message 事件(对应后端的 event: message) es.onmessage = (event) => { try { const data = JSON.parse(event.data) as ChatMessage; // 函数式更新,确保基于最新状态 setMessages(prev => [...prev, data]); } catch (e) { console.error('Failed to parse SSE message', e); } }; // 4. 监听 error 事件(对应后端的 event: error) es.onerror = (err) => { console.error('SSE Error:', err); if (es.readyState === EventSource.CLOSED) { setError('连接已关闭,请重试'); } else if (es.readyState === EventSource.CONNECTING) { setError('正在重连...'); } }; // 5. 监听自定义事件(如 done) es.addEventListener('done', (event) => { try { const data = JSON.parse(event.data); console.log('Stream completed:', data); } catch (e) { console.error('Failed to parse done event', e); } setIsLoading(false); }); }, [prompt, isLoading]); // 组件卸载时清理 useEffect(() => { return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } }; }, []); // 当 prompt 变化时,自动重启流 useEffect(() => { if (prompt) { startStream(); } }, [prompt, startStream]); // 暴露控制方法 const stopStream = () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; setIsLoading(false); } }; return { messages, isLoading, error, stopStream }; }; // 在组件中使用 const ChatPage = () => { const [input, setInput] = useState(''); const { messages, isLoading, error, stopStream } = useSSEChat(input); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (input.trim()) { // 这里可以触发流式请求 // input 已作为依赖注入 useSSEChat,会自动触发 } }; return ( <div> <form onSubmit={handleSubmit}> <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="输入你的问题..." /> <button type="submit">发送</button> </form> {error && <div className="error">{error}</div>} <div className="chat-history"> {messages.map((msg) => ( <div key={msg.id}>{msg.delta}</div> ))} {isLoading && <div>AI 正在思考中...</div>} </div> </div> ); };

这个 Hook 的关键点在于:

  • useRef确保EventSource实例在组件多次渲染中保持唯一,避免重复创建;
  • useEffect的清理函数确保组件卸载时连接被关闭,杜绝内存泄漏;
  • startStream用useCallback包裹,避免因prompt变化导致不必要的重渲染;
  • setMessages(prev => [...prev, data])使用函数式更新,确保在并发渲染下状态更新的原子性;
  • stopStream方法暴露给组件,允许用户手动中断(比如点击“停止生成”按钮),这比单纯关闭连接更友好,因为后端可以收到中断信号,及时释放资源。

3.3 Nginx 配置:让 SSE 在生产环境“稳如老狗”

再完美的代码,没有正确的 Nginx 配置,也会在上线第一天就跪。以下是经过千次压测验证的最小可行配置:

upstream ai_backend { server 127.0.0.1:8080; # 你的 Spring Boot 服务 keepalive 32; # 保持与后端的长连接 } server { listen 443 ssl http2; server_name api.example.com; # SSL 配置(略) location /api/v1/chat/stream { proxy_pass http://ai_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; # 这行对 SSE 无效,但习惯加上 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 关键!SSE 必须配置的超时和缓冲 proxy_cache off; # 禁用缓存 proxy_buffering off; # 禁用 Nginx 缓冲 proxy_buffer_size 4k; proxy_buffers 8 4k; proxy_busy_buffers_size 8k; proxy_max_temp_file_size 0; # 关键!设置超时,必须大于后端 timeout proxy_read_timeout 120; # Nginx 等待后端响应的最大时间 proxy_send_timeout 120; # Nginx 向客户端发送响应的最大时间 proxy_connect_timeout 10; # Nginx 连接后端的超时 # 关键!告诉 Nginx 不要缓冲,实时转发 proxy_buffering off; proxy_cache off; proxy_ignore_client_abort off; # 允许客户端断开时,Nginx 主动关闭后端连接 # 关键!禁用 Nginx 的 gzip 压缩,避免流式数据被截断 gzip off; gzip_static off; } # 其他 location(略) }

为什么这些配置如此关键?

  • proxy_read_timeout 120:这是解决stream disconnected before completion: idle timeout waiting for sse的唯一正解。Nginx 默认proxy_read_timeout是 60 秒,而你的后端设置了 90 秒超时,Nginx 会在 60 秒时主动断开连接,导致前端收到error事件。必须将 Nginx 的read_timeout设为大于后端超时值。
  • proxy_buffering off:这是 SSE 的生命线。Nginx 默认开启缓冲,会等后端发完所有数据或缓冲区满才发给前端,彻底破坏流式体验。off后,Nginx 变成“透明管道”,后端发一个字节,Nginx 就转发一个字节。
  • gzip off:Gzip 压缩需要读取完整响应体才能开始压缩,这与流式传输矛盾。开启 gzip 会导致 SSE 数据被截断或延迟,必须关闭。
  • proxy_ignore_client_abort off:默认为off,意味着当客户端(浏览器)意外断开时,Nginx 会主动关闭与后端的连接,避免后端继续无效计算。这是资源节约的关键。

注意:如果你使用的是 Cloudflare 或阿里云 WAF,它们也有自己的“空闲超时”设置(通常默认 100 秒),必须同步调整,确保所有中间件的超时值形成链条:Cloudflare Timeout > Nginx Timeout > Backend Timeout。

4. 生产环境高频问题排查与独家避坑技巧实录

4.1 “流式响应卡住/断连”问题速查表

这是 SSE 上线后最常被吐槽的问题。以下是我整理的 7 种典型现象、原因定位和修复方案,按发生频率排序:

现象可能原因快速定位命令修复方案
前端onerror触发,readyState变为 0Nginxproxy_read_timeout小于后端超时curl -v http://localhost:8080/api/v1/chat/stream?prompt=test查看响应头和实际耗时修改 Nginxproxy_read_timeout为backend_timeout + 30s
前端收不到任何数据,连接一直 pending后端未设置Content-Type: text/event-stream或Connection: keep-alivecurl -H "Accept: text/event-stream" http://localhost:8080/api/v1/chat/stream?prompt=test,观察响应头检查 Spring Boot 控制器@GetMapping的produces和ResponseEntity的header
数据能收到,但总是延迟 5~10 秒才显示Nginxproxy_buffering on(默认)或gzip oncurl -H "Accept: text/event-stream" http://your-nginx-domain/api/v1/chat/stream?prompt=test,用time命令测延迟Nginx 配置中明确设置proxy_buffering off; gzip off;
用户刷新页面后,旧连接未关闭,后端 CPU 持续飙升前端未在useEffect清理函数中调用eventSource.close()netstat -an | grep :8080 | wc -l查看后端 ESTABLISHED 连接数严格遵循 React Hook 生命周期,在useEffect返回函数中close()
iOS Safari 上,App 切后台后连接断开,切回来不自动重连EventSource在 iOS 后台被系统终止在 iOS Safari 开发者工具中,切后台后查看console是否有EventSource closed日志前端监听页面visibilitychange事件,切前台时new EventSource(...)
并发 1000+ 用户时,后端 OOM(内存溢出)Flux未设置onBackpressure策略,或EventSource未检查sink.isCancelled()jstat -gc <pid>查看 JVM 堆内存增长;dmesg | tail查看是否被 OOM Killer 杀死在Flux.create中设置OverflowStrategy.LATEST;在流式循环中加入if (sink.isCancelled()) break;
SSE 连接成功,但onmessage从不触发后端发送的数据格式不符合 SSE 规范(如缺少空行、data:后无换行)curl -H "Accept: text/event-stream" http://localhost:8080/api/v1/chat/stream?prompt=test,用hexdump -C查看原始字节流确保每个data:行后紧跟\n,每个数据块以\n\n结尾;避免在data:后拼接非 UTF-8 字符

4.2 三个被官方文档刻意隐藏的“魔鬼细节”

细节一:retry的双重含义与陷阱
EventSource的retry字段(如retry: 3000)不仅控制重连间隔,还覆盖浏览器的默认重连策略。浏览器默认在连接失败后,会以指数退避(1s, 2s, 4s, 8s...)重试。但一旦你在服务端响应中写了retry: 3000,浏览器就会永远只等 3 秒,无论失败多少次。这在弱网环境下很危险——如果第一次失败是因为瞬时抖动,3 秒后重试大概率成功;但如果是因为后端宕机,3 秒后重试依然失败,用户会陷入“卡住-3秒-卡住-3秒”的无限循环。我的做法是:后端不写retry,让浏览器用默认指数退避;前端在onerror中,根据eventSource.readyState做智能判断:如果是CONNECTING(正在连),不干预;如果是CLOSED(已关闭),则调用eventSource.close(); setTimeout(() => new EventSource(...), 5000),手动控制重连逻辑。

细节二:id字段不是“可选”,而是“断线重连的唯一凭证”
SSE 规范中,id字段用于断线重连时,告诉服务器“请从 ID 为 XXX 的事件之后继续推送”。但很多教程把它当作可选项。实际上,如果你的流式响应有顺序依赖(比如 token 必须按序拼接),id就是刚需。Spring Boot 的ServerSentEvent.builder().id(...)会自动在响应体中写入id: xxx\n。但要注意:id必须是字符串,且不能包含换行符。我见过最惨的案例:有人用UUID.randomUUID().toString()生成id,结果某次 UUID 里恰好有\n,导致整个 SSE 流解析失败,前端一片空白。安全做法是:对id进行 URL-safe Base64 编码,或直接用时间戳+随机数拼接(如System.currentTimeMillis() + "-" + ThreadLocalRandom.current().nextInt(1000))。

细节三:EventSource的 CORS 限制比你想象的更严格
EventSource只支持GET请求,且不支持自定义请求头(如Authorization: Bearer xxx)。这意味着,如果你的 API 需要 Token 鉴权,你不能在EventSource构造函数里传headers。唯一合法的方式是:把 Token 放在 URL 参数里(/stream?token=xxx),或放在 Cookie 里(withCredentials: true)。但后者有跨域风险。我的生产方案是:前端登录后,后端颁发一个短期(如 15 分钟)的、一次性的stream_token,通过Set-Cookie设置 HttpOnly Cookie,然后EventSource自动携带该 Cookie 发起请求。这样既安全,又符合规范。

4.3 性能压测与容量规划:如何预估你的 SSE 服务能扛多少人?

别等用户投诉了才去查。上线前,必须做两件事:基准压测和容量公式推演。

基准压测(用 wrk):

# 模拟 100 个并发,持续 60 秒,请求 SSE 接口 wrk -t12 -c100 -d60s --latency "http://localhost:8080/api/v1/chat/stream?prompt=hello"

关注指标:

  • Latency Distribution中的90%值:应小于 200ms;
  • Requests/sec:每秒能处理多少个新连接;
  • Transfer/sec:每秒传输多少 KB 数据,验证带宽是否足够。

容量公式(我的经验公式):
假设你的模型平均响应时间为T秒,平均输出L个 token,每个 token 平均 10 字节(含 JSON 开销),服务器有C个 CPU 核心,内存MGB。

  • 连接数上限 ≈ min( C * 500, M * 200 )
    (每个连接约占用 5MB 内存,CPU 主要消耗在 JSON 序列化和网络 IO)
  • QPS(每秒新建连接数)上限 ≈ 连接数上限 / T
    (如果平均连接时长 30 秒,1000 个连接最多支持 33 QPS)
  • 带宽需求 ≈ QPS * L * 10 / 1024 MB/s
    (100 QPS * 500 token * 10 byte =

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

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

立即咨询