
在对接大模型、做AI对话产品的时候我几乎每天都要回答同一个问题流式对话接口到底用SSE还是WebSocket这个问题看起来是个“二选一”的技术选型实际上背后是协议设计、网络环境、鉴权体系、服务端资源、甚至产品交互方式的一连串取舍。我自己的项目里这两种方案都完整落地过也踩过不少坑这篇文章就把原理、代码实现和工程边界一次性讲清楚。先说个结论纯单向的流式文本输出SSE几乎总是更合适一旦涉及双向交互比如语音对讲、实时协同编辑、客户端控制指令回传WebSocket就成了刚需。但现实世界远没有这么简单鉴权方式、代理超时、App打包环境、断线重连策略每一项都可能推翻你原本的选型。1. 流式对话的底层逻辑HTTP明摆着不够用1.1 一次HTTP请求为什么扛不住逐字返回传统的REST接口是“请求-响应”模型客户端发一个HTTP请求服务端处理完把完整结果一次性返回然后连接就关了。这个模型在普通查询场景下没有任何问题但在AI对话里就非常难受大模型的生成是逐token进行的一个几千字的回答可能要生成十几秒甚至更久如果让用户干等十几秒才看到第一个字产品体验基本就废了。于是就有了“流式返回”的需求服务端每生成一个token或者一小段文本就立刻推给客户端客户端收到后实时渲染。这种需求本质上要求连接不再是“一发一收就关闭”而是保持一段时间持续向客户端推送数据。HTTP协议本身其实也能做这件事也就是Chunked Transfer Encoding配合长连接。但HTTP的语义是一次请求对应一次响应想在同一个连接里持续下发多个数据块就必须翻山越岭加各种非标准头用起来非常别扭。所以行业里逐渐形成了两套专门方案一套是HTTP上的SSE一套是独立协议的WebSocket。1.2 SSE和WebSocket的本质差异单向管道与双向通道SSE全称Server-Sent Events它本质上仍然是一个HTTP响应只是服务端把Content-Type设为text/event-stream然后持续往响应体里写数据。它的核心特点是只有服务端能推送客户端不能通过这个连接直接发数据客户端要发消息得另外发HTTP请求。所以SSE是一个“单向管道”。WebSocket则完全不同它一开始通过HTTP Upgrade完成握手之后脱掉HTTP的外壳升级成一个独立的TCP长连接协议。在这个连接上客户端和服务端可以双向、平等地互相发送数据。所以WebSocket是一个“双向通道”。这个本质差异直接决定了两个协议适用场景的分水岭。流式对话大多数时候确实只需要服务端往客户端推内容但如果你的产品里还有“客户端随时打断生成”“客户端调整参数”“客户端回传音频流”这类需求单向的SSE就不够了。我在做语音问答机器人时就同时用了WebSocket做双向控制通道、SSE做文本结果下行通道两种协议在同一个系统里共存是非常常见的事。2. 协议内部机制深挖SSE和WebSocket各是怎么工作的2.1 SSE协议细节text/event-stream的字段语义SSE的传输格式很简单服务端往响应体里写文本用空行分隔事件。每行可以是以data:开头的数据行也可以是event:指定事件名、id:设置事件ID、retry:设置重连时间。event: message data: {delta: 你} data: {delta: 好} event: done data: [DONE]最低限度只写data:也完全够用很多实现就只用了data字段。因为每个事件什么都不写的时候浏览器端的EventSource会自动触发onmessage回调。SSE有一个很方便的机制如果客户端用原生EventSource接收断线后浏览器会自动重连重连时间由服务端发的retry:字段决定或者默认3秒。但EventSource有两个很烦人的限制它只能用GET请求、不能设置自定义Header。这在鉴权环节会带来不小的麻烦后面我专门展开。2.2 WebSocket握手的秘密Upgrade头与帧格式WebSocket握手是很多人忽略的一层。客户端发一个带Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key、Sec-WebSocket-Version的HTTP请求服务端校验通过后把Sec-WebSocket-Key拿去做一次SHA-1加盐哈希盐值是固定的258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼上Sec-WebSocket-Accept响应头返回101状态码HTTP连接就地升级为WebSocket连接。此后双方传的都是WebSocket帧。帧分很多种类型文本帧、二进制帧、ping/pong控制帧、close帧。文本帧和二进制帧是日常传数据用的ping/pong帧用于心跳保活close帧用于优雅关闭连接。在流式对话里WebSocket服务端可以一条接一条地发send_json每次就是一帧。客户端onmessage也会逐帧触发所以逐token渲染的实现非常直接。2.3 流式对话里“增量输出”怎么实现不管用SSE还是WebSocket流式对话的核心都是在“完整结果还没生成完”的前提下把已经生成的部分发出去。这里有一个重要的协议无关细节增量数据怎么包装才不会让前端拼错。我在项目里常用的做法是服务端生成的每个chunk都包装成一个JSON包含一个delta字段本次新增加的文本以及可选的index序号、finish_reason是否结束。前端拿到后把delta直接append到当前显示文本的末尾。不要试图在服务端替前端拼接也不要下发“当前完整文本”那样每个chunk体积会越来越大浪费带宽且增加前端渲染压力。{index: 0, delta: 你, finish_reason: null} {index: 1, delta: 好, finish_reason: null} {index: 2, delta: , finish_reason: null} {index: 3, delta: 今天想聊点什么, finish_reason: stop}前端逻辑就成了遍历事件取delta拼字符串看到finish_reason非空就收尾。这套增量语义在SSE和WebSocket之间可以完全复用区别只在于传输层的封装方式。我在两种协议的实现里都沿用同一个增量JSON结构这样后端无论切换哪种通道前端的渲染逻辑都不需要改。3. 工程选型SSE和WebSocket的7个对比维度3.1 连接模型与资源占用SSE是纯HTTP连接所以它天然继承了HTTP的“无状态”特点。每个连接在服务端就是一个被挂起的响应上下文不需要维护额外的状态机。对Spring Boot、FastAPI、Gin这类框架来说SSE的实现本质是把响应对象保存下来往OutputStream里写数据。WebSocket服务端需要维护一个连接池每个连接都有独立的状态会话ID、附属属性、存活状态。连接多了以后内存占用、心跳扫描、超时管理都是额外成本。我做过一个在线服务WebSocket连接数到3万左右时单机内存顶不住了不得不引入连接分片和横向扩容同样的推送量如果用SSE很多请求其实可以通过HTTP的负载均衡直接分散掉压力会小很多。这里要特别说一个点SSE虽然是长连接但它往往可以穿透各种网关和负载均衡器因为它是标准的HTTP响应。而WebSocket在不同负载均衡器上的会话保持session stickiness配置比较麻烦连接一旦漂移到另一台实例上原来的状态就丢了。3.2 鉴权方式与安全边界这是SSE最大的短板。如果你用浏览器原生EventSource来消费SSE对不起你没法在请求里带自定义Header因为EventSource只能发GET请求Header几乎只由浏览器决定。那Token怎么传常见方案是拼在URL query参数里const es new EventSource(/chat/stream?tokenxxxx);但query参数会写进Web服务器AccessLog也容易被浏览器历史记录、反向代理日志留下痕迹安全性打了折扣。如果改用fetch来消费SSEfetch的响应体本身是一个ReadableStream可以逐段读取那就可以带自定义Header了const response await fetch(/chat/stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer xxxx }, body: JSON.stringify({prompt: 你好}) }); const reader response.body.getReader();这种做法的前端解析代码要多写一些但换来了Header鉴权和POST传参的能力在工程上非常值得。我的项目里几乎全部是这种fetch ReadableStream模式。WebSocket在鉴权上也有类似的问题。浏览器原生WebSocket API同样不能让开发者自定义Header服务端框架虽然很擅长从Header里取Token但浏览器端压根发不出去。业界常用的替代方案有两个一是把Token放query参数Nginx和后端都能直接读到二是利用协议子协议字段Sec-WebSocket-Protocol来携带Token。我用过第二种服务端在握手回调里检查子协议值校验不通过直接拒绝握手。3.3 代理穿透与基础设施兼容性这块是SSE和WebSocket差距最大的地方之一。SSE就是一个HTTP响应天然能被Nginx、CDN、各种网关处理。但有一个前提必须关掉代理缓冲proxy buffering否则Nginx会把服务端推下来的数据攒一批再转发流式效果就变成“卡顿式”了。Nginx里对流式接口的典型配置是location /chat/sse { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; add_header X-Accel-Buffering no; }proxy_read_timeout必须调大否则Nginx默认60秒没读到新数据就掐断连接。这个配置就是文章开头热搜里那条before completion: idle timeout waiting for sse的根源。如果你用的是云厂商的API网关更要留意默认超时时间很多网关默认只有10秒或者30秒根本撑不住一次长时间的流式输出。WebSocket在Nginx里同样要配置proxy_read_timeout和proxy_send_timeout还要确保HTTP版本是1.1、Upgrade和Connection头正确传递。如果前面还有CDN很多CDN根本不支持WebSocket或者只支持很有限的功能。我们有个客户用了一套国产CDNWebSocket握手能成功但一旦数据量大就疯狂断连折腾到最后发现是CDN的WebSocket缓冲机制有bug。3.4 断线重连与消息可靠性断线重连上SSE有先天优势。浏览器原生EventSource自带自动重连机制服务端只要发过id:字段浏览器重连时还会带上Last-Event-ID头服务端可以据此告诉客户端从哪一条消息继续推送。这个“断点续传”能力在流式对话里很实用如果用户看到一半网络断了重连后可以接着生成而不是从头再来。但对使用fetch ReadableStream模式的SSE来说自动重连就没了需要自己实现。我的做法是封装一个sseConnect函数内部用try/catch包住fetch循环一旦出现网络错误或流中断就按指数退避策略重试。WebSocket断线重连没有任何协议层面的自动恢复能力。浏览器WebSocket的onclose事件触发后你必须自己建一个心跳检测和重连管理器。而且WebSocket重连后是全新的连接服务端要自行判断“这个连接是重建的之前生成到一半的任务要不要继续”。这个状态恢复比SSE的Last-Event-ID机制要繁琐得多。3.5 浏览器支持与封装成熟度兼容性方面SSE和WebSocket在现代浏览器里都得到了完整支持IE都退场了这块基本不用太担心。但要注意一个细节浏览器的并发连接数限制。HTTP/1.1下单个域名最多同时开6个TCP连接而SSE连接是常驻的每个SSE都会占掉一个连接。如果在同一个页面同时开了3个SSE你的页面加载其他资源就会受影响。HTTP/2解决了这个问题多路复用所以生产环境我建议SSE服务跑在HTTP/2后面。WebSocket不占HTTP连接数限制因为它升级后是独立的TCP连接每个域名默认可以有大量的WebSocket连接不同浏览器限制不同一般是几十到上百。这一点对需要同时开多个实时通道的场景更友好。3.6 代码复杂度与生态SSE的实现门槛极低。后端不需要引入任何额外依赖就是把响应类型设成text/event-stream然后往输出流里写。前端如果不用EventSource、用fetch解析也不难核心就是一个循环读流的小工具。WebSocket的后端实现要复杂一些。原生处理要关心帧解析、心跳、关闭握手、半包粘包、并发连接管理。好在主流语言都有成熟框架Java系有Spring WebSocket和NettyGo系有gorilla/websocket和官方库Node.js有wsPython有FastAPI自带的WebSocket支持。热搜词里有gin websocket和golang websocket语音长连接实现说明Go系做实时语音这类长连接场景的很多。我的经验是用Go gorilla/websocket做WebSocket服务心态要放平框架只解决协议问题真正的连接管理和业务状态机还是得自己写。相比之下Spring WebSocket配合STOMP协议能直接获得广播、群组、用户属性、订阅管理这些上层能力在Java技术栈里省心很多。3.7 移动端与App打包场景的特殊性这里有个热搜词特别真实websocket运行到h5可以连接,打包为app连接不了。我遇到过好几次这种情况排查下来原因五花八门最常见的是这样几个Android打包后的App里WebView的网络环境和浏览器不完全一样如果代码里用了ws://而App运行在HTTPS环境会被混合内容策略拦掉。解决办法是把WebSocket地址换成wss://。另外很多Android App的网络层会做代理检测或者有自签名证书WebSocket握手在校验证书时直接失败表现就是连不上但同一个网络下H5浏览器却好好的。iOS这边更折腾ATSApp Transport Security默认限制非HTTPS连接ws://不豁免。另外如果WebSocket服务端只监听了IPv6而App所在网络只有IPv4也会出现“H5能连App连不上”的假象。真正的排查方法是抓包看握手请求到底发出去没有、被谁拦了。SSE在App端的地位比较尴尬。iOS原生没有原生的EventSourceAndroid也一样都得靠第三方库或者自己封装网络流。而且移动端网络切换频繁SSE这种依赖HTTP长连接的方式在弱网下的断线体验比WebSocket更差。所以移动App原生开发里WebSocket基本是事实标准。3.8 决策建议速查表维度SSEWebSocket通信方向服务端到客户端单向双向实现成本低HTTP原生支持中高需管理连接池浏览器Header鉴权不支持需用fetch绕过不支持需子协议或queryNginx/CDN兼容好需关缓冲、调超时差CDN大多不支持自动重连EventSource自带无需自行实现消息断点续传支持Last-Event-ID无协议级支持并发连接数受HTTP/1.1 6连接限制独立限制更宽松移动端原生支持差无原生Support好原生API完善典型场景大模型文本流式生成语音对话、协同编辑、游戏、双向控制4. 代码落地同一个对话场景的两种实现4.1 SSE服务端实现FastAPI StreamingResponse我用Python FastAPI做过一个流式对话后端核心代码大致是这样from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse import asyncio import json app FastAPI() async def token_stream(prompt: str): 模拟LLM逐token输出 chunks [你, 好, , 今天, 想, 聊, 点, 什么, ] for chunk in chunks: payload {delta: chunk, finish_reason: None} yield fdata: {json.dumps(payload, ensure_asciiFalse)}\n\n await asyncio.sleep(0.05) yield fdata: {json.dumps({delta: , finish_reason: stop})}\n\n app.post(/chat/sse) async def chat_sse(request: Request): body await request.json() prompt body.get(prompt, ) return StreamingResponse( token_stream(prompt), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, }, )有两个容易被忽略的细节。第一是X-Accel-Buffering: no这个头专门告诉Nginx“这个响应不要缓冲”比每个location单独配proxy_buffering off更灵活。第二是StreamingResponse默认只在asyncio事件循环里逐段生成如果生成函数里有阻塞的同步I/O比如调用宿主的阻塞式HTTP客户端一定要用run_in_executor或者换成异步客户端否则整个事件循环被卡住其他用户的SSE也跟着断。另外生产环境一定要开启FastAPI的gzip中间件时要小心gzip会对整个流式响应做压缩缓冲导致前端的text/event-stream解析出现乱码或延迟。流式接口不建议开gzip省下的带宽远不够赔体验。4.2 SSE前端消费fetch ReadableStream前面强调过用fetch读SSE是更现代的方案。我封装了一个最小可用的函数async function readSSE(response, onDelta, onDone) { const reader response.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 events buffer.split(\n\n); buffer events.pop(); for (const event of events) { const dataLines event.split(\n).filter(l l.startsWith(data:)); if (dataLines.length 0) continue; const data dataLines.map(l l.slice(5).trim()).join(\n); try { const obj JSON.parse(data); if (obj.finish_reason) onDone?.(obj); else onDelta?.(obj.delta); } catch (e) { console.warn(parse SSE event failed:, data, e); } } } } const response await fetch(/chat/sse, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ prompt: 你好 }) }); readSSE(response, (delta) { // 逐字append到界面 appendToChat(delta); }, () { setLoading(false); });这里一定要处理decoder.decode(value, { stream: true })如果漏了{ stream: true }中文在多字节边界被切断时就会产生乱码这是中文流式对话里最高频的前端bug之一。另外split(\n\n)后要把最后一个残片留下来进buffer保证下一个数据块到达时能拼接成完整事件。4.3 WebSocket服务端实现FastAPI WebSocket同一个对话场景用WebSocket写的服务端长这样from fastapi import FastAPI, WebSocket, WebSocketDisconnect import asyncio import json app FastAPI() app.websocket(/chat/ws) async def chat_ws(websocket: WebSocket): # 鉴权从query参数里取token token websocket.query_params.get(token) if not is_valid_token(token): await websocket.close(code4001) return await websocket.accept() # 自定义心跳每30秒ping一次 async def heartbeat(): while True: await asyncio.sleep(30) await websocket.send_json({type: ping}) hb_task asyncio.create_task(heartbeat()) try: while True: data await websocket.receive_json() if data.get(type) cancel: # 客户端主动打断生成 break prompt data.get(prompt, ) # 模拟生成 for ch in [你, 好, , 今天, 想, 聊, 点, 什么, ]: await websocket.send_json({delta: ch, finish_reason: None}) await asyncio.sleep(0.05) await websocket.send_json({delta: , finish_reason: stop}) except WebSocketDisconnect: print(client disconnected) finally: hb_task.cancel()WebSocket版服务端的核心是外层那个while True它让一个连接可以持续接收客户端的多条指令、持续推送多条增量结果。这也是WebSocket相比SSE的真正优势客户端在生成过程中可以随时发cancel指令打断服务端收到后立刻停止生成、关闭连接或返回一个取消确认。4.4 WebSocket前端消费心跳、重连、状态机前端原生WebSocket做流式对话代码别只写在onmessage里建议封装一个带心跳和重连的小类class WsChatClient { constructor(url, token) { this.url ${url}?token${token}; this.ws null; this.heartbeatTimer null; this.reconnectCount 0; } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.reconnectCount 0; this.startHeartbeat(); }; this.ws.onmessage (e) { const data JSON.parse(e.data); if (data.type ping) return; if (data.finish_reason) this.onDone?.(data); else this.onDelta?.(data.delta); }; this.ws.onclose (e) { this.stopHeartbeat(); // 1006是异常断开1000是正常关闭 if (e.code ! 1000 this.reconnectCount 5) { this.reconnectCount; setTimeout(() this.connect(), this.reconnectCount * 1000); } }; this.ws.onerror () this.ws.close(); } startHeartbeat() { this.heartbeatTimer setInterval(() { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({type: ping})); } }, 30000); } stopHeartbeat() { clearInterval(this.heartbeatTimer); this.heartbeatTimer null; } send(payload) { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(payload)); } } }心跳的作用不只是保活它还能帮前端快速发现连接“假死”。很多网络环境里TCP连接断了但没有任何一方察觉WebSocket既不触发onclose也不触发onerror数据发出去石沉大海。前端定期发ping服务端如果30秒内没回pong就主动close掉这个连接触发重连逻辑。服务端那侧也做同样的事两边一起兜底才能把断线检测时间从分钟级缩短到秒级。5. 工程边界与常见坑实录5.1 报错before completion: idle timeout waiting for sse被代理切断的连接这个报错的典型场景是用OpenAI或类似服务做中转时长时间没有新token产生网关先超时了。在自建服务里绝大多数情况是Nginx的proxy_read_timeout默认值60秒在作怪。大模型在思考阶段可能几十秒不发一个tokenNginx等不到新数据就直接断连。解决分两层。第一代码层生成器在没有真实数据的时候要定期写一个空注释事件来“续命”async def token_stream(prompt: str): start time.time() for text in generate(prompt): yield fdata: {json.dumps({delta: text})}\n\n start time.time() # 防止长时间无输出时被代理掐断 while time.time() - start 30: # 仅为示意实际应条件触发 await asyncio.sleep(10) yield : keep-alive\n\n第二层基础设施Nginx的proxy_read_timeout调到合适值一般3600s并在location里加add_header X-Accel-Buffering no。还有一个容易忽略的场景浏览器自身对连接也有超时如果HTTP/2打开了某些代理或网关会隔一段时间主动关闭空闲流。遇见这种情况请检查网关配置别只盯着后端代码。5.2 WebSocket onclose code 1006异常断开的经典陷阱WebSocket的close事件里code: 1006代表连接异常关闭——客户端没有收到close帧TCP连接就断了。网上搜这个报错的人非常多说明大家基本都踩过。常见的1006原因有这几类网络闪断移动端切WiFi、进电梯TCP连接底层斷掉。服务端进程崩溃或重启WebSocket没有默认的心跳守护服务端重启后所有连接静默失效。代理层超时Nginx、云负载均衡的proxy_read_timeout到了时间会主动关闭连接客户端收到的是1006而不是1000。服务端主动close时没有走完整的close握手比如直接杀掉TCP连接或者Service Worker劫持了请求。排查1006最有效的方法是两端同时抓包。服务端看日志里有没有打印“connection closed normally”客户端看onclose触发前有没有收到过ping/pong。我经验里80%的1006都是代理超时特别是Nginx没有调proxy_read_timeout。所以如果你只用WebSocket做短连接、一次请求一次响应可能永远遇不到这个问题但一旦做长时间流式对话代理配置就是命根子。5.3 App打包后WebSocket连不上H5能连不代表原生也能连热搜词里那条“websocket运行到h5可以连接打包为app连接不了”属于跨端经典问题。原因大概有这么几个混合内容封锁App页面是HTTPS但WebSocket地址写的是ws://被当作不安全的混合内容拦截。改成wss://证书链完整即可解决。安卓网络权限原生App非WebView需要ACCESS_NETWORK_STATE和INTERNET权限如果你的打包配置缺了这俩HTTP请求可能还能通但WebSocket握手会被细粒度策略拦下。自签名证书与代理WebView和原生网络栈的证书校验策略不同。如果你用Charles抓包调试开发机上装好了代理证书App打包后没装就可能出现“开发环境能连、打包后连不上”。服务端只监听了localhost这个很隐蔽H5浏览器在PC上访问没问题但App在真机上访问localhost指向的是手机自己连不上。排查建议先在App里写一段最朴素的WebSocket连接测试不带任何业务逻辑连不上就依次试wss、真机IP、关闭代理、检查证书。别一上来就怀疑框架封装有问题。5.4 广播、群组与反向WebSocket热搜词里Spring Boot和Netty的WebSocket鉴权、广播群组、反向WebSocket都是工程里绕不开的话题。Spring Boot WebSocket STOMP是实现广播、群组、用户定向推送最省心的组合。STOMP是个消息协议可以基于WebSocket协议之上定义“目的地”destination和“订阅”subscription概念天然支持/topic/xxx广播和/user/xxx/queue点对点。我在Java项目里用spring-boot-starter-websocket加EnableWebSocketMessageBroker几分钟就能搭出一个支持房间群聊的实时服务。Gin做WebSocket也很常见。gin本身没有WebSocket实现一般配github.com/gorilla/websocket。长连接多了以后每个连接配一个readPump和writePump的goroutine连接管理用sync.Map存起来广播就是遍历Map向每个conn写数据。这里有个性能小坑千万不要直接在广播goroutine里同步调用WriteMessage因为某个慢消费者会拖慢全体正确做法是每个连接维护一个带缓冲的chan []byte广播只往通道塞数据由独立的写goroutine消费。“反向WebSocket”指的是客户端作为WebSocket客户端主动连接服务端而不是服务端监听端口等客户端来连。这个模式在聊天机器人、IoT透传里非常常见设备或者机器人服务作为客户端主动连上你的云端Gateway云端就能反过来向它下发指令。Netty做反向WebSocket时鉴权要在ChannelInboundHandler的channelActive阶段做拿到query里的token或子协议校验失败就ctx.close()不要等到握手完成后才发现是个非法连接。5.5 服务端框架选型Go、Java、Python怎么排如果团队技术栈是GoWebSocket服务我推荐gorilla/websocket或者官方nhooyr.io/websocket现在叫coder/websocket前者成熟稳定后者API更现代。SSE在Go里直接用net/http就能做无非是设Header然后往ResponseWriter写流。Java技术栈如果项目是Spring Boot直接上spring-boot-starter-websocket想省心就用STOMP如果要扛超高并发且协议定制需求多用Netty但团队要有Netty的深度维护能力。SSE在Spring MVC里可以用SseEmitter也是零额外依赖。Python技术栈WebSocket用FastAPI或Starlette监听底层是uvicorn的WebSocket支持性能在常规业务下够用。SSE用StreamingResponse最直接。Python做AI对话后端是主流所以Python系碰上SSE的频率最高围绕流式响应的坑半包、解码、缓冲也踩得最多。我在生产环境里选择时有一条土经验如果后端只有这一个流式接口而且大概率只会有一个选SSE就够了。如果后端未来会长出一系列实时能力语音、协同、推送、指令下发那就统一走WebSocket让一套连接池承载所有实时流量省得一个域又SSE又WebSocket网关配置和监控维度都要多一套。6. 最后再分享一个小技巧不管是SSE还是WebSocket我在生产环境监控里都会额外记录一个指标首token耗时TTFT。流式对话优化的终极目标不是每秒吐多少token而是“用户发出请求后多久能看到第一个字符”。我在SSE和WebSocket两个方案里都埋了同样的埋点客户端记录requestStart和onDelta首次触发的时间差。这个指标能直接暴露一个常见问题后端模型推理没启动或者中间链路缓冲没关干净结果首token迟迟出不来。用这个指标排查过一次很有意思的线上事故SSE接口返回200了但前端等了8秒才收到第一个token。后端日志显示模型其实0.5秒就出了第一个token后来发现是CDN把SSE响应也做了缓冲攒够了1KB才回源导致首token被卡死。后来在CDN配置里对/chat/sse路径关闭了缓冲问题立刻解决。协议选型没有银弹SSE和WebSocket也不是对立关系。我现在的项目里对外纯文本流式对话走SSE因为部署链路短、兼容性好、自动重连稳定需要使用双向控制或音频流的内部通道走WebSocket因为连接状态可控、消息粒度清晰。把两套机制都吃透再根据具体的业务边界做组合这才是“流式对话接口怎么选”的真正答案。