Spring Boot + Spring AI 从入门到实战:7天转型计划 Day 5 —— SSE 流式输出 + 打字机效果

写在前面

作为一名技术人,我深知学习新框架时的迷茫与焦虑。尤其是当你已经熟悉了一套技术栈,想要转向另一个生态时,那种"无从下手"的感觉尤为强烈。

最近,我开始系统学习Spring Boot生态,并尝试将Spring AI集成到实际项目中。经过一段时间的摸索,我整理出了一份7天学习路线图,目标是让有编程基础的开发者能快速上手Spring Boot + Spring AI,独立完成一个完整的AI对话应用。

这份计划不是零基础教程,而是为有一定后端开发经验(不限于Java)的朋友量身定制的"快速转型指南"。如果你对Python、Node.js或其他后端语言已有了解,这份计划将帮助你在最短时间内建立Spring Boot的核心认知,并跟上AI应用开发的浪潮。

下面,我将分享这7天的学习中的第五天内容,SSE 流式输出 + 打字机效果。

一、开启异步支持

Spring AI 的流式调用需要在异步线程中执行,否则会阻塞主线程。

修改 ChatApiApplication.java:

kotlin 复制代码
package com.example.chatapi;


import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;


@SpringBootApplication
@MapperScan("com.example.chatapi.mapper")
@EnableAsync          // 开启异步支持
public class ChatApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(ChatApiApplication.class, args);
    }
}

二、配置异步线程池

创建 config/AsyncConfig.java:

java 复制代码
package com.example.chatapi.config;


import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;


@Configuration
public class AsyncConfig implements AsyncConfigurer {


    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);        // 核心线程数
        executor.setMaxPoolSize(10);        // 最大线程数
        executor.setQueueCapacity(100);     // 队列容量
        executor.setThreadNamePrefix("sse-async-");
        executor.initialize();
        return executor;
    }
}

三、创建流式对话 DTO

创建 dto/StreamChatRequest.java:

kotlin 复制代码
package com.example.chatapi.dto;


import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;


@Data
public class StreamChatRequest {


    @Min(value = 1, message = "会话ID无效")
    private Long sessionId;


    @NotBlank(message = "消息内容不能为空")
    private String message;
}

四、创建流式对话 Service

创建 service/StreamChatService.java:

scss 复制代码
package com.example.chatapi.service;


import com.example.chatapi.entity.Message;
import com.example.chatapi.mapper.MessageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;


@Slf4j
@Service
@RequiredArgsConstructor
public class StreamChatService {


    private final MessageMapper messageMapper;
    private final ChatClient chatClient;


    private static final long SSE_TIMEOUT = 120000L;  // 2分钟超时


    public SseEmitter streamChat(Long sessionId, String userMessage) {
        SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);


        // 超时处理
        emitter.onTimeout(() -> {
            log.warn("SSE 超时: sessionId={}", sessionId);
            emitter.complete();
        });


        // 错误处理
        emitter.onError((ex) -> {
            log.error("SSE 错误: sessionId={}", sessionId, ex);
            emitter.completeWithError(ex);
        });


        // 异步执行流式调用
        doStreamChat(sessionId, userMessage, emitter);


        return emitter;
    }


    @Async
    public void doStreamChat(Long sessionId, String userMessage, SseEmitter emitter) {
        try {
            // 1. 先保存用户消息
            Message userMsg = new Message();
            userMsg.setSessionId(sessionId);
            userMsg.setRole("user");
            userMsg.setContent(userMessage);
            userMsg.setCreatedAt(LocalDateTime.now());
            messageMapper.insert(userMsg);
            log.info("用户消息已保存: id={}", userMsg.getId());


            // 2. 发送开始标记
            emitter.send(SseEmitter.event().name("start").data("开始生成回复..."));


            // 3. 流式调用 AI,逐块推送
            StringBuilder fullResponse = new StringBuilder();


            chatClient.prompt(userMessage).stream().content()
                .doOnNext(chunk -> {
                    fullResponse.append(chunk);
                    try {
                        emitter.send(SseEmitter.event()
                            .name("chunk")
                            .data(chunk));
                        log.debug("发送块: {}", chunk);
                    } catch (IOException e) {
                        log.error("发送块失败", e);
                        throw new RuntimeException(e);
                    }
                })
                .doOnError(error -> {
                    log.error("AI 流式调用失败", error);
                    try {
                        emitter.send(SseEmitter.event()
                            .name("error")
                            .data("AI服务出错: " + error.getMessage()));
                        emitter.completeWithError(error);
                    } catch (IOException e) {
                        log.error("发送错误信息失败", e);
                    }
                })
                .doOnComplete(() -> {
                    // 4. 保存 AI 完整回复到数据库
                    String aiResponse = fullResponse.toString();
                    if (!aiResponse.isEmpty()) {
                        Message aiMsg = new Message();
                        aiMsg.setSessionId(sessionId);
                        aiMsg.setRole("assistant");
                        aiMsg.setContent(aiResponse);
                        aiMsg.setCreatedAt(LocalDateTime.now());
                        messageMapper.insert(aiMsg);
                        log.info("AI回复已保存: id={}, 长度={}", aiMsg.getId(), aiResponse.length());
                    }


                    // 5. 发送结束标记
                    try {
                        emitter.send(SseEmitter.event().name("done").data(""));
                        emitter.complete();
                        log.info("SSE 流式响应完成: sessionId={}", sessionId);
                    } catch (IOException e) {
                        log.error("发送完成标记失败", e);
                    }
                })
                .subscribe();


        } catch (Exception e) {
            log.error("流式对话处理失败", e);
            try {
                emitter.send(SseEmitter.event()
                    .name("error")
                    .data("处理失败: " + e.getMessage()));
                emitter.completeWithError(e);
            } catch (IOException ex) {
                log.error("发送错误信息失败", ex);
            }
        }
    }
}

代码解读:

  • SseEmitter:Spring 提供的 SSE 推送器
  • @Async:在独立线程中执行,不阻塞主线程
  • .stream().content():返回 Flux,逐块推送
  • doOnNext():每收到一块就推送给前端
  • doOnComplete():所有块收完后保存到数据库并结束

五、创建流式 Controller

创建 controller/StreamController.java:

less 复制代码
package com.example.chatapi.controller;


import com.example.chatapi.dto.StreamChatRequest;
import com.example.chatapi.service.SessionService;
import com.example.chatapi.service.StreamChatService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;


@Slf4j
@RestController
@RequestMapping("/api/stream")
@RequiredArgsConstructor
public class StreamController {


    private final StreamChatService streamChatService;
    private final SessionService sessionService;


    /**
     * GET 方式流式对话
     * GET /api/stream/chat?sessionId=1&message=你好
     */
    @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter streamChat(
            @RequestParam Long sessionId,
            @RequestParam String message) {


        log.info("接收流式对话请求(GET): sessionId={}, message={}", sessionId, message);


        if (!sessionService.exists(sessionId)) {
            SseEmitter emitter = new SseEmitter();
            try {
                emitter.send(SseEmitter.event()
                    .name("error")
                    .data("会话不存在,请先创建会话"));
                emitter.complete();
            } catch (Exception e) {
                log.error("发送错误信息失败", e);
            }
            return emitter;
        }


        return streamChatService.streamChat(sessionId, message);
    }


    /**
     * POST 方式流式对话(推荐)
     * POST /api/stream/chat
     * Body: {"sessionId": 1, "message": "你好"}
     */
    @PostMapping(value = "/chat", 
                 consumes = "application/json", 
                 produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter streamChatPost(@Valid @RequestBody StreamChatRequest request) {
        log.info("接收流式对话请求(POST): sessionId={}, message={}", 
            request.getSessionId(), request.getMessage());


        if (!sessionService.exists(request.getSessionId())) {
            SseEmitter emitter = new SseEmitter();
            try {
                emitter.send(SseEmitter.event()
                    .name("error")
                    .data("会话不存在,请先创建会话"));
                emitter.complete();
            } catch (Exception e) {
                log.error("发送错误信息失败", e);
            }
            return emitter;
        }


        return streamChatService.streamChat(request.getSessionId(), request.getMessage());
    }
}

六、配置全局跨域

创建 config/WebConfig.java:

kotlin 复制代码
package com.example.chatapi.config;


import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;


@Configuration
public class WebConfig implements WebMvcConfigurer {


    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:3000", "http://127.0.0.1:3000")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

七、测试流式接口

7.1 使用 curl 命令测试

arduino 复制代码
curl -N -H "Accept: text/event-stream" \
  "http://localhost:8080/api/stream/chat?sessionId=1&message=你好"

预期输出格式:

makefile 复制代码
event:start
data:开始生成回复...


event:chunk
data:你


event:chunk
data:好


event:chunk
data:!

学习建议: 观察数据是逐块到达还是一次性到达。理解 SSE 和普通 HTTP 请求的本质区别。

欢迎关注我的公众号(onething365),最新的技术与你分享

相关推荐
一个做软件开发的牛马1 小时前
MyBatis-Plus 从零实战:完整搭建可运行 Demo,BaseMapper 零 SQL、Wrapper 条件构造、分页插件与代码生成器详解
java·后端
码事漫谈1 小时前
AI 编程的「三体」架构:OpenSpec + Superpowers + GStack 如何让一个开发者撑起整个研发团队
后端
吃饱了得干活1 小时前
深入解析 OpenFeign:从重试、拦截到负载均衡的全维度实践
后端
onething3651 小时前
Spring Boot + Spring AI 从入门到实战:7天转型计划 Day 6 —— 业务完善 + 会话消息预览
人工智能·后端·全栈
BingoGo2 小时前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
JaguarJack2 小时前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
IT_陈寒2 小时前
SpringBoot自动配置的坑,我爬了三天才出来
前端·人工智能·后端
甲维斯3 小时前
笑抽了!DeepSeek识图,豆包完胜了!
人工智能·deepseek
Lei活在当下12 小时前
【AI手记系列-2026/6/18】iSparto & Harness,Caveman 以及AI时代的生存指南
人工智能·llm·openai