前端使用SSE實(shí)現(xiàn)流式渲染實(shí)踐方案
背景
最近在做一個(gè)AI項(xiàng)目,后端調(diào)用大模型API輸出的接口為SSE(服務(wù)端流式發(fā)送事件),前端需要不斷去監(jiān)聽并獲取SSE接口流出來的數(shù)據(jù),然后進(jìn)行動(dòng)態(tài)化渲染。
SSE(Server-Sent Events,服務(wù)器發(fā)送事件)是一種允許服務(wù)器向客戶端推送更新的技術(shù)。它基于 HTTP 協(xié)議,允許服務(wù)器向客戶端發(fā)送事件流,而無需客戶端不斷地向服務(wù)器發(fā)送請(qǐng)求來獲取更新。SSE 是一種單向通信機(jī)制,即服務(wù)器可以向客戶端發(fā)送數(shù)據(jù),但客戶端不能通過 SSE 向服務(wù)器發(fā)送數(shù)據(jù)(如果需要雙向通信,可以考慮使用 WebSocket)。
對(duì)于前端而言,如何接收SSE響應(yīng)流并完成頁面的動(dòng)態(tài)渲染是我們需要關(guān)注的。這篇文章主要想給做類似場(chǎng)景開發(fā)的同學(xué)做一個(gè)技術(shù)參考。
前端方案
EventSource
EventSource是一個(gè)用于創(chuàng)建到服務(wù)器的單向事件源的 API,允許服務(wù)器向客戶端推送事件?;贖TTP,EventSource的工作原理是建立一個(gè)持久的 HTTP 連接,服務(wù)器可以通過這個(gè)連接向客戶端發(fā)送消息,客戶端只負(fù)責(zé)接收消息,不需要向服務(wù)端發(fā)送輪詢請(qǐng)求。
const eventSource = new EventSource('https://example.com/events');
// 監(jiān)聽消息事件
eventSource.onmessage = (event) => {
console.log('接收到消息:', event.data);
};
// 監(jiān)聽錯(cuò)誤事件
eventSource.onerror = (error) => {
console.error('發(fā)生錯(cuò)誤:', error);
};
// 監(jiān)聽連接打開事件
eventSource.onopen = () => {
console.log('連接已建立');
};
// 監(jiān)聽連接關(guān)閉事件
eventSource.onclose = () => {
console.log('連接已關(guān)閉');
};
基本用法很簡(jiǎn)單,注冊(cè)回調(diào)事件監(jiān)聽服務(wù)器響應(yīng)的數(shù)據(jù)流即可。
我們?cè)?code>onmessage回調(diào)中可以拿到服務(wù)器返回給我們的數(shù)據(jù),客戶端只需要被動(dòng)接收。當(dāng)請(qǐng)求失敗,連接斷開時(shí),EventSource 默認(rèn)會(huì)在連接斷開后自動(dòng)重連,如果需要自定義重連邏輯,需要在onerror事件處理程序中實(shí)現(xiàn)。
EventSource只支持GET請(qǐng)求,不支持自定義請(qǐng)求頭,沒有請(qǐng)求體,只能將請(qǐng)求參數(shù)拼接到url上,需要注意url長(zhǎng)度限制。
Fetch
fetch 本身不直接支持流式輸出,但可以使用 ReadableStream 和 TextDecoder 等 Web Streams API 來實(shí)現(xiàn)類似的效果。
const response = await fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [
{
role: 'user',
content: '前端學(xué)習(xí)曲線',
},
],
}),
});
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);
// 切割當(dāng)前流,并組裝成數(shù)組,若數(shù)組中包含多個(gè)流,則循環(huán)處理
const strlist = txt?.split('\n\n')?.filter(Boolean);
if (strlist) {
for (const deltaItem of strlist) {
getStreamValueToView(deltaItem);
}
}
}
}
這段代碼就是通過fetch完成的sse流式渲染,它通過構(gòu)造一個(gè)響應(yīng)可讀流,不斷循環(huán)讀取每一個(gè)流的信息,并解構(gòu)出流中的具體值和狀態(tài),當(dāng)狀態(tài)結(jié)束,中斷循環(huán),否則通過decoder.decode將字節(jié)流解碼為普通文本。然后根據(jù)SSE接口返回?cái)?shù)據(jù)的格式,對(duì)文本進(jìn)行切割組裝成一個(gè)新的數(shù)組,最后執(zhí)行渲染邏輯。
fetch請(qǐng)求可以支持POST請(qǐng)求方式來實(shí)現(xiàn)SSE效果,而且請(qǐng)求參數(shù)長(zhǎng)度可以得到很大的拓展,符合長(zhǎng)文本輸入的需求,另外Fetch是瀏覽器原生API支持度好,簡(jiǎn)單易用。但是對(duì)SSE的適配不如EventSource,尤其想要控制響應(yīng)流的關(guān)閉時(shí),沒有EventSource方便。
以上兩種方式是實(shí)現(xiàn)SSE流式渲染的常用方案,下面就拿最近做的項(xiàng)目來梳理一下前端處理接口流式數(shù)據(jù)渲染的流程。
實(shí)現(xiàn)案例
對(duì)于頁面樣式以及其他交互功能的實(shí)現(xiàn)本文暫不做介紹,只聚焦如何渲染后端返回的數(shù)據(jù)。
先介紹一下整個(gè)實(shí)現(xiàn)背景:后端SSE接口有3min超時(shí)時(shí)間(網(wǎng)關(guān)限制),而SSE的響應(yīng)時(shí)間一般又會(huì)超過這個(gè)時(shí)間(模型思考時(shí)間較長(zhǎng)),因此前端在監(jiān)聽SSE接口流式數(shù)據(jù)的時(shí)間不能大于3min,需要做分片渲染。后端的返回結(jié)果是字符串JSON,所以只需要解析這個(gè)JSON,就能拿到對(duì)應(yīng)的數(shù)據(jù)。
對(duì)于前端而言,整個(gè)流式數(shù)據(jù)的渲染,只需要關(guān)注響應(yīng)層面,因?yàn)槲覀冏鳛閿?shù)據(jù)的下游,只要能將數(shù)據(jù)渲染出來就可以了。由于具有停止回答功能,前端需要控制SSE響應(yīng)流的關(guān)閉,技術(shù)上選用EventSource實(shí)現(xiàn)。
/* 每過一個(gè)固定時(shí)間間隔執(zhí)行一次ES實(shí)例化 */
const intervalRun = (rid?: string, tid?: string) => {
closeStream();
eventSource.current = new EventSource(apiUrlByDetail(rid, tid));
eventSource.current.onmessage = (event) => {
render(event); // 核心渲染邏輯
};
// 請(qǐng)求斷開,需要關(guān)閉流
eventSource.current.onerror = () => {
closeStream();
closeInterval();
};
};
closeStream是關(guān)閉流的方法:
function closeStream() {
if (eventSource.current) {
eventSource.current.close(); // 終止SSE響應(yīng)
eventSource.current = null;
}
}
closeInterval是關(guān)閉定時(shí)器方法:
function closeInterval() {
if (sourceReqTimerId.current) {
clearInterval(sourceReqTimerId.current);
sourceReqTimerId.current = null;
}
}
剛進(jìn)來先將之前沒有關(guān)閉的流關(guān)閉,然后重新實(shí)例化一個(gè)新的EventSource實(shí)例,服務(wù)器收到對(duì)應(yīng)請(qǐng)求,開始發(fā)送響應(yīng)流,客戶端不斷獲取響應(yīng)數(shù)據(jù),將數(shù)據(jù)傳入渲染方法,進(jìn)行頁面動(dòng)態(tài)渲染。
const interval = (currReqId?: string, currTraceId?: string) => {
if (currReqId && currTraceId) {
intervalRun(currReqId, currTraceId);
sourceReqTimerId.current = setInterval(() => {
intervalRun(currReqId, currTraceId);
}, MAX_INTERVAL_TIME);
}
};
創(chuàng)建定時(shí)器,每隔MAX_INTERVAL_TIME執(zhí)行一次intervalRun。
下面就是渲染函數(shù),執(zhí)行核心渲染邏輯
const render = (event: Any) => {
try {
// 解析事件數(shù)據(jù),提取data字段
const { data } = event.data ? JSON.parse(event.data) : {};
// 存在reqId,開始執(zhí)行渲染邏輯
if (data?.reqId) {
// 解構(gòu)獲取reqId和traceId
const { reqId: rid, traceId: tid } = data;
// 設(shè)置請(qǐng)求ID和跟蹤ID
setIdInfo({
reqId: rid,
traceId: tid,
});
// 獲取流數(shù)據(jù)并渲染到視圖
getStreamValueToView(data, status);
}
// 流輸出結(jié)束
if (data?.finished) {
closeStream(); // 關(guān)閉流連接
closeInterval(); // 清除定時(shí)器
setRenderStatus(StreamStatusEnum.FINISHED); // 設(shè)置渲染狀態(tài)為已完成
}
} catch (error) {
// 設(shè)置渲染狀態(tài)為錯(cuò)誤
setRenderStatus(StreamStatusEnum.ERROR);
// 打印錯(cuò)誤信息
console.error(error);
}
};
因?yàn)槭莿?dòng)態(tài)流的輸出,因此我們需要?jiǎng)?chuàng)建一個(gè)狀態(tài)枚舉,不同狀態(tài)對(duì)應(yīng)不同的頁面展示。
/**
* 流式渲染枚舉
*/
export enum StreamStatusEnum {
/** 未開始 */
NOT_BEGIN,
/** 正在打印 */
PRINTING,
/** 打印結(jié)束 */
FINISHED,
/** 打印終止 */
STOPPED,
/** 打印出錯(cuò) */
ERROR,
}
根據(jù)不同枚舉值,形成狀態(tài)的過渡。
如果存在reqId(標(biāo)識(shí)),代表當(dāng)前的響應(yīng)有效,可以向頁面渲染;如果出現(xiàn)結(jié)束標(biāo)識(shí)finished,則清除SSE連接和定時(shí)器,同時(shí)將狀態(tài)設(shè)置為完成狀態(tài)。
function getStreamValueToView(deltaValue: Record<string, Any>, status: 'view' | 'detail' = 'view') {
try {
/** 針對(duì)SSE事件數(shù)據(jù)類型處理 */
if (renderStatus === StreamStatusEnum.NOT_BEGIN) {
setRenderStatus(StreamStatusEnum.PRINTING);
}
if (deltaValue) {
/** 等待結(jié)果生成 */
if (!deltaValue?.resultMap?.agentType) {
setStartLoadingText(deltaValue?.responseAll || '');
}
const tasks = deltaValue?.resultMap?.multiAgent?.tasks;
if (tasks?.length) {
/** 對(duì)話渲染 */
chatRender(tasks, status);
}
}
} catch (error) {
console.error(error);
}
}
能進(jìn)入這個(gè)方法,代表開始向頁面渲染,更新狀態(tài)為打印中。
拿到響應(yīng)數(shù)據(jù),若不存在agentType,展示loading狀態(tài),代表大模型還沒有開始向外輸出數(shù)據(jù)。(這里的標(biāo)識(shí)不是固定的,根據(jù)實(shí)際情況具體分析)
當(dāng)存在tasks.length,代表大模型已經(jīng)返回出內(nèi)容,此時(shí)走chatRender邏輯。chatRender中有一個(gè)核心的根據(jù)狀態(tài)更新的操作方法,入?yún)楫?dāng)前流,根據(jù)當(dāng)前流的狀態(tài)來進(jìn)行差異化更新。在本需求中,存在深度思考---數(shù)據(jù)分析---生成報(bào)告---總結(jié),共四種中間狀態(tài),如下代碼所示:
const operater = (currentTask: Any) => {
/** 深度思考部分 */
if (currentTask.messageType === MessageTypeEnum.ToolThought) {
setDeepThinkingTextMap((prev) => ({
...prev,
[currentTask.messageId]: currentTask.toolThought,
}));
}
/** 數(shù)據(jù)分析模塊,展示數(shù)據(jù)分析狀態(tài) */
if (currentTask.messageType === MessageTypeEnum.DataAnalysis) {
setDeepThinkingTextMap((prev) => ({
...prev,
[currentTask.messageId]: MessageTypeEnum.DataAnalysis,
}));
}
/** 頁面報(bào)告部分,展示對(duì)應(yīng)報(bào)告內(nèi)容 */
if (currentTask.messageType === MessageTypeEnum.Html) {
setDeepThinkingTextMap((prev) => ({
...prev,
[currentTask.messageId]: MessageTypeEnum.Html,
}));
}
/** 結(jié)尾文案,總結(jié) */
if (currentTask.messageType === MessageTypeEnum.Result) {
setResultText(currentTask.result || '');
}
};
歷史數(shù)據(jù)需要被記錄進(jìn)映射中(交互需要),所以對(duì)于每一個(gè)最新的狀態(tài)數(shù)據(jù),都需要其對(duì)應(yīng)的messageId去綁定。然后回到chatRender,tasks是一個(gè)二維數(shù)組,這里我們暫時(shí)只取tasks?.[0](當(dāng)前需求場(chǎng)景下只依賴數(shù)組第一項(xiàng))。
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);
}
}
每次拿到當(dāng)前數(shù)組的最后一位(最新的流狀態(tài))。大模型是遞增返回的,最新的數(shù)據(jù)入棧,根據(jù)棧的后進(jìn)先出特性,最新入棧的最先被取走,因此每次獲取數(shù)組最后一位的數(shù)據(jù),就是當(dāng)前大模型返回的最新數(shù)據(jù)。
對(duì)于停止回答,只需要給按鈕綁定一個(gè)關(guān)閉方法,并發(fā)送一個(gè)關(guān)閉請(qǐng)求給到后端即可,后端拿到請(qǐng)求會(huì)同步執(zhí)行關(guān)閉操作。
async function onStop() {
closeStream();
closeInterval();
setRenderStatus(StreamStatusEnum.STOPPED);
const { traceId: tid, reqId: rid } = idInfo;
await stopAnalysisStream({
traceId: tid,
reqId: rid,
});
}
以上就是整個(gè)SSE的前端渲染過程,我們重點(diǎn)關(guān)注響應(yīng)部分,并將響應(yīng)結(jié)構(gòu)解析出來,拿到頁面上需要的內(nèi)容,進(jìn)行更新/替換等一系列操作。
在和大模型進(jìn)行聊天交互的時(shí)候,大模型返回給我們的內(nèi)容并非一次性展示出來的,而是一個(gè)一個(gè)字吐出來的,像打字一樣。前端實(shí)現(xiàn)的方案可以采用字符串的拼接或替換:
- 拼接:后端返回的文案是斷斷續(xù)續(xù)的。
例如:
stream1: 今
stream2: 天
stream3: 是
stream4: 周
stream5: 一
此時(shí)需要進(jìn)行字符串的拼接,將拼接后的字符串渲染到頁面上。
- 替換:本需求實(shí)現(xiàn)方案,見下方代碼
setDeepThinkingTextMap((prev) => ({
...prev,
[currentTask.messageId]: currentTask.toolThought,
}));
currentTask.toolThought是遞增返回的,前端不斷去更新這個(gè)state,會(huì)連續(xù)觸發(fā)渲染,進(jìn)而實(shí)現(xiàn)頁面上的文本動(dòng)態(tài)打印,本質(zhì)上是用最新的流中的文案替換上次舊的文案
例如:
stream1: 今
stream2: 今 天
stream3: 今天 是
stream4: 今天是 周
stream5: 今天是周 一
以上兩種方案強(qiáng)依賴后端輸出的每個(gè)流中的文案長(zhǎng)度,也可以前端去控制:定時(shí)器+切割字符串。
總結(jié)
到此這篇關(guān)于前端使用SSE實(shí)現(xiàn)流式渲染實(shí)踐方案的文章就介紹到這了,更多相關(guān)前端SSE流式渲染內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
JS實(shí)現(xiàn)手機(jī)號(hào)脫敏的方法詳解
脫敏指的是通過特定的技術(shù)手段對(duì)敏感數(shù)據(jù)進(jìn)行處理,使其不再直接暴露給用戶或系統(tǒng),防止敏感信息泄露,通常在測(cè)試、開發(fā)、數(shù)據(jù)處理等場(chǎng)景中使用,本文給大家介紹了JS實(shí)現(xiàn)手機(jī)號(hào)脫敏的方法,需要的朋友可以參考下2025-03-03
js實(shí)現(xiàn)的簡(jiǎn)單圖片浮動(dòng)效果完整實(shí)例
這篇文章主要介紹了js實(shí)現(xiàn)的簡(jiǎn)單圖片浮動(dòng)效果,以完整實(shí)例形式分析了javascript實(shí)現(xiàn)圖片定時(shí)漂浮移動(dòng)功能的方法,涉及javascript定時(shí)函數(shù)與數(shù)值動(dòng)態(tài)運(yùn)算相關(guān)技巧,需要的朋友可以參考下2016-05-05
JS延時(shí)器提示框的應(yīng)用實(shí)例代碼解析
這篇文章主要介紹了JS延時(shí)器提示框的應(yīng)用實(shí)例代碼解析 的相關(guān)資料,需要的朋友可以參考下2016-04-04
JavaScript設(shè)計(jì)模式之代理模式簡(jiǎn)單實(shí)例教程
這篇文章主要介紹了JavaScript設(shè)計(jì)模式之代理模式,簡(jiǎn)單描述了代理模式的概念、功能、組成并結(jié)合實(shí)例形式較為詳細(xì)的分析了javascript代理模式的定義與使用相關(guān)操作技巧,需要的朋友可以參考下2018-07-07
javascript用函數(shù)實(shí)現(xiàn)對(duì)象的方法
這篇文章主要介紹了javascript用函數(shù)實(shí)現(xiàn)對(duì)象的方法,涉及javascript函數(shù)使用技巧,需要的朋友可以參考下2015-05-05
50行代碼實(shí)現(xiàn)Webpack組件使用次數(shù)統(tǒng)計(jì)
這篇文章主要介紹了50行代碼實(shí)現(xiàn)Webpack組件使用次數(shù)統(tǒng)計(jì),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-03-03
countUp.js實(shí)現(xiàn)數(shù)字動(dòng)態(tài)變化效果
這篇文章主要為大家詳細(xì)介紹了countUp.js實(shí)現(xiàn)數(shù)字動(dòng)態(tài)變化效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-10-10
js實(shí)現(xiàn)從左向右滑動(dòng)式輪播圖效果
這篇文章主要為大家詳細(xì)介紹了js實(shí)現(xiàn)從左向右滑動(dòng)式輪播圖效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-07-07

