Java SSE流式对话接口实战:SseEmitter、WebFlux与生产排障

发布时间:2026/10/26 20:30:09
Java SSE流式对话接口实战:SseEmitter、WebFlux与生产排障 上周有位做后端的朋友来找我说他照着文档把SseEmitter写完接口在本地用 curl 测字符是一个一个往外蹦的一部署到测试环境的网关后面就变成憋十秒、一次性吐一大坨。这个问题我前前后后踩过三次每次的原因都不一样一次是反向代理做了响应缓冲一次是 Servlet 容器的异步超时把输出流提前收了还有一次是上游返回的 chunk 边界没切干净JSON 被截成两半去解析直接抛异常。SSE 协议本身不复杂规范读十分钟就能明白但把一个 SSE 协议的流式对话接口真正跑在生产环境里坑几乎全在协议之外。这篇就把Java 项目里用 SSE 构造类对话式大模型的流式接口这件事从协议细节、服务端实现、上游解析、前端消费到线上排障一层一层捋清楚。适合已经会写 Spring Boot 接口、但被流式效果折磨过的同学也适合刚开始接触流式输出、想搞清楚为什么我这边不流的初学者。1. 先把场景摆清楚为什么非得是流式1.1 一次性返回和边生成边返回差别到底在哪对话式大模型的输出有个特点它是自回归生成的一个字一个字往外蹦一个五百字的回答可能要跑二三十秒。如果服务端等模型全部生成完再拼成一个完整 JSON 返回用户盯着转圈的加载图标要等二三十秒中途还可能因为一次网络抖动前功尽弃。流式的价值就在于把等待拆成渐进用户在 800 毫秒内看到第一个字之后的二三十秒里屏幕上一直有东西在动主观感受完全不一样。从工程角度看流式还带来两个额外好处。一是首字节时间TTFB大幅缩短网关和 CDN 的超时压力小了二是内存占用更平滑不用在服务端堆一个几百 KB 的完整字符串再序列化尤其是并发几百路对话的时候这个差别会体现在 GC 曲线上。代价也很明确连接变成长连接占用连接数和文件描述符错误处理变复杂中途失败只能靠事件通知而不是 HTTP 状态码代理、容器、客户端任何一环的缓冲策略没配对流式效果就会退化。1.2 SSE 到底是个什么东西一句话说透SSE 全称 Server-Sent Events本质上是一个永不结束的 HTTP 响应。客户端发一个普通 GET 或 POST 请求服务端回Content-Type: text/event-stream然后保持连接不关闭持续往响应体里写符合特定格式的文本块客户端按空行切分逐块消费。就这么简单——它没有握手升级没有帧结构没有二进制封装就是一个长命的 HTTP 响应体。这个简单带来的好处很实在它走标准 HTTP天然穿得过大多数企业网关和负载均衡浏览器原生EventSource就能解析不用引入额外库断线重连是协议内置的客户端会自动带Last-Event-ID重连调试的时候直接curl -N就能看到原始报文不用抓包。相比之下 WebSocket 要做协议升级某些中间设备会拦排查时也更费劲。1.3 四种方案横向拉平对比选型这件事不能拍脑袋我习惯把候选方案摆在一张表里按真实约束打分。下面这张表是我自己在流式对话场景下的结论评判维度是实现成本、穿透性、双向能力、断线恢复、运维复杂度。方案实现成本网关穿透性双向通信断线恢复适用结论SSEtext/event-stream低就是一个长响应好标准 HTTP单向服务端到客户端协议内置重连对话流式输出的首选WebSocket中需要握手、心跳、会话管理一般部分代理需额外配置双向要自己实现需要中途打断、语音双工才考虑短轮询低极好单向天然无状态只适合秒级以下的低频状态查询长轮询中服务端要挂起请求好准双向要自己实现兼容极老客户端时的兜底对话场景里用户的主要动作是发一次问、收一段答中途最多点个停止按钮。停止按钮完全可以用一个独立的取消接口解决不需要为此上 WebSocket。所以我的默认选择就是 SSE只有在需要语音实时双工、或者要在同一连接上做高频双向控制信令时才切到 WebSocket。这个判断标准我用了很久基本没出过错。2. 整体架构设计一条数据要经过几道手2.1 请求链路拆解先画清楚再写代码在动手之前我习惯先把链路在纸上过一遍明确每一段的职责边界。一个典型的流式对话请求从用户按下回车到看到第一个字要穿过这么几段浏览器发起请求 → 边缘网关可能是 Nginx 或云负载均衡→ Java 应用的 Web 容器 → 业务 Controller → 上游模型服务的 HTTP 客户端 → 模型侧开始生成 → 数据原路返回。这六段里任何一段做了缓冲、设了超时、或者改了编码流式效果都会打折。职责边界要划清楚Controller 层只负责接收参数、建立流、写事件、收尾不负责拼装 prompt 和解析业务语义上游调用单独抽一个UpstreamStreamClient对外暴露给一段对话回调每个增量的接口中间的胶水层做增量到 SSE 事件的转换。这么拆的好处是上游将来换了厂商、换了协议格式只需要改一个类Controller 一行不动。2.2 为什么我坚持边收边转而不是收完再发刚接触这块的时候我图省事写过一版上游全部收完、拼成完整字符串、再分块吐给前端的假流式。表面上看前端也在动但首字延迟等于上游全量耗时用户该等多久还是等多久而且服务端要为每个请求缓存一份完整回答。更糟的是这种做法把上游的失败窗口整体后移了——用户等了二十秒最后收到一个错误体验比等两秒就报错差得多。正确的做法是逐块转发上游来一个增量立刻写一个 SSE 事件立刻 flush。这样首字延迟就等于上游的首 token 延迟失败也能在几百毫秒内暴露用户还能看到已经说到一半然后中断而不是啥也没有。代价是错误处理要精细你得在已经写出部分内容之后再出错时用一个专门的事件类型告诉前端后面没了别等了而不是直接断连接让前端干等。2.3 线程模型和资源账得算清楚这一步很多人跳过但线上出问题往往就出在这儿。以 Spring MVC SseEmitter为例Tomcat 的工作线程在 Controller 返回 emitter 之后立刻归还线程池连接本身由 NIO 的 Poller 管理只有在你要往连接里写数据时才占用写线程。所以理论上server.tomcat.threads.max200的连接器能撑住的流式连接数远大于 200。真正的约束是文件描述符每个连接一个 fd和内存。我一般这么估单机目标并发连接数 峰值 QPS × 平均单次会话时长。举个例子峰值 20 QPS平均一次对话从提问到结束 45 秒那稳态并发连接约 900 路。每路连接占用的内存包括 socket 发送缓冲区默认几 KB 到十几 KB、emitter 对象、会话上下文按 64 KB 保守估计900 路约 57 MB完全可接受。但 fd 就是 900 个加上其他连接ulimit -n设成 65535 是基本操作别等到报Too many open files才去改。如果走 WebFlux情况更有意思。Netty 的 event loop 线程数默认是CPU 核数 × 28 核就是 16 个线程理论上能扛几万连接——前提是你的上游调用是非阻塞的。要是你偷懒用阻塞 HTTP 客户端去调上游那每个流式请求都会长时间占住一个boundedElastic线程默认上限是核数 × 108 核即 80 个超过 80 路并发就开始排队延迟飙升。这个数字我很在意因为它直接决定了要不要把上游客户端换成非阻塞的WebClient。3. 协议层细节SSE 的报文长什么样3.1 五个字段和两条硬规则SSE 的报文格式朴素到近乎简陋一共就五种行data:携带数据、event:指定事件类型、id:标记事件序号、retry:告诉客户端重连间隔、以冒号开头的注释行通常写个:或者: ping用作心跳。每个字段后面跟一个可选空格然后是值行尾用\n。两条硬规则必须记住第一一个事件以空行结束也就是连续的\n\n第二多条data:行会被客户端用\n拼成一个字符串。第二条规则很容易被忽略然后掉坑。Spring 的SseEmitter在组装 data 时如果发现你的 payload 里有换行符会自动拆成多个data:行。这意味着你塞进去一段带换行的 JSON 或者带代码块的回答最终报文里就会出现好几行data:。浏览器原生EventSource会自动帮你拼回来但如果你用fetch手动解析就必须自己处理多行data:的拼接逻辑否则解析出来的 JSON 是残缺的。我第一次遇到这个问题前端同学拿着抓包截图问我为什么 data 只有半截查了半天才反应过来。一个完整的事件长这样event: delta id: 17 data: {seq:17,text:所以流式的核心是} event: done data: [DONE]3.2 心跳、注释行与代理超时的三角关系长连接最大的敌人是中间设备觉得你死了。Nginx 的proxy_read_timeout默认 60 秒某些云负载均衡的空闲超时是 30 秒甚至 15 秒如果模型在那段时间里一个字都没吐比如遇到长思考、或者网络卡顿连接就会被中间设备单方面切断前端表现为突然结束日志里却什么都没有排查起来极其痛苦。解决办法就是心跳每隔固定间隔往流里写一行注释比如: keep-alive或者: ping。注释行不会触发客户端事件但会被计入有数据流过从而重置代理的空闲计时器。间隔怎么定我的经验是取所有中间设备里最小空闲超时的三分之一到二分之一。大多数环境最小的是 60 秒那我设 15 秒到 20 秒。设太频繁没必要白白消耗流量设太稀疏比如 55 秒一旦某次心跳因为 GC 停顿晚了两秒连接就被切了。心跳还有个隐性作用它是检测客户端已经跑了的低成本手段。往一个已经断开的连接写数据会抛异常你正好借这个异常清理服务端资源避免 emitter 泄漏。3.3 编码和换行符这两个低级但致命的坑响应头必须是Content-Type: text/event-stream;charsetUTF-8字符集不写清楚某些容器会用平台默认编码中文直接变问号。payload 里不要出现 UTF-8 BOM有些序列化工具会把 BOM 写进去客户端解析第一行时字段名对不上整个流全废。换行符这块规范允许\n、\r\n、\r三种行尾但不同客户端实现的宽容度不一样。稳妥做法是统一用\n事件分隔统一用\n\n。我见过一次故障某同学手写报文时用了 Windows 风格的\r\n\r\n做事件分隔浏览器EventSource正常但内部一个用 Go 写的旁路消费程序只认\n\n结果整个事件被当成一个超长行解析直接爆掉。跨语言、跨客户端的时候统一\n是最省心的选择。4. Java 服务端落地三种写法与取舍4.1 方案 ASpring MVC 加 SseEmitter最省事的一种如果项目本来就是 Spring MVC这是改动最小的路径。核心就是返回一个SseEmitter然后从别的线程往里写。关键点有三处超时时间、完成回调、异常兜底。PostMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestBody ChatRequest req) { // 0L 表示不设超时交给业务自己控制生命周期 SseEmitter emitter new SseEmitter(0L); ExecutorService worker chatExecutors.pick(); emitter.onCompletion(() - log.info(stream completed, id{}, req.getId())); emitter.onTimeout(() - log.warn(stream timeout, id{}, req.getId())); emitter.onError(e - log.error(stream error, id{}, req.getId(), e)); worker.submit(() - { try { upstreamClient.stream(req, delta - { try { emitter.send(SseEmitter.event() .id(String.valueOf(delta.getSeq())) .name(delta) .data(delta.toJson(), MediaType.APPLICATION_JSON)); } catch (IOException e) { // 客户端断开主动终止 throw new StreamAbortedException(e); } }); emitter.send(SseEmitter.event().name(done).data([DONE])); emitter.complete(); } catch (StreamAbortedException e) { emitter.completeWithError(e); } catch (Exception e) { safeSendError(emitter, e); emitter.complete(); } }); return emitter; }这里有几个必须解释的决策。把timeout设为0L是为了突破默认的 30 秒限制但永不超时本身就是风险所以我在业务层用一个独立的看门狗比如一次对话最长 180 秒主动complete()双保险。send里的IOException一定要捕获并向上抛因为这是客户端断开最可靠的信号比onError回调还及时。最后的complete()必须显式调用否则连接会挂着直到容器超时emitter 对象也不会释放。还有一个隐藏雷区Spring MVC 的异步请求超时由spring.mvc.async.request-timeout控制默认情况下它和SseEmitter的 timeout 是联动关系。如果这个配置设得比 emitter 的 timeout 小容器会先一步把请求收掉你的 emitter 还在那儿傻乎乎地send然后抛IllegalStateException。所以要么两处对齐要么在配置里显式设成同一个量级。4.2 方案 BWebFlux 加 Flux最优雅但要换脑子新项目或者准备做高并发的我会直接上 WebFlux。流在响应式里就是一等公民整个链路天然是来一块、转一块、出去一块不需要额外的线程池调度。PostMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString stream(RequestBody ChatRequest req) { AtomicLong seq new AtomicLong(); return upstreamClient.stream(req) // FluxString 增量 .map(text - ServerSentEvent.Stringbuilder() .id(String.valueOf(seq.incrementAndGet())) .event(delta) .data(text) .build()) .concatWith(Mono.just(ServerSentEvent.Stringbuilder() .event(done).data([DONE]).build())) .onErrorResume(e - Mono.just(ServerSentEvent.Stringbuilder() .event(error) .data({\message\:\upstream failed\}) .build())) .timeout(Duration.ofSeconds(180)); }用这套写法我要特别强调两个点。第一upstreamClient.stream必须是真的非阻塞实现比如用WebClient配合bodyToFlux(String.class)或者自己按行拆如果底层是阻塞的OkHttp调用一定要包一层subscribeOn(Schedulers.boundedElastic())否则会把 event loop 线程堵死表现是并发一上来整个服务卡住非常难查。第二timeout运算符要放在最后它管的是整个流多久没动静而不是总时长。另外要注意onErrorResume里写事件之后流就结束了如果此时响应头已经发出、状态码已经固定为 200前端拿不到 HTTP 错误码只能靠event: error这个自定义事件来识别失败。这是流式接口的通用约束前后端要约定好。4.3 方案 C原生 Servlet 异步最底层但最可控有些老项目没有 Spring MVC 的完整环境或者需要对输出流做极细粒度的控制比如自定义缓冲大小、自己管 flush 时机那就直接玩 Servlet 3.0 的异步 API。WebServlet(urlPatterns /stream, asyncSupported true) public class StreamServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { resp.setContentType(text/event-stream;charsetUTF-8); resp.setHeader(Cache-Control, no-cache); resp.setHeader(X-Accel-Buffering, no); AsyncContext ctx req.startAsync(); ctx.setTimeout(0); PrintWriter writer resp.getWriter(); executor.submit(() - { try { upstreamClient.stream(req, delta - { writer.write(event: delta\n); writer.write(data: delta \n\n); writer.flush(); }); writer.write(event: done\ndata: [DONE]\n\n); writer.flush(); } finally { ctx.complete(); } }); } }原生写法的优势是完全透明你能看到每一行报文是怎么出去的出了问题也能精确控制。劣势是所有生命周期管理都得自己来超时、断连检测、异常清理、连接数统计。我一般只在排查疑难杂症时用这个方案搭个最小复现线上主路径还是用封装好的方案。4.4 三种方案怎么选给个简单结论方案实现复杂度并发能力阻塞风险我的使用场景Spring MVC SseEmitter低中受 fd 与内存限制上游调用需另开线程池存量 MVC 项目、并发要求一般WebFlux Flux中要理解响应式高上游必须非阻塞新项目、高并发、要精细化背压原生 Servlet 异步高中同上排查问题、极特殊控制需求我个人的偏好是如果团队里没人真的搞懂响应式别硬上 WebFlux用 MVC 加独立的流式线程池更稳出问题时人人都能看懂。技术选型要看团队维护能力不是看纸面并发数。5. 上游流式响应的解析把 chunk 拆成增量5.1 上游返回的一般也是 SSE但不完全一样主流的模型服务流式接口返回的也是text/event-stream也是data: {...}加空行分隔最后来一个data: [DONE]。看起来跟我们要吐给前端的一模一样很多人就直接把上游响应体透传出去图省事。这么做有两个问题一是上游报文的字段结构比如choices[0].delta.content直接暴露给前端将来换供应商前端就得跟着改二是上游的错误事件格式、心跳格式、结束标记不一定符合你前端的预期。所以我的做法是中间加一层转换上游解析成纯增量字符串再由服务端重新组装成自己的事件格式。解析层只关心这一块是不是有效内容业务层只关心要发什么事件两边解耦。5.2 逐行读取加缓冲边界一定要自己攥着用 OkHttp 读流式响应核心是拿到BufferedSource之后逐行读不要一次性string()那会把整个响应读成完整字符串流式就没了。public void stream(ChatRequest req, ConsumerString onDelta) { Request request new Request.Builder() .url(cfg.getUpstreamUrl()) .header(Content-Type, application/json) .header(Accept, text/event-stream) .header(Authorization, Bearer cfg.getKey()) .post(RequestBody.create(buildPayload(req), JSON)) .build(); try (Response resp client.newCall(request).execute()) { if (!resp.isSuccessful()) { throw new UpstreamException(resp.code(), safeBody(resp)); } BufferedSource source Objects.requireNonNull(resp.body()).source(); String line; while ((line source.readUtf8Line()) ! null) { if (line.isEmpty() || line.startsWith(:)) { continue; // 空行分隔符与心跳注释 } if (!line.startsWith(data:)) { continue; // 忽略 event: 等字段 } String payload line.substring(5).trim(); if ([DONE].equals(payload)) { break; } String delta extractDelta(payload); // 从 JSON 里取增量 if (delta ! null !delta.isEmpty()) { onDelta.accept(delta); } } } catch (IOException e) { throw new UpstreamException(read stream failed, e); } }这段代码里有个容易被忽视的细节readUtf8Line()是按\n切的能正确处理多字节 UTF-8 字符跨块的问题所以中文不会乱码。如果自己用InputStream.read(byte[])手动拼字节一个汉字被拆在两个 chunk 里转成字符串就变乱码了这是新手最常踩的编码坑。extractDelta里还要处理一种情况上游返回的 JSON 里delta.content可能不存在比如首帧只带 role或者尾帧带 usage 统计这时候要返回 null 跳过别把 null 当内容发出去。5.3 结束标记、统计信息和错误帧的处理结束标记[DONE]必须识别并跳出循环否则你会一直读到连接关闭白等。有些上游还会在[DONE]之前发一个带 usage 统计的帧token 消耗量这个很值钱要单独提取出来记日志、算成本但不要发给前端当正文。错误帧的处理更微妙。上游在流已经开始之后出错通常不会返回非 200 状态码因为响应头早就发出去了而是在流里塞一个data: {error:{...}}。这时候你如果按正常增量解析extractDelta返回 null用户端就是说到一半没声了什么提示都没有。解决办法是在解析层判断 payload 里有没有 error 字段有就抛自定义异常由上层转成event: error事件发给前端。我吃过这一次亏之后所有上游解析都强制做这一步。6. 前后端联调客户端这一侧的门道6.1 EventSource 的天花板比你想的低浏览器原生EventSource用起来确实省事三行代码就能接上断线还自动重连。但它有两个硬限制直接影响能不能用只支持 GET 请求不能自定义请求头。第一条意味着你的对话参数得塞进 URL长 prompt 直接超长度限制敏感内容还进了访问日志第二条意味着你没法带鉴权 token只能靠 Cookie 或者把 token 塞 URL 里安全性和优雅度都不行。所以正经的对话接口我基本不用EventSource改用fetch加ReadableStream手动解析。代价是要自己写事件切分和重连逻辑但换来的是完整的 POST、自定义头、可中断的能力。6.2 fetch 加流式读取解析逻辑要写对export async function chatStream(payload, { onDelta, onDone, onError, signal }) { const resp await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream }, body: JSON.stringify(payload), signal, }); if (!resp.ok || !resp.body) { throw new Error(stream not available: 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 }); let idx; while ((idx buffer.indexOf(\n\n)) 0) { // 事件分隔 const raw buffer.slice(0, idx); buffer buffer.slice(idx 2); handleEvent(raw, { onDelta, onDone, onError }); } } } function handleEvent(raw, { onDelta, onDone, onError }) { let event message; const dataLines []; for (const line of raw.split(\n)) { if (!line || line.startsWith(:)) continue; if (line.startsWith(event:)) event line.slice(6).trim(); else if (line.startsWith(data:)) dataLines.push(line.slice(5).trim()); } const data dataLines.join(\n); if (event delta) onDelta(data); else if (event done) onDone(); else if (event error) onError(data); }这段代码有三个关键点。TextDecoder必须带{ stream: true }否则一个多字节字符被切在两个 chunk 里就会解码成乱码中文场景下这个 bug 几乎是必现的。事件切分用indexOf(\n\n)配合buffer保留残余绝对不能假设每次read()拿到的就是一个完整事件网络包的边界和事件边界毫无关系。多行data:要用\n拼回去前面提过 Spring 会把带换行的内容拆成多行。signal参数是给停止生成按钮用的用户点停止就abort()reader.read()会抛AbortError前端抑制掉这个错误即可。服务端那边会收到写失败触发清理逻辑两边配合得刚刚好。6.3 断线重连要不要做怎么做才不出重复SSE 规范里EventSource自带重连会带Last-Event-ID头让服务端续传。但对话流式的场景下续传其实很难做——模型生成到一半的连接状态在上游你没法让它从第 37 个字接着说。所以我的实践是不做消息级续传只做会话级重试。具体做法是前端保存已经收到的文本断开后显示连接中断点击重试用户点了就重新发一次请求可以在 prompt 里带上已有上下文让它接上。这样虽然不是无缝的但至少不会出现重连之后冒出重复内容这种更糟的体验。顺便说如果你真的想用id:字段做点事可以把它当成消息序号发给前端前端据此去重防止某些代理层的重发导致重复渲染。7. 生产环境的坑代理、超时、线程与内存7.1 Nginx 缓冲一号坑没有之一前面那位朋友遇到的问题就是这个。Nginx 默认对上游响应开启proxy_buffering on它的逻辑是先攒一批再发给客户端对小响应没问题对流式就是灾难。表现是本地直连正常走 Nginx 就变成一段一段地蹦或者干脆憋到最后一次性吐完。关掉的方式有三种我一般同时上两种留个双保险。第一种是在 Nginx 的 location 里配置location /api/chat/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_read_timeout 300s; proxy_send_timeout 300s; gzip off; }第二种是在应用侧响应头里加X-Accel-Buffering: no这个头 Nginx 认识会针对性关闭该响应的缓冲好处是改代码就能生效不用等运维排期。第三种是干脆让流式接口走一个不带缓冲的独立入口。顺带提一句gzip off。很多人以为开 gzip 更省流量但 gzip 需要攒够一个压缩块才输出等于又加了一层缓冲流式的实时性直接毁掉。流式接口不要开压缩这是原则问题。7.2 超时链条要一层一层对齐从浏览器到模型超时设置有一整条链任何一环比预期短都会提前掐断。我把常见的位置和推荐值整理成表配的时候照着对齐。位置参数常见默认值流式场景建议浏览器 fetch无默认超时不限制靠 AbortController 主动控制反向代理proxy_read_timeout60s300s且大于心跳间隔 3 倍以上反向代理proxy_send_timeout60s300s云负载均衡idle_timeout15s 到 60s调到 300s做不到就靠心跳保活TomcatconnectionTimeout20s只影响建连不用改Spring MVCspring.mvc.async.request-timeout视版本与 SseEmitter timeout 对齐应用内SseEmitter timeout30s0 或 600s另加业务看门狗上游客户端readTimeout10s首包超时与总超时分开设这里有个反直觉的点上游 HTTP 客户端的超时不能简单设大。OkHttp的readTimeout管的是两次读到数据之间的最大间隔设成 10 秒意味着模型思考超过 10 秒不吐字就报错。而模型的首 token 延迟经常是 5 到 10 秒长思考场景更久。我的做法是设readTimeout为 60 秒容忍长思考同时在上层用callTimeout或者自己的计时器控制总时长比如 180 秒。两层各管一段比只设一个数靠谱得多。7.3 SseEmitter 那几个不写在文档里的陷阱第一个陷阱是send的线程安全。ResponseBodyEmitter内部对写操作加了锁并发send不会把报文写串但顺序不保证。如果你的上游增量是多线程并行推过来的最终用户看到的字序就是乱的。解决办法很简单保证同一个 emitter 只被一个线程写用单线程消费队列或者在send外面自己加串行化。第二个陷阱是complete()之后又send()。这不会抛特别明确的异常有时候静默失败有时候抛IllegalStateException取决于容器版本。所以一定要有个AtomicBoolean closed标志位在onCompletion、onTimeout、onError三个回调里都置 truesend之前先检查避免无谓的异常刷日志。第三个陷阱是异常处理顺序。很多人写catch (Exception e)然后直接emitter.completeWithError(e)结果前端收到的是一个 500 页面而不是流式的错误事件因为completeWithError在响应已提交的情况下会直接断连接。正确姿势是如果流还没开始写响应未提交可以抛出去让全局异常处理器返回标准错误如果已经开始写了就用event: error事件通知再complete()。7.4 内存和背压别让快模型拖垮慢客户端理论上流式接口是上游多快我就多快但如果客户端消费慢比如移动端弱网服务端的写缓冲区就会堆积。Tomcat 的 NIO 写缓冲有上限超了之后send会阻塞或者抛异常。一般的对话场景不会出问题因为文本量小、生成速度也就每秒几十个 token。风险点在于一条回答特别长加上客户端几乎断网。我遇到过一次用户手机进了电梯连接没断但几乎收不到数据服务端还在源源不断往里写那个连接挂了快一分钟才被清理。所以我后来在发送逻辑里加了个简单判断——如果上游已经结束、客户端还没消费完直接complete()把剩余内容丢掉成本比维护一堆僵尸连接低得多。同时给整个会话设一个硬性的总时长上限到点就断宁可不完整也不能无限挂着。8. 常见问题速查与排查手法8.1 症状对照表先按现象定位流式接口的故障现象就那么几种我整理了一张对照表遇到问题先照着现象定位比乱翻日志快得多。现象最可能的原因快速验证方式处理本地流式正常部署后一次性返回反向代理缓冲未关直接连应用端口测试关 proxy_buffering加 X-Accel-Buffering每隔固定时间断一次代理空闲超时看断开间隔是否恒定加心跳间隔小于超时的 1/3首字很久才出来之后正常上游首 token 慢或 gzip 缓冲看首个事件的时间戳关 gzip检查上游延迟中文变乱码或问号字符集未声明或字节被切断用 curl 看原始字节声明 charsetUTF-8用 readUtf8Line前端解析报 JSON 错误多行 data 未拼接抓包看是否有多行 data解析时按 \n 拼接 data 行结束之后前端一直转圈done 事件未发送或格式错看最后几个事件显式发 done 并 complete并发升高后整体卡顿阻塞调用占用线程池看线程堆栈上游调用切非阻塞或加大线程池运行几小时后大量 CLOSE_WAITemitter 未正确释放netstat统计连接状态补 onCompletion 清理设业务超时8.2 我常用的三招定位手段第一招是curl -N直连。-N关掉 curl 自己的缓冲能原样看到服务端吐出来的字节。如果直连应用端口是流畅的、走网关就卡那问题 100% 在中间层不用再怀疑代码。这一步能省掉大量无效排查。第二招是在关键节点打时间戳日志尤其是收到请求、上游首包、第一个 delta 写出、done 写出这四个时刻。首字延迟高但总时长正常说明是上游慢总时长异常长说明中间某处卡住两个时刻差得离谱但日志之间没别的东西那就是 flush 没生效。我一般把这条日志链路做成 trace 级的开关平时关着出问题打开复现一次就清楚了。第三招是抓包看原始报文。浏览器开发者工具的 Network 面板对text/event-stream支持不太好有时候会显示成 pending 看不到内容用wireshark或者tcpdump抓一段最直接。看两件事报文里是不是每个事件都以空行结束Content-Type里有没有 charset。这两点能覆盖八成以上的格式类问题。提示调试流式接口时永远先怀疑中间层再怀疑代码。因为代码在本地能跑通说明逻辑基本对而中间层的缓冲、超时、压缩、连接复用策略每一家都可能不一样。8.3 几条不写在文档里的实操心得心得一把流式做成可开关的。在配置里加一个chat.stream.enabled出问题时一键降级成非流式接口用户体验差点但至少能用。这个开关救过我两次一次是网关升级后把长连接全掐了一次是某个客户端的代理环境不支持。心得二日志里不要打印完整的流式内容。看着无害实际上一次对话几十 KB 乘以几千并发日志量能直接把磁盘写满。只打 token 数、耗时、首字延迟这些指标内容做脱敏摘要。心得三给流式接口单独配一个线程池或连接池别和普通 HTTP 接口共用。长连接的资源占用模型和短请求完全不同混在一起会互相拖累而且出问题时没法单独限流。我一般是给对话流式一个独立的池子配上独立的监控指标一旦队列积压就告警。心得四把上游的Retry-After和限流响应当正经事处理。模型服务遇到限流会返回 429如果是流已经开始后限流你会收到一个错误事件。这种失败应该转成明确的提示语给用户而不是笼统的服务异常因为用户知道稍等再试和系统坏了是完全不同的心理预期。8.4 一个容易被忽略的兼容性问题HTTP/1.1 下浏览器对同一域名的并发连接数限制是 6 个。SSE 是长连接占满一个。如果用户同时开了多个标签页在对话第 7 个请求就会被浏览器挂起排队表现出来就是点了没反应。这个问题在 HTTP/2 下不存在因为多路复用共享一条连接。排查这类问题的方法是看浏览器的连接面板如果发现请求一直处于 stalled 状态基本就是这个原因。解决思路有两条升级到 HTTP/2推荐顺便还能省掉连接数限制或者把流式接口放到一个独立子域名下让浏览器单独计算连接额度。我倾向于前者但要注意 HTTP/2 下某些代理的缓冲行为又和 1.1 不一样配完还是要回归测一遍。最后分享一个小技巧如果你在本地开发时想快速验证流式效果别开浏览器直接用curl -N -X POST打接口配合ts命令给每行加时间戳就能清楚看到每个事件之间的间隔。这个组合我用了好几年比任何图形工具都靠谱。真要说这套方案后续还能怎么扩展我会考虑在事件里加上 token 计数字段前端可以做个实时的已消耗提示或者在 done 事件里带完整的使用量统计服务端顺便做成本归集。这些都是锦上添花把上面的连接保活、缓冲、超时、释放这几件基础的事做扎实流式接口在线上就基本不会给你找麻烦了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询