Spring AI 2.0:SSE

一、普通接口 vs SSE流式接口核心对比

普通登录接口:一次性 Http 响应,请求→全部数据返回→连接关闭

AI 流式聊天接口:SSE (Server‑Sent‑Events) 长连接,HTTP 不断开,后端分段持续推送文本,全部输出完毕后服务端关闭连接。

两者底层都是 HTTP 协议,但是数据传输模式、前端写法完全不一样。普通 axios axios.get/post 不能直接处理 SSE 流式,axios 会一直等待接口全部返回完毕才拿到结果,看不到中间的打字效果。

项目 普通登录接口(一次性响应) SSE流式聊天接口 Flux
HTTP响应头 Content‑Type: application/json Content‑Type: text/event‑stream
连接生命周期 返回完整数据立刻关闭TCP 长连接,持续推送,结束后关闭
后端写法 call().content() 阻塞调用 stream().content() 返回Flux
axios表现 then一次性拿到全部data 普通axios拿不到分片,需开启responseType:"stream"读取ReadableStream
前端接收时机 全部生成完成后拿到结果 收到一块token就渲染一块,实现打字效果
请求方式 GET / POST 任意 原生EventSource仅GET;POST需手动处理ReadableStream
异常断开 请求直接返回失败 前端可手动调用 close() 关闭长连接

二、SSE方案

方案A:浏览器原生 EventSource(专门处理 SSE,最简单)

缺点:

  • 只支持GET,不能 POST,不能传 JSON 请求体;
  • 不能自定义请求头,无法携带Authorization token;
  • 连接断开浏览器自动偷偷重试,无法控制重试逻辑。
js 复制代码
//Vue3示例
const sse = new EventSource(`/ai/streamChat?sid=${sid}&question=${encodeURIComponent(question)}`)

// 每收到后端推送一块数据触发
sse.onmessage = (event)=>{
  //拼接AI返回的每一段token,实现打字效果
  answer.value += event.data
}

//流式结束/异常
sse.onclose = ()=>{
  console.log("流式结束,连接关闭")
  sse.close()
}
sse.onerror = (err)=>{
  console.error("sse异常",err)
  sse.close()
}

返回Flux,Spring 自动输出为text/event‑stream SSE 响应,HTTP 连接保持打开,持续推送数据块,回答全部结束,服务端关闭 http 连接。

java 复制代码
//流式输出SSE
@GetMapping("/ai/streamChat")
public Flux<String> streamChat(@RequestParam String sid,@RequestParam String question){
    return chatClient.prompt()
            .user(question)
            .advisors(a->a.param(ChatMemory.CONVERSATION_ID,sid))
            .stream()
            .content();
}

方案B:@microsoft/fetch‑event‑source(推荐)

JS 第三方 SSE 库,微软开源,底层基于 fetch API,专门解决浏览器原生EventSource的缺陷。业务 AI 聊天(Spring AI SSE/Flux)Vue 项目最推荐用这个库。@microsoft/fetch‑event‑source全部解决:支持 POST、自定义 Header、body、AbortController 中断、精细控制重连策略。

shell 复制代码
npm install @microsoft/fetch-event-source
java 复制代码
@PostMapping(value = "/rag/streamChat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody ChatQueryDTO dto) {
    return chatClient.prompt()
            .user(dto.getQuestion())
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, dto.getSid()))
            .stream()
            .content();
}
js 复制代码
<template>
  <div>
    <textarea v-model="question"></textarea>
    <button @click="sendChat" :disabled="loading">发送</button>
    <button @click="stopChat" v-if="loading">停止输出</button>
    <pre>{{ answer }}</pre>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { fetchEventSource } from '@microsoft/fetch-event-source'

const question = ref('')
const answer = ref('')
const loading = ref(false)
//用于手动中断SSE长连接
let abortController: AbortController | null = null

async function sendChat() {
  if (!question.value.trim()) return
  answer.value = ''
  loading.value = true
  abortController = new AbortController()

  try {
    await fetchEventSource('/rag/streamChat', {
      method: 'POST',
      headers: {
        'Content‑Type': 'application/json',
        'Authorization': `Bearer ${localStorage.getItem('token')}`
      },
      body: JSON.stringify({
        sid: 'session‑001',
        question: question.value
      }),
      signal: abortController.signal,

      //连接打开回调,可以校验http状态码
      onopen(response) {
        if (!response.ok) {
          throw new Error(`请求异常${response.status}`)
        }
      },

      //收到每一段token,打字机效果
      onmessage(msg) {
        //SpringAI Flux<String>返回的每一段文本放在msg.data
        answer.value += msg.data
      },

      //服务端正常关闭连接
      onclose() {
        loading.value = false
        abortController = null
      },

      onerror(err) {
        loading.value = false
        abortController = null
        //抛出异常 → 停止库自动重试;不抛则会自动重连
        throw err
      }
    })
  } catch (e) {
    loading.value = false
  }
}

//手动终止流式输出(用户点停止按钮)
function stopChat() {
  if (abortController) {
    abortController.abort()
  }
}
</script>

方案C:Axios + ReadableStream(支持 POST、Header、token)

用 axios 获取浏览器原始 response 流,手动读取流,支持 POST 传参、携带 Authorization token。

js 复制代码
async streamChat(sid, question){
  answer.value = ""
  const res = await axios({
    method:"post",
    url:"/ai/streamChat",
    responseType:"stream", //关键!拿到浏览器ReadableStream
    headers:{
      "Content-Type":"application/json",
      "Authorization":"Bearer "+localStorage.getItem("token")
    },
    data:{sid,question},
  })
  //读取流式body
  const reader = res.data.getReader()
  const decoder = new TextDecoder("utf‑8")
  let done = false
  while(!done){
    const {value,done:isDone} = await reader.read()
    done = isDone
    if(value){
      //解码分段字符串,拼接输出
      const chunk = decoder.decode(value,{stream:true})
      answer.value += chunk
    }
  }
  console.log("流式全部接收完毕")
}
java 复制代码
@PostMapping(value = "/ai/streamChat",produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody ChatQueryDTO dto){
    return chatClient.prompt()
            .user(dto.getQuestion())
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID,dto.getSid()))
            .stream()
            .content();
}

三、@microsoft/fetch‑event‑source

js 复制代码
<template>
  <main class="simple-chat-page">
    <section class="simple-chat-card">
      <section ref="messagesRef" class="chat-messages">
        <div v-if="messages.length === 0" class="empty-state">
          <strong>你好,我是简洁版 AI 助理。</strong>
          <span>请输入问题开始对话。</span>
        </div>

        <div
          v-for="message in messages"
          :key="message.id"
          class="message-row"
          :class="`message-row--${message.role}`"
        >
          <div class="avatar">{{ message.role === 'user' ? '我' : 'AI' }}</div>
          <div class="bubble" v-html="renderMarkdown(message.content)"></div>
        </div>

        <div v-if="loading" class="loading-text">AI 正在生成...</div>
      </section>

      <footer class="chat-input">
        <el-input
          v-model="question"
          type="textarea"
          resize="none"
          :autosize="{ minRows: 3, maxRows: 6 }"
          placeholder="输入消息,Enter 发送,Shift + Enter 换行"
          @keydown.enter.exact.prevent="sendMessage"
        />
        <div class="actions">
          <el-button v-if="loading" @click="stopMessage">停止生成</el-button>
          <el-button type="primary" :disabled="!question.trim()" :loading="loading" @click="sendMessage">
            发送
          </el-button>
        </div>
      </footer>
    </section>
  </main>
</template>

<script setup>
import axios from 'axios';
import { nextTick, onBeforeUnmount, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { fetchEventSource } from '@microsoft/fetch-event-source';
import MarkdownIt from 'markdown-it';

const TOKEN_NAME = 'token';
const API_HOST = 'http://localhost:8080';
const router = useRouter();
const loading = ref(false);
const question = ref('');
const messages = ref([]);
const messagesRef = ref(null);
const sessionId = ref('');
const markdown = new MarkdownIt({ breaks: true, linkify: true });
let abortController = null;

const getToken = () => sessionStorage.getItem(TOKEN_NAME) || '';

const renderMarkdown = (content) => markdown.render(content || '');

const scrollToBottom = () => {
  nextTick(() => {
    if (messagesRef.value) {
      messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
    }
  });
};

const ensureLogin = () => {
  if (getToken()) return true;
  ElMessage.warning('请先登录后再使用简洁版聊天');
  router.push('/login');
  return false;
};

const request = async (options) => {
  const response = await axios({
    baseURL: API_HOST,
    timeout: 60000,
    headers: {
      'Content-Type': 'application/json',
      authorization: getToken(),
    },
    ...options,
  });
  return response.data;
};

const createSession = async () => {
  if (sessionId.value) return sessionId.value;

  const res = await request({ url: '/ais/session', method: 'post', data: {} });
  if (res.code !== 200 || !res.data?.sessionId) {
    throw new Error(res.msg || '创建会话失败');
  }

  sessionId.value = res.data.sessionId;
  return sessionId.value;
};

const appendAssistantContent = (content) => {
  const lastMessage = messages.value[messages.value.length - 1];
  if (lastMessage?.role === 'assistant') {
    lastMessage.content += content;
  }
  scrollToBottom();
};

const sendMessage = async () => {
  const text = question.value.trim();
  if (!text || loading.value || !ensureLogin()) return;

  messages.value.push({ id: `${Date.now()}-user`, role: 'user', content: text });
  messages.value.push({ id: `${Date.now()}-assistant`, role: 'assistant', content: '' });
  question.value = '';
  loading.value = true;
  scrollToBottom();

  abortController = new AbortController();

  try {
    const currentSessionId = await createSession();
    await fetchEventSource(`${API_HOST}/ais/chat`, {
      method: 'POST',
      headers: {
        Authorization: getToken(),
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ sessionId: currentSessionId, question: text }),
      signal: abortController.signal,
      openWhenHidden: true,
      onmessage(event) {
        const data = JSON.parse(event.data);
        if (data.eventType === 1002) {
          abortController?.abort();
          return;
        }
        if (data.eventType === 1003) return;
        appendAssistantContent(data.eventData || '');
      },
      onerror(error) {
        throw error;
      },
    });
  } catch (error) {
    if (error?.name !== 'AbortError') {
      ElMessage.error(error?.message || '发送失败,请稍后重试');
      const lastMessage = messages.value[messages.value.length - 1];
      if (lastMessage?.role === 'assistant' && !lastMessage.content) {
        lastMessage.content = '抱歉,暂时无法回复。';
      }
    }
  } finally {
    loading.value = false;
    abortController = null;
    scrollToBottom();
  }
};

const stopMessage = async () => {
  abortController?.abort();
  loading.value = false;
  if (sessionId.value) {
    await request({
      url: `/ais/chat/stop?sessionId=${sessionId.value}`,
      method: 'post',
      data: { sessionId: sessionId.value },
    }).catch(() => {});
  }
};

onBeforeUnmount(() => {
  abortController?.abort();
});
</script>

<style lang="scss" scoped>
.simple-chat-page {
  min-height: 100vh;
  padding: 32px;
  background: #f5f7fb;
}

.simple-chat-card {
  display: flex;
  flex-direction: column;
  width: min(960px, 100%);
  min-height: calc(100vh - 64px);
  margin: 0 auto;
  padding: 24px;
  border-radius: 20px;
  background: #fff;
  box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08);
}

.chat-messages {
  flex: 1;
  min-height: 420px;
  margin: 18px 0;
  padding: 18px;
  overflow-y: auto;
  border-radius: 16px;
  background: #f8fafc;
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  gap: 8px;
  color: #8a95a6;
  text-align: center;
}

.message-row {
  display: flex;
  gap: 10px;
  margin-bottom: 14px;
}

.message-row--user {
  flex-direction: row-reverse;
}

.avatar {
  flex: 0 0 36px;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: #e8f1ff;
  color: #2080f7;
  font-weight: 700;
  line-height: 36px;
  text-align: center;
}

.bubble {
  max-width: 76%;
  padding: 10px 14px;
  border-radius: 14px;
  background: #fff;
  color: #1f2937;
  font-size: 14px;
  line-height: 1.7;
  box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);

  :deep(p) {
    margin: 0;
  }
}

.message-row--user .bubble {
  background: #2080f7;
  color: #fff;
}

.loading-text {
  color: #8a95a6;
  font-size: 13px;
  text-align: center;
}

.chat-input {
  padding-top: 16px;
  border-top: 1px solid #eef1f6;
}

.actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 12px;
}
</style>

四、三种前端 SSE 方案对比

方案 GET POST/Body 自定义Header(token) 手动中断 自动重试 外部依赖
原生 EventSource ✅ close() 浏览器内置,不可控
Axios + ReadableStream ✅ AbortController 需要自己写逻辑 axios
@microsoft/fetch‑event‑source ✅ AbortController 可精细控制策略 约2KB

选型建议

  • 生产 AI 聊天 Vue 项目:优先 @microsoft/fetch‑event‑source;
  • 简单内部 Demo,GET 请求,不需要 token:原生EventSource;
  • 需要深度自定义流解析(非标准 SSE 格式):Axios+ReadableStream。

注意

  • 生产环境 Nginx 代理 SSE 接口:Nginx 默认缓存响应,需要关闭缓存,否则流式失效。

  • SSE 是单向:后端推送到前端;前端不能在同一个长连接继续发消息;每次新对话重新发起请求。

  • 超时:SSE 长连接会被网关、nginx 断开,如果回答特别长,要调大网关超时时间。

    location /rag {
    proxy_pass http://backend;
    proxy_buffering off; #关闭nginx缓冲,SSE必须配置
    proxy_cache off;
    }

相关推荐
深小乐1 小时前
AI 项目上线:折腾 Cloudflare,真香也藏不少坑
人工智能
凤山老林1 小时前
精细化流量治理:Spring Boot 动态特性开关与灰度发布体系
java·spring boot·后端
ACP广源盛139246256732 小时前
Qwen3.8‑2.4T 开源落地@ACP#国产 MoE 私有化部署下 GSV2221 视频转换芯片机遇分析
大数据·数据库·人工智能
冬奇Lab3 小时前
开源项目第190期:claude-video — 给 Claude 装上「眼睛」看视频,一条命令分析 YouTube/Loom/本地视频
人工智能·开源·claude
七牛开发者3 小时前
为什么 Go 很适合 AI 辅助开发?
数据库·人工智能·python·elasticsearch·log4j
dear_bi_MyOnly3 小时前
AI人工智能分类识别——机器如何学习
人工智能·学习·分类
冬奇Lab3 小时前
Code Agent 解剖(04):系统提示词是怎么组装的,agent 的「人格」从哪来?
人工智能·开源·agent
河南凹凸环境艺术设计3 小时前
性价比高的民宿酒店设计企业
大数据·人工智能·python