1. AI 对话体验的隐形瓶颈首 token 等待时间做 AI 对话功能之前我一直觉得体验优化的重点是模型本身——选个聪明的模型Prompt 写得好一点回答质量高了用户自然满意。直到我们第一版 AI 助手上线用户的原话是点完发送要干等七八秒这个 AI 是哑巴吗我才意识到对于大模型交互真正影响用户体感的不是模型质量而是从用户发出 prompt 到屏幕上开始出现文字的那段空白。这段空白在流式领域叫 TTFTTime To First Token首个 token 延迟用户能感知到的快往往不是完成得有多快而是开始得有多快。ChatGPT 之所以让人觉得流畅靠的也不是那个模型比开源模型聪明多少而是它做到了首 token 秒回、后续逐字播放。这个打字机效应会极大改变用户对系统速度的判断哪怕整体完成时间一样流畅输出比一次性返回在心理上快好几倍。为什么普通的 HTTP 接口做不到这件事要从大模型的生成机制说起。像 GPT 这类自回归模型生成文本是一枚枚 token 顺序解码的实际生成 1 个 token 大约需要 20-50 毫秒取决于模型大小和硬件一句 500 token 的回答光生成就要 10-25 秒。如果后端用传统的 Request-Response 模式等模型全部生成完再一次性把响应交给前端用户看到的就是长达十几秒钟的假死。而且在这段时间里HTTP 连接一直被占用客户端也不知道服务端到底是在思考还是已经挂了超时重试、用户狂点发送按钮、重复提交全都是在这种等待里滋生出来的。有的团队会想在服务端把生成的 token 攒成几段分两三次返回算是一种缓解但配合真正的流式输出效果完全不同。流式输出要求 HTTP 响应从一开始就开闸让 token 一个个或一小片一小片地涌出来前端拿到一个就渲染一个。这才是我们要做 SSE 的根本原因。2. 实现方案对比为什么大模型应用都选了 SSE2.1 WebSocket、长轮询和 SSE 的取舍逻辑要达到服务端逐步推送数据的效果业界有三条路短轮询/长轮询、WebSocket、SSEServer-Sent Events。很多第一次做 AI 应用的工程师会下意识选 WebSocket——毕竟听上去推送就应该是 WebSocket 的活。但实际对比之后你会发现大模型场景里 SSE 几乎是不可替代的最优解。轮询是最容易想到的方案前端每隔 2 秒问一次服务端生成了没有。问题是它天生有延迟而且用大量无效请求交换真实数据本来高并发就已经够呛再让服务器每秒处理几万次无意义的检查请求纯粹是雪上加霜。长轮询long polling改良了等待策略但复杂度上升也还保留着断连重连的种种边界问题。WebSocket是全双工、低延迟能力很强但它的代价是一整套独立协议需要处理帧、分片、心跳、粘包/拆包服务端要维护连接状态nginx、网关、负载均衡器要单独配置 upgrade 规则运维和排错成本都比 HTTP 高一个量级。对于用户发一段文字AI 连续回复一堆文字这种典型的单向数据流用 WebSocket 属于杀鸡用了牛刀——双向能力几乎用不上还要承担额外的复杂度。SSE本质上是 HTTP 协议上的单工服务端推送服务端往客户端单向吐数据。它有几个关键优势基于 HTTP不需要特殊协议解析浏览器原生支持 EventSource自带断线重连可以穿透现有的 nginx、负载均衡、网关体系文本协议肉眼可读排错方便。这也是 OpenAI、Anthropic 这些一线大模型服务商把流式响应做成 text/event-stream 的原因——整个行业已经默认这是大模型应用的标准协议。2.2 SSE 协议格式一条流式响应的本质SSE 的线上格式非常有规律每条消息由若干字段行组成以空行结尾。字段有四种data:消息内容可以连续多行解析时会被合并成一条event:事件类型前端根据它决定回调哪个监听器id:事件 ID配合浏览器自动重连时用于断点续传retry:重连间隔毫秒。此外还有一种注释行以冒号开头比如: ping它不会触发任何前端事件但可以维持连接活跃——这个在抗空闲超时的时候特别有用后面会细说。方案通信方向协议复杂度自动重连浏览器原生支持典型场景轮询双向请求式低无每次请求独立有低频检查状态WebSocket双向全双工高需自研心跳与重连有实时 IM、协作编辑SSE单向服务端到客户端低内置EventSource有AI 流式输出、实时行情、日志流需要特别提醒的一点浏览器 EventSource 只能发 GET 请求默认不能带 Authorization 头也不能中止后通知服务端取消生成。因此在实际的大模型项目里我更推荐用 fetch 发送 POST 来消费 SSE 流这属于前端的选型细节到第 4 节展开讲。3. 服务端实现用 Spring AI SseEmitter 把 token 流送出去3.1 Spring AI 里怎么拿到模型的增量 token做 Java 后端的同学比较幸运Spring AI 已经把各大模型厂商OpenAI、通义、DeepSeek、Ollama、Moonshot 等统一封装成了一层抽象。接入流式对话主要用 ChatClient 的 stream 能力它返回的是 Flux 或者其他响应式类型每个元素代表模型生成的一个增量片段delta。先说工程搭建。一个最小可用的 Spring Boot 3 项目引入 Spring AI 对应厂商的 starter 即可以 OpenAI 协议为例dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency配置里指定 API Key、模型名和基础地址如果是兼容 OpenAI 协议的国内模型base-url 填对应厂商的网关地址spring: ai: openai: api-key: ${API_KEY} base-url: https://api.example.com/v1 chat: options: model: gpt-4o-mini temperature: 0.7 stream: true3.2 用 SseEmitter 异步推送增量内容在 Spring MVC 的 Web 应用里最直观的流式接口写法是返回 SseEmitter。核心思路是Controller 方法先把 SseEmitter 返回给容器Servlet 线程立刻被释放模型生成的异步任务在另一个线程里执行通过 emitter.send(...) 向客户端逐段写数据写完了调用 complete() 完成响应。RestController RequestMapping(/chat) public class ChatController { private final ChatClient chatClient; private final ExecutorService aiExecutor; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); this.aiExecutor Executors.newVirtualThreadPerTaskExecutor(); } GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestParam String prompt) { SseEmitter emitter new SseEmitter(300_000L); aiExecutor.execute(() - { try { chatClient.stream(prompt) .doOnNext(token - { // token 是模型返回的增量文本片段 emitter.send(SseEmitter.event() .name(message) .data(Map.of(delta, token))); }) .doOnComplete(() - { emitter.send(SseEmitter.event() .name(done) .data()); emitter.complete(); }) .doOnError(error - { emitter.completeWithError(error); }) .subscribe(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }这段代码里有几个细节是高并发场景不能忽略的必须显式设置produces MediaType.TEXT_EVENT_STREAM_VALUE。如果不指定Spring 默认返回 JSON浏览器即使加了 EventSource 也识别不了流。SseEmitter 的构造超时参数要调大。默认值很短不同 Spring 版本差异很大有的甚至只有 30 秒模型生成一旦超过这个时间容器会自动把连接断开前端就报错。我一般给 300 秒再配合心跳保活。一定要用独立线程池去执行流式消费。如果直接在 Controller 方法里同步循环 send一个恶意慢请求就把 Tomcat 的一个工作线程占死。高并发下这等于自杀。上面的代码用虚拟线程Java 21执行异步任务每个连接一个虚拟线程成本极低。如果没有虚拟线程用带名字的固定线程池也可以但核心线程数和队列大小要专门调优。结束回调不能只做 emitter.complete()。当客户端主动断连用户点了停止或者关了页面emitter.send()会抛 IOException此时必须捕获异常并调用completeWithError()做清理同时调用大模型厂商的中断接口停止生成——否则模型还会继续把剩余的 token 算完浪费 API 计费。3.3 另一种写法Spring WebFlux 的 Flux 直出如果你的服务端直接使用 WebFlux不需要 SseEmitter直接返回 Flux 流即可Spring 会把它自动渲染成 text/event-streamGetMapping(value /stream/flux, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventMapString, String streamFlux(RequestParam String prompt) { return chatClient.stream(prompt) .map(token - ServerSentEvent.MapString, Stringbuilder() .event(message) .data(Map.of(delta, token)) .build()) .concatWith(Flux.just( ServerSentEvent.MapString, Stringbuilder() .event(done) .data(Map.of()) .build())); }WebFlux 是响应式栈天然非阻塞连接和线程的占用比 Servlet 更省但学习门槛高一些而且如果项目里还有大量普通 REST 接口混用两个 Web 框架也会带来运维心智负担。我个人的建议是已有 Spring MVC 项目的用 SseEmitter 就足够稳定不要为了追新而硬切 WebFlux。3.4 flush 的隐性坑一个很隐蔽的问题是SseEmitter 的 send() 方法内部经过了 HttpMessageConverter 的转换但底层输出流的 flush 行为并不总是按每次 send 都执行。某些版本下会出现token 已经生成完毕了前端才一次性收到一整段的攒批现象流式体验荡然无存。排查时先用浏览器 Network 面板看响应如果数据是一件一件地冒出来但前端渲染是一整段出现问题多半在前端解析如果连 Network 面板里都是一次性出现一大块数据问题就在服务端没有正确 flush。针对 SseEmitter 的写法可以通过注入原生 HttpServletResponse在每次 send 之后手动调用response.getOutputStream().flush()把控制权牢牢握在自己手里。实际踩过坑之后我比较推荐这种方式。4. 前端落地React 里用 fetch 接管 SSE实现逐字渲染4.1 为什么不用 EventSource浏览器原生提供EventSource接口使用起来一行代码const es new EventSource(/chat/stream?promptxxx); es.onmessage (e) setAnswer(e.data);但它有三个致命短板导致大模型实战中我几乎不用它EventSource 只支持 GET。AI 对话的 prompt 往往很长塞在 URL query 里既容易触发网关上限也总感觉不太正经而 POST 传业务参数才是符合习惯的。不能自定义请求头。如果接口鉴权靠 Authorization 头绝大多数团队都会这么做EventSource 就会非常尴尬——你只能把 token 放 URL既容易被日志泄露也绕不开签名校验。无法真正中止服务端的生成。EventSource.close() 只断开了浏览器这一侧的接收TCP 连接不一定立刻释放服务端如果感知不到断连模型会继续生成和计费。因此我推荐用fetchReadableStream的方式手动消费 SSE 协议流。它保留了 POST、自定义 Header、AbortController 三大能力代价是解析 SSE 的代码要自己写其实也就几十行。4.2 可复用的流式解析代码async function streamChat(prompt, { onDelta, onDone, onError, signal }) { const response await fetch(/chat/stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ prompt }), signal }); if (!response.ok) { throw new Error(HTTP ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE 消息以空行分隔 const messages buffer.split(\n\n); buffer messages.pop(); for (const msg of messages) { const lines msg.split(\n); const dataLines lines .filter(line line.startsWith(data:)) .map(line line.slice(5).trim()); if (dataLines.length 0) continue; const data dataLines.join(\n); if (data [DONE]) { onDone?.(); continue; } try { const parsed JSON.parse(data); onDelta?.(parsed.delta || ); } catch { onDelta?.(data); } } } }这段代码的要点decoder.decode(value, { stream: true })必须带stream: true否则中文字符多字节时可能被截断在边界出现乱码。按\n\n拆分消息时最后一段可能是不完整的半条消息要保留在 buffer 里等待下次读取拼接。data:前缀后面从一个空格开始取内容多行 data 用换行拼接符合 SSE 协议规范。很多大模型服务在流结束时发送一个特殊的[DONE]标记要单独判断。如果你的网关做了 gzip 压缩读取出来的字节流还需要额外处理解压这会给流式增加复杂度。所以服务端和 nginx 都要对 SSE 接口禁用压缩通常的做法是SSE 路径单独配置gzip off。4.3 React 渲染的细节与停止按钮拿到 delta 文本之后React 组件的更新很简单用 useState 累积字符串即可const [answer, setAnswer] useState(); const [streaming, setStreaming] useState(false); const handleSend async () { setAnswer(); setStreaming(true); const controller new AbortController(); abortRef.current controller; try { await streamChat(prompt, { onDelta: (text) { setAnswer(prev prev text); }, onDone: () setStreaming(false) }); } catch (e) { // AbortError 是用户主动停止不算异常 if (e.name ! AbortError) { onError(e); } } finally { setStreaming(false); } }; const handleStop () { abortRef.current?.abort(); };这里有个 React 18 之后容易被忽略的注意点连续多次 setAnswer 在 React 内部是异步批处理的如果 token 以极高频次到达比如本地模型每 10 毫秒吐一个 token页面会出现明显的渲染延迟或者掉帧。我实测下来远程模型按每秒 30-50 个 token 的速度推送直接 setState 是够用的但如果做本地 7B 模型部署、token 来得特别快可以考虑用flushSync强制同步刷新或者用useReducerrequestAnimationFrame把 token 积攒成帧再渲染。这两种方案性能更高但代码复杂度也会上升。光标闪烁动画是流式输出的点睛之笔。用 CSS 就能实现.streaming-cursor { display: inline-block; width: 8px; height: 1em; background: #333; vertical-align: text-bottom; animation: blink 1s step-end infinite; } keyframes blink { 50% { opacity: 0; } }渲染的时候{streaming span classNamestreaming-cursor /}5. 高并发场景下的四个瓶颈与对应优化策略5.1 第一个瓶颈TCP 连接数与系统文件描述符SSE 是长连接每个正在对话的用户会独占一条 TCP 连接而且这条连接会从用户发问一直保持到回答结束通常是 30 秒到 2 分钟。换算下来如果你的平台同时有 1000 个用户正在向 AI 提问那就意味着有 1000 条并发长连接挂在服务端和 nginx 上。这跟普通 REST 接口完全不是一个量级——普通接口请求毫秒级结束连接数统计意义不大而 SSE 的活跃连接数几乎等于同时对话的用户数必须提前算好容量。操作系统对单进程能打开的文件描述符数量是有限制的Linux 默认 ulimit 是 1024意味着服务进程同时保持的连接超过 1024 个时新连接就会失败。优化策略调整系统最大文件描述符ulimit -n 65535并在 systemd 服务里同步设置LimitNOFILE65535nginx 的worker_connections默认 1024要调整到 65535events { worker_connections 65535; }如果机器内存充足没必要刻意压连接数——每个空闲 SSE 连接在服务端的内存开销不过几 KB真正贵的是线程资源下一节讲。5.2 第二个瓶颈nginx 缓冲把流式变成了非流式这是流式上线最经典的故障应用直连测试一切正常但一接入 nginx 后前端死活不出字等待十几秒后一次性出现整段答案。原因是 nginx 默认开启了 proxy buffering它会等后端传完整个响应再一次性转发给客户端相当于把一个流式接口强制变成了普通接口。修复方法一句话location /chat/stream { proxy_pass http://ai-backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 600s; proxy_send_timeout 600s; 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_http_version 1.1和proxy_set_header Connection 是为了让 nginx 与后端之间复用 HTTP keep-alive 连接避免每个 SSE 后面都新拨一条 TCP否则高并发下后端的 TIME_WAIT 状连接会爆炸如果前端通过 EventSource 连接proxy_buffering同路径还要加上gzip off否则 gzip 会把流式内容压缩成块前端解析可能出问题。5.3 第三个瓶颈服务端线程被长连接耗尽Tomcat 默认工作线程数是 200。如果 Controller 里不用异步方案而是直接同步写输出流那么每个 SSE 连接会占住一个 Tomcat 线程直到流结束。算一下账200 个线程同时只能服务 200 个正在对话的用户——一旦超过所有接口不只 AI 接口都要排队服务的健康检查都可能超时整个应用就雪崩了。所以第 3 节的 SseEmitter 异步写法在这里是保命的关键容器线程只用来发起响应立刻释放真正的模型流消费在独立线程池里跑。在 Java 21 上我推荐虚拟线程每个连接一个虚拟线程成本极低10 万连接也不在话下。需要注意的是模型调用客户端比如 OpenAI SDK同样是阻塞 I/O它内部的线程池也会成为瓶颈要给调用链路的每个阶段都预留独立线程池并加上队列上限。线程池如果无限增长最终会拖垮 CPU 和内存必须对队列设定长度并配拒绝策略。5.4 第四个瓶颈超时、限流与降级高并发下最怕的不是流量大而是瞬间流量尖峰 慢模型 长连接三者的组合。应对手段无非三类超时控制。nginx 的proxy_read_timeout默认是 60 秒。如果模型生成一个冗长回答用了超过 60 秒nginx 会在中间把人家的连接掐断前端就会报类似 stream disconnected before completion: idle timeout waiting for sse 的错误。这个错误字面上是空闲超时意思是连接上超过 60 秒没有任何数据。解决办法有两个一个是把proxy_read_timeout调到 600 秒另一个更稳妥——服务端每隔 15 秒发一条 SSE 注释心跳: ping即使模型暂时没吐 token连接也有活跃数据所有层级的负载均衡器都不会判空闲。注意很多云厂商的 SLB 默认空闲超时 60 秒这个参数如果掌握在别人手里你就必须用心跳来自救。限流。SSE 长连接场景的限流策略跟普通接口不一样普通接口可以每秒限制请求数SSE 更应该限制的是同时活跃的连接数和每用户的并发会话数。我们当时的做法是用分布式信号量基于 Redis统计每个用户同时存在的对话流上限 2 路全局活跃连接数上限 5000超过的用户直接返回 429前端展示当前 AI 服务繁忙请稍后重试。重建连接的频率也必须限制——很多浏览器和客户端在断线后会疯狂自动重连如果没有令牌桶限流一轮瞬时断网就能把所有服务器连接打满。降级。大模型服务并非永远稳定。我们的降级链路是模型服务调用失败或超时后先返回一个兜底话术 熔断标记前端看到标记后切换到搜索模式按钮或者展示历史最佳答案连续失败 N 次直接熔断后续请求走普通 POST 一次性返回虽然体验略差但至少可用。熔断器我们用 Resilience4j熔断状态打点进监控看板恢复后自动放量验证。说白了流式是体验加分项绝不能让它成为稳定性减分项——在设计阶段就要想好退路。6. SSE 故障排查链路从现象一路查到根因SSE 的问题一旦出现症状往往对不上直觉。我整理了实战中遇到最多的几个问题形态、根因与修法方便大家复制排查思路。现象根因排查方法修复手段页面完全不出字但等完整结果出现nginx 或网关开了缓冲用 curl 直连应用端口对比关掉 proxy_buffering / SLB 缓冲回答生成一半断连报 idle timeout中间层的空闲超时太短看错误出现时间点是否固定如 60 秒调大超时 服务端发心跳大并发时其他 REST 接口全部变慢Tomcat 线程被 SSE 占用jstack 看线程栈数阻塞在写流的线程改用 SseEmitter/Flux 异步 独立线程池前端收到的是整段文本不是逐字流服务端没 flush 或 nginx 缓冲浏览器 Network 面板看响应节奏手动 flush关 buffering用户点了停止/关闭页面但 API 账单还在涨服务端没感知断连看模型端日志的 token 用量emitter 异常回调里调模型取消接口先讲一个我印象最深的翻车案例。当时线上集群接了 4 台应用和 2 台 nginx测试环境单机直连一切都好上到生产后用户普遍反馈回答答到一半突然停了。报错信息正是 stream disconnected before completion: idle timeout waiting for sse。我第一反应是模型生成超时但看模型日志发现生成已经完成了问题出在响应的尾部——生成完成之后客户端一直没有读到最后的 done 事件。逐步排查后定位链路是这样的先用 curl 在 nginx 后面模拟请求curl -N --no-buffer https://ai.example.com/chat/stream?prompthello复现了中断绕过 nginx直接用内网 IP 请求应用节点流正常结束——问题锁死在负载链路查看 nginx 配置发现proxy_read_timeout 60s而模型的完整回答恰好超过了 60 秒服务端又没有任何心跳机制对 nginx 来说这条连接闲着超过 60 秒直接被判定超时断开。修复方案是双管齐下nginx 把proxy_read_timeout改成 600s应用侧加了一个每 15 秒执行的定时任务向所有活跃的 SseEmitter 发送 SSE 注释: ping作为心跳。这样即使模型长时间思考没有输出链路也不会被误判为空闲。从那以后类似问题基本绝迹。排查 SSE 时一定要养成用 curl 做分端测试的习惯。curl 天然支持流式输出# 直连应用跳过所有中间层 curl -N --no-buffer http://127.0.0.1:8080/chat/stream?prompthello # 走 nginx curl -N --no-buffer https://gateway.example.com/chat/stream?prompthello # 走完整链路 curl -N --no-buffer https://example.com/chat/stream?prompthello三级请求分别做一次看在哪一级开始丢流问题范围立刻缩小。还有一个技巧在 nginx 的访问日志里查看 SSE 连接的状态码和流量字节数如果字节数一直在增长但前端没渲染是前端解析问题如果字节数和连接同时中断是超时问题如果字节数压根不涨是缓冲问题。日志不会骗人多花一分钟看日志比猜半天有效得多。最后分享一个压测心得。SSE 接口的压测不能像普通接口那样只关注 QPS/TPS要关注三个指标活跃连接数上限、TTFT 的 P95 分位数、连接断连率。我们当时用一台压测机发起了 1000 个并发 SSE 请求每个连接保持 45 秒重点观察服务端线程数、活跃连接数和内存曲线。压测之后又专门模拟了压测中途所有连接同时断开的场景验证了服务端能及时释放线程和模型调用资源。这种先看容量再看极限的顺序能让你在高并发场景下的流式体验优化始终有的放矢而不是临时抱佛脚。