第5篇:SpringBoot实现流式输出,和ChatGPT体验一模一样 (Java+AI落地实战系列 | 复制可用 | 生产可落地)

16 阅读7分钟

第5篇:SpringBoot实现流式输出,和ChatGPT体验一模一样

(Java+AI落地实战系列 | 复制可用 | 生产可落地)


大家好,我是三石。

上一篇我们用10分钟跑通了SpringBoot对接通义千问,实现了最基础的AI对话接口。

但是那个版本有个问题:
你一问,AI要“憋半天”才把完整答案吐出来。

如果答案短,还能接受;
如果是一段长代码、一篇技术方案、一次故障排查思路,用户就会盯着转圈加载器怀疑人生。

这就是真实项目里必须解决的第二个问题:流式输出


先搞懂:什么是流式输出?

普通对话是这样的:

  1. 用户发送问题
  2. 后端完整调用大模型
  3. 大模型把全部答案生成完
  4. 后端一次性返回给前端

体验像这样:

用户:SpringBoot怎么实现流式输出?
等待3秒……
AI:下面我给你完整代码……

而流式输出是这样的:

  1. 用户发送问题
  2. 大模型一边生成,一边把内容返回
  3. 前端逐字展示
  4. 用户看到的是AI正在“打字”

体验明显更像现在的ChatGPT、豆包、通义千问网页版。

在这里插入图片描述

一句话总结:
流式输出不是让大模型回复更快,而是让用户感知到“AI正在回答”,减少等待焦虑。


一、技术选型:我们用什么实现?

在SpringBoot里做流式输出,常见有几种方式:

方案适合场景推荐度
返回字符串,一次性返回简单Demo、短答案
SSE /api/chat/stream网页端、后台系统、企业应用★★★★★
WebSocket高并发聊天应用、IM场景★★★★
长轮询兼容旧浏览器★★

本篇我们用 SSE

原因很简单:

  • 不需要维护WebSocket连接
  • 不需要额外处理前后端复杂协议
  • 接口调试方便
  • 企业后台、智能客服、知识库问答系统里最常用
  • SpringBoot原生支持较好

在这里插入图片描述

二、先回顾核心依赖

上一篇我们已经引入了这些依赖,本篇继续沿用:

<dependencies>
    <!-- SpringBoot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.7.18</version>
    </dependency>

    <!-- 通义千问官方SDK -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>dashscope-sdk-java</artifactId>
        <version>2.14.5</version>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.30</version>
        <optional>true</optional>
    </dependency>
</dependencies>

注意:通义千问SDK版本建议保持在 2.14.5 附近。
旧版本对流式输出的回调处理不够稳定,容易出现断句、重复、提前结束等问题。


三、配置文件不变,但接口要改

application.yml 基本不用动:

server:
  port: 8080

ai:
  qwen:
    api-key: sk-xxxxxxxxxxxxxxxx
    model: qwen-turbo
    temperature: 0.7
    max-tokens: 1024

但核心变化是:

以前我们返回的是一个完整字符串。
现在我们要返回 持续流


四、核心代码:SSE流式对话接口

我们直接新建一个 StreamChatController

@RestController
@RequestMapping("/api/chat")
public class StreamChatController {

    @Value("${ai.qwen.api-key}")
    private String apiKey;

    @Value("${ai.qwen.model}")
    private String model;

    @Value("${ai.qwen.temperature}")
    private Float temperature;

    @Value("${ai.qwen.max-tokens}")
    private Integer maxTokens;

    /**
     * 流式对话接口
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter streamChat(String message) {

        // 1. 创建SSE发射器,设置超时时间
        SseEmitter emitter = new SseEmitter(60 * 1000L);

        // 2. 异步处理流式请求
        CompletableFuture.runAsync(() -> {
            DashScope dashScope = new DashScope(apiKey);

            // 3. 构建消息列表
            List<Message> messages = Arrays.asList(
                Message.builder()
                        .role(Role.SYSTEM)
                        .content("你是专业的Java开发助手,回答精准简洁,代码注释清晰。")
                        .build(),
                Message.builder()
                        .role(Role.USER)
                        .content(message)
                        .build()
            );

            // 4. 构建流式请求
            GenerationRequest request = GenerationRequest.builder()
                    .model(model)
                    .input(MessageInput.builder().messages(messages).build())
                    .temperature(temperature)
                    .maxTokens(maxTokens)
                    .incrementalOutput(true)
                    .resultFormat(ResultFormat.MESSAGE)
                    .build();

            // 5. 调用流式接口并处理回调
            dashScope.streamCall(request, new ResultCallback<GenerationResult>() {

                @Override
                public void onEvent(GenerationResult result) {
                    try {
                        String content = result.getOutput()
                                .getChoices()
                                .get(0)
                                .getMessage()
                                .getContent();

                        if (StrUtil.isNotBlank(content)) {
                            emitter.send(SseEmitter.event().data(content));
                        }
                    } catch (IOException e) {
                        emitter.completeWithError(e);
                    }
                }

                @Override
                public void onComplete() {
                    emitter.complete();
                }

                @Override
                public void onError(Exception e) {
                    emitter.completeWithError(e);
                }
            });

        }, AsyncTaskExecutorConfig.taskExecutor);

        return emitter;
    }
}

这里最关键的一行是:

.incrementalOutput(true)

它告诉大模型:
不要等全部生成完,生成一块就返回一块。


五、异步线程池配置

上面代码里用到了 AsyncTaskExecutorConfig.taskExecutor

为什么不直接用线程池?
因为流式输出如果不用异步处理,容易占用请求线程,导致接口超时或阻塞。

我们新建一个配置类:

@Configuration
public class AsyncTaskExecutorConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("ai-stream-task-");
        executor.initialize();
        return executor;
    }

    public static Executor taskExecutor;

    @PostConstruct
    public void init() {
        taskExecutor = taskExecutor();
    }
}

这个配置在Demo阶段可以先用。
真实生产环境要根据你的并发量调整:

  • 内部后台系统:核心线程数不用太大,5-10够用
  • 面向用户的智能客服:可以调到10-30
  • 高并发场景:要结合限流、排队、超时一起设计

六、前端怎么接?

后端接口地址是:

GET http://localhost:8080/api/chat/stream?message=用Java写一个冒泡排序

前端可以用 EventSource 接收:

const source = new EventSource('http://localhost:8080/api/chat/stream?message=用Java写一个冒泡排序');

source.onmessage = function(event) {
    const content = event.data;
    console.log('AI正在回复:', content);
    // 把content追加到页面DOM里
    document.getElementById('answer').innerHTML += content;
};

source.onerror = function(error) {
    console.error('SSE连接失败', error);
    source.close();
};

这样前端就能看到AI一个字一个字往外吐。

在这里插入图片描述

七、测试时怎么验证?

不要用普通Postman JSON请求测流式接口。

推荐两种方式:

方式一:浏览器直接访问

http://localhost:8080/api/chat/stream?message=解释一下SpringBoot的IOC

如果看到内容持续输出,说明成功。

方式二:Apifox / Postman使用SSE调试

在Apifox里选择:

接口协议:SSE
请求方式:GET

输入参数后发送,就能看到持续返回的数据流。 在这里插入图片描述

八、常见坑:为什么你的流式输出不“流”?

坑1:没有开启 incrementalOutput(true)

这个是核心。
不开启的话,大模型还是会一次性返回。

坑2:接口返回类型没写对

必须是:

produces = MediaType.TEXT_EVENT_STREAM_VALUE

如果返回普通 application/json,浏览器不会把它当成SSE流处理。

坑3:前端跨域

如果前端和后端端口不一致,会出现跨域问题。

可以先加一个CORS配置:

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return new CorsFilter(source);
    }
}

注意:本地开发可以这样临时处理。
生产环境不要直接 *,要限制真实域名。

坑4:超时时间太短

SSE连接默认不能太短。
如果大模型生成答案需要时间,超时设置太短会导致连接断开。

SseEmitter emitter = new SseEmitter(60 * 1000L);

这个是60秒,适合普通问答场景。


九、真实项目里还需要考虑什么?

Demo跑通只是第一步。
生产环境还要考虑这几件事:

1. 连接断开处理

用户关闭页面后,服务端要及时释放资源,不要继续推送给已经断开的客户端。

2. 错误重试

流式接口失败后,不能让用户一直转圈。
要给前端明确提示:

AI响应超时,请重新提问

3. 内容拼接

有些流式接口返回的内容可能是一块一块的。
前端要负责把它们拼接成完整答案。

4. 代码块、Markdown渲染

如果AI返回代码,前端要能识别:

```java
public void bubbleSort(int[] arr) {
    // 排序逻辑
}

否则用户看到的只是纯文本,体验会打折扣。

5. 权限校验

不要把流式接口做成公开接口。
真实业务里要加:

  • 登录态校验
  • 接口权限
  • 限流
  • 用户配额
  • 日志审计

在这里插入图片描述

十、本篇小结

这一篇我们实现了流式输出。

核心点只有几个:

  1. 接口返回类型改为 SseEmitter
  2. 设置 produces = TEXT_EVENT_STREAM_VALUE
  3. 开启 incrementalOutput(true)
  4. 使用异步线程处理流式回调
  5. 前端用 EventSource 接收并逐块渲染

到这里,你的AI对话接口已经从“能用”变成“像个真正产品”了。


下篇预告

现在我们能对话了,也能流式输出了。
但还有一个问题:AI不记得你刚才说过什么。

比如你问:

帮我写一个单例模式

AI写完后,你再问:

用枚举方式优化一下

如果没有对话记忆,AI根本不知道你在说哪个单例。

所以下一篇我们讲:

第6篇:对话记忆怎么实现?Java版多轮上下文对话完整方案

内容会覆盖:

  • 本地缓存实现会话记忆
  • Redis实现分布式会话记忆
  • Token超限自动裁剪
  • 单轮/多轮两种接口设计
  • 生产环境会话清理策略

🎁 粉丝福利

本篇完整代码已整理进系列源码包,包含:

  • 第5篇流式输出完整Controller
  • 异步线程池配置
  • CORS跨域配置
  • 前端SSE接收示例
  • 后续第6篇会话记忆代码同步更新

关注Java-AI工程师,后台回复:

系列源码

即可免费领取。
每更新一篇,我都会往资料包里新增对应源码,方便你跟着系列持续落地。