系统学习agent系列之SSE服务端
导语:在上一篇文章中,我们彻底搞懂了 SSE(Server-Sent Events)协议的原理。但在实际开发中,前端不可能直接拿着 API Key 去连 OpenAI 或百度的服务器。我们需要在中间加一层“二传手”——Spring Boot 服务端。
怎么用 Spring Boot 优雅地实现流式转发?为什么 SseEmitter 不会阻塞 Tomcat 线程?生产环境又有哪些致命的坑(比如 Nginx 缓冲)?
今天,我们直接上干货,手把手教你搭建企业级的 SSE 服务端。
一、 灵魂拷问:为什么不能让前端直连大模型?
很多小白为了图省事,前端拿到 API Key 后直接发请求给大模型 API,这是极其危险且不专业的做法,原因有四:
破局之道:后端创建一个 SseEmitter 对象,把请求“挂起”后立刻 return。Controller 线程瞬间释放回线程池去处理其他请求,真正的推流工作交给后台异步线程去干。
二、 揭秘 Spring Boot SseEmitter 核心机制
SseEmitter 是 Spring MVC 提供的异步响应工具。它最大的特点是:除非你主动调用 emitter.complete(),否则这个 HTTP 连接永远不断开。
它的线程模型是怎样的?
这是面试常考、实战必知的核心:
- 接收 HTTP 请求。
- new SseEmitter() 创建对象。
- 把 Emitter 丢给异步任务(比如丢给自定义的线程池)。
- 直接返回 Emitter 对象,此时 Tomcat 线程任务结束,归位!
- 拿着 Emitter 对象去调用大模型的流式 API。
- 在回调里,每收到一个字,就调用 emitter.send(数据) 转发给前端。
- 大模型说完,调用 emitter.complete() 关闭连接。
三、 完整链路:从大模型到前端的“搬运工”
整个数据流转链路如下:
前端发起请求
↓
Controller 创建 SseEmitter,启动异步线程,立即 Return
↓
异步线程调用大模型 API(使用 OkHttp 等客户端)
↓
大模型返回数据流,逐行读取 data: 行
↓
解析出 delta.content(真正的内容)
↓
通过 emitter.send() 推给前端
↓
收到大模型的 [DONE] 标记 → emitter.complete() 关闭连接
💻 Java 核心代码骨架(伪代码)
@GetMapping("/chat")
public SseEmitter chat(String question) {
// 1. 设置超时时间(例如3分钟),防止连接永远挂着
SseEmitter emitter = new SseEmitter(180000L);
// 2. 丢给线程池执行,不阻塞主线程
threadPool.execute(() -> {
try {
// 3. 调用大模型流式接口(大模型的回调里)
llmClient.streamChat(question, new Callback() {
public void onToken(String token) {
// 4. 核心:把字发给前端
emitter.send(SseEmitter.event().data(token));
}
public void onComplete() {
emitter.complete(); // 结束
}
public void onError(Exception e) {
emitter.completeWithError(e); // 报错结束
}
});
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
四、 生产环境的终极避坑指南(极度重要)
在本地跑通很容易,但上生产环境,你会遇到一堆诡异的问题。
1. 超时设置(两道关卡)
- SseEmitter 的超时:如果设得太短,模型还没生成完就断了;设得太长,空闲连接会占满服务器资源。
- HTTP 客户端(如 OkHttp)的 readTimeout:大模型如果 30 秒不吐字(可能卡住了),你的客户端要主动断开,否则会引发内存泄漏。
2. 心跳保活(防中间件杀连接)
由于 Nginx 或网关有默认的 proxy_read_timeout(通常 60s),如果大模型半天不吐字,网关会认为连接死了直接掐断。
- 解法:后端开启一个定时任务,每 15 秒调用一次 emitter.send(SseEmitter.event().comment("heartbeat")) 给前端发个空注释。
3. 客户端断连检测
用户在网页上点了“停止生成”,或者直接关了浏览器,你的后台可能还在傻傻地接收大模型的数据并 send()。
- 解法:监听 Emitter 的生命周期。
emitter.onCompletion(() -> { /* 释放资源,停止调用大模型 */ });
emitter.onTimeout(() -> { /* 超时清理 */ });
emitter.onError((e) -> { /* 报错清理 */ });
4. 🚨 Nginx 反向代理配置(必改)
如果你用了 Nginx,默认的缓冲机制会导致 SSE 失效。Nginx 会把后端发来的流攒满一个 Buffer 再发给前端,导致前端感觉是一卡一卡地出字。
必须在 Nginx 配置里关闭缓冲:
location /api/chat {
proxy_pass http://backend_server;
proxy_http_version 1.1;
proxy_set_header Connection '';
# 核心:关闭 Nginx 缓存
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}
5. 负载均衡与无状态设计
如果你的后端部署了多台机器(如 K8s 集群)。用户第一次请求打到了机器 A,建立长连接。由于长连接可能持续几分钟,期间如果网络波动重连,打到了机器 B,上下文就丢了。
- 解法:使用Sticky Session(会话粘滞)(如 Nginx 的 ip_hash),或者保证你的后端是无状态的(把会话历史存 Redis,哪台机器接都能续上)。
五、 前端怎么接?(EventSource vs Fetch)
1. 原生 EventSource (简单但有局限)
const es = new EventSource('/api/chat?question=你好');
es.onmessage = (e) => { console.log(e.data); };
- 致命缺点:只支持 GET 请求,不能传复杂参数,不能自定义 Header(比如无法传 Token)。
2. fetch API + ReadableStream (现代主流方案)
fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer xxx' },
body: JSON.stringify({ question: '你好' })
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
// 循环读取流数据…
});
- 优点:支持 POST,完全自定义 Header,目前绝大多数 AI 应用的打字机效果都是这么实现的。
总结
要做一个好用的 AI 聊天应用,SSE 服务端不仅是“二传手”,更是保证高并发、防资源泄漏、防网关掐断的“大管家”。
把 SseEmitter 的线程模型搞懂,把 Nginx 缓冲关掉,把心跳和清理回调加上,你的 SSE 服务端就能稳如老狗了。
如果觉得这篇干货对你有帮助,求个免费的赞和收藏!我们下期见!



