AI 前端落地实战:SSE 流式输出、断点续传、打字机渲染

AI 前端落地实战:SSE 流式输出、断点续传、打字机渲染

一、基础概念

  1. SSE(Server‑Sent Events) HTTP 单向长连接,服务端按data: xxx\n\n格式分段返回文本,适合 AI 大模型逐字返回 token;区别 WebSocket:单向、基于 HTTP、不用双向握手,只服务端向客户端推数据

缺点:只能服务端推,浏览器自带EventSource,默认不支持自定义请求头(token/authorization),这是最大坑,生产环境一般用 fetch 模拟 SSE。

  1. 打字机渲染 拿到 SSE 返回的分片 token,前端增量拼接 DOM,模拟一字一字输出效果,不是前端延时模拟定时器打字,真实场景是跟随后端流返回节奏渲染
  2. 断点续传(会话断点恢复) 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]

四、断点续传前端完整逻辑

核心思路

  1. 生成唯一conversationId会话 ID :新建对话生成,存在localStorage;同一个对话全程不变。
  2. 前端记录偏移量 offset:记录当前已经渲染完毕的字符总长度。
  3. 异常场景触发重连:断网、页面刷新、浏览器休眠导致流中断。
  4. 重连调用接口带上 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 断点;如果中途报错,保留断点等待下次恢复。

边界坑

  1. 中文多字节,不要直接用 byte 长度做 offset,要用字符数,否则会乱码。
  2. 网络抖动会出现流分片截断,前端必须做 buffer 缓冲区,不能直接按块解析。

五、打字机渲染的两种实现对比

表格

方案 实现方式 适用场景
跟随 SSE 流直接追加(上面代码) 后端返回多少,页面渲染多少 真实 AI 产品,推荐,速度受模型输出决定
定时器 setInterval 模拟打字 前端拿完整全部文本,逐字符延时输出 纯静态演示,假流式,不用于 AI 接口

❌ 不要用定时器模拟真实 AI 流式,体验会假,模型卡顿的时候定时器还在跑,会出现和实际输出脱节。

六、生产环境常见踩坑汇总

  1. 跨域:SSE/Fetch 流同样遵守 CORS,后端需要配置允许暴露响应头。
  2. nginx 代理超时 :SSE 长连接,nginx 默认 60s 超时,需要调大proxy_read_timeout 300s;,否则大回答直接断开。
  3. 部分分片粘包 :后端多条data:被 tcp 合并返回,前端必须用缓冲区分割\n\n,不能直接把拿到的 value 当一条消息。
  4. 中断请求 :用户点击停止生成,需要调用reader.cancel()终止流,防止后台继续消耗 token。
scss 复制代码
// 用户停止输出
function stopGenerate(reader) {
  reader?.cancel();
}
  1. 断点续传局限性:后端上下文缓存有有效期,过期之后无法恢复,此时前端提示用户无法恢复,重新发起提问。

七、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>
相关推荐
晴天161 小时前
前端跨域方案解析:JSONP 的原理、实战与演进
前端·状态模式
web打印社区1 小时前
浏览器静默打印?先别装第三个库了
前端·vue.js·chrome·electron·pdf
艾伦野鸽ggg1 小时前
前端异步请求的状态竞争(请求竞态)问题
前端·javascript·axios
是立不是利1 小时前
前端交互基石:深入剖析JavaScript三级联动背后的设计哲学
开发语言·前端·javascript
动恰客流统计1 小时前
传统红外对射客流统计为何逐步淡出主流?准确率与场景限制深度分析
大数据·前端·人工智能
Rooting++2 小时前
小程序{{ }}和vue{{ }}语法的区别
前端·vue.js·小程序
豆约翰2 小时前
css自制图书封面效果
前端·css
积硅步致千里2 小时前
Fyne 兼容性:报错还能救,透明窗才要命
前端·后端
可乐鸡翅yeah_3 小时前
hls.js 播放质量埋点实战,采集卡顿、起播、错误指标定位线上用户问题
开发语言·前端·javascript·ecmascript·音视频·m3u8·音视频在线播放