做过本地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、SSE(Server-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> <groupId>org.springframework.ai</groupId> <artifactId>spring-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;charset=UTF-8") public Flux<ChatStreamResponse> 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错误。如果你直接返回Flux<String>,遇到错误时前端很难区分这是正常内容还是异常消息,后面调试会非常头疼。
Service层的实现,以Spring AI的ChatClient为例:
public Flux<String> streamReply(String message, String sessionId) { String systemPrompt = buildSystemPrompt(sessionId); // 从会话上下文组装 return chatClient.prompt() .system(systemPrompt) .user(message) .stream() .content(); }stream().content()返回的是Flux<String>,每个元素是大模型生成的一个增量片段。有的模型供应商一次返回一个词,有的返回半个句子,这都没关系,关键是及时推给前端,由前端去拼。
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规则、有超时控制、有终止信号传递、有前端状态管理,这些散落在不同技术栈里的细节才是大家常说的“做懂前后端联接”的真正含义。按照我你项目的规模,先把这条路走通,后面接什么模型、接什么知识库,都会顺很多。