你一定用过SELECT * FROM table LIMIT 10000的痛——用户盯着白屏干等8秒。LLM也一样一次性返回完整回答可能要20秒。流式输出就是LLM的分页查询数据边生成边推送用户第一个字0.5秒就能看到。为什么LLM必须流式返回先理解一个事实LLM是逐token生成的。它不是像数据库那样先算好再返回而是一个token一个token往外蹦。一次对话生成500个token每个token耗时30-50ms总共15-25秒。如果等全部生成完再返回非流式用户体验是这样的用户提问 → 白屏等待15秒 → 突然蹦出一大段文字如果边生成边推送流式用户体验变成用户提问 → 0.5秒后开始出字 → 像打字机一样逐字显示 → 15秒后全部显示完同样15秒的总耗时体感完全不同。这就是ChatGPT打字机效果背后的技术本质。Java类比Stream的惰性求值如果你用过Java 8的Stream API流式输出的概念就很好理解Java概念LLM流式输出共同点list.stream().map(...)惰性求值LLM逐token生成不是一次性算完而是按需产出Stream.forEach(System.out::println)SSE逐事件推送数据产出一个就消费一个Flux.fromIterable(list).delayElements(...)Token流异步、非阻塞、背压控制BufferedReader.readLine()循环SSE事件循环逐行/逐事件读取直到结束标志用代码表达就是// 非流式 collect再处理用户干等ListStringresultstream.collect(Collectors.toList());returnresult;// 全部收集完才返回// 流式 forEach边产出边消费用户边看边等stream.forEach(token-pushToClient(token));// 产出一个就推一个SSE协议HTTP版的消息推送LLM流式输出用的是**SSEServer-Sent Events**协议。你可以把它理解为HTTP版的WebSocket单向版——服务端持续向客户端推送数据客户端只需要监听。SSE vs WebSocket vs 轮询特性SSEWebSocketHTTP轮询通信方向单向服务端→客户端双向客户端主动拉协议HTTP/1.1ws://HTTP断线重连浏览器自动重连需手动实现天然支持数据格式纯文本text/event-stream二进制/文本任意适用场景LLM流式输出、通知推送聊天室、实时协作简单状态轮询SSE数据格式SSE的报文格式极其简单只有4个字段event: chunk data: {content:你} event: chunk data: {content:好} event: done data: {totalLength:2}核心规则Content-Type必须是text/event-stream每个事件用空行分隔data:后面跟数据内容通常是JSONevent:可选指定事件类型不写则默认message在Spring Boot中SSE通过SseEmitter实现。LangChain4j的流式输出上一讲我们用的是ChatLanguageModel——同步调用等全部生成完才返回。现在换成它的流式版本StreamingChatLanguageModel。新增依赖在第2讲的pom.xml基础上不需要额外依赖——langchain4j-open-ai-spring-boot-starter已经同时支持同步和流式模型。配置流式模型langchain4j:open-ai:streaming-chat-model:api-key:${DEEPSEEK_API_KEY:sk-xxxxx}model-name:deepseek-chatbase-url:https://api.deepseek.com/v1注意key的变化chat-model→streaming-chat-model。Spring Boot会自动注入一个StreamingChatLanguageModelBean。后端ControllerSSE流式接口RestControllerRequestMapping(/api/chat)publicclassStreamChatController{privatefinalStreamingChatLanguageModelstreamingModel;publicStreamChatController(StreamingChatLanguageModelstreamingModel){this.streamingModelstreamingModel;}GetMapping(value/stream,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicSseEmitterchat(RequestParamStringmessage){// 超时5分钟LLM长回答可能需要较长时间SseEmitteremitternewSseEmitter(5*60*1000L);streamingModel.generate(message,newStreamingResponseHandlerAiMessage(){OverridepublicvoidonNext(Stringtoken){try{// 每生成一个token就推送一次emitter.send(SseEmitter.event().name(chunk).data(Map.of(content,token)));}catch(IOExceptione){emitter.completeWithError(e);}}OverridepublicvoidonComplete(ResponseAiMessageresponse){try{emitter.send(SseEmitter.event().name(done).data(Map.of(totalTokens,response.tokenUsage().totalTokenCount())));emitter.complete();}catch(IOExceptione){emitter.completeWithError(e);}}OverridepublicvoidonError(Throwableerror){emitter.completeWithError(error);}});returnemitter;}}这个接口做的事情很简单创建一个SseEmitter设置5分钟超时调用streamingModel.generate()传入回调处理器每收到一个token就通过emitter.send()推送给前端生成完毕调用emitter.complete()关闭连接前端打字机效果前端用浏览器原生的EventSourceAPI接收SSE流。下面是一个完整的HTML页面复制到src/main/resources/static/index.html即可运行!DOCTYPEhtmlhtmllangzh-CNheadmetacharsetUTF-8titleLLM流式对话/titlestyle*{margin:0;padding:0;box-sizing:border-box;}body{font-family:-apple-system,sans-serif;background:#f5f5f5;display:flex;justify-content:center;padding-top:40px;}.container{width:700px;}.chat-box{background:white;border-radius:12px;padding:24px;min-height:300px;margin-bottom:16px;box-shadow:0 2px 8pxrgba(0,0,0,0.08);}#response{white-space:pre-wrap;line-height:1.8;color:#333;}.cursor{display:inline-block;width:2px;height:1em;background:#333;animation:blink 0.8s infinite;vertical-align:text-bottom;}keyframesblink{50%{opacity:0;}}.input-row{display:flex;gap:8px;}input{flex:1;padding:12px 16px;border:1px solid #ddd;border-radius:8px;font-size:15px;outline:none;}button{padding:12px 24px;background:#1a73e8;color:white;border:none;border-radius:8px;font-size:15px;cursor:pointer;}button:disabled{background:#ccc;}.status{margin-top:8px;font-size:13px;color:#999;}/style/headbodydivclasscontainerdivclasschat-boxdividresponse等待提问.../div/divdivclassinput-rowinputidinputplaceholder输入你的问题.../buttonidsendBtnonclicksendMessage()发送/button/divdivclassstatusidstatus/div/divscriptleteventSourcenull;functionsendMessage(){constinputdocument.getElementById(input);constmessageinput.value.trim();if(!message)return;// 重置UIdocument.getElementById(response).innerHTML;document.getElementById(sendBtn).disabledtrue;document.getElementById(status).textContent生成中...;input.value;// 关闭上一个连接if(eventSource)eventSource.close();// 建立SSE连接consturl/api/chat/stream?message${encodeURIComponent(message)};eventSourcenewEventSource(url);constresponseEldocument.getElementById(response);// 监听chunk事件——每个token到达时触发eventSource.addEventListener(chunk,function(e){constdataJSON.parse(e.data);responseEl.textContentdata.content;});// 监听done事件——生成完毕eventSource.addEventListener(done,function(e){constdataJSON.parse(e.data);document.getElementById(status).textContent生成完毕共${data.totalTokens}tokens;document.getElementById(sendBtn).disabledfalse;eventSource.close();});// 错误处理eventSource.onerrorfunction(){document.getElementById(status).textContent连接断开;document.getElementById(sendBtn).disabledfalse;eventSource.close();};}// 回车发送document.getElementById(input).addEventListener(keydown,function(e){if(e.keyEnter)sendMessage();});/script/body/html启动Spring Boot应用访问http://localhost:8080输入问题你就能看到LLM的回答像打字机一样逐字蹦出来。关键代码解读new EventSource(url)建立SSE长连接浏览器原生支持addEventListener(chunk, ...)监听自定义事件类型对应后端SseEmitter.event().name(chunk)eventSource.close()生成完毕后主动关闭连接释放资源生产实录实录1WebSocket vs SSE双通道——为什么不是二选一在我们的AI助手平台中我们同时使用了WebSocket和SSE两种协议各管各的活儿通道协议端点用途主对话WebSocket/chat/mainMainChatWebsocketServer主AI助手对话主对话WebSocketSimpleChatWebsocketServer简洁模式对话主对话WebSocketPlanChatWebsocketServer教案生成对话主对话WebSocketMultimodalChatWebsocketServer多模态对话辅助功能SSEMindMapSseController录音思维导图生成为什么主对话用WebSocket而不是SSE核心原因是对话场景需要双向通信——用户可以随时中断生成点停止按钮这需要客户端主动发消息给服务端。SSE是单向的做不到。为什么思维导图用SSE而不是WebSocket因为它是触发一次→等结果的单向推送场景不需要用户中途交互。SSE比WebSocket轻量得多不需要维护长连接、不需要心跳、用标准HTTP就能走——一行GetMapping(produces TEXT_EVENT_STREAM_VALUE)搞定。选型结论需要双向通信对话、协作→ WebSocket只需服务端推送进度条、流式生成→ SSE。不要为了统一而强行只用一种。实录2客户端断了怎么办——SafeSseEmitterRef的clientGone模式生产环境有一个高频场景用户触发了思维导图生成但中途关掉了页面。此时SSE连接断开如果你的代码直接往SseEmitter.send()里写会抛IOException。更危险的是如果异常没处理好LLM的流式调用也跟着被中断——结果没入库用户下次打开还得重新生成。生产项目中我们封装了一个SafeSseEmitterRef来解决这个问题。核心思路// 示意实现SafeSseEmitterRef.javaclassSafeSseEmitterRef{privatefinalSseEmitteremitter;privatevolatilebooleanclientGonefalse;// 关键标记privatevoidsendInternal(StringeventName,StringdataJson){if(clientGone){return;// 客户端已断开直接跳过不抛异常}try{emitter.send(SseEmitter.event().name(eventName).data(dataJson));}catch(IOExceptione){clientGonetrue;// 标记断连后续所有send变成no-oplog.warn(SSE send 失败标记 clientGone后续不再发送);}catch(IllegalStateExceptione){clientGonetrue;// emitter已被complete/timeout}}}调用方的逻辑也很简单——不管客户端在不在LLM流都跑完、结果都入库// 示意实现MindMapService.javallmApi.chat(SceneConstant.Scene.DEFAULT_LLM).userInput(userInput).consumer(resp-{Stringdeltaresp.getContent();if(StringUtils.isNotEmpty(delta)){acc.append(delta);// 无论如何都累积ref.sendChunk(delta);// clientGone时自动no-op}}).callStream();// 流结束后结果入库不受客户端断连影响recordingMapper.markMindMapCompleted(taskId,acc.toString());log.info(思维导图入库完成, taskId{}, clientGone{},taskId,ref.isClientGone());这个模式的本质是把推送和持久化解耦推送失败不影响持久化用户下次打开直接查库拿结果。实录3SSE中间层透传——另一个项目的中间层转发架构我参与的另一个AI助手项目的架构更典型前端不直接连LLM中间有一层中间层转发服务。架构如下前端 ←─SSE─→ 中间层转发服务 ←─HTTP─→ LLM Gateway ←─→ 大模型中间层转发服务的ChatService做的是SSE透传从LLM Gateway读取流式响应经过处理后逐事件转发给前端// 示意实现ChatService.java简化// 1. 从LLM Gateway读取SSE流BufferedReaderreadernewBufferedReader(newInputStreamReader(inputStream,StandardCharsets.UTF_8));SseEventParserparsernewSseEventParser();OutputSafetyFiltersafetyFilternewOutputSafetyFilter(sensitiveValues,brandName);booleanclientAlivetrue;// 2. 逐行读取、过滤、转发while((linereader.readLine())!null){fullResponseBuilder.append(line).append(\n);// 无论如何都累积完整响应StringeventDataparser.feedLine(line);if(eventDatanull)continue;StringfilteredsafetyFilter.processEvent(eventData);// 敏感信息过滤if(!clientAlive)continue;// 客户端断了就不推了但流要读完try{outputStream.write(filtered.getBytes(StandardCharsets.UTF_8));outputStream.flush();}catch(IOExceptionclientAbortEx){clientAlivefalse;// 和SafeSseEmitterRef同样的思路log.warn(客户端已断开继续消费响应完成入库);}}中间层的价值不只是转发更重要的是可以在流中注入和过滤用户画像注入ChatContextAssembler在请求发给LLM之前把用户角色、学校类型等上下文拼进prompt敏感信息过滤OutputSafetyFilter实时过滤LLM回答中的敏感内容系统路径、凭据等正则安全过滤StreamingRegexFilter用滑动窗口机制处理跨token边界的正则匹配——这在流式场景下不是trivial的问题流式缓存断线后前端可通过pending接口轮询拿到已生成的内容实现断线续看本讲要点LLM是逐token生成的流式输出不是优化手段而是匹配其生成机制的自然选择SSE协议 HTTP长连接 text/event-streamdata:行格式Spring Boot用SseEmitter实现LangChain4j流式调用把ChatLanguageModel换成StreamingChatLanguageModel在onNext回调中逐token推送前端用EventSource接收SSEaddEventListener监听自定义事件类型生产必须处理客户端断连用clientGone标记模式让LLM流跑完、结果入库推送失败只是静默跳过SSE vs WebSocket选型单向推送用SSE双向通信用WebSocket不要为了统一强行二选一动手练习基础跑通本讲的完整demoSpring Boot 前端体验打字机效果进阶给流式接口加一个停止生成功能。提示前端eventSource.close()断开连接后后端SseEmitter的onCompletion回调会触发——在这里中断LLM调用挑战参考SafeSseEmitterRef的设计封装一个你自己的SafeSseEmitter——send不抛异常、自动标记断连、提供isClientGone()查询方法下一讲预告第4讲多模型策略——LLM的负载均衡——生产环境不会只用一个模型。下一讲我们用Java的策略模式 YAML配置实现多模型路由和降级切换。你会发现这和你做过的支付通道路由、多数据源切换是一模一样的套路。「Java转AI实战」完整目录共16讲讲次标题第1讲Java工程师的AI转型地图第2讲开发环境搭建与第一次LLM调用第3讲流式输出——LLM的分页查询本文第4讲多模型策略——LLM的负载均衡第5讲Prompt Engineering——给LLM写需求文档第6讲RAG全景——为什么LLM需要开卷考试第7讲文档解析——把非结构化数据变成可检索的文本第8讲智能切分——文档的分库分表第9讲Embedding向量化——文本的序列化第10讲检索策略——从SQL查询到语义搜索第11讲端到端RAG应用——把前5讲串起来第12讲从对话到行动——Agent是什么第13讲MCP协议——AI的标准API网关第14讲Agent实战——构建一个能执行任务的AI助手第15讲从Demo到生产——你的Java经验在这里爆发第16讲转型行动指南——从会了到找到AI工作前4讲免费阅读第5讲起进入RAG核心区和Agent实战在知识星球持续更新。获取完整课程流式输出只是AI应用的门面功夫真正决定AI应用质量的是RAG检索链路——文档怎么切分、向量怎么检索、结果怎么精排。这些都在第6-11讲里配合真实生产项目的代码逐步拆解。完整16讲 持续更新的AI实战内容都在知识星球**「Java转AI实战内参」**里完整课程16讲从LLM调用到Agent实战一站式覆盖真实生产项目的架构设计和代码级拆解Java转AI的面试准备和简历优化社群答疑有问必答星球定价199/年首年限时优惠99元。微信搜索**「Java转AI实战内参」**加入星球或者私信我获取加入方式。