AI 前端落地实战:SSE 流式输出、断点续传、打字机渲染
一、基础概念
- SSE(Server‑Sent Events) HTTP 单向长连接,服务端按
data: xxx\n\n格式分段返回文本,适合 AI 大模型逐字返回 token;区别 WebSocket:单向、基于 HTTP、不用双向握手,只服务端向客户端推数据。
缺点:只能服务端推,浏览器自带
EventSource,默认不支持自定义请求头(token/authorization),这是最大坑,生产环境一般用 fetch 模拟 SSE。
- 打字机渲染 拿到 SSE 返回的分片 token,前端增量拼接 DOM,模拟一字一字输出效果,不是前端延时模拟定时器打字,真实场景是跟随后端流返回节奏渲染。
- 断点续传(会话断点恢复) AI 对话中途断网、页面刷新、浏览器关闭后,重新连接时,不需要让模型重新生成全部内容;前端传递会话 id + 已经接收完成的上下文 / 偏移位置,后端从上次中断的 token 位置继续输出剩余内容。
二、方案 1:浏览器原生 EventSource(简单,不推荐生产)
⚠️ 限制:不能设置Authorization请求头,鉴权很难做,只适合无鉴权 demo
ini
// js
const source = new EventSource('/api/ai/stream');
source.onmessage = (e) => {
// e.data 拿到后端返回token片段
appendTypeWriter(e.data);
};
source.onerror = (err) => {
console.error('sse出错', err);
source.close();
};
// 打字机追加渲染
function appendTypeWriter(text) {
const dom = document.getElementById('ai-output');
dom.innerText += text;
}
三、方案 2:Fetch 模拟 SSE(生产首选,支持 header、post 请求)
AI 接口大多是 POST 传参,需要 token 鉴权,
EventSource不支持 POST,所以用fetch + ReadableStream解析流。
javascript
/**
* fetch实现SSE流式请求AI接口
* @param {string} conversationId 会话ID 用于断点续传
* @param {number} offset 断点偏移量,上次接收的字符位置
*/
async function aiStream(conversationId, offset = 0) {
const res = await fetch("/api/ai/chat-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
prompt: "你的提问",
conversationId,
offset // 传给后端:从offset位置继续输出,实现断点续传
})
});
if (!res.ok) throw new Error('请求失败');
// 获取可读流
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while(true) {
const { done, value } = await reader.read();
if(done) break;
// 二进制转字符串
buffer += decoder.decode(value, { stream: true });
// SSE协议分割:每条消息以 \n\n 结束
const parts = buffer.split('\n\n');
buffer = parts.pop(); // 剩下不完整片段留在buffer,下一轮继续解析
for(const part of parts) {
if(!part.startsWith('data: ')) continue;
const payload = part.replace('data: ', '');
if(payload === '[DONE]') {
// 流结束标记
console.log("AI输出完成");
return;
}
// 拿到token片段,执行打字机渲染
typeWriterRender(payload);
}
}
}
// 真实流式打字机渲染(直接增量,不用setTimeout模拟延时)
function typeWriterRender(chunk) {
const outputDom = document.querySelector('#ai-result');
outputDom.textContent += chunk;
// 滚动到底部
outputDom.scrollTop = outputDom.scrollHeight;
}
SSE 后端返回数据格式示例
css
data: {"content":"今"}
data: {"content":"天"}
data: {"content":"天气很好"}
data: [DONE]
四、断点续传前端完整逻辑
核心思路
- 生成唯一
conversationId会话 ID :新建对话生成,存在localStorage;同一个对话全程不变。 - 前端记录偏移量 offset:记录当前已经渲染完毕的字符总长度。
- 异常场景触发重连:断网、页面刷新、浏览器休眠导致流中断。
- 重连调用接口带上
conversationId+offset,后端不再从头生成,直接返回 offset 之后剩余 token。
javascript
// 会话状态存储
const STREAM_KEY = 'ai_stream_state';
// 保存断点
function saveBreakPoint(conversationId, finishedCharCount) {
localStorage.setItem(STREAM_KEY, JSON.stringify({
conversationId,
offset: finishedCharCount
}))
}
// 读取断点
function getBreakPoint() {
const str = localStorage.getItem(STREAM_KEY);
return str ? JSON.parse(str) : null;
}
// 页面刷新后恢复
async function resumeStream() {
const bp = getBreakPoint();
if(!bp) return;
// dom先回填已经渲染的历史文本(从本地缓存或者接口拉历史记录)
// 再调用流式接口,从offset继续拉剩余内容
await aiStream(bp.conversationId, bp.offset);
}
⚠️ 前后端约定:
- offset:字符偏移,不是 token 序号,或者也可以约定 token 序号;
- 后端要维护会话上下文缓存,根据
conversationId读取历史,跳过 offset 之前内容,返回后续片段;- 流输出结束之后,清除 localStorage 断点;如果中途报错,保留断点等待下次恢复。
边界坑
- 中文多字节,不要直接用 byte 长度做 offset,要用字符数,否则会乱码。
- 网络抖动会出现流分片截断,前端必须做 buffer 缓冲区,不能直接按块解析。
五、打字机渲染的两种实现对比
表格
| 方案 | 实现方式 | 适用场景 |
|---|---|---|
| 跟随 SSE 流直接追加(上面代码) | 后端返回多少,页面渲染多少 | 真实 AI 产品,推荐,速度受模型输出决定 |
| 定时器 setInterval 模拟打字 | 前端拿完整全部文本,逐字符延时输出 | 纯静态演示,假流式,不用于 AI 接口 |
❌ 不要用定时器模拟真实 AI 流式,体验会假,模型卡顿的时候定时器还在跑,会出现和实际输出脱节。
六、生产环境常见踩坑汇总
- 跨域:SSE/Fetch 流同样遵守 CORS,后端需要配置允许暴露响应头。
- nginx 代理超时 :SSE 长连接,nginx 默认 60s 超时,需要调大
proxy_read_timeout 300s;,否则大回答直接断开。 - 部分分片粘包 :后端多条
data:被 tcp 合并返回,前端必须用缓冲区分割\n\n,不能直接把拿到的 value 当一条消息。 - 中断请求 :用户点击停止生成,需要调用
reader.cancel()终止流,防止后台继续消耗 token。
scss
// 用户停止输出
function stopGenerate(reader) {
reader?.cancel();
}
- 断点续传局限性:后端上下文缓存有有效期,过期之后无法恢复,此时前端提示用户无法恢复,重新发起提问。
七、Vue3 极简封装示例
xml
<template>
<div>
<div id="ai-output" class="output">{{ aiText }}</div>
<button @click="startChat">开始对话</button>
<button @click="stop">停止</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
let reader = ref(null)
const aiText = ref('')
async function startChat(){
aiText.value = ''
const res = await fetch('/api/ai/chat-stream', { method:'POST' })
reader.value = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while(true) {
const {done, value} = await reader.value.read()
if(done) break
buffer += decoder.decode(value,{stream:true})
const list = buffer.split('\n\n')
buffer = list.pop()
for(let item of list){
if(item.startsWith('data: ')){
const d = item.slice(6)
if(d === '[DONE]') return
aiText.value += d
}
}
}
}
function stop(){
reader.value?.cancel()
}
</script>