【Java转AI实战】第3讲:流式输出——LLM的“分页查询“,附生产级SSE踩坑实录

你一定用过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-modelstreaming-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;
    }
}

这个接口做的事情很简单:

  1. 创建一个SseEmitter,设置5分钟超时
  2. 调用streamingModel.generate(),传入回调处理器
  3. 每收到一个token就通过emitter.send()推送给前端
  4. 生成完毕调用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接口轮询拿到已生成的内容,实现断线续看

本讲要点

  1. LLM是逐token生成的,流式输出不是优化手段,而是匹配其生成机制的自然选择
  2. SSE协议 = HTTP长连接 + text/event-stream + data: 行格式,Spring Boot用SseEmitter实现
  3. LangChain4j流式调用 :把ChatLanguageModel换成StreamingChatLanguageModel,在onNext回调中逐token推送
  4. 前端用EventSource接收SSEaddEventListener监听自定义事件类型
  5. 生产必须处理客户端断连 :用clientGone标记模式,让LLM流跑完、结果入库,推送失败只是静默跳过
  6. SSE vs WebSocket选型:单向推送用SSE,双向通信用WebSocket,不要为了统一强行二选一

动手练习

  1. 基础:跑通本讲的完整demo(Spring Boot + 前端),体验打字机效果
  2. 进阶 :给流式接口加一个"停止生成"功能。提示:前端eventSource.close()断开连接后,后端SseEmitteronCompletion回调会触发------在这里中断LLM调用
  3. 挑战 :参考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元