你一定用过
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再处理(用户干等)List<String>result=stream.collect(Collectors.toList());returnresult;// 全部收集完才返回// 流式 = forEach边产出边消费(用户边看边等)stream.forEach(token->pushToClient(token));// 产出一个就推一个SSE协议:HTTP版的"消息推送"
LLM流式输出用的是**SSE(Server-Sent Events)**协议。你可以把它理解为"HTTP版的WebSocket单向版"——服务端持续向客户端推送数据,客户端只需要监听。
SSE vs WebSocket vs 轮询
| 特性 | SSE | WebSocket | HTTP轮询 |
|---|---|---|---|
| 通信方向 | 单向(服务端→客户端) | 双向 | 客户端主动拉 |
| 协议 | HTTP/1.1 | ws:// | 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:后面跟数据内容(通常是JSON)event:可选,指定事件类型(不写则默认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。
后端Controller:SSE流式接口
@RestController@RequestMapping("/api/chat")publicclassStreamChatController{privatefinalStreamingChatLanguageModelstreamingModel;publicStreamChatController(StreamingChatLanguageModelstreamingModel){this.streamingModel=streamingModel;}@GetMapping(value="/stream",produces=MediaType.TEXT_EVENT_STREAM_VALUE)publicSseEmitterchat(@RequestParamStringmessage){// 超时5分钟,LLM长回答可能需要较长时间SseEmitteremitter=newSseEmitter(5*60*1000L);streamingModel.generate(message,newStreamingResponseHandler<AiMessage>(){@OverridepublicvoidonNext(Stringtoken){try{// 每生成一个token就推送一次emitter.send(SseEmitter.event().name("chunk").data(Map.of("content",token)));}catch(IOExceptione){emitter.completeWithError(e);}}@OverridepublicvoidonComplete(Response<AiMessage>response){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即可运行:
<!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="UTF-8"><title>LLM流式对话</title><style>*{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></head><body><divclass="container"><divclass="chat-box"><divid="response">等待提问...</div></div><divclass="input-row"><inputid="input"placeholder="输入你的问题..."/><buttonid="sendBtn"onclick="sendMessage()">发送</button></div><divclass="status"id="status"></div></div><script>leteventSource=null;functionsendMessage(){constinput=document.getElementById('input');constmessage=input.value.trim();if(!message)return;// 重置UIdocument.getElementById('response').innerHTML='';document.getElementById('sendBtn').disabled=true;document.getElementById('status').textContent='生成中...';input.value='';// 关闭上一个连接if(eventSource)eventSource.close();// 建立SSE连接consturl=`/api/chat/stream?message=${encodeURIComponent(message)}`;eventSource=newEventSource(url);constresponseEl=document.getElementById('response');// 监听chunk事件——每个token到达时触发eventSource.addEventListener('chunk',function(e){constdata=JSON.parse(e.data);responseEl.textContent+=data.content;});// 监听done事件——生成完毕eventSource.addEventListener('done',function(e){constdata=JSON.parse(e.data);document.getElementById('status').textContent=`生成完毕,共${data.totalTokens}tokens`;document.getElementById('sendBtn').disabled=false;eventSource.close();});// 错误处理eventSource.onerror=function(){document.getElementById('status').textContent='连接断开';document.getElementById('sendBtn').disabled=false;eventSource.close();};}// 回车发送document.getElementById('input').addEventListener('keydown',function(e){if(e.key==='Enter')sendMessage();});</script></body></html>启动Spring Boot应用,访问http://localhost:8080,输入问题,你就能看到LLM的回答像打字机一样逐字蹦出来。
关键代码解读:
new EventSource(url)建立SSE长连接,浏览器原生支持addEventListener('chunk', ...)监听自定义事件类型,对应后端SseEmitter.event().name("chunk")eventSource.close()生成完毕后主动关闭连接,释放资源
生产实录
实录1:WebSocket vs SSE双通道——为什么不是"二选一"
在我们的AI助手平台中,我们同时使用了WebSocket和SSE两种协议,各管各的活儿:
| 通道 | 协议 | 端点 | 用途 |
|---|---|---|---|
| 主对话 | WebSocket | /chat/main(MainChatWebsocketServer) | 主AI助手对话 |
| 主对话 | WebSocket | SimpleChatWebsocketServer | 简洁模式对话 |
| 主对话 | WebSocket | PlanChatWebsocketServer | 教案生成对话 |
| 主对话 | WebSocket | MultimodalChatWebsocketServer | 多模态对话 |
| 辅助功能 | SSE | MindMapSseController | 录音思维导图生成 |
为什么主对话用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;privatevolatilebooleanclientGone=false;// 关键标记privatevoidsendInternal(StringeventName,StringdataJson){if(clientGone){return;// 客户端已断开,直接跳过,不抛异常}try{emitter.send(SseEmitter.event().name(eventName).data(dataJson));}catch(IOExceptione){clientGone=true;// 标记断连,后续所有send变成no-oplog.warn("SSE send 失败,标记 clientGone,后续不再发送");}catch(IllegalStateExceptione){clientGone=true;// emitter已被complete/timeout}}}调用方的逻辑也很简单——不管客户端在不在,LLM流都跑完、结果都入库:
// 示意实现:MindMapService.javallmApi.chat(SceneConstant.Scene.DEFAULT_LLM).userInput(userInput).consumer(resp->{Stringdelta=resp.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());这个模式的本质是把"推送"和"持久化"解耦:推送失败不影响持久化,用户下次打开直接查库拿结果。
实录3:SSE中间层透传——另一个项目的中间层转发架构
我参与的另一个AI助手项目的架构更典型:前端不直接连LLM,中间有一层中间层转发服务。架构如下:
前端 ←─SSE─→ 中间层转发服务 ←─HTTP─→ LLM Gateway ←─→ 大模型中间层转发服务的ChatService做的是SSE透传:从LLM Gateway读取流式响应,经过处理后逐事件转发给前端:
// 示意实现:ChatService.java(简化)// 1. 从LLM Gateway读取SSE流BufferedReaderreader=newBufferedReader(newInputStreamReader(inputStream,StandardCharsets.UTF_8));SseEventParserparser=newSseEventParser();OutputSafetyFiltersafetyFilter=newOutputSafetyFilter(sensitiveValues,brandName);booleanclientAlive=true;// 2. 逐行读取、过滤、转发while((line=reader.readLine())!=null){fullResponseBuilder.append(line).append('\n');// 无论如何都累积完整响应StringeventData=parser.feedLine(line);if(eventData==null)continue;Stringfiltered=safetyFilter.processEvent(eventData);// 敏感信息过滤if(!clientAlive)continue;// 客户端断了就不推了,但流要读完try{outputStream.write(filtered.getBytes(StandardCharsets.UTF_8));outputStream.flush();}catch(IOExceptionclientAbortEx){clientAlive=false;// 和SafeSseEmitterRef同样的思路log.warn("客户端已断开,继续消费响应完成入库");}}中间层的价值不只是"转发",更重要的是可以在流中注入和过滤:
- 用户画像注入:
ChatContextAssembler在请求发给LLM之前,把用户角色、学校类型等上下文拼进prompt - 敏感信息过滤:
OutputSafetyFilter实时过滤LLM回答中的敏感内容(系统路径、凭据等) - 正则安全过滤:
StreamingRegexFilter用滑动窗口机制,处理跨token边界的正则匹配——这在流式场景下不是trivial的问题 - 流式缓存:断线后前端可通过pending接口轮询拿到已生成的内容,实现断线续看
本讲要点
- LLM是逐token生成的,流式输出不是优化手段,而是匹配其生成机制的自然选择
- SSE协议= HTTP长连接 +
text/event-stream+data:行格式,Spring Boot用SseEmitter实现 - LangChain4j流式调用:把
ChatLanguageModel换成StreamingChatLanguageModel,在onNext回调中逐token推送 - 前端用
EventSource接收SSE,addEventListener监听自定义事件类型 - 生产必须处理客户端断连:用
clientGone标记模式,让LLM流跑完、结果入库,推送失败只是静默跳过 - SSE vs WebSocket选型:单向推送用SSE,双向通信用WebSocket,不要为了统一强行二选一
动手练习
- 基础:跑通本讲的完整demo(Spring 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实战内参」**加入星球,或者私信我获取加入方式。