你一定用过
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事件循环 | 逐行/逐事件读取,直到结束标志 |
用代码表达就是:
java
// 非流式 = collect再处理(用户干等)
List<String> result = stream.collect(Collectors.toList());
return result; // 全部收集完才返回
// 流式 = 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已经同时支持同步和流式模型。
配置流式模型
yaml
langchain4j:
open-ai:
streaming-chat-model:
api-key: ${DEEPSEEK_API_KEY:sk-xxxxx}
model-name: deepseek-chat
base-url: https://api.deepseek.com/v1
注意key的变化:chat-model → streaming-chat-model。Spring Boot会自动注入一个StreamingChatLanguageModel Bean。
后端Controller:SSE流式接口
java
@RestController
@RequestMapping("/api/chat")
public class StreamChatController {
private final StreamingChatLanguageModel streamingModel;
public StreamChatController(StreamingChatLanguageModel streamingModel) {
this.streamingModel = streamingModel;
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String message) {
// 超时5分钟,LLM长回答可能需要较长时间
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
streamingModel.generate(message, new StreamingResponseHandler<AiMessage>() {
@Override
public void onNext(String token) {
try {
// 每生成一个token就推送一次
emitter.send(SseEmitter.event()
.name("chunk")
.data(Map.of("content", token)));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
@Override
public void onComplete(Response<AiMessage> response) {
try {
emitter.send(SseEmitter.event()
.name("done")
.data(Map.of("totalTokens",
response.tokenUsage().totalTokenCount())));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
@Override
public void onError(Throwable error) {
emitter.completeWithError(error);
}
});
return emitter;
}
}
这个接口做的事情很简单:
- 创建一个
SseEmitter,设置5分钟超时 - 调用
streamingModel.generate(),传入回调处理器 - 每收到一个token就通过
emitter.send()推送给前端 - 生成完毕调用
emitter.complete()关闭连接
前端:打字机效果
前端用浏览器原生的EventSource API接收SSE流。下面是一个完整的HTML页面,复制到src/main/resources/static/index.html即可运行:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="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 8px rgba(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; }
@keyframes blink { 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>
<div class="container">
<div class="chat-box">
<div id="response">等待提问...</div>
</div>
<div class="input-row">
<input id="input" placeholder="输入你的问题..." />
<button id="sendBtn" onclick="sendMessage()">发送</button>
</div>
<div class="status" id="status"></div>
</div>
<script>
let eventSource = null;
function sendMessage() {
const input = document.getElementById('input');
const message = input.value.trim();
if (!message) return;
// 重置UI
document.getElementById('response').innerHTML = '';
document.getElementById('sendBtn').disabled = true;
document.getElementById('status').textContent = '生成中...';
input.value = '';
// 关闭上一个连接
if (eventSource) eventSource.close();
// 建立SSE连接
const url = `/api/chat/stream?message=${encodeURIComponent(message)}`;
eventSource = new EventSource(url);
const responseEl = document.getElementById('response');
// 监听chunk事件------每个token到达时触发
eventSource.addEventListener('chunk', function(e) {
const data = JSON.parse(e.data);
responseEl.textContent += data.content;
});
// 监听done事件------生成完毕
eventSource.addEventListener('done', function(e) {
const data = 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/xiaoxin(XiaoXingChatWebsocketServer) |
小星AI对话 |
| 主对话 | WebSocket | SimpleChatWebsocketServer |
简洁模式对话 |
| 主对话 | WebSocket | PlanChatWebsocketServer |
教案生成对话 |
| 主对话 | WebSocket | MultimodalChatWebsocketServer |
多模态对话 |
| 辅助功能 | SSE | AudioMindMapController |
录音思维导图生成 |
为什么主对话用WebSocket而不是SSE? 核心原因是对话场景需要双向通信------用户可以随时中断生成(点"停止"按钮),这需要客户端主动发消息给服务端。SSE是单向的,做不到。
为什么思维导图用SSE而不是WebSocket? 因为它是"触发一次→等结果"的单向推送场景,不需要用户中途交互。SSE比WebSocket轻量得多:不需要维护长连接、不需要心跳、用标准HTTP就能走------一行@GetMapping(produces = TEXT_EVENT_STREAM_VALUE)搞定。
选型结论: 需要双向通信(对话、协作)→ WebSocket;只需服务端推送(进度条、流式生成)→ SSE。不要为了"统一"而强行只用一种。
实录2:客户端断了怎么办------MindMapEmitterRef的clientGone模式
生产环境有一个高频场景:用户触发了思维导图生成,但中途关掉了页面。此时SSE连接断开,如果你的代码直接往SseEmitter.send()里写,会抛IOException。更危险的是,如果异常没处理好,LLM的流式调用也跟着被中断------结果没入库,用户下次打开还得重新生成。
知了项目中,我们封装了一个MindMapEmitterRef来解决这个问题。核心思路:
java
// 源码来自:MindMapEmitterRef.java
class MindMapEmitterRef {
private final SseEmitter emitter;
private volatile boolean clientGone = false; // 关键标记
private void sendInternal(String eventName, String dataJson) {
if (clientGone) {
return; // 客户端已断开,直接跳过,不抛异常
}
try {
emitter.send(SseEmitter.event().name(eventName).data(dataJson));
} catch (IOException e) {
clientGone = true; // 标记断连,后续所有send变成no-op
log.warn("SSE send 失败,标记 clientGone,后续不再发送");
} catch (IllegalStateException e) {
clientGone = true; // emitter已被complete/timeout
}
}
}
调用方的逻辑也很简单------不管客户端在不在,LLM流都跑完、结果都入库:
java
// 源码来自:MindMapServiceImpl.java
llmApi.chat(AbilityConstant.Scene.DEFAULT_LLM)
.userInput(userInput)
.consumer(resp -> {
String delta = 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中间层透传------宁晓智项目的bridging架构
宁晓智(Copaw)项目的架构更典型:前端不直接连LLM,中间有一层bridging服务。架构如下:
前端 ←─SSE─→ bridging服务 ←─HTTP─→ LLM Gateway ←─→ 大模型
bridging服务的ChatService做的是SSE透传:从LLM Gateway读取流式响应,经过处理后逐事件转发给前端:
java
// 源码来自:ChatService.java(简化)
// 1. 从LLM Gateway读取SSE流
BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, StandardCharsets.UTF_8));
SseEventParser parser = new SseEventParser();
OutputSafetyFilter safetyFilter = new OutputSafetyFilter(sensitiveValues, brandName);
boolean clientAlive = true;
// 2. 逐行读取、过滤、转发
while ((line = reader.readLine()) != null) {
fullResponseBuilder.append(line).append('\n'); // 无论如何都累积完整响应
String eventData = parser.feedLine(line);
if (eventData == null) continue;
String filtered = safetyFilter.processEvent(eventData); // 敏感信息过滤
if (!clientAlive) continue; // 客户端断了就不推了,但流要读完
try {
outputStream.write(filtered.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch (IOException clientAbortEx) {
clientAlive = false; // 和MindMapEmitterRef同样的思路
log.warn("客户端已断开,继续消费响应完成入库");
}
}
中间层的价值不只是"转发",更重要的是可以在流中注入和过滤:
- 用户画像注入 :
ChatContextAssemblerManage在请求发给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调用 - 挑战 :参考
MindMapEmitterRef的设计,封装一个你自己的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元。