尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
大模型流式对话实战:SSE协议原理与前后端实现
做过本地LLM对话系统的朋友应该都有这种感受模型推理其实已经开始了可网页上一片空白想点取消又怕断掉刷新又怕丢上下文。这一篇我不聊基础接口怎么调专门把“通用LLM流式对话”里最容易让人卡壳的部分讲透——前后端之间的数据通道怎么建、流式事件怎么解析、服务端怎么推、前端怎么接以及那些只有真跑起来才会碰到的坑。项目场景是用Spring AI搭一套通用对话机器人支持普通对话和流式输出两大核心功能采用前后端分离结构前端Vue/React任意后端统一封装EventStream接口。简单说看完你就能自己把一个大模型对话页面从“问完傻等”改成“字往外蹦”。1. 为什么流式输出是刚需不是花活1.1 LLM的“思考时间”和用户的耐心之间差一个流式接口大模型推理和普通API返回有一个本质区别计算本身是边生成边出结果的。一段300字的回答在GPU上可能要分几十个token逐步生成总耗时3到8秒。如果后端老老实实等全部token生成完再一次性返回那么从用户点击发送到看到第一个字中间这3到8秒就是纯空白。尤其是前端还做了一个“请求中”的loading动画用户会觉得系统卡死了轻则反复点击重则直接刷新页面。这根本不是模型能力问题是交互链路设计问题。流式对话解决的就是这个“首字延迟”的体感问题。后端每生成一小段内容就立刻推给前端用户看到的是文字一个词一个词地冒出来哪怕总耗时不变主观等待感会大幅下降。尤其长回答场景比如让它写代码、列方案流式输出能把“等待”变成“阅读”这个体验差别说一句天壤之别不过分。1.2 SSE、WebSocket、轮询我为什么选了SSE做前后端实时通信常见三条路轮询、WebSocket、SSEServer-Sent Events。轮询本质上是最容易想到的土办法前端每隔几百毫秒去请求一次“生成好了没有”。但它有两个硬伤一是无法精准知道何时结束容易多出很多无效请求二是轮询过程中真正的新内容只能靠后台临时缓存接口设计绕一圈才拿到增量数据代码又丑又难维护。我用过一次就放弃了只有在某些无法改动服务端的情况下才会考虑。WebSocket是双向连接功能更强但解决一个“服务器单向推文字”的问题有点杀鸡用牛刀。而且WebSocket的心跳、重连、消息分帧、鉴权都要自己处理中间还有一层反向代理的时候配置一不小心就断线。对纯文本流式返回这种场景WebSocket的“双向能力”基本用不上成本倒是实打实的。SSE是我最终采用的方案它本质还是HTTP请求服务端通过Content-Type: text/event-stream把多个事件持续推给前端。好处很明显有标准协议前端一个fetch或EventSource就能接天然支持断线重连和普通HTTP一样可以走Nginx、走网关不需要额外的长连接协议转换。唯一的限制是它是服务器到客户端的单向流但LLM流式对话恰好就是单向场景。所以我非常推荐先把SSE方案做扎实这基本就是当前前后端分离架构下做大模型对话的默认解法。1.3 整个项目的链路图先理干净再动手动手之前我建议先把整条链路在脑子里过一遍。一次流式对话的完整路径是用户在页面输入问题前端通过HTTP请求把问题发给后端接口后端拿到问题后调用大模型服务这个调用本身是流式的不断返回内容增量后端把增量转成SSE事件格式通过同一个HTTP连接的响应体写出去前端从response.body里逐段读取字节流解析出事件里的文本做增量渲染。项目采用前后端分离以后还有一个关键点后端对外暴露的接口不需要关心页面长什么样只需要保证“同一个会话的多次提问”能识别出来并且流的格式是固定的。我一般会在前端维护一个会话ID每次请求都带上后端用这个ID决定把哪些历史上下文拼进当前请求。这样即使前端刷新了页面后端也还能从存储里捞回上下文继续聊。2. 后端接入把“整个回答”拆成“一串事件”2.1 基于Spring AI的项目配置和核心依赖我当前这个项目是基于Spring AI搭的工程这也是目前Java生态里接入LLM最省事的框架。它把OpenAI、Ollama、通义、DeepSeek之类的模型接口统一抽象成ChatClient代码层只认一种写法后面想换模型供应商不需要把所有Controller改一遍。需要的核心依赖很简单如果你连接的是OpenAI兼容接口加一个dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency如果你的模型走的是本地Ollama或者国产模型的OpenAI兼容网关把starter换成对应的就行。Spring AI统一封装之后这些差异都被隐藏了代码里我只需要注入ChatClient。项目结构上我采用了前后端分离的典型分层Controller只负责HTTP协议和SSE封装Service层负责组装Prompt、调用模型、处理流式数据。这样后面接知识库或者接记忆模块不会把业务逻辑全部挤在Controller里。2.2 一个真正能跑的SSE接口怎么写核心逻辑其实很简洁Controller返回的是Flux类型Spring WebFlux会自动把它序列化成text/event-stream流。如果你用Spring MVC也可以做到但WebFlux对背压和挂起连接的资源控制更顺手所以这种流式接口项目直接把WebFlux依赖加上是最省心的。我实现的接口长这样RestController RequestMapping(/api/chat) public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } GetMapping(value /stream, produces text/event-stream;charsetUTF-8) public FluxChatStreamResponse stream(RequestParam(message) String message, RequestHeader(value X-Session-Id, required false) String sessionId) { return chatService.streamReply(message, sessionId) .map(token - new ChatStreamResponse(delta, token)) .doOnComplete(() - log.info(session {} stream completed, sessionId)) .onErrorResume(e - Flux.just(new ChatStreamResponse(error, e.getMessage()))); } }这里有一个细节ChatStreamResponse不能直接返回字符串我统一用一个包装对象它包含两个字段type表示事件类型content表示内容。这样前端接的时候可以区分delta增量、done结束、error错误。如果你直接返回FluxString遇到错误时前端很难区分这是正常内容还是异常消息后面调试会非常头疼。Service层的实现以Spring AI的ChatClient为例public FluxString streamReply(String message, String sessionId) { String systemPrompt buildSystemPrompt(sessionId); // 从会话上下文组装 return chatClient.prompt() .system(systemPrompt) .user(message) .stream() .content(); }stream().content()返回的是FluxString每个元素是大模型生成的一个增量片段。有的模型供应商一次返回一个词有的返回半个句子这都没关系关键是及时推给前端由前端去拼。2.3 两个容易被带沟里的参数结束事件和超时配置流式接口容易在新手手里变成“假流式”最常见的原因是漏了结束事件。前端必须知道“流什么时候结束”才能停掉打字光标、收起loading状态。如果后端只有增量流前端只能在读不到数据时默默判断结束这在代理断连、模型超时的情况下会产生误判。所以我建议在流结束前显式补发一个结束事件return chatService.streamReply(message, sessionId) .map(token - new ChatStreamResponse(delta, token)) .concatWithValues(new ChatStreamResponse(done, ));注意这里用的是concatWithValues而不是concatWith因为我要插入的是一个固定值不是另一个流。另一个参数是超时。大模型流式接口的等待逻辑和普通接口不同正常接口超时是指“整个请求要多久完成”但流式接口超时要注意的是“两个增量之间最多等多久”。如果模型短时间内没有任何输出这个连接可能挂起了应该断开。我一般把Spring AI的response超时设置成30秒但流式场景下这不是关键真正重要的是不要让前端无限期等下去。更稳妥的做法是做一层“空闲超时”如果30秒内没有产生任何事件主动触发onErrorResume返回错误事件前端收到后立即结束渲染。2.4 取消、异常和边界情况一个不留心就整个连接失效我还遇到过用户发到一半想停止生成或者页面已经关闭但后端模型还在傻傻地生成。这种情况如果不处理不只是浪费算力还会让后端线程一直被占着。好在前端如果关闭了页面或调用了AbortController.abort()后端的Flux会收到取消信号。前提是你必须在Service链路里把取消信号传播到模型调用层。Spring AI的ChatClient本身是响应式的链路天然支持取消但如果你在中间用了block()或者把响应式流转换成了同步阻塞调用取消信号就会断掉。所以这条需要特别检查流式链路里不能用任何阻塞方法不能用Thread.sleep()不能把Flux先collectList()再返回。异常处理的位次也很重要。有的模型服务中途会报错比如上下文超长、内容审核拦截、网络抖动。我习惯把错误处理放在最外层用onErrorResume兜底这样前端能收到一条格式统一的error事件再弹出友好提示。千万别让异常直接变成HTTP 500否则前端拿到的是一大段HTML错误页解析SSE事件时直接懵掉。3. 前端联动让文字真的“流”起来3.1 为什么用fetch流式读取而不是EventSource前端接收SSE有一种看似最省事的方案EventSource。它支持自动重连代码也简单。但它有一个致命限制它只能发送GET请求不能自定义请求头。如果我的后端需要传一个X-Session-Id或者Authorization头EventSource就力不从心了。有的项目会把会话ID放在URL参数里绕开这个限制但URL长度有限而且从安全上看用户身份信息和密钥放URL更危险。所以我的方案是用fetch配合ReadableStream手动解析字节流。这样我可以自由设置请求方法、请求头可以传POST体也可以随时调用AbortController中止连接。核心代码模板async function streamChat(text, sessionId, onToken, onDone, onError) { const controller new AbortController(); try { const resp await fetch(${API_BASE}/api/chat/stream?message${encodeURIComponent(text)}, { headers: { Accept: text/event-stream, X-Session-Id: sessionId }, signal: controller.signal }); 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; buffer decoder.decode(value, { stream: true }); let boundary; while ((boundary buffer.indexOf(\n\n)) 0) { const rawEvent buffer.slice(0, boundary); buffer buffer.slice(boundary 2); handleSSEEvent(rawEvent, onToken, onDone, onError); } } } catch (err) { if (err.name AbortError) { onDone?.(); } else { onError?.(err); } } }这里最重要的是buffer处理逻辑Fetch返回的字节流在网络上分块传输一个SSE事件可能被拆成两半到达也可能多个事件挤在一个数据块里。因此必须先攒进一个缓冲区每次找\n\n分隔符把完整事件切出来解析剩下的留在缓冲区里等待下一次写入。3.2 解析SSE数据别只用data:开头一刀切SSE协议的标准格式是每个事件由若干行组成最后以空行结束常见的行有data:、event:、id:、retry:。对大模型流式接口来说我主要关心data字段自定义的event字段用来区分增量类型。所以解析函数里我会先按行拆分再取第一个以data:开头的行去掉前缀后JSON.parse。如果不做这个过滤很容易踩到一个坑某些SSE实现会在流开始时发送若干个注释行每个注释行以:开头这是为了检测连接是否存活而发的心跳包。直接拿整行去JSON.parse会报错导致整个解析中断。所以解析出每行后如果这一行不是以data:开头必须跳过不要尝试解析不然一次网络探活就把页面搞崩。事件格式我统一约定成这样{type: delta, content: 你好} {type: done, content: } {type: error, content: 请求超时}前端根据type决定走哪个分支这样增量和结束事件一目了然。有人喜欢把整个返回用一次data:大JSON包起来但我更推荐每个增量单独一条事件解析更简单前端渲染更及时。3.3 Markdown渲染和打字光标字体流畅度的最后一公里流式对话接口通了以后最容易被忽略的其实是展示层。如果返回的是纯文本直接textContent拼接就能看到效果。但现实中大模型更爱返回Markdown代码块、列表、标题满天飞。如果你每次拿到一个增量就把整段Markdown丢给marked.js或markdown-it重新渲染页面会闪烁而且光标位置也会乱跳。我的做法是维护两个状态rawText保存累积的原始文本renderedHTML是上一次已经渲染过的部分。每次新增增量时先把增量拼到rawText然后只把“新增的那部分”做增量渲染而不是整个重新渲染。但是Markdown块级语法存在“上下文不完整”问题比如代码块的三反引号还没闭合时直接对半截Markdown渲染会解析错乱。所以我做了个折中每次都从最近一个明确的块级边界开始重新渲染这样既不用全量重绘又能处理大部分Markdown边界场景。另外打字光标的实现我推荐不要真的用CSS动画字符闪烁。在流式渲染过程中给消息容器加一个::after伪元素内容设为竖线等收到done事件时移除即可。如果频繁重建DOM反而会造成闪烁伪元素是最稳的。3.4 停止、重发、以及会话上下文前端不能只有“发送”一个按钮。我见过太多只做单向请求的Demo生成的答案太长时用户只能傻等想停止只能关页面。用fetch方案时停止操作很简单把上面代码里的controller暴露到组件作用域点击停止按钮时调用controller.abort()。需要特别注意的是abort()之后后端理论上会收到连接断开信号但有些HTTP网关会吞掉这个信号导致后端模型还在继续生成。所以我前端设计上会做双重保险abort的同时再向后端发一条“终止会话生成”的请求后端维护着一个会话ID到生成任务的映射收到请求后主动dispose()当前任务。这个设计在长连接比较多的高并发场景下尤其重要。会话上下文方面我前端的每个会话都有一个独立的会话ID换对话窗口或者刷新页面时重新生成ID或从存储里恢复。后端根据会话ID把前几轮消息组织成一个上下文数组塞进Prompt模型才能记住之前聊了什么。如果不做这一步用户说“刚才那句话再解释一下”模型根本不知道你指的是哪句。4. 我踩过的几个大坑4.1 反向代理缓冲前端刷新一整页才出字这个坑排名第一。前后端分离以后前端和API之间的必经之路大概率有一层Nginx。Nginx默认会缓冲响应它是等后端全部写完了再一次性把整个响应交给前端。流式接口在浏览器端完全失效表现就是页面空白半天然后突然一整段全出现跟你没做流式之前一模一样。解决办法有二在Nginx里对这个API路径关闭缓冲location /api/chat/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_http_version 1.1; proxy_set_header Connection ; }或者在后端响应头里加一个X-Accel-Buffering: no这个响应头能让Nginx在对这个路径关闭缓冲不用改全局配置。我用的是后者更精准不影响其他接口。还有一个隐藏问题如果中间还有Spring Cloud Gateway之类的网关它也可能做缓冲。要查链路里的每一层别改一个地方就以为行了。4.2 CORS预检自定义请求头引发的“三秒延迟”前端如果用X-Session-Id这种自定义头发起请求浏览器会先发一个OPTIONS预检请求。如果后端没有正确处理OPTIONS整个跨域请求会失败。我在Spring Boot里专门写了一个CORS配置放开对应路径的预检请求Configuration public class CorsConfig { Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedHeader(*); config.addAllowedMethod(*); return new CorsWebFilter(source - config); } }注意这里用的是CorsWebFilter不是Spring MVC里常见的CorsFilter。因为SSE接口走的是WebFlux两者在Servlet和响应式两套体系里互不相通。这也是一开始我踩过的一个细节坑代码写的是Spring MVC的CorsFilter结果WebFlux接口的跨域照样报错。4.3 前端闭包里的旧状态明明追加了覆盖了流式渲染过程中最容易出现的诡异Bug是“刷新一次就消失一段”或“上一次回答被下一次覆盖”。根源多半是前端把rawText存在组件闭包里而流的回调函数在外面被注册时捕获了旧值。每次onToken回调执行时拿到的还是第一帧时的快照于是新内容一直覆盖旧内容。解决思路是永远不要靠闭包里的普通变量保存增量状态。用useRef保存当前文本或者用函数式更新setState(prev prev token)。React函数式更新能保证每次读到的是最新状态闭包捕获问题直接消失。这个属于前端框架基础功但在流式长连接场景里它特别容易发作因为回调执行时间跨度长跨过多次组件渲染。4.4 错误信息被“优雅”地吞掉调试变得无比困难我在开发早期把错误处理写得太简单后端onErrorResume只返回一条error事件前端又在解析时把它当成普通delta文本拼到正文里。结果就是用户看到回答正文里多了一行“连接失败”却不知道这是错误信息后端日志也打了但前端表现完全不对。后面我强制做了一件事错误事件和增量事件在协议上必须分开而且前端在收到error事件后一定要用一个独立的错误提示组件展示不能混进对话消息正文。然后为了排查我还在每次请求里加了一个traceId前端报错时能把这个ID贴给后端后端查日志直接定位到是哪次生成过程的哪一步出了问题。这个设计在多人协作时尤其重要——不然你会陷入“我这没问题你前端肯定写错了”这种互相扯皮的死循环。5. 当流式对话遇到知识库和记忆5.1 把RAG检索插到流式流程的哪个位置项目做到后期自然会往知识库方向走搜索热词里的LLM Wiki、RAG概念本质都是在回答里接入私域知识。做法并不复杂用户发问后后端不是直接把这个问题扔给模型而是先做一次向量检索把相关的知识片段找出来拼到系统提示词里再启动流式输出。流程里的关键点是知识检索阶段要不要也做成流式我的答案是不用。检索通常几百毫秒就能完成没必要做流式真正需要流式的还是模型生成阶段。所以Controller的流式接口里我第一步先同步执行检索检索完成后进入Flux流式生成阶段。如果检索时间确实很长前端可以收到一个status事件提示“正在搜索知识库”避免页面看起来像死掉一样。有的RAG方案会把引用来源打成一个特殊事件推给前端比如{type: citations, content: [doc-1.pdf, doc-2.docx]}前端在这个事件到达时展示引文列表这个设计能让用户知道信息到底是从哪个文档里来的体验比光秃秃一堆文字好得多。5.2 记忆管理别把整个历史都塞给模型接记忆模块最容易犯的错是把所有聊天记录一股脑全塞进Prompt。对话超过十轮以后上下文越来越长模型要么忽略早期内容要么直接超出窗口。我做的是滑动窗口式记忆后端为每个会话ID维护一个消息队列只保留最近六轮用户和助手的对话超过的部分裁剪掉。流式过程中的“思考链”问题也要注意。部分模型在返回最终答案前会输出一段推理过程如果直接推给前端用户会看到模型在自言自语。前端要么过滤掉特定关键词后端要么在解析ChatResponse时把推理字段过滤掉只把最终content转为SSE事件。这点在接入不同模型时差异很大务必做一层适配。5.3 接入知识库后的真实效果首字延迟变短了还是变长了我自己跑通这个链路后发现一个反常识的现象接了知识库之后总输出质量明显提升但首字延迟反而可能变长。原因很简单检索和Prompt组装用了额外时间。如果优化做得不好用户看到的就是转圈圈时间变长。这里我建议把检索阶段过程中的状态实时同步给前端至少让用户知道系统在想什么。后面我又做了一个优化把知识库命中的片段做一次轻量排序只取最相关的三到五条塞进Prompt而不是把所有命中的结果全部堆进去。检索召回五条就够用了太多反而会让模型犯迷糊。实测下来首字延迟从加知识库前的1.2秒变成了加知识库后的1.8秒但回答准确率提升非常明显这个代价完全值得。这套方案的后续扩展方向说一说我后续最想补的一块把流式接口统一成一套类似OpenAI那样的标准格式。目前项目里每个模型的调用差异是靠Spring AI适配层吃掉的但前端面对的事件格式完全由后端自定义。如果以后要多端复用比如小程序、桌面客户端一套通用的事件协议会比前端和后端强耦合省太多维护成本。另一个值得试的方向是把普通对话和流式输出拆成两个语义更清晰的接口一个非流式接口给内部服务调用一个流式接口给用户端页面使用。不要让前端自己去判断要不要读流而是由接口类型直接决定行为。目前我这个项目就是让同一个Service层同时暴露两种返回值业务逻辑没有重复但接口职责却分得清清楚楚。我在实现这个完整链路的时候最大的体会是真正的技术难点从来不是某个框架的API而是整个链路里每一层怎么协作。一个流式接口背后有反向代理配置、有CORS规则、有超时控制、有终止信号传递、有前端状态管理这些散落在不同技术栈里的细节才是大家常说的“做懂前后端联接”的真正含义。按照我你项目的规模先把这条路走通后面接什么模型、接什么知识库都会顺很多。
RELATED

相关推荐

骑手金融防护系统:实时风控与场景化安防实践

骑手金融防护系统:实时风控与场景化安防实践

1. 项目背景与核心价值 "护佑城市新动力 骑心共守金融权"这个项目名称乍看有些抽象,但拆解后能发现它精准捕捉了当下城市发展中的两个关键群体——新业态从业者(特别是外卖骑手、快递员等)和金融机构的协同关系。我在参与某城市数字…

📅 2026/11/4 14:05:46
Folia主界面、播放面板与设置入口上手配置指南

Folia主界面、播放面板与设置入口上手配置指南

第一次打开 Folia 的人,十个里有八个会在同一个地方卡住:界面确实干净,干净到你不知道下一步该点哪。左上角有一排图标,底部有一条灰黑色的横条,右上角有个小齿轮静静待着,主界面中间大片留白——这时候大多…

📅 2026/11/4 14:05:46
Agent记忆组件:分层架构与实战选型指南

Agent记忆组件:分层架构与实战选型指南

1. 什么是Agent记忆组件:不是“记住东西”,而是让AI拥有时间感和身份感你刚写完一个能调用天气API、查完航班、再订好酒店的Agent,运行三次后发现——它完全不记得上一次帮你订的是哪家酒店,也不记得你说过“讨厌早班机”&#xf…

📅 2026/11/4 14:05:46
MORE NEWS

更多资讯

📰

Hadoop伪分布式环境部署与网络配置实战

1. Hadoop伪分布式环境部署概述 作为一名大数据工程师,我经常需要在本地搭建Hadoop伪分布式环境进行开发和测试。与完全分布式环境相比,伪分布式模式在单台机器上模拟了Hadoop集群的运行方式,既保留了Hadoop的核心特性,又简化了部…

📰

纯前端抽奖系统开发:内定名单与防重复中奖实现

1. 项目背景与需求解析去年帮本地商会开发年会抽奖系统时,发现市面上大多数免费抽奖工具都存在两个致命缺陷:要么无法设置内定中奖名单(比如必须让赞助商代表中奖),要么缺乏完善的防重复中奖机制(导致同一个…

📰

MySQL 8.0在Windows环境下的性能优化与部署实践

1. MySQL 8.0在Windows环境下的核心价值 作为从业15年的数据库工程师,我见证了MySQL从5.0到8.0的演进历程。2026年的当下,MySQL 8.0 LTS版本已经成为企业级应用的事实标准。与旧版相比,它在Windows平台的表现尤为突出: 性能基准测…

📰

双链表数据结构:数组模拟实现与算法应用

1. 双链表数据结构基础认知第一次接触双链表是在解决某个需要频繁插入删除的问题时,当时用数组模拟单链表已经无法满足需求。双链表这种每个节点同时保存前后指针的结构,让我在处理双向遍历和中间插入时效率提升了数倍。和单链表相比,双链表就…

📰

RAG如何构建AI Agent可靠的知识获取管道

1. 这不是“加个检索”那么简单:RAG 为什么成了 AI Agent 的知识命脉 你有没有试过让一个 AI Agent 去查公司内部的销售合同模板?或者让它从上百份产品规格书里,精准定位某款芯片的散热参数?如果它张口就来、答得头头是道&#xf…

📰

Jev 开源两周生态爆发:TypeSafe AI 与 RLCD 如何重塑 AI 应用集成

1. 从“Jev 火了两周”说起:一个开源现象的真实切面“Jev 火了两周,开源生态已经长出 28 个项目”——这句话我第一次看到的时候,第一反应不是“又一个新模型”,而是“两周 28 个项目”这个数字。做过开源的人都知道,一…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬