拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSE在AI流式响应中的应用:从协议原理到生产实践

SSE在AI流式响应中的应用:从协议原理到生产实践

1. 从一次AI流式响应说起:SSE 为什么成了大模型应用的默认协议

1.1 打字机效果的背后是一次 HTTP 长连接

先讲一个自己项目里遇到的场景。去年做 AI 对话类功能时,前同事把大模型接口接上后问我:为什么用户输入一句话要在页面里干等二十多秒?我一看代码就明白了,他用的是普通 HTTP 请求——发起调用,前端 loading,等大模型把整段回复全部生成完,后端一次性把完整 JSON 返回,前端才渲染。ChatGPT 式的逐字输出效果根本做不出来。

原因很简单:大模型的回复是 token 逐个生成的,第一个 token 可能一两秒就出来了,但完整回复可能要二十秒甚至更久。如果把人当作交互对象来看,等完整回复再"一次性交卷"是完全反人性的。用户要的是第一个字尽快出现,然后看着内容一路"长出来"。

这个场景下,SSE(Server-Sent Events,服务器推送事件)几乎是最顺手的方案。它本质上是一条普通的 HTTP 长连接,服务端在这个连接上持续向客户端推送文本消息,直到主动关闭。协议层面对客户端的要求极低——请求头里带一个Accept: text/event-stream,响应走Content-Type: text/event-stream,之后服务端就可以源源不断地写数据。每个事件以空行分隔,事件内容由若干key: value字段构成,最常见的字段是data,还可以有id、event、retry。

一个最简单的 SSE 响应长这样:

data: {"content":"你"} data: {"content":"好"} data: {"content":"!"} data: [DONE]

注意每个data:后面跟一个换行,事件和事件之间用一个空行(即两个连续换行)隔开。客户端收到后在内存里按行解析,遇到空行就认为一个事件结束。整个过程不需要 WebSocket 那种双向通道,也不需要在连接上跑自己的协议帧,就是一个"按行读文本"的过程。

1.2 为什么是 SSE 而不是 WebSocket

很多人在项目里一听到"实时"就直接上 WebSocket,我建议先冷静。AI 对话这个场景,数据流向是单向的:服务端生成多少就推多少,客户端几乎不需要回推业务数据(顶多发个取消信号,这个用普通 HTTP 也能做)。SSE 是单向的,天然匹配;WebSocket 是双向的,功能更多但复杂度也更多——握手协议独立、鉴权要另做、断线重连要自己处理、前端还要维护 socket 状态机。

SSE 最舒服的地方在于它"披着 HTTP 的皮"。不需要额外的端口和协议,认证可以直接复用 HTTP 的 Cookie 或 Header;中间代理设备天然能识别 HTTP 流量;浏览器原生支持EventSource对象,几行代码就能消费;掉线之后客户端可以根据Last-Event-ID请求续传,这个能力是协议自带的。

当然 SSE 也有短板:最大并发连接数在大多数浏览器里限制为 6 个(HTTP/1.1 下),单向不适合聊天消息这种需要客户端主动发的场景。但在"模型流式吐字 + 用户偶尔打断"这种需求下,它比 WebSocket 省掉了一大堆不必要的工程复杂度。这个选择不酷,但足够务实。

1.3 AI 场景下事件流的组织方式

实际对接大模型时,SSE 事件里装的不只是文本。以主流模型服务的流式接口为例,每个 chunk 常常包含delta增量内容、finish_reason结束标志、usage用量统计等字段。为了区分"内容数据"和"结束信号",很多服务会在流末尾发一个[DONE]事件,或者在一个事件的finish_reason字段里标记stop。

客户端解析时就要厘清:什么字段是给用户看的增量文本、什么时候该停止渲染光标、什么时候调用计费统计。这些细节是"显式调用"阶段必须亲手处理的,也正是在这个阶段,你会意识到 SSE 解析远没有"按行读 data"看起来那么简单。后面第三部分的封装,所有头疼都从这里开始。

2. 显式调用:手写 SSE 客户端时最容易踩的坑

2.1 用 OkHttp 手撸一个最小 SSE 读取器

当初我刚接触 SSE 时,第一反应是找现成库。但为了搞懂协议细节,还是先用 OkHttp 手写了一个最小实现跑通了模型链路。这一步非常推荐新手做:手写一遍,你对 SSE 的协议理解深度和"直接用库"完全是两个级别。

核心逻辑其实就是发起一次普通 GET 请求,拿到响应体 InputStream 后按行读取。看代码:

OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // 流式场景必须设为无限,否则会掐断连接 .build(); Request request = new Request.Builder() .url("https://api.example.com/v1/chat/stream") .header("Accept", "text/event-stream") .header("Authorization", "Bearer " + token) .post(body) .build(); try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful()) { System.err.println("HTTP error: " + response.code()); return; } BufferedReader reader = new BufferedReader( new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8)); String line; StringBuilder eventData = new StringBuilder(); while ((line = reader.readLine()) != null) { if (line.isEmpty()) { // 空行表示一个事件结束,交给解析器 handleEvent(eventData.toString()); eventData.setLength(0); } else if (line.startsWith("data:")) { // 去掉 "data:" 前缀和可选的前导空格 eventData.append(line.substring(5).trim()); } // 其它字段如 id/event/retry 这里先忽略 } }

这段代码背后有一个极其关键的细节:readTimeout必须设成无限。原因很直白,服务端生成两个 token 之间的间隔如果超过了你设置的读超时,连接会被判定为"一直没数据"而直接掐掉。首页那句热搜stream disconnected before completion: idle timeout waiting for sse,八成就是这一步配置错了。

另一个细节:data:后的拼接用的是append不换行,但在 SSE 规范里,同一个事件的多个data:行应该用单个换行\n连接。真实世界的服务端实现五花八门,有的直接拼 JSON 字符串,有的会把 JSON 拆成多行,这地方必须根据上游服务实际情况去匹配,不能死按规范来。具体的兜底策略我在第五部分展开。

2.2 边界情况与协议细节

手写读取器能在简单链路上跑通,但远谈不上可用。SSE 协议里有一堆边界情况,每一个都值得踩一次:

  • CRLF 问题:SSE 规范允许\r\n作为换行,但很多服务端发的是\n。用readLine()还好,它内部两者都认;如果你在 Netty 之类的地方手工切字节,只按\n去切就会出现半行解析事故。

  • 注释行:SSE 允许: keep-alive这种以冒号开头的注释行,这是最常用的心跳机制。客户端必须能识别并忽略它,不能让注释行混入data里去。很多 AI 网关为了保持链路活跃,会每 15 秒发一个注释行,如果你的解析器把注释也算进了事件内容,解析出来的 JSON 就是坏的。

  • 多行 data 合并:规范里同一事件可有多行data:,它们应该用\n连接成一个完整 payload。上面我写的示例代码用了无脑append,这其实有问题。正确逻辑是:追加时如果该事件已有一个 data 字段,就在中间补一个\n。

  • id 字段与续传:如果服务端在事件里发了id,客户端掉线重连时应把Last-Event-ID请求头带上,让服务端能断点续传。AI 场景里一般服务端不怎么支持这个,但作为通用客户端,这个 header 逻辑建议留好。

2.3 一次"idle timeout"故障的完整排查过程

讲到超时,必须复述一下那次排查经历。现象是:内部工具调用大模型接口做长文本生成,跑大约 40 秒后必断,控制台报stream disconnected before completion: idle timeout waiting for sse。我们的第一反应是服务端挂了,去查服务端日志,发现它还在正常生成,只是推送的 HTTP 响应被中间某个环节切断了。

顺着链路往下查,先看客户端配置,发现readTimeout设的是 30 秒。这个值来自我们普通接口的统一超时配置,但在流式场景下显然有问题——模型生成一个长段落时,偶尔两个 token 之间超过 30 秒并不奇怪(尤其思考型模型)。把 readTimeout 调成 0(无限)之后,故障概率大幅下降,但偶尔仍会断。

再看中间层,发现我们经过了 Nginx 和一层云负载均衡。云 LB 的空闲连接超时默认值往往是 60 秒或 300 秒,如果链路上一段时间没有任何字节流动,中间层就会主动断开连接。解决的常规做法是让服务端在流式响应期间定期发心跳注释行,比如每 15 秒写一个: keep-alive\n\n,这样链路永远不会有"空闲"窗口。把这三层配置调完,问题才算彻底解决。

这个排查给我留下的核心经验是:SSE 连接稳定与否,不只是客户端代码的事,整条链路的超时口径要统一——客户端读超时 > 中间代理空闲超时 > 服务端心跳间隔,这个顺序不能反。

3. 隐式封装:把流式解析、重连与生命周期打包成一个类

3.1 为什么必须封装:显式调用在业务代码里的灾难

手写读取器跑通百次业务调用后,封装的念头很快会冒出来。因为你会发现自己开始在各处复制粘贴同一套代码:建连接、按行读、切空行、拼 JSON、处理异常。更麻烦的是,重试逻辑和资源释放散落在每段调用里,极容易漏。

我当时接需求接了三个:AI 对话助手、文档摘要生成、代码 review 助手。三个业务都需要 SSE 流式消费,但各自都要处理 token、超时、断开重连。如果每处都写一遍前面那段上世纪风格的读取循环,那就是三份 bug 等着修。封装的目标很明确:业务方传一个 URL、一组参数、一个事件回调,剩下的连接管理、断线重试、超时控制、资源释放,全部由底层组件搞定。

这个阶段的思考方式其实就是老生常谈的面向对象思想:把变化的部分(业务处理)交给回调,把不变的部分(协议解析、链路管理)收敛在框架类里。不用整什么花哨设计模式,一个SseClient+ 事件监听器接口就够。

3.2 核心设计:连接管理、事件回调、自动重连

一个实用的封装类,我建议至少包含这几个能力:

  • 异步启动:start()发起连接并开启后台读取循环,不要阻塞调用线程。
  • 事件监听:onEvent(String eventName, String data)回调,或者更简单点,Consumer<String>直接接解析后的 JSON。
  • 断线重连:网络异常或 5xx 错误后按指数退避重试,4xx 不重试(协议性错误,重试必败)。
  • 手动停止:close()必须幂等,能关闭连接、停掉读取线程、释放底层资源。

骨架代码大概长这样:

public class SseStreamClient { private final OkHttpClient httpClient; private final Request request; private final Consumer<String> onData; private final Consumer<Throwable> onError; private final long reconnectBaseDelayMs; private volatile boolean running; private volatile boolean closed; public void start() { running = true; Thread.ofVirtual().name("sse-client-").start(this::runLoop); } private void runLoop() { long attempt = 0; while (running) { try { connectOnce(); attempt = 0; // 连接成功则重置重试计数 } catch (Throwable t) { if (closed) break; onError.accept(t); if (shouldRetry(t)) { sleepWithBackoff(attempt++); } else { break; } } } } private void connectOnce() throws IOException { try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException("HTTP " + response.code()); } BufferedReader reader = new BufferedReader( new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8)); String line; StringBuilder eventData = new StringBuilder(); while (running && (line = reader.readLine()) != null) { if (line.isEmpty()) { if (eventData.length() > 0) { onData.accept(eventData.toString()); eventData.setLength(0); } } else if (line.startsWith("data:")) { if (eventData.length() > 0) { eventData.append('\n'); } eventData.append(line.substring(5).trim()); } // 其余字段按实际需求处理 } } } private boolean shouldRetry(Throwable t) { // 4xx 不重试,超时、连接重置等重试 return !(t instanceof IOException) || !isHttp4xx(t); } private void sleepWithBackoff(long attempt) { long delay = Math.min(reconnectBaseDelayMs * (1L << Math.min(attempt, 6)), 30000); // 加随机抖动,避免所有连接同时重连 delay += ThreadLocalRandom.current().nextLong(1000); try { Thread.sleep(delay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }

这里有两个容易忽视的设计点。

第一个是重试策略。不要所有异常都无限重试——4xx 说明请求本身有问题,重试只是浪费资源;5xx 和网络异常可以重试。重试间隔要有指数退避,并且要加随机抖动。不加抖动的后果是:下游服务抖动恢复后,所有断开的客户端会像约好了一样同时扑上来,把服务再压垮一次。

第二个是回调线程模型。注意上面这个设计是在读取循环所在线程里直接执行onData.accept(...)。如果业务回调里写了耗时的处理逻辑(比如访问数据库、调用其它接口),整条流的读取会被卡住。一个事件卡住,服务端还在继续推,数据积累在 TCP 缓冲区里,等到回调恢复,一次性涌来一大堆。这就是典型的背压问题。初版封装想省事可以把回调放同步执行,但要给使用者明确约定:不要在回调里做重活。后面引入虚拟线程时,这个约束会被重新审视。

3.3 集成 Spring 生态:SseEmitter 与 WebClient 的取舍

如果你的服务端也打算用 SSE 把内部模型的流式结果转发给前端,Spring 的SseEmitter是最高效的选择。Controller 里先创建一个SseEmitter返回给 Spring,然后在另一个线程里往 emitter 里send()数据。Spring 框架负责把数据按 SSE 协议写回客户端、处理连接断开回调。

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(@RequestParam String question) { SseEmitter emitter = new SseEmitter(0L); // 0 表示不超时 // 提交给虚拟线程执行器,避免占用请求线程 virtualThreadExecutor.execute(() -> { try { modelClient.streamChat(question, chunk -> { emitter.send(SseEmitter.event().data(chunk)); }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }

客户端消费方面,Spring WebClient 也内置了对 SSE 的支持,用retrieve().bodyToFlux(ServerSentEvent.class)就能拿到事件流,并天然支持背压。不过实战中我发现 WebClient 的 SSE 解耦比较学院派,自定义事件的解析、断线重连逻辑还是要自己包一层,最后我会用它还是自研封装完全取决于团队已有依赖。

坦白讲,自己封装和引入现成机制之间没有绝对优劣。我的建议是:项目里如果已有 WebClient 依赖,优先用它;如果团队对响应式编程不熟,自研一个小而美的SseStreamClient反而更可控——这就是我们在项目里的最终选择。

4. 虚拟线程:当 SSE 长连接数量破千时,线程模型该怎么换

4.1 为什么 Tomcat 的两百个线程挡不住两千个 SSE 连接

SSE 方案上线后,前期一切正常。连接数从几十涨到几百,某天压测到上千并发时,服务端响应开始全面劣化——不只是 SSE 接口,连普通的查询接口都变慢了。现象是线程池被打满,看线程 dump,几乎全是阻塞在 SSE 连接的写操作上。

这里得算一笔账。Tomcat 默认maxThreads是 200,也就是说同时最多处理 200 个请求。普通的 HTTP 接口"占用线程"的时间是请求到响应返回那几十毫秒;但 SSE 接口不同,连接建立后线程就一直挂着,等到下一个事件再写数据,两个事件之间可能隔十几秒。这期间线程在干嘛?阻塞等待,什么都没干。一个 SSE 连接占 1 个线程,200 个连接就把线程池吃光了。更别说还有别的接口在同一池子里抢线程。

有人会说:把 maxThreads 调大不就行了?调成 5000 不就解决了?不行。平台线程(这里指操作系统线程)的资源成本非常高——每个线程默认栈大小 1MB,5000 个线程光栈就是 5GB 内存;而且线程调度要陷入内核,上下文切换带来显著 CPU 开销。把线程池盲目调大,是从一个坑跳进另一个坑的经典招数。

4.2 虚拟线程的原理:阻塞即让出,而不是靠异步

JDK 21 的虚拟线程(Project Loom)解决了这个矛盾。要理解它为什么适合 SSE,得先搞明白它和平台线程的核心差异。

平台线程是操作系统线程,一个阻塞调用(比如socket.read())会把线程真的挂起,操作系统调度器切换走。虚拟线程是 JVM 层面的轻量线程,跑在数量有限的载体线程(也就是平台线程)上。关键行为在于:当虚拟线程内部执行到阻塞 I/O 操作时,JVM 检测到这个阻塞,会把这个虚拟线程挂起,把载体线程腾出来让给其它可运行的虚拟线程。你写的代码是同步阻塞式,但底层被"自动改造成了非阻塞调度"。

打个比方:平台线程是请来的一位正式员工,等他一句话不说到处瞎转时你也得给他发工资、占着工位;虚拟线程是共享工位上的灵活用工,他干等时工位立刻给下一个人用,人本身还能不断招。

对 SSE 场景,这意味着什么呢?过去我们需要为每个连接分配一个平台线程,或者用响应式编程拆成事件回调来减少线程占用。现在只需要给每个连接分配一个虚拟线程,它阻塞在读流上,释放了执行载体,一个平台线程可以支撑成百上千个虚拟线程。线程不再是连接数的瓶颈,连接数只受内存和文件描述符限制。

启用虚拟线程很轻量。JDK 21 下直接用Thread.ofVirtual()就能创建:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Spring Boot 3.2 自带了虚拟线程开关,打开后 Tomcat 的请求处理线程会切换为虚拟线程,非常适合 SSE 服务端大量长连接挂载的场景:

spring.threads.virtual.enabled=true

客户端那侧同样受益。回到第三部分的SseStreamClient,我可以放心地把每个连接交给一个虚拟线程去阻塞读,不再担心线程池耗尽。代码几乎不变,把启动线程的方式从普通new Thread()换成虚拟线程即可。

4.3 铺开后我们实测的指标与需要注意的坑

切到虚拟线程后,我们做了对照压测。机器是 4 核 8G,Tomcat 平台线程池模式:同时挂 500 个 SSE 连接,线程池开始紧张,其它接口延迟明显上升;换虚拟线程后,挂到 5000 个连接,CPU 和内存没有明显变化,普通接口延迟维持在个位数毫秒级,压测到 10000 个时才看到内存因为连接缓存开始缓慢增长。这个结果和 Loom 的预期一致——IO 密集场景,虚拟线程收益巨大。

但在写进生产之前,有几个坑必须提前踩明白:

  • 虚拟线程不治 CPU 密集。虚拟线程的优势只在阻塞时体现,如果事件回调里有大段循环计算、加解密、JSON 序列化等 CPU 密集操作,它占住的载体线程不会自动让出,多个虚拟线程轮流抢占载体线程反而可能增加调度开销。所以封装层里要明确划分:阻塞 I/O 用虚拟线程,CPU 计算挪到独立线程池或保持轻量。

  • synchronized 的 pinning 限制。JDK 21 默认虚拟线程在持有synchronized锁时调用阻塞操作,会 pin 住底层载体线程(因为 synchronized 是偏向锁/JNI 相关机制,无法在阻塞点安全释放)。项目里大量用 synchronized 的话,虚拟线程收益会被削弱。规避办法:尽量用ReentrantLock替代,或升级到 JDK 24 之后的版本(那个版本修了 pinning 问题)。

  • ThreadLocal 要克制。平台线程池里线程数量有限,ThreadLocal 缓存一些重对象是常见优化;虚拟线程是海量的,每个线程挂一个 ThreadLocal 对象,积少成多也是内存压力。需要线程级缓存时先想一想:是不是换成连接级全局缓存就够了。

  • 依赖和运行版本要同步。虚拟线程从 JDK 21 才成为正式特性,如果线上基础镜像还停在 JDK 17,或者用了不支持虚拟线程的执行器配合,代码会编译不过或运行时版本报错。团队做这个切换前,先统一升级基础环境,不要只升编译器版本就上生产。

5. 生产环境中的真实故障:代理缓冲、超时配置与消息解析兜底

5.1 Nginx 缓冲把流式响应吞成了批处理

线上跑了一段时间后,前端同事反馈了一个诡异的 bug:模型输出有时候是"一字一字"出来的,有时候却变成"等 20 秒一次性弹出一大段"。服务端日志显示数据一直有在推,前端却像是"攒够了才收到"。

排查到最后,根因在 Nginx。前端和后端之间隔了一层 Nginx 做反代,Nginx 默认proxy_buffering是开启的——上游响应先被 Nginx 缓冲,缓冲器满或上游关闭连接后才转发给客户端。这是为了后端小响应的合并优化设计的,但用在 SSE 流式场景就是灾难:每个 chunk 被 Nginx 攒着,等攒够一个包才转发,流式效果自然就没了。

解决办法是必须在 Nginx 层面关掉对这个接口的缓冲:

location /chat/stream { proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_set_header Connection ''; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Accel-Buffering no; }

后端代码层面也应显式告诉中间代理这个响应不要缓冲,加一个响应头即可:

response.setHeader("X-Accel-Buffering", "no");

压缩也是一个隐形杀手。如果 Nginx 对这个 location 开了gzip on,流式数据会被压缩模块攒包等待压缩收益最大化,同样会破坏流式节奏。SSE 接口建议明确关掉 gzip,或用gzip_types白名单方式不匹配text/event-stream。

5.2 服务端心跳、客户端空闲超时与链路代理的配合

超时是 SSE 场景里最容易被"层层叠加坑"的配置,我梳理一下各层的关注点:

  • 客户端读超时:这是连接"多久没读到数据就断开"的阈值。普通 HTTP 建议 30 秒,SSE 不能这么设,你无法保证服务端在 30 秒内必然有事件。一般设为 120 秒以上,前提是服务端心跳间隔小于这个值。
  • 服务端心跳:AI 推理过程中 token 生成间隔无法保证,服务端应主动每 15 秒写一个注释行: keep-alive。这样即使没有业务数据,链路上也有字节流动,中间代理不会因为空闲断开。
  • 代理层的 idle timeout:云 LB 和 Nginx 都有自己的空闲超时,通常比客户端读超时小。只要客户端读超时 > 心跳间隔 + 代理空闲超时的余量,链路基本能保持。

一张表把口径列清楚:

配置项建议值说明
客户端 connectTimeout5-10 秒建连失败快速失败
客户端 readTimeout120 秒必须大于心跳间隔
服务端心跳间隔15 秒用注释行实现
Nginx proxy_read_timeout3600 秒兜底,常驻连接
云 LB 空闲超时按需调大或降级为无空闲
Todoist 事件解析不设超时只在业务层做取消

还有一点细节:注释行到达客户端时,解析器要正确处理且不能把它当业务事件触发回调,但它确实会重置底层 socket 的"最后一次读数据时间",所以对于链路存活而言它是有效的。

5.3 解析层兜底:非标准格式服务器的兼容策略

网上那些大模型服务,SSE 实现并不都严格按规范来。我遇到过至少三种非标准行为:

第一种是"裸 JSON 流":服务端不写data:前缀,直接发{"content":"你"}\n\n。我们的解析器在line.startsWith("data:")判断处把它丢弃了,结果前端一片空白。兜底方案是:如果当前行既不是注释、也不是data:、也不是空行,则按data内容处理。这样裸 JSON 也能兼容。

第二种是"多行 JSON 被拆开":一个完整事件被服务端拆成多行data:,且每行内容本身还可能是半个 JSON 字符串(可能是网关做了 chunked 转发)。解析时不能简单把每行独立解析成一个事件,而是要按空行分块,先把一个事件块内所有data:行合并成完整字符串再交给 JSON 解析器。上面封装代码里我已经用了这个策略。

第三种是"粘包":多个事件挤在一行里(比如服务端一次性 write 了一大批字节,中间没按空行整齐分隔)。按行读取时不会遇到这个问题,因为BufferedReader.readLine()是按换行切的;但如果直接把字节流按 HTTP chunk 分块处理,就可能出现一个 chunk 里包含多个事件。标准做法始终是"按换行分词 + 按空行定界",不要直接信任网络包边界。

解析兜底的思路是:先严格按 SSE 解析,命中失败再降级尝试裸 JSON/手动拆包。这个降级逻辑千万别一上来就全打开,否则会把标准服务端的数据搞乱。

5.4 日志与可观测性:SSE 调试需要什么

SSE 调试和普通接口完全不是一回事,最大的区别在于:普通接口是一次性的,日志打印完整请求响应很自然;SSE 是长会话,全量打日志会爆炸——每个 token 都打一条,一分钟几百条,磁盘和日志系统都扛不住。

我的经验是只打这几类关键指标:

  • 连接建立时间、首包延迟(首 token 出来前的等待时长,这个指标很能反映模型服务的冷启动情况)。
  • 事件间隔最大值和平均值(发现间隔超过设置的超时时间,就是心跳或超时配置要出问题了)。
  • 断线次数、重连次数、重连耗时(这个数据能直接反映链路稳定性)。
  • 事件总数和总字节数(用于计费和容量评估,不适合打全量内容)。

本地调试用curl -N是最直观的,-N参数禁止 curl 缓冲输出:

curl -N -H "Accept: text/event-stream" \ -H "Authorization: Bearer $TOKEN" \ -X POST https://api.example.com/v1/chat/stream \ -d '{"question":"你好"}'

看到一行一行data:冒出来,就说明链路从服务端到 curl 是通的。如果这步不通,那是后端问题;如果 curl 通但浏览器 EventSource 不通,再去排查跨域、浏览器并发数限制、前端解析代码。一张排查顺序表比一上来就翻代码有用得多:链路方向永远优先于业务逻辑方向。

浏览器端如果要深层调试,Chrome DevTools 的 Network 面板里可以直接看到event-stream类型的请求,Response 标签会实时展示收到的事件流,不用开 WebSocket 面板。顺着这个视图能看到前端实际收到了什么,再和 curl 收到的对比,很快能定位是"没推到浏览器"还是"推到但没渲染"。


这个方案从"手写读取器"到"封装好重连和回调"再到"换上虚拟线程扛住万级连接",走完三个阶段后,反而觉得 SSE 本身并不神秘——它就是一个能让你按行读文本的 HTTP 连接。真正费劲的全在链路和边界:代理缓冲、超时口径、断线重连、解析兜底。如果你也在做类似的 AI 应用接入,我的建议是不必一上来就用虚拟线程,先把显式调用和封装跑明白,等连接数真的扛不住时,再切虚拟线程会非常顺滑——JDK 21 的开关就在那等着,一行配置的事。最后提醒一句我自己踩过最深的一个坑:SSE 最坑的不是协议本身,而是链路里每一个不流式的中间件;排查问题时,从最外层代理往内层一层层排除,比盯着解析代码死磕有效得多。

返回列表