☰
大模型流式输出与SSE:前端接入原理与实战避坑指南
2026/10/16 9:50:17 网站建设 项目流程

先给你看一段我在AI对话项目里见过多次的响应。打开浏览器Network面板,随便发一句“你好”,Response标签页里就会冒出这样一串东西:

data: {"content":"","delta":{"role":"assistant"}} data: {"content":"你","delta":{"content":"你"}} data: {"content":"好","delta":{"content":"好"}}

第一次接触大模型接口的人多半会愣一下:接口返回的不应该是JSON吗?怎么是一行行以data:开头的纯文本?这不是聊天记录,也不是完整JSON,而是大模型流式输出最底层的通信方式——SSE(Server-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. 三个实时方案放一起选,为什么胜出的是SSE

2.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?id=123'); sse.onopen = () => { console.log('连接已建立'); }; sse.onmessage = (event) => { const data = JSON.parse(event.data); // 处理流式返回的数据 }; sse.onerror = (err) => { console.error('连接异常', err); };

只用几行代码,就能建立一个SSE连接,并且浏览器会自动处理断线重连。这看起来很完美,但真实项目里EventSource的局限性非常明显:

  1. 只支持GET请求。大模型接口往往需要传复杂参数(模型名、参数、历史消息),GET请求在URL长度和日志记录上都很难受。
  2. 不能自定义请求头。现在的大模型接口普遍要求带Authorization或API-KEY鉴权,EventSource的请求头是浏览器固定的,你没法往里面塞token。
  3. 拿不到详细错误响应。当接口返回401、403、429时,EventSource只会触发onerror,你不知道具体状态码,也无法读取服务端返回的错误信息,前端排查困难。
  4. 不能随便发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: Record<string, unknown>, headers: Record<string, 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数据的几个细节,处理不好就出bug

SSE格式看着简单,但实际解析时很容易漏掉几个坑。

第一个是空行是事件分隔符。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请求的标准做法是用AbortController:

const 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 = useRef<AbortController | 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其实没那么多弯弯绕,剩下的事情都跟工程化有关。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询