ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SSE协议详解:大模型流式输出的底层原理与工程实践

SSE协议详解:大模型流式输出的底层原理与工程实践 让大模型边想边说的SSE到底在传输什么第一次接大模型流式输出的时候我盯着终端里逐字蹦出来的文字脑子里冒出一个疑问明明是一次HTTP请求为什么响应能像流水一样断断续续地到后来仔细翻了协议规范才发现秘密全在响应头里那行Content-Type: text/event-stream——它背后的协议叫SSE全称Server-Sent Events服务端推送事件的HTTP标准方案。大模型流式输出的底层原理说穿了就是一套HTTP长连接上跑事件流的机制。这篇东西没有高深晦涩的数学推导也没打算把RFC 2616搬来逐条念。我会从实际开发的角度出发把SSE协议拆开看它和大模型打字机体验之间是什么关系、数据在网络上到底长什么样、后端怎么发、前端怎么收、中间的反向代理又为什么经常捣乱。无论你是在搭OpenAI兼容接口还是在用FastAPI写本地推理服务的流式转发又或者只是好奇前端那行getReader()拿到的是什么这篇都适合你。1. 为什么要流式大模型场景里的体验刚需1.1 从等30秒到边想边答先说一个最简单的现象。你调用GPT或DeepSeek的接口如果不用流式就得等模型把整段回答全部生成完HTTP响应才返回。这个过程有多久依我的实测普通长度的回答总生成时间在5到20秒之间复杂一点的任务甚至奔着30秒去。用户那边看到的是什么一个转圈圈加载条转得人心慌。更要命的是大模型生成token的时间曲线不是线性的。首token延迟生成第一个字的时间通常只有几百毫秒到2秒后续每个token的间隔也就几十毫秒。也就是说模型在1秒内就能开口但把整段话说完要20秒。用户白白在那干等19秒而这19秒里内容其实一直在生成只是被接口挡在水管闸门后面了。流式输出直接把这道闸门打开。后端每生成一个token或者一小批token立刻顺着连接推给前端。于是用户看到的第一行字在1秒内就出现了然后是第二行、第三行整个体验从加载中变成了打字机。对习惯了即时反馈的人来说这不仅是观感问题还会直接影响他们对智能的感知——一个会边想边说的助手远比一个憋半天再甩一大段的盒子更像真人。1.2 HTTP轮询方案为什么救不了场可能有人会想那我不改接口结构仍然用普通HTTP只是前端每隔100毫秒轮询一次模拟流式效果行不行行但从工程角度看相当不划算。轮询的本质是重复发请求。每100毫秒一次20秒就是200个请求其中绝大多数拿到的都是还没有新内容这个结论。请求本身有HTTP握手的开销有网络往返延迟还会给网关带来可观的并发压力。稍微算一下账100个用户同时在用轮询方案每秒就要扛1000次请求而真正的流式连接只有100个活跃长连接哪个对运维更友好一目了然。更麻烦的是轮询的乱序问题。假设你分两次轮询第一次拿到了第1到5个token第二次拿到第6到10个。可如果中间某次请求出现了重试、超时、或者网关把两次响应合并了前端就得处理缺段重复乱序三类情况。这本质上是在用HTTP的短连接语义拼接流的效果属于拿锤子拧螺丝能用但别扭。SSE的思路完全不同它只发一次HTTP请求然后服务器在这条连接上源源不断地推送事件连接的寿命由业务决定而不是由请求-响应的闭合规则决定。数据顺序天然就是生成的顺序不需要前端拼图。1.3 SSE怎么跑在HTTP这条老路上的SSE的全称是Server-Sent Events名字直译就是服务端发送的事件。它不是一个新协议而是建立在HTTP之上的、由W3C标准化的一套推送方案。它的关键动作有两个服务端在响应头声明Content-Type: text/event-stream告诉客户端这条响应我打算持续开着不是等你说完就关。服务端以文本块的形式往连接里写事件每个事件之间用空行分隔事件内容由若干个字段: 值构成。因为底层就是HTTP所以它不用像WebSocket那样先发一次特殊的握手请求、完成协议升级。任何能发HTTP请求的客户端——浏览器、curl、Python的requests、Node的fetch——理论上都能消费SSE流。这也是大模型服务商从OpenAI到国产模型的API普遍选择SSE做流式输出的原因兼容面极广几乎不需要改基础设施。理解到这一层流式输出的底层原理这顶帽子就摘掉一半了。剩下一半要看懂网上真正跑的字节长什么样。2. 动手拆协议SSE在网络上到底长什么样2.1 一段响应搞定所有text/event-stream我用一个最朴素的例子演示。假设服务端要发三个事件给客户端内容分别是你好我是AI再见。响应报文大致是这个样子HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive data: 你好 data: 我是AI data: 再见注意几个细节Content-Type必须是text/event-stream这是客户端识别这是SSE流的唯一标志。Cache-Control: no-cache是标准建议防止中间缓存节点把流内容囤起来导致前端收不到增量。每个data:后面跟的是这一条事件的内容事件与事件之间必须有一个空行这个空行就是分帧符。连接不会在再见之后立刻关闭只有服务端主动断开连接客户端才会知道流结束了。如果你想告知正常结束业内习惯是在流末尾写一个data: [DONE]之类的特殊约定OpenAI就是这么做的也可以直接关连接让前端走onclose。有人可能会拿curl实测curl -N http://your-api/stream看输出确实是多行data:文本。这已经足够说明问题——SSE在网络层就是一段永不结束的HTTP响应体。2.2 消息边界空行分隔的事件流语义SSE的格式里没有JSON容器、没有Length字段它用换行 空行来划分事件边界。前端解析时其实是按行读读到空行才认为一个事件完整了。看这个例子data: 第一行内容 data: 第二行内容这两行data:属于同一个事件。SSE规范规定多个data:字段会以换行符拼接成同一条事件的内容。所以前端收到的message.data是第一行内容\n第二行内容。这个逻辑对换行敏感。写服务端代码时如果漏了在事件末尾加空行前端会一直傻等认为事件还没结束。我在写第一版流式接口时就踩过这个坑curl能一路看到数据但浏览器的onmessage死活不触发——排查半天发现是生成器里每条消息后面没追加\n\n。这里也顺带解释一个容易混淆的点事件event和消息message在SSE上下文里基本是同一个意思可以互换理解。规范里正式叫法是事件浏览器API里的事件名是message。2.3 命名事件、断线重连与Last-Event-IDdata只是SSE的一个字段完整的字段体系还包括字段作用使用场景data事件内容几乎必用大模型流式场景的token就放在这里id事件编号客户端断线重连时把这个值回传给服务端服务端可从该位置续传event事件类型给事件起名字前端可用addEventListener(自定义名)监听retry重连间隔毫秒客户端在连接断开后按这个值等待再重连:注释行以冒号开头的行会被客户端忽略常用于做心跳保活在长任务场景里id和retry尤其关键。想象你正在用大模型API生成一篇长文生到一半网络闪断了。如果服务端支持用id标记每个事件浏览器会自动带上Last-Event-ID请求头重新发起连接。服务端拿到这个ID就知道这哥们已经收到第N个token了可以从N1接着发不用重新生成一遍。这在大模型场景不算特别常见大部分厂商是重新开始但做私有化部署时这个机制做得好是很亮眼的体验优化。event字段则适合给流里的不同内容打标签。比如推理过程和最终答案是两类内容服务端可以一边发event: reasoning\ndata: ...另一边发event: answer\ndata: ...前端用两个监听器分开处理渲染区域也能拆成思考过程区和正式答案区。用不用看业务需要但知道有这招设计接口时会从容很多。2.4 规范和格式的细节清单我把自己实践过程中最常用的格式约定整理成了一份速查表你可以直接对照使用每条字段写成字段名: 字段值冒号后面有一个空格空格分隔再多个的写法不适用。每条事件以空行结束。如果忘了空行浏览器端的EventSource会一直不触发回调。不要用\r\n之外的换行符实际上规范支持\r\n、\n和\r但为了跨平台稳妥服务端代码统一输出\n就够。查询参数的URL编码要考虑如果data:里是JSON字符串里不能出现裸换行JSON本身不允许需要压缩成单行JSON串。流内不能有Content-Length因为长度未知。实际响应头通常会省略该字段配合chunked传输。3. SSE、WebSocket、gRPC流大模型场景怎么选3.1 为什么大模型服务商几乎都选了SSE如果你打开OpenAI的API文档会发现流式模式叫streamtrue返回的content-type就是text/event-stream。国内主流大模型API基本上也是这个套路。为什么大家不约而同选了SSE而不是功能更强大的WebSocket我理解的核心原因有三个单向性足够。大模型的交互链路是用户先发一次请求带prompt服务端持续返回内容。这是一个典型的客户端发起、服务端单向下行的模式整个交互周期里客户端不需要往连接里写东西。WebSocket的双向能力在这里其实用不太上。代理友好。SSE是标准HTTP能过HTTP/1.1的反向代理、负载均衡不需要像WebSocket那样考虑Upgrade握手、跨域预检、连接状态在代理层的额外管理。很多老旧网关甚至不需要改配置就能透传SSE只要别开缓冲。浏览器原生支持。前端一个new EventSource(url)就搞定了自带重连不需要引入第三方SDK。对做演示项目、快速原型来说这是最省事的路。3.2 SSE真正短板单向、连接数、代理缓冲SSE要是十项全能WebSocket大概早就被人遗忘了。它的真实短板得摆到台面上看单向。服务端不能接收来自这条连接的新消息。如果用户想在生成过程中打断或者追加指令还是得发新的HTTP请求去触达服务端。好在大多数大模型场景不需要打断——你顶多是在前端不渲染后续内容而不是真在传输层把流掐断。并发连接数限制。HTTP/1.1下浏览器对同一个域名的最大并发连接数大概是6个各家略有不同。如果你的页面同时开3个SSE流、再加载几张小图分分钟把连接额度吃光。解决方案是HTTP/2多路复用或者给SSE单独配子域名。代理缓冲。这是实际踩坑率最高的一项。Nginx默认会缓冲代理响应把一小段一小段的数据攒成完整大块才丢给客户端——流式接口遇上它效果直接退化成10秒一蹦字。必须显式关闭缓冲详见后面第5节。3.3 正确的选型姿势我的个人判断是这么个标准服务端单方面给客户端推数据、数据量大且连续 →SSE。客户端与服务端频繁双向交互比如实时协同编辑、聊天室、在线游戏 →WebSocket。需要强类型、多路复用、跨语言RPC体系 →gRPC流在大模型推理框架内部挺常见比如vLLM和TGI之间。已经有HTTP/2基础设施、想减少连接数 →SSE over HTTP/2或者直接用WebSocket套一层。大模型API场景说实话90%的流式返回用SSE就够了。做私有化部署时如果推理引擎用的vLLM内部是gRPC那也是内部通信的事你暴露给客户的API仍可以转成SSE。流式协议之间没有绝对的优劣核心是匹配传输模型。4. 从后端到前端的全链路SSE实现4.1 服务端用FastAPI写一个流式接口假设你的上游是本地推理引擎或OpenAI兼容API你要做的是把那边的token流转发出来。直接给一段可运行的FastAPI实现import asyncio import json from fastapi import FastAPI from fastapi.responses import StreamingResponse app FastAPI() def mock_token_stream(text: str): 模拟一个逐token产出内容的上游源替换成真实的模型调用即可。 for char in text: yield char async def sse_generator(): full_text 你好我是流式AI。这个演示会逐字输出内容。 for token in mock_token_stream(full_text): # 每次只产出一个 token按 SSE 格式包一层 payload json.dumps({choices: [{delta: {content: token}}]}, ensure_asciiFalse) yield fdata: {payload}\n\n # 模拟模型推理间隔真实场景会把上游的等待时间自然透传出来 await asyncio.sleep(0.05) app.get(/v1/chat/stream) async def chat_stream(): return StreamingResponse( sse_generator(), media_typetext/event-stream, headers{ Cache-Control: no-cache, X-Accel-Buffering: no, # 告诉Nginx等代理别缓冲后文细说 }, )这里有两个点要特别说明。第一StreamingResponse里的generator是一个异步生成器FastAPI会持续从里面取数据写入响应。每写一次数据就通过HTTP chunked传输推给客户端。yield fdata: {payload}\n\n这行是整个流式输出的灵魂它把JSON包装成SSE事件末尾的空行标记事件结束。第二ensure_asciiFalse一定要带上。如果默认True中文会被转成\u4f60\u597d这种形式内容没错但前端想实时渲染就会遇到一个汉字显示成6个字符的尴尬而且可读性极差。如果你用的是Flask思路也一样只是不用StreamingResponse改成在视图函数里返回一个生成器配合响应头的content-type设置即可。4.2 中间层Nginx关闭缓冲别让令牌被团购服务和用户之间隔着一层Nginx的话十有八九会遇到流式接口变傻的问题。Nginx收到上游发来的数据默认等攒够buffer通常4KB/8KB或者等上游关闭连接才一次性转发给下游。对于SSE这意味着用户看到的是卡几秒、跳一大段。解决办法在Nginx配置里location /v1/chat/stream { proxy_pass http://backend_upstream; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding on; }proxy_buffering off直接关闭代理缓冲数据逐块透传。代码里那个X-Accel-Buffering: no响应头是给Nginx看的这条响应别缓冲的显式指令。两处都写上双保险。proxy_read_timeout 300s是防超时断开。SSE连接可能长时间没有新数据比如模型在思考默认的60秒可能就被掐断了。Connection: 配合proxy_http_version 1.1是为了让上游和代理之间也能维持长连接。Caddy的处理更简单默认不缓冲基本不用额外配置。如果是云厂商的SLB/ALB去控制台把响应缓冲或HTTP响应压缩关掉。我遇到过阿里云SLB把SSE当普通响应缓冲了的情况症状极其诡异——浏览器偶尔收到完整内容、偶尔卡住。4.3 前端用fetch的ReadableStream逐行解析浏览器端的EventSource虽然原生支持SSE且自动重连但它有个限制只能GET请求没法带Authorization头除非用token放在query里。这在调用很多需要鉴权的大模型API时很别扭。更通用的方案是用fetch ReadableStream。下面是一段兼容SSE格式的解析逻辑async function parseSSE(response) { const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE事件按空行分隔切出完整事件再逐条处理 const lines buffer.split(\n); buffer lines.pop(); // 最后一段可能不完整留到下一轮 let dataLines []; for (const line of lines) { if (line ) { // 空行表示一个事件结束 if (dataLines.length) { const payload dataLines.join(\n); handleEvent(payload); // 你的业务回调 dataLines []; } } else if (line.startsWith(data:)) { dataLines.push(line.slice(5).trimStart()); } } } }几个关键点decoder.decode(value, { stream: true })解决中文编码的截断问题。网络包到达时可能把一个UTF-8字符切成两个chunk不用stream模式会把字节顺序搞坏出现乱码。这个参数等于告诉TextDecoder字节还没全别急着报错。buffer.split(\n)然后最后一段留到buffer里是典型的增量解析套路。不这样做一次read返回的可能只是半行。事件结束后dataLines.join(\n)可以通过event字段做分支。进一步处理时很多API的SSE消息是data: {json}这种JSON.parse之前务必去掉data:前缀。不用EventSource也就不用担心它不支持自定义请求头的问题。用fetch做流式时要注意所有浏览器对response.body的读取都要求服务端返回了正确的content-type。如果你的接口返回的是application/json某些浏览器可能直接放弃流式读取。4.4 中文场景的坑UTF-8分块乱码这是多语言场景特有的坑值得单独拎出来。SSE是文本协议数据在网络上以字节形式传输而中文在UTF-8下占3个字节。TCP/IP和HTTP/2的分帧并不保证一个字符的3个字节落在同一个TCP段里于是可能发生这样的情况后端发了你字的三字节的一部分前端恰好在这个位置切了chunk页面渲染就出现一个。解决方式就是我前面说的前端解析时用TextDecoder(utf-8, { stream: true })它会智能地把不完整的字节序列留在内部缓冲里凑齐了再输出字符。如果在前端框架里做SSE解析记住这个参数比记住SSE格式还重要——我见过好几个项目乱码排查半天发现就是解码方式不对。另一个小建议服务端发送时尽量整字节地发送一个完整的UTF-8字符前端省心后端也不费什么劲。生成式模型出来的通常本来就是完整字符不太容易踩这个坑但如果你做了某种字节级的token压缩、或中间有转码代理就要特别小心。5. 上线前必看流式接口生产环境避坑实录5.1 常见问题速查表我把实操中反复遇到过的SSE问题整理成一张表排查时按图索骥即可症状根因解法前端一直不触发message事件curl却能看数据事件末尾缺少空行分隔符每条事件用\n\n结尾浏览器转圈半天突然一次性全量展示代理缓冲开启数据被攒批Nginx加proxy_buffering off或后端加X-Accel-Buffering: no输出隔几十秒才蹦一下上游本身真实生成慢或遇上了压缩缓冲确认上游间隔观察慢的是模型还是管道页面onerror被触发连接频繁断开代理超时设置过短proxy_read_timeout、keepalive_timeout调大前端中文渲染乱码TextDecoder没开stream模式new TextDecoder(utf-8, { stream: true })跨域访问拿不到流CORS没放行添加Access-Control-Allow-Origin等响应头必要时处理preflight多条事件合并成一个大错误消息JSON被意外截断单行JSON不要嵌入裸换行符5.2 心跳与超时空转连接怎么保活SSE连接如果长时间没有数据代理节点和浏览器都可能有超时清理策略。大模型场景里有一种常见情况模型进入深度思考可能一两分钟内没产出任何token如果你的架构是全部构思完再输出这就更明显。这时连接空转很容易被中间链路误杀。业界标准做法是发注释行当心跳async def heartbeat(interval15): while True: yield : heartbeat\n\n await asyncio.sleep(interval)以冒号开头的行在SSE规范里是注释客户端会直接忽略既不会触发message事件也不影响事件流语义。但它在网络层是活跃数据能让Nginx、SLB之类的节点认为连接还是活的不会因为闲置而回收。几套主流方案里retry字段也能帮上忙断开后客户端会按这个毫秒值等待再重连。如果服务端知道自己的流会长时间静默可以把retry设得短一点比如15秒让前端更快恢复连接。5.3 流控别让一个慢用户拖垮整个服务SSE长连接的资源占用比普通短请求高。同样一小时内短请求发完就释放连接而SSE连接可能保持几分钟甚至半小时。如果并发用户数大、又不做任何限制服务器的连接数、内存、句柄数都可能告急。几个相对实用的策略限流在网关或应用层按用户维度限制并发流数量比如同一token最多同时3条流。闲置超时超过N分钟没有下行数据的连接自动断开让客户端重连续传。背压处理大模型推理速度快于网络发送速度时服务端不能一股脑把数据塞进内核缓冲区。实际做法是控制生成器循环的节奏比如按批次休眠asyncio.sleep(0.01)或者用容量有限的队列衔接推理线程和发送协程。断线续传可选高级功能配合id字段实现重连时带上Last-Event-ID从断点续发长文生成场景体验提升明显。我见过一个部署案例内网带宽有限服务端每秒钟产出的token远超网络吞吐结果负责发送的协程被疯狂积压内存飙到几百MB。加了二级队列缓冲后一切恢复正常。记得流式接口不只是把生成器接上去后端的生产速度和消费速度必须匹配。5.4 实测记录用openai-sdk时如何拿到真正的流如果你直接用OpenAI官方Python SDK流式模式通常长这样from openai import OpenAI client OpenAI(api_keyyour-key, base_urlyour-endpoint) response client.chat.completions.create( modelyour-model, streamTrue, messages[{role: user, content: 讲个笑话}], ) for chunk in response: delta chunk.choices[0].delta.content if delta: print(delta, end, flushTrue)SDK内部发出去的是普通HTTP请求请求头里带streamtrue返回响应后SDK再把text/event-stream的文本逐事件解析出来转成Python对象让你迭代。如果你要自己造一个兼容OpenAI格式的流式API关键就是把你输出的event结构对齐成它约定的格式{choices: [{delta: {content: 你}, index: 0}]}把content字段换成增量文本最后一个事件往往带finish_reason: stop。照着这个结构发市面上几乎所有OpenAI兼容客户端都能直接消费你的流。6. 更进一步SSE的边界与流式方案的想象力6.1 服务端到服务端Python的SSE消费姿势很多教程默认SSE的消费方是浏览器但实际大量流式接口的下游是另一个服务。比如你在中间层做大模型网关需要把上游的SSE流转发给下游系统用Python就能直接读import requests resp requests.get(https://api.example.com/v1/chat/stream, streamTrue) for line in resp.iter_lines(): if not line: continue if line.startswith(data:): payload line[5:].strip() if payload [DONE]: break print(payload)这里iter_lines()是requests库提供的按行迭代方法天然适合SSE这种行协议。如果字节流被分包requests内部会帮你攒行攒好了再说。实际用下来比手动处理buffer split要省心很多。6.2 HTTP/2多路复用SSE的并发瓶颈破局点前面提到HTTP/1.1下浏览器单域名6连接的限制会让同时开多个SSE流变得紧张。HTTP/2的多路复用允许在一个TCP连接里同时跑多个流也就是说同一域名下的SSE数量不再受6条限制配置得当甚至可以开几十条。但这有个前提你的反向代理和浏览器之间得真的走HTTP/2。如果是给浏览器提供API一般要配合Nginx开启HTTP/2 TLS目前多数浏览器要求HTTP/2必须配TLS如果是服务端之间的通信倒是简单许多Nginx侧加http2 on;即可。如果你的大模型应用要在页面上同时展示多个Agent的实时思考过程这些流会同时打开。这时候HTTP/2几乎是必选项不然单域名6连接分分钟耗尽。6.3 从SSE到全双工MCP与工具的流式落地最近大模型圈很火的MCPModel Context Protocol场景里流式输出也有了新角色Agent在运行工具并返回结果时可以用流式通道把中间过程实时推出。比如你让模型调用一个搜索工具模型正在翻阅资料的状态、工具返回的中间片段、最终结论都可以拆成不同类型的事件用event字段区分前端就能把这些过程渲染成思考中→工具调用→输出结论的完整时间线。我实际试验过一种方案Agent工具链执行到文件写入步骤时通过SSE把写入进度比如正在写第300行实时推到前端工具完全结束后再发一条event: done。效果比干等一个工具调用的最终结果好很多——用户至少知道它没卡死还在干活。这类流式输出内容到文件/工具状态回传的需求用SSE的命名事件实现起来非常顺手。6.4 流式方案的发展不是互相取代SSE、WebSocket、gRPC这些方案本质上是传输模型的适配器未来也不会是谁取代谁而是按场景各就各位。SSE因为简单、标准、兼容面广在大模型API这波浪潮里重新火了一把——它一直是那个最省事的选项。真正做架构的时候也别嫌它功能弱先把HTTP层用好把缓冲关对把编码处理好大部分流式需求已经能覆盖得七七八八。最后分享一个我自己形成的习惯每写完一个流式接口第一件事不是看前端页面而是开一个终端用curl-N直接看裸的响应流。这个方法能绕过所有前端解析逻辑一眼就分清问题是出在服务端没发出来还是前端没解析对。先确认上游的data:和空行是干净的再排查下游的代理和编码几乎能解决90%的SSE疑难杂症。流式调试这种活越是基础的工具越能救急。
返回列表