做后端的人大概都遇到过这种需求:产品经理拿着竞品截图说,"这里的回答要一个字一个字蹦出来"。第一次接这个需求,很多人的第一反应是上 WebSocket,第二反应是干脆让前端定时轮询。我最早也这么干过,轮询的版本上线三天就被前端投诉——接口 QPS 翻了六倍,答案还是一坨一坨地出现,体验毫无改善。后来换成 SSE,代码量反而少了一半,还顺便把"连接断了怎么续""生成到一半用户关页面了怎么止损"这些问题一并解决了。
这篇要讲的,就是在 Java 项目里用 SSE(Server-Sent Events)把大模型的流式输出接到前端的那套完整做法:推送端点怎么建、上游返回的分块内容怎么翻译成事件、连接为什么总断在莫名其妙的地方、上线之后会踩哪些坑。适合已经写过 Spring Boot 接口、正准备做流式对话功能的人,也适合被 WebSocket 那一套折磨过一轮、想换个轻量路子的人。代码以 Spring 生态为主,MVC 和 WebFlux 两条路都给出来,前端给 fetch 版本——因为我们要用 POST 带上下文,EventSource 那套 GET 写法的天花板很明显。
1. 流式对话为什么最后大多落到 SSE 上
1.1 三种方案的真实取舍账
选推送方案的时候,大家摆在桌上的一般就三个:轮询、WebSocket、SSE。我把它们放在同一个维度下比较,这张表是我自己踩过坑之后总结的,比网上那些泛泛而谈的对比要实在一些:
| 维度 | 短轮询 | WebSocket | SSE |
|---|---|---|---|
| 通信方向 | 客户端拉 | 全双工 | 服务端单向推送 |
| 协议基础 | HTTP | 独立握手协议 | 纯 HTTP |
| 前端原生 API | fetch | WebSocket 对象 | EventSource / fetch 流 |
| 断线重连 | 天然支持 | 要自己写 | EventSource 内置 retry |
| 穿过接入层 | 无压力 | 需要配置 Upgrade | 走普通 HTTP,但要关缓冲 |
| 带自定义请求头 | 可以 | 握手阶段受限 | EventSource 不行,fetch 可以 |
| 传二进制 | 可以 | 可以 | 只能文本 |
| 服务端实现复杂度 | 低 | 中高 | 低 |
对话场景的本质是"一次请求、持续下行",客户端在生成过程中除了点个"停止生成"按钮之外,几乎不需要往服务端发东西。全双工的 WebSocket 在这里属于杀鸡用牛刀,而且它跟 HTTP 体系是割裂的:鉴权要重新设计、网关要做 Upgrade 配置、Nginx 要额外开proxy_set_header Upgrade、跨域和 Cookie 那一套都得重来。SSE 就没这些问题,它就是一个 Content-Type 为text/event-stream的普通 HTTP 响应,只是服务端不一次性把 body 写完,而是一块一块地吐。你原有的鉴权过滤器、登录态、链路追踪全部能复用。
提示:如果项目里已经有成熟的 WebSocket 通道(比如 IM 系统),复用它做对话推送不是不行,但要注意 WebSocket 的消息边界和 SSE 的事件边界不是一回事,客户端解析逻辑得重写,别指望复用。
1.2 SSE 在 HTTP 报文里到底长什么样
很多人用了半年 SSE,也没认真看过它裸报文长什么样,结果出问题的时候连从哪里下手都不知道。它其实简单到有点朴素:一个 HTTP 响应,Content-Type 是text/event-stream,body 里是用空行分隔的一条条事件,每条事件由若干个字段名: 值的行组成。
HTTP/1.1 200 OK Content-Type: text/event-stream;charset=UTF-8 Cache-Control: no-cache Connection: keep-alive event: delta data: 你好 event: delta data: ,我是 : ping event: done data: {"reason":"stop"}四个字段要记牢:event是事件名,前端靠它分流;data是载荷,可以有多个data:行,前端拼接时会用换行符连起来;id用于断线续传时的 Last-Event-ID;retry告诉浏览器重连间隔。另外以:开头的行是注释,浏览器会直接忽略——这条看起来没什么用,实际上它是保活的心跳线,后面第 4 节会重点用到。
这里有个细节值得单独拎出来:data的值里不能出现裸换行。如果模型输出的文本里恰好带了换行,你要么把它拆成多个data:行(前端会拼回来),要么老老实实把整个增量包成一个 JSON 对象再发。我推荐后者,因为前端解析 JSON 比解析多行 data 直观得多,而且后面要加字段(比如累计 token 数、消息 id)的时候不用改协议。
1.3 什么场景不该用它
SSE 不是万金油,我见过有团队把它硬套在需要实时语音回传的项目上,最后被迫推倒重来。判断标准很简单:
- 需要客户端高频上行。比如实时语音、协同编辑的光标同步,这类场景 SSE 完全无能为力,因为它只有下行通道。
- 需要传二进制。SSE 规定只能是 UTF-8 文本,音频流、图片流都得走别的路。
- 对首包延迟极度敏感且数据量很大。SSE 是文本协议,每条事件都有
event:和data:的固定开销,如果你要推的是几千条结构化数据,JSON 序列化加上文本编码的开销不容忽视。
对话场景恰好三条都不沾:上行只有一次请求,内容全是文本,单次生成的增量包体积很小。所以 SSE 在这里几乎是量身定做的。
2. 服务端推送骨架:SseEmitter 与 Flux 怎么选
2.1 SseEmitter 的线程模型,以及为什么必须配独立线程池
Spring MVC 提供的SseEmitter是上手最快的方案。它的原理是把 Servlet 3.0 的异步能力包装了一层,return emitter的时候请求线程就被归还给 Tomcat 了,真正的推送发生在你自己开的线程里。
@RestController @RequestMapping("/api/chat") public class ChatStreamController { private final ChatUpstream upstream; private final ThreadPoolExecutor streamPool; private final ScheduledExecutorService heartbeat; @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(@RequestBody ChatRequest req) { // 传 0L 表示不设超时,具体超时交给下游网关控制 SseEmitter emitter = new SseEmitter(0L); String taskId = TaskId.next(); emitter.onTimeout(() -> log.warn("sse timeout, task={}", taskId)); emitter.onError(e -> log.warn("sse error, task={}", taskId, e)); emitter.onCompletion(() -> log.info("sse completed, task={}", taskId)); streamPool.execute(() -> { StringBuilder acc = new StringBuilder(); ScheduledFuture<?> hb = heartbeat.scheduleAtFixedRate(() -> { try { emitter.send(SseEmitter.event().comment("ping")); } catch (Exception ignored) { // 发送失败说明连接已断,交给下面的流程收拾 } }, 15, 15, TimeUnit.SECONDS); try { upstream.stream(req.getQuestion()).forEach(delta -> { try { // 关键:显式指定 TEXT_PLAIN,否则字符串会被当 JSON 序列化 emitter.send(SseEmitter.event() .name("delta") .data(delta, MediaType.TEXT_PLAIN)); } catch (IOException e) { throw new ClientGoneException(e); } acc.append(delta); }); emitter.send(SseEmitter.event().name("done").data("{\"len\":" + acc.length() + "}")); emitter.complete(); } catch (ClientGoneException e) { log.info("client closed early, task={}, generated={} chars", taskId, acc.length()); emitter.completeWithError(e); } catch (Exception e) { safeSend(emitter, SseEmitter.event().name("error").data("{\"msg\":\"生成被中断\"}")); emitter.completeWithError(e); } finally { hb.cancel(false); saveHistoryAsync(taskId, req, acc.toString()); } }); return emitter; } }这段代码里有三个点,是我调试了两天才搞明白的,值得单独说。
第一,SseEmitter.event().data(Object)在不指定 MediaType 的时候,默认按application/json处理,一个普通的 Java String 会被 JSON 序列化成带双引号的形式。结果就是前端收到的每一段都是"你好"而不是你好,逐字显示变成了逐字带引号。指定MediaType.TEXT_PLAIN就正常了。
第二,new SseEmitter(0L)传 0 不是"立刻超时",而是"永不超时"。这个参数很容易看反。真正卡你的是 Tomcat 的server.tomcat.connection-timeout和接入层的读超时,这两处才是连接莫名其妙消失的真凶。
第三,线程池必须独立。如果你图省事直接写在请求线程里同步循环发,那SseEmitter的异步能力就白搭了,Tomcat 的 200 个工作线程很快被长连接占满,其他接口全部排队。我一般给流式任务开一个核心线程数不大、队列有界的池子,队列满了直接拒绝,让用户重试——这比无限排队最后全部超时体验好得多。
2.2 WebFlux 那条路:Flux 与背压的天然契合
如果项目本身就是响应式栈,或者你压根不想管线程池和阻塞读,那 WebFlux 版本的代码会干净得非常舒服:
@GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> stream(@RequestParam String q) { return upstream.stream(q) .map(delta -> ServerSentEvent.<String>builder() .event("delta") .data(delta) .build()) .concatWithValues(ServerSentEvent.<String>builder() .event("done") .data("{\"reason\":\"stop\"}") .build()) .onErrorResume(e -> Flux.just(ServerSentEvent.<String>builder() .event("error") .data("{\"msg\":\"生成被中断\"}") .build())); }这段的优势在于取消传播。用户点"停止生成"或者直接关掉页面时,WebFlux 会把取消信号沿着 Flux 链一路往上传,最终调用到上游 HTTP 客户端的cancel(),把它那边的连接也断掉。这一点极其重要——大模型的流式接口是按 token 计费的,如果用户已经关了页面你还在傻乎乎地读完整个响应,那就是纯烧钱。MVC 那套你得自己在onError或者发送异常里手动去关上游连接,一不小心就漏了。
背压是另一个隐形的优势。前端网络慢的时候,SseEmitter的send会一直阻塞,数据在内存里堆积;而 Flux 会尊重下游的请求量(虽然 SSE 的响应式写出去之后,实际能施加的背压有限),配合onBackpressureBuffer(Duration.ofSeconds(30), 4096, BufferOverflowStrategy.DROP_OLDEST)之类的手段,至少能保证内存不被打爆。
2.3 二选一的判断标准
这张表可以直接拿去跟团队对:
| 判断条件 | 选 SseEmitter | 选 WebFlux Flux |
|---|---|---|
| 现有项目栈 | 传统 MVC / 阻塞 JDBC | 已经是 WebFlux / R2DBC |
| 团队对响应式熟悉度 | 一般 | 熟悉 |
| 上游 SDK | 阻塞式 HTTP 客户端 | 支持响应式 |
| 需要精细控制每条事件的发送时机 | 方便 | 需要额外操作符 |
| 取消传播与资源回收 | 需手动处理 | 框架自动处理 |
我的实际结论是:已有项目老老实实用 SseEmitter,别为了一个接口把整个技术栈改成响应式。响应式那套调试成本很高,出一个线程切换的问题能查一下午。但如果是新项目、团队里有懂的人,WebFlux 写出来的流式接口确实更扎实。
3. 把上游模型的分块响应翻译成 SSE 事件
3.1 上游给的其实是一行一个事件的纯文本
这里有个认知差:很多人以为调大模型接口拿回来的是"一个个 token",其实你拿到的是一个个 JSON 片段,每个片段包着若干个字符。上游返回的流长这样:
data: {"id":"c1","choices":[{"delta":{"content":"你"},"index":0}]} data: {"id":"c1","choices":[{"delta":{"content":"好"},"index":0}]} data: [DONE]也就是说,我们做的是"协议的转译":把上游的data: {...}里面的content抽出来,重新包成我们自己的event: delta/data: xxx。中间可能还要做字段映射、过滤空增量、拼接终止原因。
用 WebClient 的话,如果上游响应头确实是text/event-stream,bodyToFlux(String.class)会调用 Spring 内置的 SSE 解码器,直接把每个事件的 data 部分作为字符串给你,省掉手写解析:
public Flux<String> stream(String prompt) { Map<String, Object> body = Map.of( "model", modelName, "stream", true, "messages", List.of(Map.of("role", "user", "content", prompt)) ); return webClient.post() .uri("/v1/chat/completions") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.TEXT_EVENT_STREAM) .bodyValue(body) .retrieve() .bodyToFlux(String.class) .takeWhile(line -> !"[DONE]".equals(line.trim())) .mapNotNull(this::extractDelta) .filter(s -> !s.isEmpty()); } private String extractDelta(String json) { try { JsonNode root = objectMapper.readTree(json); JsonNode choices = root.path("choices"); if (!choices.isArray() || choices.isEmpty()) { return null; } return choices.get(0).path("delta").path("content").asText(""); } catch (Exception e) { // 解析失败的片段直接丢弃,不要中断整条流 return null; } }.takeWhile(...)这句很关键。上游用[DONE]作为结束标记,它不是合法 JSON,你要是让它流进extractDelta,那就会触发解析异常。用takeWhile在它之前截断,语义最清晰。
3.2 缓冲区切分:中文被劈成两半是乱码的主要来源
但现实往往没这么理想。有些服务方返回的 Content-Type 是text/plain或者干脆是application/octet-stream,这时候bodyToFlux(String.class)拿到的就不是完整事件,而是任意字节边界切出来的数据块。一个中文字符在 UTF-8 下占三个字节,完全有可能第一个数据块里只有前两个字节,第二个数据块里是剩下的一个字节。你要是对每个块单独new String(bytes, UTF_8),那必然出现乱码——而且这种乱码是偶发的,本地测试一百次都正常,一上线就被用户截图。
正确的做法是维护一个字节缓冲区,只在遇到完整事件边界(\n\n)之后才做解码:
public class SseChunkDecoder { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(8192); public synchronized List<String> feed(byte[] chunk) { buffer.write(chunk, 0, chunk.length); byte[] all = buffer.toByteArray(); List<String> events = new ArrayList<>(); int start = 0; for (int i = 0; i + 1 < all.length; i++) { if (all[i] == '\n' && all[i + 1] == '\n') { // 边界内的所有字节一起解码,保证多字节字符完整 events.add(new String(all, start, i - start, StandardCharsets.UTF_8)); start = i + 2; i++; } } buffer.reset(); if (start < all.length) { buffer.write(all, start, all.length - start); } return events; } }这段逻辑的核心思想就一句话:只用完整的字节序列去解码。缓冲区里剩下来的半截事件,等下一个数据块来了再一起处理。同时要注意,\r\n\r\n也是合法的分隔符,严谨一点的做法是先把\r\n规整成\n再找边界,或者两个都判断。我在实际项目里吃过一次亏:本地自建的服务用\n\n,切到云端服务就变成了\r\n\r\n,结果前端一条事件都收不到,排查了半天。
还有一点容易忽略——缓冲区要有上限。如果上游抽风一直不发空行,你这个 buffer 会一路涨到 OOM。加个判断,超过 1MB 还没等到边界,直接抛异常断开连接,把问题暴露出来比闷声吃内存强。
3.3 增量提取与结束信号的处理
把事件切出来之后,逐条解析。这一步我建议做成一个纯函数,输入是事件文本,输出是"要么一个增量字符串,要么一个结束信号",这样好写单测:
public record Delta(String text, boolean finished) {} public Delta parse(String event) { StringBuilder data = new StringBuilder(); for (String line : event.split("\n")) { if (line.startsWith("data:")) { // 冒号后面可能有一个空格,也可能没有,都要兼容 String v = line.substring(5); if (v.startsWith(" ")) { v = v.substring(1); } data.append(v); } } String payload = data.toString().trim(); if (payload.isEmpty()) { return new Delta("", false); } if ("[DONE]".equals(payload)) { return new Delta("", true); } try { JsonNode root = objectMapper.readTree(payload); JsonNode choices = root.path("choices"); if (choices.isArray() && !choices.isEmpty()) { JsonNode delta = choices.get(0).path("delta"); JsonNode content = delta.get("content"); return new Delta(content == null ? "" : content.asText(), false); } } catch (Exception e) { log.warn("skip malformed chunk: {}", payload); } return new Delta("", false); }注意substring(5)之后处理那个可选的空格。规范里写的是data:后面跟一个空格再跟值,但实际实现里两种都有,不兼容就等着丢字符吧。另外content字段在最后一两块里可能根本不存在(只带finish_reason),用get判空,别直接asText()。
3.4 错误也必须是一种事件
SSE 有个绕不开的尴尬:一旦开始推送数据,HTTP 状态码就已经固定成 200 了。你没法像普通接口那样返回 500 让前端去判断。上游模型超时、额度不足、内容被拦截,这些错误只能包装成一条自定义事件发出去:
event: error data: {"code":"UPSTREAM_TIMEOUT","msg":"生成超时,请重试"}前端必须监听这个事件,收不到done也没收到error的情况也要处理——那说明连接是异常断开的。我在线上见过一次,接入层在生成到第 8 秒的时候掐断了连接,前端既没收到 done 也没收到 error,界面就一直卡在"思考中"。后来加了个前端侧的空闲计时器,超过 30 秒没有任何事件就自动结束并提示重试,才算把体验兜住。
4. 断线、缓冲与超时:上线后最容易翻车的地方
4.1 转发层把流"攒"起来了
本地跑得好好的,一部署到测试环境就变成"等十秒然后一次性全出来",九成是转发层的缓冲在作怪。Nginx 默认会开启proxy_buffering,它会把上游的响应先收进自己的缓冲区,攒够一定量(默认 4KB 或 8KB)才往下发。对于普通接口这是优化,对于 SSE 这就是灾难。
location /api/chat/ { proxy_pass http://backend_pool; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 关键四行:关掉一切缓冲 proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; gzip off; # 读超时要大于单次生成的最长耗时 proxy_read_timeout 300s; proxy_send_timeout 300s; }gzip off这行是血泪教训。gzip 是压缩流,它需要攒够一定数据才能有效压缩,天然就会引入延迟。有些环境在入口层统一开了 gzip,你只改应用层的 Nginx 是没用的,得往上找到真正的压缩点。如果实在关不掉,退而求其次的做法是:在流的开头先塞一段足够长的注释行(比如 2KB 的空填充),把压缩缓冲区顶满,后续数据就能顺利流出来。这招不好看,但管用。
另外proxy_read_timeout一定要设得比单次生成的最长耗时长。默认 60 秒,而模型生成一段长回答经常超过这个数,结果就是生成到一半被 Nginx 断开。如果前面还有一层云的负载均衡,那层的空闲超时也得一起调,链条上任何一个环节短了都不行。
4.2 心跳、超时与重连的三层配合
一条 SSE 连接可能在两处被判定为"死了":服务端的异步超时、客户端的空闲超时。前者靠SseEmitter的构造参数或者server.tomcat.connection-timeout控制,后者各家浏览器不一样。
心跳的作用就是让这条连接在每一层看来都是"活的"。但心跳有个前提——它必须是字节。HTTP 层的超时计时器看的是有没有数据传输,应用层发一个空字符串它是不认的。所以心跳要用注释行:
ScheduledFuture<?> hb = scheduler.scheduleAtFixedRate(() -> { try { synchronized (emitter) { emitter.send(SseEmitter.event().comment("ping")); } } catch (Exception e) { log.debug("heartbeat failed, connection likely closed"); } }, 15, 15, TimeUnit.SECONDS);synchronized那一层别省。心跳线程和业务线程可能同时往同一个emitter写,虽然底层的send内部有一定同步,但事件之间穿插着写会出现半条事件被截断的情况,我遇到过前端解析出一条半截 JSON 的报错,查了很久才发现是这个竞态。
至于重连,EventSource自带重连,但你如果用的是 fetch 手动解析流,就得自己实现。我的建议是对话场景不要自动重连。因为重连意味着重新发起一次生成,上下文和计费都会乱套。正确做法是断线后提示用户"连接中断,点击重试",并且把已经生成的内容保留在输入框旁边,让用户自己决定是接着问还是重新问。
4.3 客户端断开后的资源回收
这是最容易被忽略、也最烧钱的一环。用户关掉页面、切走标签、按了停止键,连接会断,但你的服务端线程可能还在forEach里欢快地跑着,一边读上游一边往一个已经没人接收的emitter里写。
SseEmitter提供了onCompletion和onError回调,但它们只在正常结束或者抛异常时触发,单纯的客户端断开不一定能立刻感知。真正可靠的信号是写失败——往一个断开的连接send时会抛IOException,这时候立刻中断循环,并取消上游订阅:
AtomicBoolean alive = new AtomicBoolean(true); Disposable upstreamTask = upstream.chat(prompt) .subscribe(delta -> { if (!alive.get()) return; try { emitter.send(SseEmitter.event().name("delta").data(delta, MediaType.TEXT_PLAIN)); } catch (IOException e) { alive.set(false); throw new ClientGoneException(); } }, err -> { /* 错误事件 */ }, () -> { /* 结束事件 */ }); emitter.onCompletion(() -> { alive.set(false); upstreamTask.dispose(); // 主动断开上游连接,停止计费 }); emitter.onTimeout(() -> { alive.set(false); upstreamTask.dispose(); });如果你用的是纯阻塞客户端,没有Disposable这套,那就只能在流式读取的循环里检查标志位,每次读之前看一眼alive,为 false 直接跳出并关闭底层连接。虽然不那么优雅,但至少能止损。
4.4 浏览器对同域名的连接数限制
这个坑不常见,但一旦遇上特别难查。HTTP/1.1 下浏览器对同一个域名的并发连接数限制通常是 6 个。每条 SSE 连接长期占着一个,用户如果开了几个标签页,或者在同一个页面里同时打开多个对话窗口,很容易把名额吃光,表现就是页面里其他图片、接口请求全部卡住排队。
解法有两个:一是升级到 HTTP/2,多路复用能绕开这个限制;二是把 SSE 的域名单独拆出来,或者严格限制单页面同时只有一个活跃的流。我一般两件事都做——HTTP/2 是基础设施层面的事,控件层面也顺手限制一下,双保险。
5. 前端怎么吃这条流
5.1 EventSource 的能力边界
EventSource是浏览器原生的 SSE 客户端,用起来确实省心:自动重连、自动解析event和data、只需要几十行代码。但它有几个硬限制:
- 只能是 GET 请求,没法带 POST body 传对话上下文;
- 不能自定义请求头,Token 只能塞在 URL 参数或者 Cookie 里;
- 没法拿到 HTTP 状态码,服务端返回 401 它也无能为力,只会一直重试。
对话接口必然要传一段结构化的历史消息,还可能带一些参数(温度、模型名),塞进 URL 既不优雅也容易超长。所以只要不是简单的"频道订阅"场景,我都会放弃EventSource改用 fetch。
5.2 fetch 手写解析器
fetch 拿到的是ReadableStream,自己按事件边界切分就行。上代码:
async function streamChat(payload, onEvent, onFinish) { const resp = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, body: JSON.stringify(payload) }); if (!resp.ok) { throw new Error('HTTP ' + resp.status); } const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; // stream: true 让解码器自己处理跨块的多字节字符 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); const evt = parseEvent(raw); if (evt) onEvent(evt); } } // 流结束时把解码器里残留的字节冲出来 buffer += decoder.decode(); onFinish(); } function parseEvent(raw) { let event = 'message'; const dataLines = []; for (const line of raw.split('\n')) { if (line.startsWith(':')) continue; // 心跳注释,直接跳过 if (line.startsWith('event:')) { event = line.slice(6).trim(); } else if (line.startsWith('data:')) { let v = line.slice(5); if (v.startsWith(' ')) v = v.slice(1); dataLines.push(v); } } if (dataLines.length === 0) return null; return { event, data: dataLines.join('\n') }; }TextDecoder的{ stream: true }参数和 Java 那边的字节缓冲是同一个道理——它会把不完整的 UTF-8 字节序列暂存起来,等下一块数据到了再拼。不加这个参数,中文一样会乱。很多人手写解析跑到测试环境才发现中文乱码,根子就在这。
还有一点:done为 true 之后,别忘了再调一次decoder.decode()把残留字节冲出来。虽然通常此时缓冲区是空的,但万一最后一段正好卡在多字节字符中间,不冲就会丢字。
5.3 联调阶段最顺手的几个手段
调试流式接口,打开开发者工具看响应体是最直观的——Chrome 的 Network 面板对text/event-stream有专门的事件流视图,能一条一条列出来,比看原始文本舒服得多。但它只能看出"有没有收到",看不出"什么时候收到的",排查延迟问题还得靠命令行:
curl -N -X POST http://localhost:8080/api/chat/stream \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d '{"question":"你好"}'-N是关掉 curl 自己的输出缓冲,不加这个你会看到数据在最后一次性冒出来,容易误判成服务端有问题。如果加了-N还是一次性出来,那问题就在服务端或者中间链路。
再加一层,用时间戳看清楚每条事件之间的间隔:
curl -N -X POST http://localhost:8080/api/chat/stream \ -H "Content-Type: application/json" -d '{"question":"你好"}' \ | while IFS= read -r line; do printf '%s %s\n' "$(date +%H:%M:%S.%3N)" "$line"; done如果发现首条事件到得很快、后续事件却间隔好几秒,基本可以确定是上游本来就这么慢;如果所有事件都挤在最后一起出现,那就是缓冲问题,回去查 Nginx。
6. 上线前还得补齐的几件事
6.1 会话落库与逐字计量
流式接口有个和普通接口不一样的地方:响应结束时才拿得到完整结果。所以在finally里落库是个标配动作,但要注意别把落库做成同步阻塞——用户可能已经收到done事件了,你的请求线程还卡在数据库上,白白占着资源。我一般把落库丢给一个独立的异步线程池,失败就重试两次,再失败写进补偿表。
计量也是同一个道理。有些上游接口只在最后一个数据块里带usage字段,包含 prompt tokens 和 completion tokens,你得在解析的时候专门把它捞出来存好。如果没有usage字段,那就按累计字符数估算,虽然不准,但至少能用来做异常检测——比如某个用户一天之内累计生成了几十万字,那就该触发告警了。
6.2 限流不能只看 QPS
普通接口限流看 QPS 就够了,流式接口不行。一个 SSE 请求的持续时间可能是 5 秒,也可能是 60 秒,QPS 一样的情况下,占用的并发连接数差了十倍。所以限流维度至少要加两个:并发连接数和累计生成字符数。
并发连接数限制比较好做,用信号量在stream方法入口处tryAcquire,拿不到直接返回一个"当前排队人数较多"的事件然后结束。注意这里返回的仍然是 200 + SSE 格式,因为前端拿到非 200 也没法正常解析事件。
累计字符数的限制则要延迟到生成过程中:每累计到一定数量就检查一次配额,超了就主动发一条error事件并断开。这条线是防刷的关键——不加的话,有人写个脚本挂着连接,一天能消耗掉相当可观的额度。
6.3 日志和链路追踪怎么串起来
流式场景的日志特别容易散。一次对话会经过"控制器入口—上游请求—若干个增量回调—结束落库"这么多环节,如果不用一个统一的 traceId 串起来,出问题的时候根本没法还原现场。
具体做法是在stream方法一开始生成一个taskId,同时塞进 MDC、塞进上游请求头、塞进发给前端的第一条事件里(event: meta),前端后续报错时能把这个 id 带回来。日志里每一行都打上它,事后grep一下就能看到完整的前因后果。
还要特别记两类日志:一是首字节耗时(从收到请求到发出第一条delta事件),这个指标能直接反映上游的健康状况;二是流中断的原因(客户端断开、上游报错、心跳失败、配额耗尽),分类统计之后,你就知道该往哪儿优化了。
7. 一些在项目里反复验证过的细节
写完这套之后我自己复盘了一下,有几个细节属于"当时不知道,知道后少走很多弯路"的:
用 POST 而不是 GET。虽然规范里没限制,但 GET 的 URL 长度在面对多轮对话历史时会很快撑爆。用 POST +Accept: text/event-stream,语义清晰,前端解析逻辑一点不比 EventSource 复杂。
事件名只用小写单词。delta、done、error、meta,四个就够了。别搞messageStart这种驼峰,因为不同浏览器对event字段的大小写处理曾经有过差异,白白踩坑。
第一帧先发一个 meta 事件。内容是{"taskId":"xxx","model":"yyy","startedAt":1690000000000}。好处有两个:让前端立刻确认连接建立成功(而不是卡在"思考中"),以及给用户一个可以提交反馈的标识。
上游的并发要单独设限。SSE 连接数一多,服务端对上游的并发请求也会同步放大,很容易把上游的速率限制打满,结果所有用户一起变慢。用Semaphore或者RateLimiter在最外层挡一道,超出的请求直接走排队提示,比全部超时体验好得多。
别在事件里塞大对象。有人图省事把整段 JSON 塞进data,前端每条都要全量解析一遍,逐字显示的流畅感就没了。增量就只发增量,需要补充的元信息走单独的meta事件。
测试阶段一定要模拟慢网。用浏览器开发者工具的 Network Throttling 调到 "Slow 3G",你会发现很多平时发现不了的问题——比如前端解析器没处理跨块的情况、比如服务端的写阻塞导致缓冲区堆积。我现在的习惯是每个流式接口上线前都过一遍限速测试,比写十行单测有用。
最后分享一个排查思路:当用户反馈"卡住不动"的时候,先别急着查代码,按"上游 → 服务端 → 网关 → 浏览器"的顺序,在每一层用curl -N打一次,看数据是在哪一层停住的。这个土办法能解决八成以上的流式问题,比翻日志快得多。