背景
最近在做一个AI项目,后端调用大模型API输出的接口为SSE(服务端流式发送事件),前端需要不断去监听并获取SSE接口流出来的数据,然后进行动态化渲染。
SSE(Server-Sent Events,服务器发送事件)是一种允许服务器向客户端推送更新的技术。它基于 HTTP 协议,允许服务器向客户端发送事件流,而无需客户端不断地向服务器发送请求来获取更新。SSE 是一种单向通信机制,即服务器可以向客户端发送数据,但客户端不能通过 SSE 向服务器发送数据(如果需要双向通信,可以考虑使用 WebSocket)。
对于前端而言,如何接收SSE响应流并完成页面的动态渲染是我们需要关注的。这篇文章主要想给做类似场景开发的同学做一个技术参考。
前端方案
EventSource
EventSource是一个用于创建到服务器的单向事件源的 API,允许服务器向客户端推送事件 。基于HTTP,EventSource的工作原理是建立一个持久的 HTTP 连接,服务器可以通过这个连接向客户端发送消息,客户端只负责接收消息,不需要向服务端发送轮询请求。
js
const eventSource = new EventSource('https://example.com/events');
// 监听消息事件
eventSource.onmessage = (event) => {
console.log('接收到消息:', event.data);
};
// 监听错误事件
eventSource.onerror = (error) => {
console.error('发生错误:', error);
};
// 监听连接打开事件
eventSource.onopen = () => {
console.log('连接已建立');
};
// 监听连接关闭事件
eventSource.onclose = () => {
console.log('连接已关闭');
};
基本用法很简单,注册回调事件监听服务器响应的数据流即可。
我们在onmessage回调中可以拿到服务器返回给我们的数据,客户端只需要被动接收。当请求失败,连接断开时,EventSource 默认会在连接断开后自动重连,如果需要自定义重连逻辑,需要在onerror事件处理程序中实现。
EventSource只支持GET请求,不支持自定义请求头,没有请求体,只能将请求参数拼接到url上,需要注意url长度限制。
Fetch
fetch 本身不直接支持流式输出,但可以使用 ReadableStream 和 TextDecoder 等 Web Streams API 来实现类似的效果。
php
const response = await fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [
{
role: 'user',
content: '前端学习曲线',
},
],
}),
});
const resHeader = response.headers;
const contentType: string = resHeader.get('content-type') ?? '';
if (contentType.indexOf('text/event-stream') > -1) {
const reader = response?.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = (await reader?.read()) ?? {};
if (done) break;
const txt = decoder.decode(value);
// 切割当前流,并组装成数组,若数组中包含多个流,则循环处理
const strlist = txt?.split('\n\n')?.filter(Boolean);
if (strlist) {
for (const deltaItem of strlist) {
getStreamValueToView(deltaItem);
}
}
}
}
这段代码就是通过fetch完成的sse流式渲染,它通过构造一个响应可读流,不断循环读取每一个流的信息,并解构出流中的具体值和状态,当状态结束,中断循环,否则通过decoder.decode将字节流解码为普通文本。然后根据SSE接口返回数据的格式,对文本进行切割组装成一个新的数组,最后执行渲染逻辑。
fetch请求可以支持POST请求方式来实现SSE效果,而且请求参数长度可以得到很大的拓展,符合长文本输入的需求,另外Fetch是浏览器原生API支持度好,简单易用。但是对SSE的适配不如EventSource,尤其想要控制响应流的关闭时,没有EventSource方便。
以上两种方式是实现SSE流式渲染的常用方案,下面就拿最近做的项目来梳理一下前端处理接口流式数据渲染的流程。
实现案例
对于页面样式以及其他交互功能的实现本文暂不做介绍,只聚焦如何渲染后端返回的数据。
先介绍一下整个实现背景:后端SSE接口有3min超时时间(网关限制),而SSE的响应时间一般又会超过这个时间(模型思考时间较长),因此前端在监听SSE接口流式数据的时间不能大于3min,需要做分片渲染。后端的返回结果是字符串JSON,所以只需要解析这个JSON,就能拿到对应的数据。
对于前端而言,整个流式数据的渲染,只需要关注响应层面,因为我们作为数据的下游,只要能将数据渲染出来就可以了。由于具有停止回答功能,前端需要控制SSE响应流的关闭,技术上选用EventSource实现。
js
/* 每过一个固定时间间隔执行一次ES实例化 */
const intervalRun = (rid?: string, tid?: string) => {
closeStream();
eventSource.current = new EventSource(apiUrlByDetail(rid, tid));
eventSource.current.onmessage = (event) => {
render(event); // 核心渲染逻辑
};
// 请求断开,需要关闭流
eventSource.current.onerror = () => {
closeStream();
closeInterval();
};
};
closeStream是关闭流的方法:
js
function closeStream() {
if (eventSource.current) {
eventSource.current.close(); // 终止SSE响应
eventSource.current = null;
}
}
closeInterval是关闭定时器方法:
js
function closeInterval() {
if (sourceReqTimerId.current) {
clearInterval(sourceReqTimerId.current);
sourceReqTimerId.current = null;
}
}
刚进来先将之前没有关闭的流关闭,然后重新实例化一个新的EventSource实例,服务器收到对应请求,开始发送响应流,客户端不断获取响应数据,将数据传入渲染方法,进行页面动态渲染。
js
const interval = (currReqId?: string, currTraceId?: string) => {
if (currReqId && currTraceId) {
intervalRun(currReqId, currTraceId);
sourceReqTimerId.current = setInterval(() => {
intervalRun(currReqId, currTraceId);
}, MAX_INTERVAL_TIME);
}
};
创建定时器,每隔MAX_INTERVAL_TIME执行一次intervalRun。
下面就是渲染函数,执行核心渲染逻辑
js
const render = (event: Any) => {
try {
// 解析事件数据,提取data字段
const { data } = event.data ? JSON.parse(event.data) : {};
// 存在reqId,开始执行渲染逻辑
if (data?.reqId) {
// 解构获取reqId和traceId
const { reqId: rid, traceId: tid } = data;
// 设置请求ID和跟踪ID
setIdInfo({
reqId: rid,
traceId: tid,
});
// 获取流数据并渲染到视图
getStreamValueToView(data, status);
}
// 流输出结束
if (data?.finished) {
closeStream(); // 关闭流连接
closeInterval(); // 清除定时器
setRenderStatus(StreamStatusEnum.FINISHED); // 设置渲染状态为已完成
}
} catch (error) {
// 设置渲染状态为错误
setRenderStatus(StreamStatusEnum.ERROR);
// 打印错误信息
console.error(error);
}
};
因为是动态流的输出,因此我们需要创建一个状态枚举,不同状态对应不同的页面展示。
js
/**
* 流式渲染枚举
*/
export enum StreamStatusEnum {
/** 未开始 */
NOT_BEGIN,
/** 正在打印 */
PRINTING,
/** 打印结束 */
FINISHED,
/** 打印终止 */
STOPPED,
/** 打印出错 */
ERROR,
}
根据不同枚举值,形成状态的过渡。
如果存在reqId(标识),代表当前的响应有效,可以向页面渲染;如果出现结束标识finished,则清除SSE连接和定时器,同时将状态设置为完成状态。
js
function getStreamValueToView(deltaValue: Record<string, Any>, status: 'view' | 'detail' = 'view') {
try {
/** 针对SSE事件数据类型处理 */
if (renderStatus === StreamStatusEnum.NOT_BEGIN) {
setRenderStatus(StreamStatusEnum.PRINTING);
}
if (deltaValue) {
/** 等待结果生成 */
if (!deltaValue?.resultMap?.agentType) {
setStartLoadingText(deltaValue?.responseAll || '');
}
const tasks = deltaValue?.resultMap?.multiAgent?.tasks;
if (tasks?.length) {
/** 对话渲染 */
chatRender(tasks, status);
}
}
} catch (error) {
console.error(error);
}
}
能进入这个方法,代表开始向页面渲染,更新状态为打印中。
拿到响应数据,若不存在agentType,展示loading状态,代表大模型还没有开始向外输出数据。(这里的标识不是固定的,根据实际情况具体分析)
当存在tasks.length,代表大模型已经返回出内容,此时走chatRender逻辑。chatRender中有一个核心的根据状态更新的操作方法,入参为当前流,根据当前流的状态来进行差异化更新。在本需求中,存在深度思考---数据分析---生成报告---总结,共四种中间状态,如下代码所示:
js
const operater = (currentTask: Any) => {
/** 深度思考部分 */
if (currentTask.messageType === MessageTypeEnum.ToolThought) {
setDeepThinkingTextMap((prev) => ({
...prev,
[currentTask.messageId]: currentTask.toolThought,
}));
}
/** 数据分析模块,展示数据分析状态 */
if (currentTask.messageType === MessageTypeEnum.DataAnalysis) {
setDeepThinkingTextMap((prev) => ({
...prev,
[currentTask.messageId]: MessageTypeEnum.DataAnalysis,
}));
}
/** 页面报告部分,展示对应报告内容 */
if (currentTask.messageType === MessageTypeEnum.Html) {
setDeepThinkingTextMap((prev) => ({
...prev,
[currentTask.messageId]: MessageTypeEnum.Html,
}));
}
/** 结尾文案,总结 */
if (currentTask.messageType === MessageTypeEnum.Result) {
setResultText(currentTask.result || '');
}
};
历史数据需要被记录进映射中(交互需要),所以对于每一个最新的状态数据,都需要其对应的messageId去绑定。然后回到chatRender,tasks是一个二维数组,这里我们暂时只取tasks?.[0](当前需求场景下只依赖数组第一项)。
js
function multiRender(tasks: Record<string, Any>[], status: 'view' | 'detail' = 'view') {
const singlePlanTasks = tasks?.[0];
const operater = (currentTask: Any) => {......}
if (singlePlanTasks?.length) {
const currentTask = singlePlanTasks[singlePlanTasks.length - 1] ?? {};
operater(currentTask);
}
}
每次拿到当前数组的最后一位(最新的流状态)。大模型是递增返回的,最新的数据入栈,根据栈的后进先出特性,最新入栈的最先被取走,因此每次获取数组最后一位的数据,就是当前大模型返回的最新数据。
对于停止回答,只需要给按钮绑定一个关闭方法,并发送一个关闭请求给到后端即可,后端拿到请求会同步执行关闭操作。
js
async function onStop() {
closeStream();
closeInterval();
setRenderStatus(StreamStatusEnum.STOPPED);
const { traceId: tid, reqId: rid } = idInfo;
await stopAnalysisStream({
traceId: tid,
reqId: rid,
});
}
以上就是整个SSE的前端渲染过程,我们重点关注响应部分,并将响应结构解析出来,拿到页面上需要的内容,进行更新/替换等一系列操作。
在和大模型进行聊天交互的时候,大模型返回给我们的内容并非一次性展示出来的,而是一个一个字吐出来的,像打字一样。前端实现的方案可以采用字符串的拼接或替换:
- 拼接:后端返回的文案是断断续续的。
例如:
stream1: 今
stream2: 天
stream3: 是
stream4: 周
stream5: 一
此时需要进行字符串的拼接,将拼接后的字符串渲染到页面上。
- 替换:本需求实现方案,见下方代码
js
setDeepThinkingTextMap((prev) => ({
...prev,
[currentTask.messageId]: currentTask.toolThought,
}));
currentTask.toolThought是递增返回的,前端不断去更新这个state,会连续触发渲染,进而实现页面上的文本动态打印,本质上是用最新的流中的文案替换上次旧的文案
例如:
stream1: 今
stream2: 今 天
stream3: 今天 是
stream4: 今天是 周
stream5: 今天是周 一
以上两种方案强依赖后端输出的每个流中的文案长度,也可以前端去控制:定时器+切割字符串。