首页 / 资讯中心 / 文章详情

大模型流式输出与SSE:前端接入原理与实战避坑指南

大模型流式输出与SSE:前端接入原理与实战避坑指南 ★ FEATURED ARTICLE
先给你看一段我在AI对话项目里见过多次的响应。打开浏览器Network面板随便发一句“你好”Response标签页里就会冒出这样一串东西data: {content:,delta:{role:assistant}} data: {content:你,delta:{content:你}} data: {content:好,delta:{content:好}}第一次接触大模型接口的人多半会愣一下接口返回的不应该是JSON吗怎么是一行行以data:开头的纯文本这不是聊天记录也不是完整JSON而是大模型流式输出最底层的通信方式——SSEServer-Sent Events服务器发送事件。说白了就是服务器把生成的内容一块一块实时推给浏览器前端拿到一块渲染一块。这篇文章我就围绕“大模型 前端 SSE”这条线讲清楚为什么大模型应用绕不开SSE前端接入时有哪些方案、哪些细节以及我在真实项目里踩过的坑。适合正在接大模型接口、被流式返回弄到头大的前端同学也适合想系统理解SSE原理的后端同学。1. 大模型应用里SSE为什么绕不开1.1 大模型天生就是“边算边输出”的大模型做文本生成本质上是自回归过程一个token一个token地往下预测每生成一个token都要把前面已经生成的token重新作为上下文再算一次。你可以把它理解成一个人讲话不是先在脑子里写好一整篇演讲稿再一口气背出来而是边想边说说出口的每一个字都影响下一句怎么接。这就带来一个很现实的问题如果让模型把完整回答全部生成完再一次性返回给前端一个500字左右的回答主流模型大概要花15到30秒。这期间用户看到什么一个空空的对话框或者一个转圈菊花。等30秒后整段文字“啪”一下出现用户早就以为系统卡死了。所以大模型应用必须做流式输出模型算出一个token就立刻把这个token通过HTTP响应推给浏览器。用户感受到的效果是“字一个个蹦出来”像打字机一样。这个体验不仅让等待变得可接受还让用户确信系统正在工作。那么问题来了这种“边算边传”的数据通过HTTP怎么传答案就是SSE。1.2 流式返回后前端拿到的是这样一串东西先看一段真实的SSE流式响应内容Content-Type: text/event-stream data: {id:chatcmpl-1,choices:[{index:0,delta:{role:assistant},finish_reason:null}]} data: {id:chatcmpl-1,choices:[{index:0,delta:{content:你},finish_reason:null}]} data: {id:chatcmpl-1,choices:[{index:0,delta:{content:好},finish_reason:null}]} data: [DONE]注意几个关键点响应头的Content-Type是text/event-stream而不是常见的application/json。这一下子就告诉浏览器这不是普通JSON响应请用流式的方式读取。每条事件由若干字段行组成以空行\n\n分隔。字段格式是字段名: 值比如data: {...}。data:后面的内容才是真正要解析的数据通常是JSON字符串。流结束时常见接口会追加一行data: [DONE]表示生成完毕。也就是说前端不能像以前接普通接口那样拿到响应体之后直接JSON.parse。你拿到的是“一整块文本”其中包含多条消息必须自己按行切分、按空行分组、逐条解析。这就是“SSE解析”这层逻辑存在的意义。1.3 协议层面的完美匹配SSE的完整定义是Server-Sent Events翻译过来就是“服务器发送事件”。它本质上还是HTTP协议但服务端利用HTTP的分块传输机制把响应体拆成很多小块持续发送连接会保持一段时间而不立即关闭。你不需要额外引入其他协议栈也不需要像WebSocket那样先做一次握手升级。SSE极其朴素服务端往响应体里写东西写完一部分就flush一下浏览器就能读到一部分。在大模型应用里通信模型恰好也是单向的用户发一次提问然后就是模型持续回答。回答过程中用户基本不需要再向服务器主动发什么除了“停止生成”这种极少数操作。这跟SSE的设计初衷完全对称——服务器单向推送客户端被动接收。所以你会发现几乎所有主流大模型接口都把流式输出作为默认方式返回格式也直接沿着SSE标准走。前端想绕开SSE基本上不可能。2. 三个实时方案放一起选为什么胜出的是SSE2.1 轮询、WebSocket、SSE先看本质区别前端做“实时”效果绕不过三个方案轮询、WebSocket、SSE。很多人上来就选WebSocket觉得它功能全、双向、实时性最强。但在大模型场景里WebSocket通常不是最优解。先把三者本质区别摆出来。方案通信方向基于协议实现复杂度自动重连典型场景轮询客户端主动问服务器被动答HTTP最低天然支持短数据周期刷新WebSocket全双工双向独立协议握手时升级高需自实现聊天室、协同编辑、实时游戏SSE服务器单向推送HTTP低原生支持实时通知、大模型流式输出轮询很好理解就是前端每隔几秒发起一次请求问服务器“好了没”。大模型场景用轮询基本是灾难模型生成期间前20次请求服务器几乎都只能回答“还在生成”白白浪费大量请求就算你控制住频率用户看到的文字也是一段一段“跳”出来的首字体验极差。WebSocket则是很多人的下意识选择。功能确实强大双向通信想发什么发什么。但也要付出代价连接需要先通过HTTP Upgrade握手服务端要维护连接状态消息有帧的概念还要处理心跳、断线重连、代理超时等问题。这些在简单场景里都是负担。SSE最突出的特点是“刚刚好”它只需要服务器单方向发数据不需要客户端一直向上推送因此基于普通HTTP就能实现服务端不用维护复杂的连接状态前端解析逻辑也简单。2.2 用大模型场景反推选型回到大模型应用本身选型最大的判断标准只有一个通信模型是否匹配。用户与模型之间本质上是“一次请求持续响应”。请求是用户发的响应是模型持续输出的。中间没有高频的双向交互需求。这个模型和SSE是天然匹配的SSE是单向的够用。用户不需要在生成过程中频繁发消息给服务器顶多发送一个“停止”指令而这个指令完全可以通过另外的HTTP请求或者直接中断连接来实现。SSE基于HTTP请求链路简单。前端用原生fetch就能发起并读取流不用额外引入socket库。SSE文本友好。大模型返回的数据本身就是文本JSON片段SSE的data:字段完全够用不需要走二进制帧。还有一个容易被忽视的运维因素WebSocket需要代理服务器专门配置长连接超时、连接数、心跳参数SSE因为就是普通HTTP响应Nginx、CDN等中间层基本不需要额外处理就能透传。对很多正在快速迭代的团队来说这个方案省下来的运维成本真实可观。2.3 什么情况下就别硬上SSE了我并不是说WebSocket一无是处。SSE单向推送的本质决定了只要出现“客户端需要高频向服务器发送数据”的场景它就不够用。比如多人协同编辑用户A的光标位置要实时同步给用户B同时也要接收B的移动双向高频。实时语音/视频互动端上的音频流持续上行其他人实时接收。在线白板协作每个用户的绘制操作都要双向同步。这些场景里WebSocket才是顺手的工具。大模型应用如果以后真要做“语音对话打断”这种功能用户在说话过程中需要不断上传音频、同时接收识别结果和模型回复那时就可能要WS和SSE混合使用。简单说单向推送优先SSE真正需要双向高频交互再上WebSocket。不要因为“WebSocket听起来更高级”而忽略业务模型。3. 前端接入SSE的两种主流方式3.1 EventSource学起来快但限制先摆清楚前端接入SSE最直接的方式是使用浏览器原生API——EventSource。const sse new EventSource(/api/chat-stream?id123); sse.onopen () { console.log(连接已建立); }; sse.onmessage (event) { const data JSON.parse(event.data); // 处理流式返回的数据 }; sse.onerror (err) { console.error(连接异常, err); };只用几行代码就能建立一个SSE连接并且浏览器会自动处理断线重连。这看起来很完美但真实项目里EventSource的局限性非常明显只支持GET请求。大模型接口往往需要传复杂参数模型名、参数、历史消息GET请求在URL长度和日志记录上都很难受。不能自定义请求头。现在的大模型接口普遍要求带Authorization或API-KEY鉴权EventSource的请求头是浏览器固定的你没法往里面塞token。拿不到详细错误响应。当接口返回401、403、429时EventSource只会触发onerror你不知道具体状态码也无法读取服务端返回的错误信息前端排查困难。不能随便发POST无法通过请求体传数据很多场景直接卡死。所以EventSource在我的项目里只适合用来做快速验证确认服务端能不能正常推送、看看SSE格式对不对。真要接入生产环境几乎都会被fetch方案替代。3.2 fetch ReadableStream现在的主流解法为什么fetch能用来接SSE关键在Response.body。Fetch API返回的响应是一个Response对象它的body属性是一个ReadableStream——一个可读的流对象。你可以像读取文件流一样一段一段地读取响应内容。用fetch接SSE既能保留流式读取的能力又不受EventSource的GET、请求头限制。下面是我在项目里用的一段核心代码async function fetchSSE( url: string, params: Recordstring, unknown, headers: Recordstring, string, onMessage: (data: unknown) void, signal?: AbortSignal ) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json, ...headers, }, body: JSON.stringify(params), signal, }); if (!response.ok) { throw new Error(HTTP error: ${response.status} ${response.statusText}); } if (!response.body) { throw new Error(当前浏览器不支持流式响应); } const reader response.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消息的分隔符拆行 const lines buffer.split(\n); // 最后一行可能不完整留到下次读取再处理 buffer lines.pop() ?? ; for (const line of lines) { const trimmed line.trim(); if (trimmed.startsWith(:)) continue; // 注释行忽略 if (trimmed.startsWith(data:)) { const data trimmed.slice(5).trim(); if (data [DONE]) return; // 结束标识 try { const json JSON.parse(data); onMessage(json); } catch (e) { console.warn(解析失败等待下一块补齐数据, data, e); } } } } }这段代码的核心逻辑是先把响应的reader取出来循环调用reader.read()读取二进制块然后用TextDecoder把二进制解码成字符串再把字符串按行切分只处理以data:开头的行遇到[DONE]结束循环。使用fetch方案的优点很明显可以任意设置请求方法、请求头、请求体还能拿到HTTP状态码出错时能读取服务端返回的错误信息。配合AbortController前端可以随时中断正在进行的流式请求——这一点对大模型应用至关重要。3.3 解析SSE数据的几个细节处理不好就出bugSSE格式看着简单但实际解析时很容易漏掉几个坑。第一个是空行是事件分隔符。SSE每条消息以空行结束。在我上面的代码里我选择按\n一行行拆解处理这样遇到data:行就能立刻解析。但如果你的服务端一次输出很大一块文本一次性到达的数据可能包含多条完整消息需要循环处理如果数据被拆成多个chunk到达又可能出现半行数据所以必须把最后一行残留在buffer里下次拼接。第二个是多行data的合并。SSE规范里一个事件的data字段可以有多行浏览器会把它们用换行符拼接成一个完整字符串。虽然大多数大模型接口只使用单行data但严谨的解析器应该考虑这种情况。可以用一个数组缓存所有以data:开头的行直到遇到空行再统一拼接并JSON.parse。第三个是注释行。SSE允许服务器发送以冒号开头的注释行例如: keep-alive。这种行不是数据纯粹是让连接保持活跃前端必须忽略否则解析会出错。第四个是[DONE]不是SSE协议本身的标准。它是主流大模型接口的业务约定用来表示“流式生成结束”。因此解析时要把[DONE]当成特殊值单独判断不要把JSON.parse([DONE])。如果服务端做了二次封装也可能是其他结束标记最好在接口文档里明确。3.4 中断请求与资源清理别让连接成了幽灵大模型生成是一个耗时过程用户随时可能点击“停止生成”按钮或者直接关闭页面。这时候如果前端不主动中断请求会发生什么浏览器与服务器的连接会一直挂着服务端模型还会继续计算、继续消耗token白花成本。取消SSE请求的标准做法是用AbortControllerconst controller new AbortController(); // 用户点击停止 stopBtn.onclick () { controller.abort(); }; // 发起请求 fetchSSE(/api/chat, params, headers, onMessage, controller.signal);controller.abort()会中断fetch请求浏览器自动断开下层连接服务端感知到连接断开后通常也会停止生成。在React组件里还要注意组件卸载时清理连接useEffect(() { const controller new AbortController(); startFetch(controller.signal); return () controller.abort(); }, []);如果不做这一步请求返回后组件已经卸载前端setState会造成内存泄漏警告连接也一直占着不释放。生产环境里这个点常被忽略但排查线上问题十次有八次都跟它有关。4. 从请求发起到文本渲染的完整流程4.1 主流大模型接口的返回格式长这样市面上主流大模型接口的流式返回格式高度相似基本都遵循“data:带上JSON字符串空行分隔结束标记[DONE]”的套路。常见的数据结构如下{ id: chatcmpl-xxx, choices: [ { index: 0, delta: { content: 你 }, finish_reason: null } ] }每一条data里的JSON描述的是“这一小段生成的增量内容”。delta.content就是新的文本片段。把这些片段按顺序拼接起来就是最终的回答内容。有些接口直接把完整字段放到data里比如{ content: 你好 }也有接口会返回thought、reasoning_content这种“思考过程”字段需要前端决定是展示给用户还是隐藏。面对不同服务商前端最稳妥的做法是写一层适配器把不同接口的返回格式统一转换成一种内部结构再交给渲染层。这样以后换模型、换服务商前端代码改动量最小。4.2 完整代码示例一个对话消息的最简实现下面给一个完整的React示例从发起到渲染都覆盖到。基于刚才封装的fetchSSE函数加上React状态管理。type StreamMessage { content?: string; thought?: string; finish_reason?: string; }; function useChatStream() { const [answer, setAnswer] useState(); const [loading, setLoading] useState(false); const abortRef useRefAbortController | null(null); const stop () { abortRef.current?.abort(); setLoading(false); }; const send async (messages: Array{ role: string; content: string }) { setLoading(true); setAnswer(); const controller new AbortController(); abortRef.current controller; try { await fetchSSE( /api/chat-stream, { messages, stream: true }, { Authorization: Bearer ${localStorage.getItem(token)} }, (data: any) { const delta data?.choices?.[0]?.delta ?? {}; const thought delta?.reasoning_content ?? ; const content delta?.content ?? ; if (content) { setAnswer((prev) prev content); } // 思考过程可以单独存起来用于折叠展示 if (thought) { setThought((prev) prev thought); } }, controller.signal ); } catch (err) { if ((err as Error).name ! AbortError) { // 非用户主动取消做错误提示 setError(生成失败请重试); } } finally { setLoading(false); } }; return { answer, loading, send, stop }; }这里有一个容易踩的坑把setAnswer写成setAnswer(answer content)。因为SSE的回调触发频率很高React状态更新是异步批处理的直接用外部answer拼接经常会出现漏内容的问题。正确写法是使用setAnswer((prev) prev content)保证每次都基于最新状态追加。渲染层随便用一个div{answer}/div就好了如果输出的是Markdown格式可以用支持流式渲染的Markdown组件或者自己在渲染前手动分段处理。4.3 多轮对话里那些容易被忽略的细节接完单轮流式输出很多人以为大功告成但多轮对话才是真正考验细节的地方。半截消息要不要入历史用户点击“停止生成”后当前这轮回答是不完整的。我的建议是中断产生的半截消息不入对话历史只把它作为一次性展示内容。否则下次请求时历史里塞进一段残缺字符串模型可能被误导。如果有“重新生成”功能这轮完整内容再覆盖进去。思考过程如何展示部分模型返回的流式数据里会包含“思考过程”它不属于最终答案。前端要做两层处理界面上一层给用户看“正在思考”的状态一层在思考结束后展示最终答案逻辑上要把思考过程和回答内容分开存储不要混在一起传给下一次请求。历史消息何时更新建议在用户发送后先把用户消息加入历史在完整回答收到后再把assistant消息加入历史回答中断时不加或标记为“已中断”。这样既能保证上下文准确又不会把半截内容传给模型。5. 生产环境常见问题与排查技巧5.1 中文输出乱码问题不在字符集表在解码器流式输出中文时最容易遇到的现象是文字显示到一半出现“”替换符或者最后的几个字突然变成乱码。很多人第一反应是服务端编码问题其实问题出在前端的解码方式。HTTP响应体是二进制字节流UTF-8编码的中文字符由多个字节组成。比如“你”在UTF-8里占3个字节。网络传输时这3个字节可能被分拆到两个不同的chunk里。如果你每拿到一个chunk就立刻TextDecoder.decode()第一个chunk只收到“你”的半个字节解码器不知道后续字节还来不来于是输出一个“”替换符后续的字节继续拼接反而错位。解决办法就是我在代码里写的const decoder new TextDecoder(utf-8); let buffer ; // 重点是 stream: true buffer decoder.decode(value, { stream: true });stream: true告诉解码器这次的字节流可能还没结束先缓存不完整的多字节序列等下一次decode时再补全。这样中文就能稳定输出不会乱码。这是SSE前端最容易踩的坑没有之一。5.2 流一直不收尾连接挂死在页面上正常情况服务端生成完毕会发送[DONE]并关闭连接。但生产环境里总有意外服务端进程崩溃、检测网络异常、或者某个代理节点吞掉了最后的字节前端就会一直卡在“正在生成”的状态。我在项目里的兜底方案是加一个“最大空闲超时”。每次收到数据时刷新一个标记如果连续15秒没有任何新的数据块到达就认为连接已经死掉主动abort并提醒用户重试。let lastActivity Date.now(); const timer setInterval(() { if (Date.now() - lastActivity 15000) { controller.abort(); clearInterval(timer); // 提示用户“连接超时请重试” } }, 3000);这个策略不能替代服务端的健康检查但至少能避免前端无限等待。5.3 拿不到错误状态码前端无从兜底如果用的是EventSource接口返回401等错误时前端只能触发onerror状态码和错误详情一律拿不到。生产环境必须用fetch方案。用fetch接SSE时应对错误要分两层第一层是HTTP状态码。response.ok为false时直接按状态码做提示比如401提示登录过期429提示触发限流5xx提示服务端过载。第二层是服务端返回的“错误信息”。有些大模型接口即使失败也会返回200同时在流里塞一个错误JSON或者返回一个包含error字段的事件。前端在解析data:数据时要加一个判断如果数据里带error字段直接中断并展示错误信息。5.4 结束信号到底谁说了算[DONE]这种结束标记并非SSE标准而是业界主流接口的约定。那是不是没有[DONE]就是失败不一定是。如果服务端正常生成完毕主动关闭了连接fetch的reader.read()会返回done: true这时即使没收到[DONE]前端也应该把已经解析的内容正常展示并认为生成结束。所以前端逻辑要区分两种情况收到[DONE]正常结束清空加载状态。连接被关闭done: true但没有[DONE]按“已结束但结果可能是完整的”处理此时检查最后一段数据是否被截断被截断就不强行parse直接展示已有内容。还有一种异常情况最后一条data:携带的JSON不完整比如只发了一半就被中断。前端强行JSON.parse会抛异常导致整轮内容崩掉。正确做法是先把不完整数据缓存到buffer里等下一个chunk到达后再尝试拼接解析。5.5 断网、刷新、重连几种棘手场景的处理浏览器断网时fetch请求会被中断reader.read()会抛异常。EventSource因为有内置自动重连断网恢复后会自动重新连接但fetch没有这种兜底机制需要自己实现重试。我的建议是区分场景处理如果是短暂的网络抖动可以用指数退避重试策略比如1秒、2秒、4秒间隔重连最多重试3次。注意大模型生成是有耗时的如果连接重试后重新发起同一个请求服务端会重新生成一遍内容可能产生重复计费。所以重试前要和服务端确认上次请求是否已经消耗了额度。如果是用户主动刷新页面前端应该暂时存储“这个会话还没结束”的状态。刷新后重新拉起一个请求仍然需要历史消息这时最好把当前这轮已生成的内容拼回历史消息里避免上下文断裂。还有一个很微妙的点浏览器标签页切到后台SSE连接可能被浏览器冻结或延迟。如果用户切出去很久再切回来收到的内容会“突增”一大段。前端渲染时最好保持增量追加的稳定性不要因为大量数据同时到达导致页面卡顿。6. 写在最后做完几个大模型对话项目之后我的体会是SSE本身是个很简单的协议真正难的是把它放在真实的大模型应用里前端要处理编码、中断、状态回收、错误识别、断线重连这些零零碎碎的问题。这些细节在文档里很少被强调但线上问题十有八九都出在这里。我比较推荐的做法是把SSE的读取、拆包、JSON解析逻辑封装成独立模块暴露onMessage、onDone、onError这几个回调。这样无论是接对话接口还是接图像生成接口无论是换模型还是换后端前端都不用大改只动适配层就够了。另外一个建议是用心做好“停止生成”的用户体验。用户点了停止连接要断得干净按钮状态要切得及时已输出的内容要保留不要闪一下消失。这些细节做扎实了用户对大模型应用的感知会好很多。如果你正准备接大模型流式接口可以把这篇文章里的代码直接当作第一版原型再根据自己项目的接口格式调整解析细节。踩过一轮坑之后你会发现SSE其实没那么多弯弯绕剩下的事情都跟工程化有关。
阅读完成 · 觉得有帮助?
咨询建站