Spring Ai超简单教程系列 - 02流式对话

在网上找了一些Spring AI相关的教程,因为官方API更新较快的原因,大部分教程的内容都已过时。所以在参照官方参考文档学习的同时,沉淀每篇文章,为同样热爱学习的你,提供参考。
本系列教程参考Spring AI官方1.1.x版本文档:https://docs.spring.io/spring-ai/reference/1.1/index.html

流式对话

快速入门中我们通过阻塞式API调用的方式,实现了大模型调用。受限于大模型响应速度的原因,阻塞式调用对用户体验感不好,主流调用方式还是通过SSE协议实现流式响应。

流式响应接口

@GetMapping(value = "/ai/chat/stream", produces = "text/event-stream;charset=UTF-8")
public Flux<String> stream(@RequestParam(value = "message") String message) {
    return this.chatModel.stream(new Prompt(message))
            .filter(chatResponse -> chatResponse.getResult() != null
                    && chatResponse.getResult().getOutput().getText() != null)
            .map(chatResponse -> chatResponse.getResult().getOutput().getText())
            .filter(StringUtils::hasText);
}

接口调用

image
data:SSE(Server-Sent Events)本身的协议格式响应头。

前端调用

AI生成的SSE前端调用代码:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vue SSE 流式 Markdown 渲染</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  <style>
    body { font-family: Arial, sans-serif; padding: 20px; }
    .box { border: 1px solid #ddd; padding: 12px; border-radius: 8px; }
    .output { margin-top: 10px; min-height: 80px; }
    .output pre { background: #f6f8fa; padding: 10px; border-radius: 6px; overflow-x: auto; }
    .output code { background: #f6f8fa; padding: 2px 4px; border-radius: 3px; }
    .output pre code { background: none; padding: 0; }
    .output table { border-collapse: collapse; }
    .output th, .output td { border: 1px solid #ddd; padding: 4px 8px; }
    .status { color: #888; margin-bottom: 8px; }
  </style>
</head>
<body>
<div id="app">
  <chat-stream></chat-stream>
</div>

<script>
  const { createApp, ref, computed, onMounted, onBeforeUnmount } = Vue;

  const ChatStream = {
    template: `
        <div class="box">
          <h3>SSE 流式输出(Markdown)</h3>
          <div class="status">
            <span v-if="running">正在接收…</span>
            <span v-else-if="error" style="color: #c00">{{ error }}</span>
            <span v-else-if="output">完成</span>
            <span v-else>等待中…</span>
          </div>
          <div class="output" v-html="rendered"></div>
        </div>
      `,
    setup() {
      const output = ref('');
      const running = ref(false);
      const error = ref('');
      let abortController = null;

      // 将流式拼接的原文按 markdown 渲染
      const rendered = computed(() =>
        output.value ? marked.parse(output.value) : ''
      );

      const stopStream = () => {
        running.value = false;
        if (abortController) {
          abortController.abort();
          abortController = null;
        }
      };

      // 页面加载即从 query 参数读取 message,编码后请求 SSE 接口
      const startStream = async () => {
        const params = new URLSearchParams(window.location.search);
        const message = params.get('message') || '';
        const url = '/ai/chat/stream?message=' + encodeURIComponent(message);

        output.value = '';
        error.value = '';
        running.value = true;
        abortController = new AbortController();

        try {
          const res = await fetch(url, {
            headers: { 'Accept': 'text/event-stream' },
            signal: abortController.signal
          });
          if (!res.ok || !res.body) {
            throw new Error('请求失败:HTTP ' + res.status);
          }

          const reader = res.body.getReader();
          const decoder = new TextDecoder();
          let buffer = '';

          // 按块读取并解析 SSE,读到流关闭(done)自然结束,不会重连
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;

            buffer += decoder.decode(value, { stream: true });

            // SSE 事件以空行分隔
            const events = buffer.split('\n\n');
            buffer = events.pop(); // 最后一段可能不完整,留到下一轮

            for (const event of events) {
              const data = event
                .split('\n')
                .filter(line => line.startsWith('data:'))
                // EventSource 语义:多个 data: 行用换行拼接
                .map(line => line.slice(5).replace(/^ /, ''))
                .join('\n');
              if (data) {
                output.value += data;
              }
            }
          }
        } catch (e) {
          // 手动停止不算错误
          if (e.name !== 'AbortError') {
            error.value = e.message || '连接中断';
          }
        } finally {
          running.value = false;
          abortController = null;
        }
      };

      onMounted(startStream);
      onBeforeUnmount(stopStream);

      return { output, running, error, rendered };
    }
  };

  createApp({
    components: { ChatStream }
  }).mount('#app');
</script>
</body>
</html>

添加Web模版引擎

引入thymeleaf模版引擎组件依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

增加thymeleaf配置项:

spring.thymeleaf.cache=false
spring.thymeleaf.encoding=UTF-8

页面跳转端点

@Controller
public class PageController {

    @GetMapping("/page/stream")
    String toStream() {
        return "chat_stream";
    }

}

页面调用

录屏2026-09-02 17.29.40

posted @ 2026-09-02 17:37  codest  阅读(4)  评论(0)    收藏  举报