一、普通接口 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;
}