上周有位做后端的朋友来找我,说他照着文档把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 四种方案横向拉平对比
选型这件事不能拍脑袋,我习惯把候选方案摆在一张表里,按真实约束打分。下面这张表是我自己在流式对话场景下的结论,评判维度是"实现成本、穿透性、双向能力、断线恢复、运维复杂度"。
| 方案 | 实现成本 | 网关穿透性 | 双向通信 | 断线恢复 | 适用结论 |
|---|---|---|---|---|---|
| SSE(text/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.max=200的连接器能撑住的流式连接数,远大于 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 核数 × 2,8 核就是 16 个线程,理论上能扛几万连接——前提是你的上游调用是非阻塞的。要是你偷懒用阻塞 HTTP 客户端去调上游,那每个流式请求都会长时间占住一个boundedElastic线程(默认上限是核数 × 10,8 核即 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;charset=UTF-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 方案 A:Spring 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 方案 B:WebFlux 加 Flux,最优雅但要换脑子
新项目或者准备做高并发的,我会直接上 WebFlux。流在响应式里就是一等公民,整个链路天然是"来一块、转一块、出去一块",不需要额外的线程池调度。
@PostMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> stream(@RequestBody ChatRequest req) { AtomicLong seq = new AtomicLong(); return upstreamClient.stream(req) // Flux<String> 增量 .map(text -> ServerSentEvent.<String>builder() .id(String.valueOf(seq.incrementAndGet())) .event("delta") .data(text) .build()) .concatWith(Mono.just(ServerSentEvent.<String>builder() .event("done").data("[DONE]").build())) .onErrorResume(e -> Mono.just(ServerSentEvent.<String>builder() .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;charset=UTF-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, Consumer<String> 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_timeout | 60s | 300s,且大于心跳间隔 3 倍以上 |
| 反向代理 | proxy_send_timeout | 60s | 300s |
| 云负载均衡 | idle_timeout | 15s 到 60s | 调到 300s,做不到就靠心跳保活 |
| Tomcat | connectionTimeout | 20s | 只影响建连,不用改 |
| Spring MVC | spring.mvc.async.request-timeout | 视版本 | 与 SseEmitter timeout 对齐 |
| 应用内 | SseEmitter timeout | 30s | 0 或 600s,另加业务看门狗 |
| 上游客户端 | readTimeout | 10s | 首包超时与总超时分开设 |
这里有个反直觉的点:上游 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三个回调里都置 true,send之前先检查,避免无谓的异常刷日志。
第三个陷阱是异常处理顺序。很多人写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 看原始字节 | 声明 charset=UTF-8,用 readUtf8Line |
| 前端解析报 JSON 错误 | 多行 data 未拼接 | 抓包看是否有多行 data | 解析时按 \n 拼接 data 行 |
| 结束之后前端一直转圈 | done 事件未发送或格式错 | 看最后几个事件 | 显式发 done 并 complete |
| 并发升高后整体卡顿 | 阻塞调用占用线程池 | 看线程堆栈 | 上游调用切非阻塞或加大线程池 |
| 运行几小时后大量 CLOSE_WAIT | emitter 未正确释放 | 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 事件里带完整的使用量统计,服务端顺便做成本归集。这些都是锦上添花,把上面的连接保活、缓冲、超时、释放这几件基础的事做扎实,流式接口在线上就基本不会给你找麻烦了。