第5篇:SpringBoot实现流式输出,和ChatGPT体验一模一样
(Java+AI落地实战系列 | 复制可用 | 生产可落地)
大家好,我是三石。
上一篇我们用10分钟跑通了SpringBoot对接通义千问,实现了最基础的AI对话接口。
但是那个版本有个问题:
你一问,AI要“憋半天”才把完整答案吐出来。
如果答案短,还能接受;
如果是一段长代码、一篇技术方案、一次故障排查思路,用户就会盯着转圈加载器怀疑人生。
这就是真实项目里必须解决的第二个问题:流式输出。
先搞懂:什么是流式输出?
普通对话是这样的:
- 用户发送问题
- 后端完整调用大模型
- 大模型把全部答案生成完
- 后端一次性返回给前端
体验像这样:
用户:SpringBoot怎么实现流式输出?
等待3秒……
AI:下面我给你完整代码……
而流式输出是这样的:
- 用户发送问题
- 大模型一边生成,一边把内容返回
- 前端逐字展示
- 用户看到的是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. 权限校验
不要把流式接口做成公开接口。
真实业务里要加:
- 登录态校验
- 接口权限
- 限流
- 用户配额
- 日志审计
十、本篇小结
这一篇我们实现了流式输出。
核心点只有几个:
- 接口返回类型改为
SseEmitter - 设置
produces = TEXT_EVENT_STREAM_VALUE - 开启
incrementalOutput(true) - 使用异步线程处理流式回调
- 前端用
EventSource接收并逐块渲染
到这里,你的AI对话接口已经从“能用”变成“像个真正产品”了。
下篇预告
现在我们能对话了,也能流式输出了。
但还有一个问题:AI不记得你刚才说过什么。
比如你问:
帮我写一个单例模式
AI写完后,你再问:
用枚举方式优化一下
如果没有对话记忆,AI根本不知道你在说哪个单例。
所以下一篇我们讲:
第6篇:对话记忆怎么实现?Java版多轮上下文对话完整方案
内容会覆盖:
- 本地缓存实现会话记忆
- Redis实现分布式会话记忆
- Token超限自动裁剪
- 单轮/多轮两种接口设计
- 生产环境会话清理策略
🎁 粉丝福利
本篇完整代码已整理进系列源码包,包含:
- 第5篇流式输出完整Controller
- 异步线程池配置
- CORS跨域配置
- 前端SSE接收示例
- 后续第6篇会话记忆代码同步更新
关注Java-AI工程师,后台回复:
系列源码
即可免费领取。
每更新一篇,我都会往资料包里新增对应源码,方便你跟着系列持续落地。