LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒
作者:鱼宵 | LangChain4j实战精通营 · 第 5 篇
你用过 ChatGPT 吧?你问个问题,回答是一个字一个字往外蹦的,像打字机。但你有没有想过:为什么前几课我们调模型,都是"转半天圈圈,然后整句话一次性砸出来"?
差的就是一个流式。前四课用的是阻塞式ChatModel——模型在后台把整段话生成完,网络才一次性把整段文本递过来,你干等几秒没任何反馈,特别焦虑。这一课换成StreamingChatModel+TokenStream,让模型吐一个词就回调一次,再用 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(Consumer<String>) | 每来一小段文本 | 把这个词推给前端(打字机的字) |
.onCompleteResponse(...) | 整段生成完 | 关闭 SSE 连接 |
.onError(Consumer<Throwable>) | 出错 | 把错误交给前端 |
.start() | 手动调用 | 不调用就不发请求,最后必须调 |
assistant.chat(msg).onPartialResponse(token->System.out.print(token))// 逐字打印.onCompleteResponse(r->System.out.println("\n[完]")).onError(err->err.printStackTrace()).start();// 真正发请求4. SSE:服务器单向推流,比 WebSocket 简单
SSE(Server-Sent Events):HTTP 的一种"长连接推流"协议。浏览器发一次普通 GET,服务器保持连接不关,陆续往里面写data: 你好\n\n格式的事件,浏览器每收到一段就触发一次onmessage。
为什么用它做打字机:方向是"服务器→浏览器"单向,聊天回答正是这个方向;基于普通 HTTP,浏览器有原生EventSource,比 WebSocket 简单。面试常被追问和 WebSocket 的区别:
| 对比 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务器→浏览器单向 | 全双工双向 |
| 协议 | 普通 HTTP | 独立 ws:// |
| 浏览器原生 | EventSource | WebSocket对象 |
| 适合 | 流式回答、推送 | 聊天室、对战 |
一句话:只需要"服务器单方面吐数据"(AI 回答)就用 SSE;要"双方随时互发"才上 WebSocket。
二、动手:启动 Web 服务,浏览器看打字机
本课是 Web 课,端口8086,进程常驻不会自己退出。
cd langchain4j-journey\lesson-05$env:JAVA_HOME="C:\Program Files\Java\jdk-17"mvn clean install-DskipTests java-Dfile.encoding=UTF-8-jar target\lesson-05-1.0.0.jar看到Tomcat started on port 8086即就绪。浏览器打开http://localhost:8086/,默认问题已填好,点"发送"——下面的字会一个一个蹦出来。
想看 SSE 原始帧,另开一个 PowerShell:
curl.exe-N-s"http://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;/** 流式模型 Bean:builder 参数和阻塞式 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.assistant=assistant;}@GetMapping(value="/chat/stream",produces=MediaType.TEXT_EVENT_STREAM_VALUE)publicSseEmitterchatStream(@RequestParam(defaultValue="用三句话介绍郑州")Stringmsg){SseEmitteremitter=newSseEmitter(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 接流,逐字显示。
<script>functionsend(){vartext=document.getElementById('msg').value;varbox=document.getElementById('box');box.textContent='';// EventSource:浏览器内置 SSE 客户端,只发 GET、服务器单向推vares=newEventSource('/chat/stream?msg='+encodeURIComponent(text));es.onmessage=function(e){box.textContent+=e.data;// 每收到一格就追加到页面 → 打字机};es.onerror=function(){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 +=一次,就成了你看到的打字机。
排查提示:页面卡住、整段最后才出现?多半是没加
produces=TEXT_EVENT_STREAM_VALUE,Spring 把响应当普通 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() |
忘了produces=TEXT_EVENT_STREAM_VALUE | 浏览器最后才一次性收到 | Spring 把响应当普通 JSON 缓冲了,必须加 |
| curl 看不到逐字 | 一次性刷出全部 | curl 加-N(no buffer) |
| EventSource 自动重连 | 服务器关流后又请求第二次 | 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打字机页面)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口" |
| 2 | LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用 |
| 3 | LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析 |
| 4 | LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比 |
| 5 | LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒 |
| 6 | LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测 |
| 7 | LangChain4j 向量检索:文本怎么变成可搜索的坐标 |
| 8 | LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试 |
| 9 | LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作 |
| 10 | LangChain4j 企业智能客服: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 直接跑。