大模型流式输出必备:SSE协议详解与生产环境避坑指南

发布时间:2026/10/11 5:04:55
大模型流式输出必备:SSE协议详解与生产环境避坑指南 做AI应用开发的人大概率都遇到过这种场景调用大模型接口时原本以为要等上好几秒然后一次性收到一大段完整答案结果请求发出去不到半秒接口就开始往回调里吐token一个字、一个词地往外冒屏幕上的回答像真人打字一样边生成边显示。这个“边生成边显示”的体验背后就是SSE在干活。SSE 全称 Server-Sent Events翻译过来是“服务端事件推送”。它很不显眼因为浏览器、HTTP协议栈早就把它当普通响应在处理大部分前端同学甚至没直接听过这个名词只会说“用EventSource接的流”。但从我这几年的实战经验看想在生产环境把大模型流式输出做好把SSE协议彻底吃透是绕不开的一步——服务端为什么要按这种格式吐数据代理层为什么会把流“吞掉”前端怎么解析增量重连语义怎么处理每个坑都藏在协议细节里。这篇文章我按自己排查问题时的思路来写先把SSE解决的问题讲清楚再一个字段一个字段拆协议报文然后给出一套能直接抄的大模型流式接入方案最后把我在线上踩过的坑和排查方法整理成实录。适合后端工程师、客户端同学以及所有想搞清楚“大模型接口为什么这样设计”的读者。1. 为什么要流式SSE解决了什么问题1.1 传统HTTP响应模型为什么不适合大模型先回到最朴素的HTTP请求-响应模型。浏览器发一个请求服务器计算出完整结果把Content-Length算好一次性把响应体返回给客户端。这个过程是“全有或全无”客户端拿到的是一块完整的数据在它到来之前客户端只能干等。大模型生成内容的时候这个模型就很不舒服。模型必须一个token一个token地做自回归生成哪怕生成速度很快一个几百字的回答也要跑好几秒。如果用传统模型用户发出问题之后看到的就是一个转圈圈或者一个空页面等三秒、五秒、甚至十几秒屏幕上才突然蹦出整篇回答。这个等待过程在真实产品里是不能忍的用户会以为系统卡死了或者触发超时重试。我第一次接大模型API时就在想能不能把“生成一点、发送一点”变成可能答案就是流式响应。服务器不计算完再返回而是边算边把已经生成的增量推给客户端。用户看到的“打字机效果”背后就是每个token从模型出来之后经SSE通道被推送到了前端。这跟餐厅上菜有点像。传统HTTP是“一桌菜全部做齐再上桌”SSE是“做完一道上一道”。用户不需要等到最后一刻才能动筷子第一道菜端上来的时间远比整桌菜全部齐了要早这个“第一道菜的时间”在大模型场景里有个专门名词叫TTFTTime To First Token是衡量流式体验的核心指标。1.2 为什么推送这件事偏偏选了SSE既然要流式推送可选的技术方案并不少。WebSocket也能推WebSocket也已经很成熟为什么大模型服务几乎都默认选择了SSE第一个原因是“简单”。SSE不是一套新协议它就是在HTTP响应里按约定格式持续写数据。不涉及握手升级、不涉及帧协议解析、不需要专门维护连接状态机。服务端想要实现一个流式接口三五行代码就能推起来客户端在浏览器里直接用EventSource就能接连解析都不用自己写。第二个原因是“穿透性”。WebSocket需要HTTP Upgrade到特定协议很多企业内网的代理、网关、防火墙对Upgrade不友好中间设备一多就容易出问题。SSE走的是普通HTTP响应代理、负载均衡、HTTPS终端都不需要特殊处理只要让请求保持连接不关闭就行。对To B、私有化部署场景这个优势极其重要。第三个原因是“语义匹配”。大模型输出本质上就是服务器往客户端单向吐数据不需要客户端频繁往服务端发消息除了最开始的那次请求。SSE本身就是单向通道服务端推给客户端客户端不需要维持上行长期连接。那种“为了双向而双向”的过度设计在这里没有意义。所以一句话总结不是WebSocket不够好而是SSE在“单向推送、基于HTTP、低复杂度”这三个维度上刚好卡在大模型流式输出的需求点上。2. SSE协议的底层细节报文、字段与连接2.1 报文的最小规范data、换行与事件边界SSE协议从外面看就是一个HTTP响应关键在响应头里那个Content-Type: text/event-stream。只要这个类型声明了客户端尤其是浏览器就会进入“事件流模式”不再把响应体当做一个普通文本去等完整返回而是按行解析后续到达的数据。除了Content-Type一般还会带上Cache-Control: no-cache。SSE是动态连续流连缓存都不能要。HTTP/1.1下默认请求会保持连接不用专门写Connection: keep-alive但很多框架为了明确语义会主动带一个。真正的协议内容在响应体里。格式看起来像纯文本但每行都有含义data: {message:你好} data: {message:世界}每一行以“字段名: 空格”开头带data:前缀的行表示一条事件荷载。一个SSE消息以空行结束也就是连续两个换行符\n\n。服务端推送一条消息就是先写一行或几行data:然后补一个空行。这里有个细节经常踩坑一个事件可以有多个数据行。协议规定同一个事件的多个data:行会被合并以换行符连接所以服务端如果想推一段多行文本不要自作聪明地去掉换行直接分多条data:发客户端拼出来自然是对的。协议还允许以冒号开头的注释行比如: ping。注释行不会被当作事件它的作用是“保活”很多服务端拿它当心跳用。2.2 事件元数据字段id、event、retry到底干什么除了dataSSE协议还定义了三个元数据字段理解它们是掌握重连语义的关键。id:是事件ID。服务端可以在每条消息里带一个自增或业务ID。客户端从流中断开时浏览器会自动在重连请求里带上Last-Event-ID头值就是最后收到的那个事件ID。服务端读到这个头就知道客户端已经处理到哪了可以从下一个事件继续发。这是SSE“断点续传”的实现基础。event:是事件类型。可以不写默认是message浏览器里用onmessage或addEventListener(message)接收。如果要自定义事件名可以写成event: update客户端对应监听update事件。大模型场景里用得不多但在流式日志、任务进度等业务场景里很有用。retry:是重连时间。单位是毫秒告诉浏览器如果连接断了隔多久重连。不写的话浏览器各实现有自己的默认值生产环境里我建议服务端主动发比如retry: 3000避免不同浏览器行为不一致。一个完整的SSE事件长这样id: 1 event: message data: {choices:[{delta:{content:你}}]}字段之间顺序无所谓空行才是一件事的终止符。有个容易忽略的点如果一行以未知字段名开头浏览器直接忽略这一行协议兼容性做得很好不会因为一行坏数据整个流崩溃。2.3 底层传输没有Content-Length的HTTP怎么做到“发一点收一点”普通HTTP响应一到客户端第一件事就是找Content-Length好知道响应体有多长。但SSE的响应长度是动态的、服务端自己也不知道最终会推多少字节所以响应里不会带Content-Length。HTTP/1.1在这种情况下自动采用分块传输编码Transfer-Encoding: chunked响应体会被切成一块一块每块自带长度服务端每写完一小块就往连接上刷一次客户端就能立刻收到。这个机制解释了一个常见现象为什么SSE流在客户端看起来是“小块小块”到达的而不是积攒到某个缓冲区大小才一次性出来。因为服务端只要主动调用响应对象的write/flush数据就会沿着TCP连接发出去。SSE连接的存续完全由“不断开”决定。服务端推完所有数据可以直接结束响应也可以发一个业务层面的结束标记比如大模型接口里常见的data: [DONE]再优雅关闭。哪怕服务端不主动关浏览器也始终认为连接活着直到网络断开或超时。从协议层面讲SSE就是一行行纯文本加上一个保持打开的HTTP响应。没有复杂的编解码没有二进制帧正是这种朴素让它在链路中能轻松穿透各类代理。3. 大模型流式输出里的SSE实战3.1 大模型接口返回的SSE流长什么样大模型API的流式返回各家实现大同小异核心思想是一致的每个SSE事件推送一个增量块里面只包含相较于上一个事件的“变化量”。拿对话补全类接口举例返回流大致长这样data: {id:123,object:chat.completion.chunk,choices:[{index:0,delta:{role:assistant},finish_reason:null}]} data: {id:123,object:chat.completion.chunk,choices:[{index:0,delta:{content:你},finish_reason:null}]} data: {id:123,object:chat.completion.chunk,choices:[{index:0,delta:{content:好},finish_reason:null}]} data: [DONE]第一个事件往往带delta.role通知客户端“助手开始说话了”后续每个事件带一个delta.content就是一个增量token可能是半个词、一个字甚至一个标点最后一个事件是data: [DONE]标记流结束。把整个流拼起来客户端要做的事情是初始化一条空的助手消息然后把每个事件里的delta.content追加到这条消息里。不是替换是追加。这个细节搞错的话答案会只显示最后一个字。finish_reason也是判断结束的重要信号。正常结束为stop内容过长截断为length主动停止为cancelled。业务层应该区分对待尤其是length前端一般要提示“回答超长被截断”。3.2 服务端几行代码实现一个SSE端点自己写SSE端点核心是把响应对象持续写而不关闭。我以Python FastAPI和Node.js为例各写一个典型版本。FastAPI里最省事的是StreamingResponse配合异步生成器from fastapi import FastAPI from fastapi.responses import StreamingResponse import json app FastAPI() def generate_chunks(): # 模拟模型增量输出 for token in [你, 好, , 世, 界]: payload {delta: {content: token}} yield fdata: {json.dumps(payload)}\n\n app.get(/sse) async def sse(): headers { Cache-Control: no-cache, X-Accel-Buffering: no, # 让Nginx不要缓冲 } return StreamingResponse(generate_chunks(), media_typetext/event-stream, headersheaders)Node.js里用原生的HTTP响应对象更直接const http require(http); http.createServer((req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive }); const tokens [你, 好, , 世, 界]; let idx 0; const timer setInterval(() { if (idx tokens.length) { res.write(data: [DONE]\n\n); clearInterval(timer); res.end(); return; } const payload JSON.stringify({ delta: { content: tokens[idx] } }); res.write(data: ${payload}\n\n); }, 100); });几个服务端关键点不要调用res.end()直到全部数据推完。如果想做心跳隔几十秒写一个冒号注释行:\n\n就行不要推空事件空事件会让前端触发一次空消息容易引起业务误解。框架的GZip压缩别开。压缩会引入缓冲流式效果会被破坏。客户断开时连接会收到close事件一定要清理定时器和生成器避免后台任务泄漏。3.3 客户端EventSource与fetch两条路线浏览器原生支持EventSource用法极其简单const es new EventSource(/sse); es.onmessage (event) { const data JSON.parse(event.data); appendDelta(data.delta.content); };但它有一个硬伤EventSource只支持GET请求无法自定义请求头。大模型API绝大多数要求POST加上Authorization: Bearer xxx。直接拿EventSource去接大模型API是接不上的。生产环境的主流做法是用fetch请求接口拿回一个ReadableStream响应体自己按SSE格式逐行解析。const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ messages: [...] }) }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8, { stream: true }); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop(); // 最后一段可能不完整留到下一次 for (const line of lines) { if (!line.startsWith(data:)) continue; const payload line.slice(5).trim(); if (payload [DONE]) { reader.cancel(); return; } const json JSON.parse(payload); appendDelta(json.choices?.[0]?.delta?.content || ); } }这里decoder.decode(value, { stream: true })非常重要。SSE规范规定文本是UTF-8编码而一个中文字符在UTF-8里占3个字节网络分块可能刚好把一个字劈成两半。stream: true让解码器把不完整的字节留在内部下一块数据到达时再拼接不会出现乱码。4. 工程实测流式输出的坑与排查实录4.1 症状“流而不动”代理、网关在帮你攒包最典型的线上事故“接口通了代码看着没问题但前端回复不是一字一字冒出来的而是几秒后一次性蹦出一大段。”这不是代码问题是中间链路缓冲问题。Nginx默认开启proxy_buffering它会尽量把上游响应攒在自己的缓冲区里攒够大小或等到关闭才转发给客户端。在SSE场景里这意味着事件流被Nginx“截胡”成了全量响应。排查方法很简单用curl -N直连后端服务如果流式正常再通过Nginx访问如果变成一次性返回基本就是缓冲问题。解决方式有两招。或在Nginx配置里关掉缓冲proxy_buffering off;或后端在响应头里加X-Accel-Buffering: noNginx读到这个头会自动对这个请求禁用缓冲。我的习惯是后者因为可以通过后端代码控制特定接口行为不用动全局配置。CDN节点同理。虽然大多数CDN能识别text/event-stream并关闭缓冲但私有化部署里的企业网关、API网关不一定认识常常会做全量缓冲。接入流式接口前最好在测试环境先做一轮“全链路直连对比”确认每一个中间节点都不缓冲。另外负载均衡器和网关的read timeout也要调大。SSE连接可能持续数分钟甚至更长默认的60秒空闲超时会把连接掐断。对长思考时间的模型来说超时阈值建议至少300秒心跳保活则是双保险。4.2 中文乱码多字节字符被切在分块边界用fetch流式读大模型输出如果看到“”或者间歇性缺字就是解码姿势不对。本质是响应分块边界和字符编码边界不重合一个UTF-8字符的3个字节分到了两个网络包里。正确的做法在前面代码里已经体现始终用带{ stream: true }的TextDecoder。如果你的解析方式是reader.read()拿到原始字节后直接String.fromCharCode或者转成字符串再拼就一定会遇到这个坑。服务端同理。如果服务端不是真流式而是先攒一个大的JSON字符串再一次写入那中文不会乱码但一旦变成“攒一小块写一块”就必须保证每次写入的块边界落在完整字符边界上。Python里文件默认按字节写用json.dumps之后再encode(utf-8)一般不会切在中间但手动切割字符串再编码时就要小心。我在项目里干脆把前端SSE解析器封装成了一个模块输入是字节流内部维护字符串缓冲、字符解码、事件行切分输出是格式化后的JSON对象。这样处理乱码、半行、多data合并的问题都集中在一处。4.3 断线重连Last-Event-ID没那么万能浏览器原生的EventSource有自动重连能力断线后会自动携带Last-Event-ID重新连接服务端通过它续传。这在新闻推送、日志流场景下很好用。但放到大模型对话场景很多团队发现这套机制不够用。原因在于大模型的对话流通常是“一次请求一次事件流”流结束[DONE]之后这条SSE连接就结束了不存在“中间断了从断点续传”的语义。如果用户网络闪断最合理的方案不是SSE自动重连去追增量而是业务层发一次新的完整请求让模型重新生成同时前端尽量保留已显示内容避免用户感知明显跳变。所以我的建议是理解Last-Event-ID作为协议能力但不要硬套到大模型场景。协议层重连属于“尽力而为”业务层要做到的是“幂等重试”——前端记录已渲染的文本长度重试时拿到全新流后只展示超出部分或者干脆从头展示并提示“网络不稳定重新连接中”。还有一个服务端容易忽略的问题客户端断开后服务端还在继续调模型、继续生成token、继续往已死连接上写数据。这些写操作最终会触发异常但如果没监听后台定时器和生成器会一直挂着。服务端务必在流的close事件里做清理把模型调用也一并取消既省钱又省资源。4.4 鉴权、跨域与心跳三个客户端侧的隐藏问题EventSource无法自定义Authorization头这在接入需要鉴权的大模型API时很麻烦。成熟的解法有三种。一是后端做代理BFF前端只连自己的服务端鉴权信息由后端代管前端EventSource或fetch都不用直接暴露密钥。二是URL携带一次性token适合短生命周期场景但注意token会出现在网关日志里别长期有效。三是放弃EventSource直接用fetch携带Header这也是我主要采用的方式。跨域方面SSE同样受CORS约束。服务端要配置Access-Control-Allow-Origin如果前端用EventSource且需要带Cookie还得设置EventSource的withCredentials: true服务端同时返回Access-Control-Allow-Credentials: true而且不能使用*通配Origin。这组条件配不好前端会看到连接直接失败或事件能到但Cookie不带。心跳的意义在于防止网络设备空闲断开。很多企业防火墙对“长时间无数据的连接”有清理策略。大模型思考时间稍长可能连续几十秒没有任何输出流连接看起来就像“死”了。服务端每15到30秒发一行注释:或一个自定义ping事件就能让连接保持活跃。前端收到心跳事件时只需忽略不要渲染。5. SSE的选型边界与扩展玩法5.1 SSE、WebSocket、长轮询怎么选维度SSEWebSocket长轮询定时轮询方向服务端到客户端单向双向客户端到服务端模拟推送客户端到服务端协议基础纯HTTP独立协议HTTP升级握手纯HTTP纯HTTP自动重连浏览器内置无需自己实现无天然反复请求请求头自定义EventSource不支持fetch可自己解析支持支持支持延迟低最低较高高复杂度低高中最低适用场景单向事件流、大模型输出、通知实时聊天、游戏、协同编辑、真正需要双向老系统临时改造低频弱实时需求从我的实践看选择标准其实很简单如果是“服务端主动往客户端推”并且客户端不太需要给服务端回消息优先SSE。如果是“客户端和服务端都要随时发言”才需要WebSocket。大模型输出是典型单向流SSE在复杂度上优势明显。5.2 不只能推大模型SSE的通用变体与场景SSE不止能推token。我把这类协议按数据形态分成几种通用格式标准SSE用data: ...\n\n分割事件适合浏览器和规范约定。NDJSON变体每一行一个JSON对象用\n分割格式更简单适合后端对后端、或者自己写的客户端解析。二进制流服务端直接写原始字节前端用fetch加ReadableStream读不走SSE格式适合音频流。在大模型产品里我就用SSE推过多种业务事件除了delta增量token还有status任务状态排队中、生成中、完成、tool_callAgent工具调用参数、error错误信息。这些如果都塞在同一个data事件里前端就得靠JSON字段区分类型利用SSE的event字段直接定义event: status、event: delta前端监听不同事件名代码清晰很多。流式能力还能复用到很多场景CI/CD的构建日志逐行外推、商品库同步进度、服务端指标看板实时刷新。SSE就是一个“服务器想说话就能随时说”的通道。5.3 警惕SSE的边界单向、连接数与HTTP版本SSE的短板也很明确。单向性决定它做不了“流式上传流式输出”的双向实时会话。语音助手那种边说话边被打断、边听边回的场景仍然得用WebSocket。还有一层限制浏览器对同一个域名的并发连接数有限制HTTP/1.1下大概是6个。如果页面同时打开多个SSE连接第7个会一直等待。这个问题在HTTP/2到来后缓解HTTP/2支持多路复用一个TCP连接可以承载多个SSE流这也是新协议能提升SSE体验的底层原因。代理兼容性也要注意。非常老的中间组件遇到长时间不结束的HTTP响应可能直接按超时处理遇到Transfer-Encoding: chunked也可能错误地缓冲。上线前对每个中间环节做一次真实的流式压测比在代码里找半天问题有效得多。最后SSE没有规定消息编码但实际约束一定是UTF-8。非UTF-8文本都会出乱码服务端生成内容时先统一做好编码转换再进入流。我个人做流式接入最大的体会是SSE本身极其简单真正的复杂度全在链路。协议规范读一遍就能懂但Nginx缓冲、CDN策略、浏览器解码、鉴权方式、重连语义、心跳设计每一环都可能把简单的事情弄复杂。所以越早把这一整条链路摸清后面排查问题就越顺手。如果你即将接手一个流式大模型应用我的建议是先写一个透传SSE的代理Demo用curl压一遍直连和经过网关两条路径把链路里的“隐形缓冲区”全部找出来再开始写业务代码。这样后面每一步你都清楚地知道数据是怎么从模型流到用户屏幕上的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询