LangChain4j 流式输出打字机效果 SSE回答不再干等三秒作者鱼宵 LangChain4j实战精通营 · 第 5 篇你用过 ChatGPT 吧你问个问题回答是一个字一个字往外蹦的像打字机。但你有没有想过为什么前几课我们调模型都是转半天圈圈然后整句话一次性砸出来差的就是一个流式。前四课用的是阻塞式ChatModel——模型在后台把整段话生成完网络才一次性把整段文本递过来你干等几秒没任何反馈特别焦虑。这一课换成StreamingChatModelTokenStream让模型吐一个词就回调一次再用 SSE 把这股字流推到浏览器——你在 ChatGPT 里看到的逐字效果今晚自己动手做一个。代码全在仓库lesson-05/clone 下来启动后浏览器点一下发送字是一个一个蹦出来的。一、核心原理等一桶水 vs 水龙头放水一句话阻塞式是等整段话生成完一次性返回流式是生成一个词就立刻回调一个词体验天差地别。1. 阻塞 vs 流式阻塞式前四课的chatModel.chat()模型在后台把整段话生成完网络才一次性把整段文本返回。就像你点杯奶茶店员不说先给你抿一口而是做完整杯、装袋、插吸管最后一次性递你手里——期间你盯着干等十几秒没反馈。流式本课模型一边生成一边把刚吐出来的那一小段立刻发过来前端收到一点显示一点。像水龙头拧开就有水珠滴出来越接越多。你第一时间就看到它在动心理上觉得很快、很响应。为什么聊天产品全用流式整段生成要等几秒甚至十几秒流式让你在第一个字出来时就确认它开始说了体感延迟骤降。2. 首 token 延迟TTFT流式体验好坏的关键指标叫首 token 延迟Time To First Token从你发请求到收到第一个字所花的时间。打个比方上菜先闻香。流式体验好不好关键不在最后那道菜几点上齐而在多久先闻到香味。第一个字来得快人就不慌后面字陆续跟上就是舒服的打字机。阻塞式没有这个概念——它要么不给、要么给整段。3. TokenStream挂三个回调最后必须 start()StreamingChatModel是 1.21.0 的流式聊天接口老教程叫StreamingChatLanguageModel已改名。用 AiServices 时接口方法返回TokenStream而不是String——它是个还没开始跑的流句柄你往上面挂回调回调方法何时触发干嘛用.onPartialResponse(ConsumerString)每来一小段文本把这个词推给前端打字机的字.onCompleteResponse(...)整段生成完关闭 SSE 连接.onError(ConsumerThrowable)出错把错误交给前端.start()手动调用不调用就不发请求最后必须调assistant.chat(msg).onPartialResponse(token-System.out.print(token))// 逐字打印.onCompleteResponse(r-System.out.println(\n[完])).onError(err-err.printStackTrace()).start();// 真正发请求4. SSE服务器单向推流比 WebSocket 简单SSEServer-Sent EventsHTTP 的一种长连接推流协议。浏览器发一次普通 GET服务器保持连接不关陆续往里面写data: 你好\n\n格式的事件浏览器每收到一段就触发一次onmessage。为什么用它做打字机方向是服务器→浏览器单向聊天回答正是这个方向基于普通 HTTP浏览器有原生EventSource比 WebSocket 简单。面试常被追问和 WebSocket 的区别对比SSEWebSocket方向服务器→浏览器单向全双工双向协议普通 HTTP独立 ws://浏览器原生EventSourceWebSocket对象适合流式回答、推送聊天室、对战一句话只需要服务器单方面吐数据AI 回答就用 SSE要双方随时互发才上 WebSocket。二、动手启动 Web 服务浏览器看打字机本课是 Web 课端口8086进程常驻不会自己退出。cd langchain4j-journey\lesson-05$env:JAVA_HOMEC:\Program Files\Java\jdk-17mvn clean install-DskipTests java-Dfile.encodingUTF-8-jar target\lesson-05-1.0.0.jar看到Tomcat started on port 8086即就绪。浏览器打开http://localhost:8086/默认问题已填好点发送——下面的字会一个一个蹦出来。想看 SSE 原始帧另开一个 PowerShellcurl.exe-N-shttp://localhost:8086/chat/stream?msg用一句话介绍郑州# -N 关闭缓冲否则 curl 攒一批才打印看不到逐字效果停服务时先Get-NetTCPConnection -LocalPort 8086查到 PID 再停别误杀别的课程进程。三、关键代码四个文件串起整条流第一段LlmConfig.java——流式模型 Bean和阻塞式几乎一样。packagecom.langchain4j.lesson05;importdev.langchain4j.model.openai.OpenAiStreamingChatModel;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;/** 流式模型 Beanbuilder 参数和阻塞式 OpenAiChatModel 完全通用。 */ConfigurationpublicclassLlmConfig{BeanpublicOpenAiStreamingChatModelstreamingChatModel(){returnOpenAiStreamingChatModel.builder().baseUrl(System.getenv().getOrDefault(LLM_BASE_URL,https://api.deepseek.com)).apiKey(System.getenv(DEEPSEEK_API_KEY))// Key 只从环境变量读.modelName(System.getenv().getOrDefault(LLM_MODEL,deepseek-chat)).maxTokens(300).temperature(0.7).build();}}第二段Assistant.java——返回值从 String 换成 TokenStream就这一行之差。packagecom.langchain4j.lesson05;importdev.langchain4j.service.TokenStream;/** 流式助手接口。和第 4 课唯一区别返回值从 String 变成 TokenStream。 */publicinterfaceAssistant{// 返回 TokenStream 流式句柄你往它上面挂回调不是 String 阻塞等整段TokenStreamchat(StringuserMessage);}第三段StreamChatController.java——SSE 出口本课核心。packagecom.langchain4j.lesson05;importorg.springframework.http.MediaType;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;importorg.springframework.web.servlet.mvc.method.annotation.SseEmitter;importjava.io.IOException;/** 流式对话控制器把模型逐字吐字的流通过 SSE 推给浏览器/curl。 */RestControllerpublicclassStreamChatController{privatefinalAssistantassistant;publicStreamChatController(Assistantassistant){this.assistantassistant;}GetMapping(value/chat/stream,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicSseEmitterchatStream(RequestParam(defaultValue用三句话介绍郑州)Stringmsg){SseEmitteremitternewSseEmitter(0L);// 0L不超时慢慢等模型生产要设合理超时assistant.chat(msg)// 拿到流句柄此刻还没答案.onPartialResponse(token-{// 每来一个词try{emitter.send(SseEmitter.event().data(token));// 立刻往 SSE 推一格}catch(IOExceptione){emitter.completeWithError(e);// 用户关页面就结束}}).onCompleteResponse(r-emitter.complete())// 生成完关流.onError(err-emitter.completeWithError(err))// 出错关流.start();// 必须 start() 才真发请求returnemitter;// 方法先返回内容在后台陆续推}}两个关键点produces TEXT_EVENT_STREAM_VALUE告诉浏览器这是 SSE 事件流别等我一次性结束return emitter方法先返回Tomcat 不关这条连接内容在后台陆续推。第四段index.html——浏览器用 EventSource 接流逐字显示。scriptfunctionsend(){vartextdocument.getElementById(msg).value;varboxdocument.getElementById(box);box.textContent;// EventSource浏览器内置 SSE 客户端只发 GET、服务器单向推varesnewEventSource(/chat/stream?msgencodeURIComponent(text));es.onmessagefunction(e){box.textContente.data;// 每收到一格就追加到页面 → 打字机};es.onerrorfunction(){es.close();};// 服务器关流后手动关防自动重连}/script四、实测输出curl 看到的 SSE 逐字帧以下为 2026-10-05 本机真实运行DeepSeek 实测端口 8086。启动日志关键行Tomcat initialized with port 8086 (http) Adding welcome page: class path resource [static/index.html] Tomcat started on port 8086 (http) with context path / Started StreamApplication in 1.757 secondscurl 打 SSE 接口逐字帧$ curl.exe -N -s http://localhost:8086/chat/stream?msg用一句话介绍郑州 data:郑州 data:是一座 data:兼具 data:三 data:千年 data:商 data:都 data:底蕴 data:与现代 data:枢纽 data:活力的 data:中原 data:核心 data:城市 data:。逐行看不是一行大 JSON而是十几条独立的data:事件每条只有两三个字——这就是 SSE 逐字推流模型吐一点、服务器推一点、curl 收一点。把这些data:后面的字连起来“郑州是一座兼具三千年商都底蕴与现代枢纽活力的中原核心城市。”——完整通顺。浏览器里onmessage每收到一条data:就textContent 一次就成了你看到的打字机。排查提示页面卡住、整段最后才出现多半是没加producesTEXT_EVENT_STREAM_VALUESpring 把响应当普通 JSON 缓冲了。curl 一次性刷出全部忘了加-N默认缓冲。五、挑战题改参数看看会怎样⭐改个长问题把默认问题改成用 100 个字介绍 Spring Boot浏览器里观察长回答是不是更明显地逐字爬出来。答案就在index.html那个 input 的 value。⭐加首字计时在 controller 里记录start()前后时间在onPartialResponse第一次触发时打印首字延迟 xx ms亲手量一次 TTFT。答案在StreamChatController那两个回调里。⭐⭐故意改错 Key把DEEPSEEK_API_KEY临时改错重启看onError是不是触发、前端是不是收到错误、连接是不是正常关掉而不是一直挂着。答案在.onError(...)那行。六、生产环境进阶三个加分项1. SseEmitter 别用 0L 裸奔。教学用new SseEmitter(0L)关掉超时模型生成慢也不断生产要设合理超时并对慢请求兜底不然挂死连接占资源。2. 流式只改善体验不省 token。别误会——流式的 token 计费和阻塞式一样输入输出 token 数不变它只是让你早看到字钱该花还是花。3. 连接断开要处理。用户关页面、网络断了onPartialResponse里的emitter.send会抛 IOException得completeWithError收尾不然线程挂着。七、面试回答模板面试官阻塞式调用和流式调用有什么区别为什么聊天产品都用流式一句话阻塞是等整段生成完一次性返回流式是逐段回调返回。展开阻塞首字延迟高用户干等几秒没反馈流式让用户在第一个字出来时就确认它开始说了体感延迟骤降。聊天类体验必须流式核心指标是首 token 延迟TTFT。指向本课第一节 curl 实测追问SSE 是什么它和 WebSocket 有什么区别SSE 是基于 HTTP 的服务器→浏览器单向推流格式data: ...\n\n浏览器原生EventSource自带自动重连。和 WebSocket 比SSE 单向、普通 HTTP、简单WebSocket 全双工、独立协议、复杂。AI 回答这种单向吐字用 SSE 足够聊天室/对战才上 WebSocket。指向本课第 4 小节对比表追问LangChain4j 里 TokenStream 的三个回调分别干嘛onPartialResponse每来一个词回调一次打字机的字、onCompleteResponse整段生成完回调一次关流、onError出错回调。它是懒启动挂完回调最后必须.start()才真发请求不 start 接口一直挂着。指向本课第 3 小节 StreamChatController追问首 token 延迟TTFT为什么重要流式体验好坏不看总时长看多久出第一个字。第一个字来得快用户就不慌后面字陆续跟上就是舒服的打字机。流式的工程优化很多都围绕怎么让第一个字更快。指向本课第 2 小节八、总结表坑现象解法忘了.start()接口一直挂着没反应TokenStream 懒启动挂完回调必须 start()忘了producesTEXT_EVENT_STREAM_VALUE浏览器最后才一次性收到Spring 把响应当普通 JSON 缓冲了必须加curl 看不到逐字一次性刷出全部curl 加-Nno bufferEventSource 自动重连服务器关流后又请求第二次onerror里手动es.close()SseEmitter 超时模型生成慢被掐断教学new SseEmitter(0L)生产设合理超时旧教程类名编译报错1.21.0 是StreamingChatModel不是旧名九、关于这个系列本文是「Java 后端实战精通营」系列第 5 篇原则实战驱动、由浅到深、面试向每篇文章的结论都可以亲手验证。LangChain4j 实战精通营10 课https://gitee.com/j67mk2/langchain4j-journey本文对应源码位置lesson-05/Web 工程端口 8086内含流式模型 Bean StreamChatControllerSSE 出口 static/index.html打字机页面系列文章一览按发布顺序篇主题1LangChain4j 第一个对话程序手写 LLM 调用链路看清模型怎么开口2LangChain4j 提示词工程模板 few-shot 温度提示词从写死到复用3LangChain4j 结构化输出让模型吐出 Java 对象JSON 不再手写解析4LangChain4j 对话记忆让模型记住聊过什么三种方式实测对比5LangChain4j 流式输出打字机效果 SSE回答不再干等三秒6LangChain4j 工具调用Function Calling 让模型动手查数据多工具连调实测7LangChain4j 向量检索文本怎么变成可搜索的坐标8LangChain4j RAG 流水线手写问答全流程给 AI 开卷考试9LangChain4j Agent多步推理自主调用放手让实习生自己安排工作10LangChain4j 企业智能客服RAG 工具 记忆 流式 兜底验收 5 项全过下一篇预告《LangChain4j 工具调用Function Calling 让模型动手查数据多工具连调实测》——到现在模型都只会动嘴问天气它说我查不了实时天气。下一课用Tool把你的 Java 方法查订单、查库存暴露给模型让它自己判断该调哪个工具、传什么参数。跑完有任何报错把终端输出发评论区一起排查。标签建议LangChain4j、流式输出、SSE摘要建议≤256 字为什么你调模型要干等三秒、整句话一次性蹦出来因为用的是阻塞式调用。本文换成 StreamingChatModel TokenStream让回答一个字一个字往外蹦再用 SSE 把这股字流通过 HTTP 推到浏览器做出 ChatGPT 那样的打字机效果。逐行拆解 TokenStream 三个回调、SseEmitter 出口、EventSource 接收curl 实测看到十几条独立 data: 帧附 SSE vs WebSocket 对比与 3 道挑战题源码在 lesson-05 可 clone 直接跑。