ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

客服类 SSE 流式渲染 + 半包处理技术复盘:从 Fetch 读流到 rAF 贴底自愈

2026/8/6 13:24:42 拓冰建站 浏览量
客服类 SSE 流式渲染 + 半包处理技术复盘:从 Fetch 读流到 rAF 贴底自愈 客服类 SSE 流式渲染 半包处理技术复盘从 Fetch 读流到 rAF 贴底自愈前言最近在做一个教育行业的 H5 智能客服项目用户提问后AI 回答像 ChatGPT 一样打字机式一个字一个字往外冒。做完整套链路之后踩过的坑主要集中在三块协议怎么选、半包怎么处理、体验和容错怎么做。这篇文章把这三块的实现思路和踩坑经验完整复盘一遍代码是精简后的示意实现核心逻辑和线上一致。先放一张全景图后面按图索骥展开用户发问 → 插入占位气泡pending true → Fetch POST 发起请求Accept: text/event-stream → 后端边检索边生成边推 SSE 数据帧 → 前端边读边解析行缓冲防半包 → 每解析出一段更新气泡内容 rAF 贴底 → 流结束清 pending持久化 sessionId → 离开页面 / 弱网中断AbortController 取消 下次进页自愈一、为什么不用原生 EventSource也不用 WebSocket很多人一看到流式输出第一反应是用浏览器原生的EventSource。但落地到真实业务时会发现它满足不了几个硬约束约束EventSource 原生能力我们的业务需求请求方法只能 GET需要 POST问题内容、历史对话放 Body 里更合适自定义 Header很难自定义需要带鉴权 Token、加密标识等自定义 HeaderBody不支持带 Body需要传加密后的业务 Body问题、多轮历史、设备信息所以结论是不是不用 SSE而是不用EventSource这个 API。SSE 本质上只是一种协议约定——响应头Content-Type: text/event-streambody 是持续追加的data: xxx\n\n文本流。只要客户端愿意自己读这个流用什么 API 发请求都可以。于是选择用Fetch ReadableStream自己实现一个能带 Header、能带加密 Body 的 SSE 客户端。那为什么不干脆上 WebSocket因为业务场景是用户问 → AI 单向流式吐字不需要双工实时通信后端已经是标准 HTTP SSE 输出换成 WS 需要额外的握手、心跳、网关改造、鉴权重新设计成本明显大于收益。一句话总结选型逻辑协议约束决定选型不是技术偏好。二、Fetch 读流的最小实现先看一个精简但完整的 Fetch 读流骨架asyncfunctionrequestAiStream(payload,{onChunk,signal}){constresponseawaitfetch(/api/chat,{method:POST,headers:{Content-Type:application/json,Authorization:getToken(),},body:JSON.stringify(payload),signal,// 配合 AbortController 支持取消})if(!response.ok||!response.body){thrownewError(请求失败:${response.status})}constreaderresponse.body.getReader()// 关键点 1stream: true防止多字节字符被 chunk 边界切断导致乱码constdecodernewTextDecoder(utf-8)letbufferletfullContentletfullReasoningwhile(true){const{done,value}awaitreader.read()if(done)breakbufferdecoder.decode(value,{stream:true})// 关键点 2行缓冲处理半包constlinesbuffer.split(\n)bufferlines.pop()// 最后一行可能不完整留到下一次拼接for(constlineoflines){if(!line.startsWith(data:))continueconstrawline.slice(5).trim()if(raw[DONE])continuetry{const{content,reasoning,sessionId}JSON.parse(raw)fullContentcontent fullReasoningreasoningonChunk({content,fullContent,reasoning,fullReasoning,sessionId})}catch(e){// 正常情况下不该走到这里一旦走到通常是半包处理漏了边界console.warn(解析异常行跳过,raw,e)}}}return{fullContent,fullReasoning}}这段代码里最容易被忽视、也是面试里最常被追问的两个点单独展开讲。2.1 什么是半包—— 不是汉字乱码第一次做这个功能时我以为半包就是汉字被切成两半显示乱码后来才发现这是两个完全不同层面的问题必须分开处理第一层TCP/HTTP 传输层的字节切断乱码层一个中文字符在 UTF-8 编码下通常占 3 个字节如果这 3 个字节恰好被拆分到两个 chunk 里直接decode会产生乱码或者报错。这一层的解法很简单TextDecoder构造时不用管但每次decode调用时必须传{ stream: true }告诉解码器这不是最后一段遇到不完整的多字节字符先缓存住等下一段数据来了再拼。第二层业务协议层的行被切断半包层这是主要问题SSE 协议约定一条消息是data: {...}\n\n这样一整行或多行。但 TCP 是流式传输reader.read()一次拿到的chunk长度是不确定的完全可能出现第一次 read() 拿到data: {content:你好请问需 第二次 read() 拿到要了解哪方面的迎新流程}\n\n如果不做处理直接对第一段做JSON.parse必然会报错——这才是半包真正要解决的问题一次read不一定够一条完整的 SSE 行而不是字符乱码。解法就是代码里的行缓冲buffer 本次新解码出来的文本 按 \n 切分成多行 最后一行不确定是否完整 → pop 出来放回 buffer留到下一次再拼 剩下的完整行 → 逐行按 data: 前缀解析同时还有粘包的情况一次read里可能包含好几条完整的data:行比如网络突然通畅、后端瞬间推了好几个 token这种情况用split(\n)之后循环处理即可不需要特殊逻辑。两层要叠着说stream: true解决的是字符编码层面的乱码行缓冲解决的是业务协议层面的半包二者互不替代实际项目里两个都得做。三、content / reasoning 双通道思考过程和正式答案要分开处理如果后端接的是支持思维链的模型SSE 帧里通常会同时下发两个字段{content:根据招生简章您可以...,reasoning:用户问的是录取流程我需要先...}content最终展示给用户的正式答案通常是 Markdown 格式需要被渲染成富文本reasoning模型的思考过程产品上一般作为思考中的折叠区展示当作纯文本处理不需要渲染 Markdown。这里有一个容易踩的安全坑两者的防 XSS 处理方式不一样不能用同一套逻辑。import{marked}frommarkedimportDOMPurifyfromdompurify// content先用 marked 把 Markdown 转成 HTML再用 DOMPurify 消毒functionrenderContent(markdownText){consthtmlmarked.parse(markdownText)returnDOMPurify.sanitize(html)}// reasoning产品上当纯文本展示直接转义即可不走 Markdown 解析functionrenderReasoning(text){returntext.replace(//g,amp;).replace(//g,lt;).replace(//g,gt;)}需要强调一点不要把区别处理的原因说成reasoning 更可能包含恶意 HTML——这是本末倒置。真正的原因是产品设计上思考过程就是纯文本展示可折叠而正式答案需要支持 Markdown 富文本列表、加粗、代码块等所以走了不同的渲染管线DOMPurify消毒是 Markdown → HTML 这条链路上必须有的一环而不是因为它更危险才加。另外一个细节流式过程中每次onChunk回调UI 更新用的是全量内容fullContent而不是本次增量content因为 Markdown 语法经常是半截的比如加粗符号**只出现了一半用增量做局部渲染很容易出现格式错乱用全量重新渲染整个气泡内容更稳定。四、滚动贴底为什么用requestAnimationFrame而不是每个 chunk 直接改scrollTop流式输出时聊天气泡的高度一直在变化如果每次onChunk都同步去改scrollTop或者用 Vue 的$nextTick之后再滚动会出现字出来了、滚动却慢半拍的顿挫感。原因是直接在onChunk里改scrollTop这时候 DOM 可能还没更新到最新高度滚动值算出来是旧的$nextTick保证的只是Vue 完成本次 DOM 更新但和浏览器下一次真正的屏幕绘制并不是同一个时机尤其滚动再叠加动画效果时更容易感觉卡顿。解法是用requestAnimationFrame让内容高度更新和滚动贴底在同一帧里完成天然和屏幕刷新对齐letrafIdnullfunctionscheduleStickToBottom(scrollContainer){if(rafId)return// 一帧只调度一次避免重复排队rafIdrequestAnimationFrame((){rafIdnullif(autoFollow){scrollContainer.scrollTopscrollContainer.scrollHeight}})}// 用户主动上滑超过一定阈值就临时关闭自动贴底避免抢用户的滚动操作scrollContainer.addEventListener(scroll,(){constdistanceToBottomscrollContainer.scrollHeight-scrollContainer.scrollTop-scrollContainer.clientHeight autoFollowdistanceToBottom50})需要诚实说明这个优化的边界rAF解决的是滚动跟手感这一个体验问题它不会让模型出字变快也不等于做了增量 Markdown 渲染——如果 AI 回答很长每个 chunk 都做一次全量marked.parseCPU 开销依然存在这块可以再叠加一个 50ms 节流、结束时强制刷新一次的策略去优化属于锦上添花而不是贴底这个问题本身要解决的。五、中断与自愈AbortController pending 占位用户在等待回答的过程中切后台、断网、或者直接把 App 的 WebView 重建了怎么保证不出现卡死转圈圈的僵尸气泡5.1 发问时先插占位functionsendQuestion(question){appendMessage({role:user,content:question})constaiMessageIndexappendMessage({role:assistant,content:,pending:true,// 还没收到完整回答isThinking:true,})constcontrollernewAbortController()currentAbortControllercontrollerrequestAiStream(buildPayload(question),{signal:controller.signal,onChunk:(chunk)updateMessage(aiMessageIndex,chunk),}).then((result){finalizeMessage(aiMessageIndex,result)}).catch((err){if(err.nameAbortError){// 关键这里故意什么都不做不去清 pending// 把脏状态留给下次进入页面时统一自愈处理return}markMessageFailed(aiMessageIndex)})}5.2 离开页面只负责断开连接不负责清理状态beforeDestroy(){currentAbortController?.abort()}这里的设计精髓在于AbortError被捕获之后故意不清理pending状态而不是顺手清干净。原因是离页那一刻UI 已经不可见了没必要在那个时机去做状态修复把所有残留的中断态统一交给下次进入页面时的自愈逻辑处理逻辑更集中、更不容易漏处理路径。5.3 下次进页统一自愈functionhealInterruptedChatList(messages){returnmessages.map((msg){if(msg.roleassistantmsg.pending){return{...msg,content:msg.content||回答中断请刷新重试,pending:false,isThinking:false,isInterrupted:true,// 关键标记}}returnmsg})}isInterrupted: true的消息在后续拼多轮对话历史history时会被跳过不会被塞进下一次请求的上下文里——这一点很容易被忽略但很重要如果把半截、异常提示的文案也当成正常回答塞进上下文会污染模型接下来的生成质量。需要诚实说明当前方案的局限这套自愈只是前端层面的兜底abort()只是断开了浏览器这一端的连接并不会通知后端停止正在进行的模型推理后端算力依然在跑只是结果扔了如果要做到真取消需要后端配合做基于请求 ID 的取消接口这是一个可以规划的后续优化点。六、多轮对话历史怎么维护流式对话要支持上下文记忆前端在每次发问时需要拼装历史对话数组一并传给后端functionbuildHistory(messages,currentQuestion){returnmessages.filter((msg)!msg.isThinking!msg.isInterrupted!msg.pending)// 跳过异常/未完成的.filter((msg,index,arr){// 去掉末尾与本轮提问重复的那条 user 消息本轮 question 会单独字段传constisLastUserindexarr.length-1msg.roleuserreturn!(isLastUsermsg.contentcurrentQuestion)}).slice(-10)// 只保留最近 10 条约 5 轮问答.map((msg)({role:msg.role,content:msg.content}))}几个容易被问到、也容易说错的细节数量控制页面上可以展示更多条比如最近 50 条控制的是 DOM 节点数和本地存储体积但真正送给模型的历史一般只保留最近 10 条左右核心是控制 Token 消耗成本 长度后端一般也会再做一次兜底裁剪。sessionId不是消息 ID它是会话级标识流式回答结束后从响应里取到并存到本地下次请求带上即可。真正的多轮上下文靠的是前端拼的history数组而不是后端维护了一张完整的会话消息表——这一点如果做的是无状态后端架构务必想清楚不要把sessionId和后端记得所有历史划等号。持久化用localStorage而不是sessionStorage如果是嵌在 App 里的 WebView 场景WebView 很容易因为切后台、内存回收被系统重建sessionStorage这种标签页级存储很容易丢localStorage更稳妥。同时存储的 key 要拼上登录用户标识分桶避免同一设备多个账号登录时聊天记录串号。七、小结这套方案解决了什么、还没解决什么已经做到的用 Fetch ReadableStream 自建了一个支持自定义 Header/Body 的 SSE 客户端行缓冲 TextDecoder(stream: true)双层保障正确处理半包/粘包/多字节字符切断content/reasoning双通道差异化渲染兼顾体验和 XSS 安全rAF同帧贴底 用户滚动意图检测避免抢滚动、避免顿挫AbortControllerpending占位 进页自愈的容错闭环应对弱网、离页、WebView 重建。还可以继续演进的方向前端abort目前只断连接没有联动后端取消模型推理可以补一个基于请求 ID 的后端取消接口长回答场景下每个 chunk 全量marked.parse有 CPU 开销可以叠加节流 结束时强制刷新做增量渲染优化目前没有独立的停止生成按钮只能靠离开页面触发中断产品层面可以补一个更明确的交互入口检索侧目前是基于 Lucene 的关键词检索Sparse 检索增强生成没有引入向量语义检索长尾模糊问法的召回率还有提升空间后续可以考虑做成关键词 向量的混合检索。流式交互看着效果炫酷但真正决定体验和稳定性的往往是半包处理、状态机设计和容错自愈这些看不见的细节。希望这篇复盘能给同样在做 AI 对话类需求的前端同学一些参考也欢迎评论区交流你们踩过的坑。