ARTICLE DETAIL

建站实战干货

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

SSE 事件设计与可中断的打字机效果

2026/8/11 18:26:33 拓冰建站 浏览量
SSE 事件设计与可中断的打字机效果 上一篇讲了 SSE 怎么连EventSourcevsfetch流。连上之后真正决定体验的是两件事事件协议怎么设计双方约定看什么字段前端怎么边收边展示并且能中途停下本文按「最小可用协议 → 前端打字机 → 可中断」往下写。1. 先定一条生命周期流式生成可以想成一次「有始有终」的任务开始 → 持续产出内容 → 正常结束 ↘ 中途失败对应到 SSE最小事件集通常是四个事件何时发前端干什么start任务真正开始时发一次清空旧文案、显示「生成中」、记下traceIdcontent产出增量文本时多次发追加到本地字符串打字机end成功收尾时发一次定稿、关 loading、展示引用/校验结果error失败时发一次展示错误、关 loading一般不再等end一条健康的成功流start content content content ... end失败流start content ← 可选失败前可能已有部分输出 error用户主动中断时服务端未必来得及发error前端应靠AbortError自己收尾后面细说。2. 事件载荷怎么设计推荐每条data:都是一个 JSON里面用event字段区分类型。data: {event:start,traceId:t_123,chapterNo:12} data: {event:content,data:夜色,traceId:t_123} data: {event:content,data:渐深,traceId:t_123} data: {event:end,traceId:t_123,finalText:夜色渐深……,citations:[]}建议字段start{event:start,traceId:t_123,chapterNo:12}traceId整次任务的关联 ID日志/客服排障靠它其它上下文章节号、模式名可按业务加但保持「只发一次」content{event:content,data:增量文本,traceId:t_123}data是增量不是全文否则越推越大也难做打字机换行若怕被传输层弄乱可约定服务端把\n写成\\n前端再还原end{event:end,traceId:t_123,finalText:完整最终稿可选,citations:[],consistencyNotes:[]}放「只在结束时才有」的汇总信息引用、校验结果、最终全文前端可以用流式拼接结果finalText适合做一次校准防拼接误差error{event:error,data:上游模型超时,traceId:t_123}data给用户可读原因发完error后应尽快结束响应不要再继续推content可选增强有需要再加事件用途progress/ 阶段事件「检索中 / 拼装 Prompt / 等待模型」content_replace用全文替换当前缓冲例如安全改写后整段重写原则最小四件套先跑通阶段信息是锦上添花别一上来把协议设计成状态机百科。3. 服务端按约定推事件typeSsePayload|{event:start;traceId:string;chapterNo?:number}|{event:content;data:string;traceId:string}|{event:end;traceId:string;finalText?:string}|{event:error;data:string;traceId:string};functionwriteEvent(res:Response,payload:SsePayload){res.write(data:${JSON.stringify(payload)}\n\n);}asyncfunctionstreamGenerate(res:Response,traceId:string){writeEvent(res,{event:start,traceId});try{forawait(constchunkofcallLlmStream()){writeEvent(res,{event:content,data:chunk.replace(/\n/g,\\n),traceId,});}writeEvent(res,{event:end,traceId,finalText:fullText,});}catch(err){writeEvent(res,{event:error,data:errinstanceofError?err.message:生成失败,traceId,});}finally{res.end();}}几个纪律start尽早发让前端立刻进入「生成中」content只发增量成功只走end失败只走error不要两个都发把前端搞懵客户端断开后停止write听req.on(close)4. 前端打字机效果怎么做打字机不是动画库核心就是每次收到content把增量拼到一个字符串上模板绑定这个字符串。conststreamingTextref();construnningref(false);consterrorMessageref();constfinalResultrefnull|{finalText?:string}(null);functionhandleSseDataLine(dataLine:string){constmsgJSON.parse(dataLine)as{event:string;data?:string;traceId?:string;finalText?:string;};switch(msg.event){casestart:streamingText.value;errorMessage.value;finalResult.valuenull;running.valuetrue;break;casecontent:streamingText.value(msg.data||).replace(/\\n/g,\n);break;caseend:running.valuefalse;finalResult.value{finalText:msg.finalText};// 可选用最终稿校准if(msg.finalText){streamingText.valuemsg.finalText;}break;caseerror:running.valuefalse;errorMessage.valuemsg.data||生成失败;break;}}模板里直接渲染pre v-ifrunning || streamingText{{ streamingText }}/pre p v-iferrorMessage classerror{{ errorMessage }}/p如果内容是 Markdown注意别每个 token 都做重型全量渲染。可以流式阶段用纯文本 / 轻量渲染或给 Markdown 组件加节流例如 100–200ms 再解析一次MarkdownContent :sourcestreamingText :throttle-ms200 /5. 可中断AbortController 一条线打通「可中断」要同时管三层用户点「停止」 → abortController.abort() → fetch 取消 / reader 停止 → 前端 catch 到 AbortError不当成业务失败 → UI 退出「生成中」5.1 一个小的流控制封装exportfunctionuseAbortableSse(){letcontroller:AbortController|nullnull;conststreamingref(false);functionabort(){controller?.abort();controllernull;streaming.valuefalse;}functionbegin(){abort();// 开新流前先停旧流避免串流controllernewAbortController();streaming.valuetrue;returncontroller.signal;}return{streaming,begin,abort};}5.2 发起请求时带上 signalconstsseuseAbortableSse();asyncfunctionstartGenerate(){constsignalsse.begin();streamingText.value;try{awaitfetchSse(/api/generate/draft,{method:POST,headers:{Content-Type:application/json,Authorization:Bearer${token},},body:JSON.stringify({/* task */}),signal,},handleSseDataLine);}catch(error){if(isAbortError(error)){// 用户主动停止静默收尾即可return;}errorMessage.valueerrorinstanceofError?error.message:生成失败;}finally{sse.abort();running.valuefalse;}}functionstopGenerate(){sse.abort();}5.3 识别「中断」和「真错误」functionisAbortError(error:unknown):boolean{return((errorinstanceofDOMExceptionerror.nameAbortError)||(errorinstanceofErrorerror.nameSseAbortError));}情况用户提示主动中断「已停止生成」不要弹红错业务error事件展示服务端data网络/HTTP 失败展示请求失败原因这是体验分水岭很多产品把 Abort 也当成异常 Toast用户会觉得「我点停止怎么还报错」。5.4 按钮状态button :disabledstreaming clickstartGenerate开始生成/button button :disabled!streaming clickstopGenerate中断/button6. 把协议和 UI 绑在一起的完整时序点击「开始」 begin() 得到 signal 清空 streamingText POST fetch 流 收到 start running true 多次 content streamingText delta ← 打字机 收到 end running false 可用 finalText 校准 —— 或 —— 用户点「中断」 abort() fetch/reader 停止 catch AbortError → 静默 running false 保留已生成的 streamingText或按产品决定清空 —— 或 —— 收到 error running false 展示错误文案产品决策一句说清即可中断后保留已生成文本写作场景更常见中断后清空适合「要么完整结果、要么不要」的任务7. 常见坑content误发全文每次都推完整文章流量大前端还要从头刷新打字机变「闪烁全文」。成功后又发error或失败后还发end前端状态机乱套。约定互斥。中断当失败一定要分流AbortError。开新任务不停旧流begin()里先abort()旧的避免两个流同时往同一个streamingText上追加。Markdown 每个 token 重渲染长文会卡。节流或流式期降级为纯文本。只有前端 abort、服务端还在生成连接断了服务端应停止写前端 abort 至少能立刻恢复 UI。服务端最好也听close。8. 最小回调接口便于复用业务一多建议把「解析 SSE」和「页面怎么展示」拆开interfaceSseCallbacks{onStart?:(traceId:string)void;onContent?:(text:string)void;onEnd?:(payload:{traceId:string;finalText?:string})void;onError?:(message:string)void;}页面只关心awaitgenerateDraftSSE(params,{onStart:(){streamingText.value;},onContent:(text){streamingText.valuetext;},onEnd:({finalText}){if(finalText)streamingText.valuefinalText;},onError:(message){errorMessage.valuemessage;},},{signal});API Client 负责读流、拆包、JSON.parse、按event分发。页面负责字符串累加、loading、中断按钮。9. 小结先约定最小四事件start/content/end/errorcontent发增量end带汇总error与end互斥打字机 streamingText delta 模板绑定可中断 AbortController贯穿 fetch/reader并且Abort ≠ 业务错误开新流前停旧流重渲染要节流上一篇讲「怎么连上 SSE」这篇讲「连上之后双方怎么说话、前端怎么演」。两者合在一起才是完整的流式生成体验。扩展中断后「继续生成」怎么做上面讲的是中断就停已有内容保留。有一种更进一步的产品形态是用户中断后已生成的内容作为「前缀」点「继续」让 AI 接着往下写。思路不复杂拆成三段来说。思路一纯前端拼接无状态续写最简单。中断时保留streamingText点「继续」时把它作为参数带进下一次请求。服务端接收一个prefixText字段把它拼到 Prompt 里让模型从这里接着写// 前端续写请求awaitgenerateDraftSSE({prefixText:streamingText.value,// 已生成的内容task:currentTask,},callbacks,{signal});// 服务端拼进 promptconstprompt已生成内容请直接续写不要重复${prefixText}--- 请继续;收到content事件时照常追加casecontent:streamingText.valuedelta;// 直接接在后面优点改动极小纯协议层扩展。缺点每次续写都要把前缀全量发给服务端前缀很长时 token 费用和延迟都会上升。思路二服务端记 session有状态续写更健壮的方案。服务端维护一个sessionId记住本次任务的上下文已生成内容、参数、进度续写时只传sessionId服务端自己知道从哪里接。事件协议加一个字段{event:start,traceId:t_123,sessionId:sess_abc}前端把sessionId存起来casestart:currentSessionId.valuemsg.sessionId;break;续写接口只需awaitresumeGenerateSSE({sessionId:currentSessionId.value,},callbacks,{signal});服务端用sessionId拿回上下文继续生成。优点前端不用传大量前缀内容服务端可以做更精细的断点控制。缺点服务端要存 session增加状态管理复杂度。前端状态机加一个interrupted状态不管哪种方案前端的状态都需要区分「空闲」和「已中断可续写」typeGenerationStatusidle|streaming|interrupted|done|error;functioninterruptStreaming(){abortController?.abort();if(status.valuestreaming){status.valueinterrupted;// 不是 idle而是可续写}}按钮根据状态切换button v-ifstatus idle || status done clickstartGenerate 开始生成 /button button v-ifstatus streaming clickinterruptStreaming 中断 /button button v-ifstatus interrupted clickresumeGenerate 继续生成 /buttonresumeGenerate内部就是带上prefixText思路一或sessionId思路二重新发 SSE 请求成功后把状态从interrupted切回streaming。一个需要注意的细节续写拼接点AI 续写时很可能从一句话中间接上前后都是完整文字看不出断点——这是最理想的效果。但也可能重复最后一句或者在语义上跳跃。实践里常见的处理传前缀时截去最后一个不完整的句子按标点找最后的句号/问号/感叹号截断在 Prompt 里明确说「已有内容的最后几句」而不是全文降低 token 用量的同时也给模型清晰的锚点end事件里加canResume: true/false标记告诉前端这次生成是否支持续写小结方案适合场景关键改动纯前端拼接内容短、快速验证请求加prefixTextPrompt 拼接服务端 session长文、多轮续写服务端存上下文事件带sessionId共同点前端都需要interrupted状态 「继续」按钮核心流控和打字机逻辑不用改。系列导航上一篇SSE 的两种连接方式