ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒

LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒

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 的区别:

对比SSEWebSocket
方向服务器→浏览器单向全双工双向
协议普通 HTTP独立 ws://
浏览器原生EventSourceWebSocket对象
适合流式回答、推送聊天室、对战

一句话:只需要"服务器单方面吐数据"(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 seconds

curl 打 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(默认缓冲)。

五、挑战题:改参数,看看会怎样

  1. ⭐改个长问题:把默认问题改成"用 100 个字介绍 Spring Boot",浏览器里观察长回答是不是更明显地"逐字爬出来"。答案就在index.html那个 input 的 value。
  2. ⭐加首字计时:在 controller 里记录start()前后时间,在onPartialResponse第一次触发时打印"首字延迟 xx ms",亲手量一次 TTFT。答案在StreamChatController那两个回调里。
  3. ⭐⭐故意改错 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打字机页面)

系列文章一览(按发布顺序):

篇主题
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 直接跑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进