
如果你亲手搭过大模型对话应用一定对“转圈圈”不陌生。用户发来一句话你的后端去调LLM大语言模型接口在拿到最终完整回复之前前端页面只能一直loading。这个问题的根源在于LLM本身的生成机制——token是一个一个蹦出来的而不是像普通接口那样一次性返回一块完整数据。而SSEServer-Sent Events服务端发送事件就是目前解决LLM流式输出问题最主流、也最轻量的一套方案。这篇文章我会从LLM为什么要流式输出讲起把SSE协议拆开揉碎再给出前后端完整可跑的代码实现最后把我实际项目中踩过的坑和排查思路都列出来。适合正在做LLM应用开发、想搞懂流式输出原理、或者被“idle timeout waiting for sse”这类报错折磨过的同学。1. 为什么大模型聊天总在“转圈圈”流式输出的本质理解流式输出之前得先接受一个事实LLM的推理过程和传统接口不一样。传统接口是“接收请求-计算-返回完整结果”LLM是“接收请求-不断自回归生成-每次都只产生下一个token”。这个差异决定了你的系统架构、前后端交互方式甚至用户感知的产品体验。1.1 LLM的推理机制决定了它天生着急不来LLM生成文本可以粗略分成两个阶段prefill预填充和decode解码。prefill负责把你输入的prompt一次性编码成KV Cache这一步通常很快几百毫秒到一两秒真正耗时的是decode阶段模型要一步一步地预测下一个token每生成一个token都要做一次前向推理而且当前token依赖前面生成的所有内容。以主流模型为例一个token大概是0.75个英文单词或0.4-0.5个汉字生成速度根据模型尺寸和硬件不同从每秒十几个token到每秒上百个token不等。一个300字的回答至少需要600-800个token光decode阶段就要好几秒甚至十几秒。如果你让用户一直等到全部生成完毕再展示体验就是长时间的白屏或者loading转圈用户会怀疑服务是不是挂了。所以流式输出的核心价值就一句话把总耗时从“用户无感知”变成“用户有感知的渐进过程”。第一个token越快到达用户心理上的等待时间就越短。很多产品的体感优化本质就是在做首token延迟TTFTTime To First Token的优化。1.2 阻塞式输出 vs 流式输出用户体验差在哪阻塞式输出的流程是用户在输入框发送消息后端调LLM接口并等待完整响应完整响应返回后再一次性推给前端。前端拿到的是一个完整的句子渲染上确实简单但代价是交互体验非常差。举两个实际场景。第一用户问了一个长问题模型要思考很久才产出一个4000字的答复在阻塞模式下用户看到的是长达20秒的“发送中”状态期间没有任何反馈用户大概率会怀疑网络断了然后刷新页面或重复发送。第二用户其实已经看到前面生成的内容有方向性错误想中途打断重新问但阻塞模式下根本无法打断只能等整套token生成完浪费时间和算力。流式输出则把这两个问题都解决了。用户能看到文字一个字一个字地打出来像真人聊天一样既降低了焦虑感又能在发现方向不对的时候提前取消请求节省资源。从产品层面说流式输出已经是AI对话类应用的标配没有流式输出的AI应用竞争力直接少一半。1.3 选型之前先看三种传输方案的取舍实现“服务器主动推送数据给浏览器”这件事业内有三条技术路线传统轮询、WebSocket、SSE。很多人一上来就想用WebSocket但我不建议在纯LLM流式场景里首选它后面会说原因。方案连接方式数据格式自动重连实现复杂度适用场景轮询HTTP短连接任意无低低频、非实时SSEHTTP长连接纯文本/UTF-8自带低单向推送LLM流式首选WebSocketTCP长连接二进制/文本无高双向通信聊天室、协作编辑轮询的问题在于浪费资源和延迟不可控每隔几秒打一次接口既做不到真正的实时又对服务器造成额外压力。WebSocket虽然是全双工但它的复杂度明显更高需要处理连接升级、心跳保活、二进制帧解析、断线重连逻辑而且很多SSE能自动完成的机制它都要你自己写。SSE的优势在于它建立在HTTP之上协议简单浏览器原生支持自带断线重连和事件ID机制。LLM流式输出本质上是“服务器单向持续往客户端推送token”这种单向模型和SSE的模式完美匹配。除非你的应用还需要客户端频繁往服务器发消息比如多轮对话中的实时输入状态同步否则不一定要引入WebSocket。2. 扒开SSE的协议外衣SSE不是什么新东西它早在HTML5时代就定了标准只是之前一直没有大规模应用。现在随着LLM流式输出成为刚需SSE又焕发了第二春。要玩转它你得先搞清楚它传输的数据长什么样以及它的底层机制是怎么工作的。2.1 SSE的格式与事件流SSE的响应Content-Type是text/event-stream它把服务器要推送的数据按特定格式切割成一块一块的“事件”event。每个事件可以包含多个字段最常用的几个是data:事件的数据内容可以多行以空行结束当前事件event:事件类型默认是messageid:事件ID用于断线重连时通过Last-Event-ID请求头续传retry:告诉浏览器断线后多少毫秒重连一个典型的SSE响应长这样data: 你好 data: 世界 data: 这是第二行 data: 拼接后的完整内容浏览器端EventSource收到后默认情况下会把一个事件块里的多行data:自动用换行符拼接成一个字符串触发onmessage回调。注意看第二个块它有两行data最终回调拿到的值是“这是第二行\n拼接后的完整内容”。这里最容易被忽略的就是空行的作用空行是事件结束的标志。如果服务端没有在每条消息后输出空行前端EventSource会一直等不触发任何事件。很多人在写测试脚本时踩过这个坑以为数据没推过来其实是格式不合法。2.2 为什么SSE和LLM是天作之合大模型推理接口比如OpenAI兼容接口、各家国产模型的API的流式输出绝大多数都采用了SSE格式或者类似SSE的分块格式。你去看LangChain、Dify这类框架的底层实现它们在做流式转发时本质就是在处理和转发SSE数据流。SSE和LLM契合的点在于消息边界很清晰。LLM产出的token我们希望拿到一个就推一个但又不希望前端因为网络原因频繁重连导致消息错乱。SSE的id字段和retry字段天然解决了这个问题浏览器断线后会自动带上Last-Event-ID重新连接服务端可以根据这个ID判断从哪里继续推送。比如你已经推送了100个token客户端掉线重连时带了Last-Event-ID: 99服务端就知道该从第100个token开始继续而不是从头再来。对于聊天这种弱状态场景这个机制已经足够用。当然LLM推理是一次性的模型无法从中间状态恢复所以实际生产中更多是靠前端的展示层做幂等去重而不是真的续传未完成的token流这一点后面讲Agent场景时会展开。2.3 SSE鉴权EventSource的天然短板怎么破如果你直接用浏览器原生EventSource很快会发现一个痛点它不支持自定义请求头。很多后端接口为了安全会把鉴权token放在Authorization头里但EventSource只能通过URL参数或者withCredentials带上Cookie这就很尴尬。我常用的解决方案有三套第一把token放到URL query参数或路径里服务端从query取。比如/api/chat?tokenxxx。简单粗暴但token会暴露在网关访问日志里生产环境要谨慎建议配合短时有效的签名参数。第二用Cookie做鉴权开启EventSource.withCredentials true。前提是前后端同域或者后端配置了对应的CORS跨域策略允许携带凭证。好处是日志不会泄露token缺点是Cookie本身有CSRF风险需要做好防护。第三抛弃EventSource改用fetchreadable stream去手动读取SSE流。fetch支持自定义Header代码层面也不会复杂太多。这也是我在实际项目里更推荐的做法因为同时还能拿到HTTP状态码方便处理401、429这种异常。我在下一节会给出具体实现。3. 从零手写一套流式对话接口理论说再多不如直接看能跑的代码。这里我以Python FastAPI作为后端前端分别演示EventSource和fetch流式两种方式再把中间涉及的网关配置问题讲清楚。3.1 整体架构与数据流设计先画一下我们这套东西的数据流不涉及具体组件只讲链路逻辑浏览器发起流式请求。后端收到后立刻返回200和text/event-stream响应头。后端内部调用大模型API大模型API流式返回token块后端每拿到一个token块就往响应流里写一条data: {...}\n\n。浏览器从EventSource或fetch的reader里不断读取这些数据块解析后append到页面上。这里的关键是“立刻返回响应头”这件事。HTTP响应不是一次性发完的只要服务端在拿到完整业务结果之前先把响应头flush给客户端客户端就会进入流式接收状态。所以哪怕后端内部准备时间再长只要响应头先出去了前端连接就是活的不会被认为是超时。3.2 后端实现FastAPI 流式响应FastAPI天然支持StreamingResponse配合Python生成器可以非常优雅地实现流式推送。下面是一份简化的真实代码我把注释写细一点。import asyncio import json from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse app FastAPI() async def llm_stream_gen(prompt: str): 模拟大模型流式返回token。实际项目里替换成对OpenAI/国产模型API的调用即可。 result f这段话是为 {prompt} 生成的模拟回答内容可以拆成很多个token分批吐出来。 for i in range(0, len(result), 5): # 模拟推理延迟实际场景里这里是等待上游LLM的下一个token await asyncio.sleep(0.2) yield result[i:i5] app.post(/api/chat) async def chat(request: Request): body await request.json() prompt body.get(prompt, ) async def event_generator(): # 第一步先发一个事件表示开始前端可以用来标记本次请求开始计时 yield data: {\event\: \start\}\n\n # 第二步逐token推进每个token都包装成SSE事件 async for chunk in llm_stream_gen(prompt): # 如果客户端断开生成器再往下输出没有意义及时退出 if await request.is_disconnected(): break payload json.dumps({event: token, content: chunk}, ensure_asciiFalse) yield fdata: {payload}\n\n # 第三步发送结束事件前端收到后知道自己组装完成 yield data: {\event\: \done\}\n\n return StreamingResponse( event_generator(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, # 重要关掉Nginx等网关节点的缓冲 } )几个容易踩的点我直接标出来了第一media_type必须是text/event-stream否则浏览器EventSource不认。第二Cache-Control: no-cache是必须的不然有些浏览器或中间层会缓存整个响应体导致前端等不到流式数据。第三X-Accel-Buffering: no是给Nginx看的告诉它别替我做缓冲后面网关那一节我再展开。如果是真实调用LLM接口比如OpenAI兼容接口llm_stream_gen里通常是这样的逻辑from openai import AsyncOpenAI client AsyncOpenAI() async def llm_stream_gen(prompt: str): stream await client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], streamTrue, ) async for chunk in stream: if not chunk.choices: continue delta chunk.choices[0].delta.content if delta: yield delta核心逻辑没变上游怎么流式吐我们就怎么转推给客户端。中间可以做增量解析、敏感词过滤、或者把token攒起来做统计但不要为了统计去破坏流式节奏请选择异步旁路。3.3 前端实现EventSource与fetch流式读取前端最简单的方式是用EventSource但它只能发GET请求而且不能自定义Header。如果你的接口是POST比如要传prompt字符串EventSource就不太合适了。所以我把两种方式都写出来。EventSource方式const es new EventSource(/api/chat?prompt${encodeURIComponent(你好)}); es.onmessage (event) { const data JSON.parse(event.data); if (data.event token) { container.innerText data.content; } else if (data.event done) { es.close(); } }; es.onerror () { // 自动重连会触发onerror如果业务上不希望重连要在这里close es.close(); };fetch ReadableStream方式这种方式更灵活能解决POST、自定义Header、错误处理这三大难题const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token}, }, body: JSON.stringify({ prompt: 你好 }), }); // 注意fetch的响应体是ReadableStream需要手动按块读取 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事件因为我们的服务端是用\n\n分隔每个事件的 const events buffer.split(\n\n); buffer events.pop() || ; for (const event of events) { const lines event.split(\n).filter(line line.startsWith(data:)); const data lines.map(line line.slice(5).trim()).join(\n); if (data) { handleMessage(JSON.parse(data)); } } }这段代码的关键是decoder.decode(value, { stream: true })。流式传输时一个中文字符的UTF-8字节可能被拆在两个chunk里如果直接按二进制转字符串很容易出现乱码。TextDecoder的stream: true模式就是用来处理这种跨chunk的字节切割的这是前端最容易忽略的细节。3.4 网关与中间件缓冲是流式输出最大的敌人本地联调一切正常一上测试环境就发现数据不往下吐大概率是网关或反向代理在作怪。Nginx默认会缓冲上游响应意味着它会等上游响应体攒到一定大小再一次性返回给客户端这个行为对流式输出是致命的。Nginx相关配置要打开location /api/chat { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; # 避免长时间没有token导致超时断开 proxy_send_timeout 300s; chunked_transfer_encoding off; }有些云厂商的负载均衡器也有类似的“响应缓冲”开关需要在控制台或配置里找一下关闭。如果你用的是Kubernetes和Ingress也要检查Ingress Controller的proxy-buffering设置。总的来说凡是在客户端和后端之间的中转层都要检查它是否对text/event-stream做了缓冲。另外一个常见场景是接入Dify这类平台。Dify自身有流式输出能力但你如果用自定义前端去调Dify的接口要确认它返回的是不是标准SSE格式我遇到过一次Dify返回的Content-Type被网关改成了application/json前端怎么解析都是空的最后排查到是入口网关的问题。4. 实战中那些让人挠头的坑这一节我整理了自己在实际项目中踩过、也帮别人排查过的典型问题每一个都对应一条血泪经验。尤其那个“idle timeout waiting for sse”的报错如果你经常用Python的requests或httpx去消费上游SSE接口大概率见过它。4.1 请求被网关切断before completion: idle timeout waiting for sse“before completion: idle timeout waiting for sse”这句报错最常见于你用某种HTTP客户端比如Java的OkHttp、Python的requests去请求一个SSE接口时。它含义是连接已经建立了但没有数据到达触发了底层socket的idle timeout。为什么会没有数据到达两个原因。第一上游LLM在思考阶段很慢第一个token迟迟没有推出来。比如你的prompt特别长或者模型在做复杂的reasoning可能好几秒甚至十几秒都没有任何输出而中间层的idle timeout设置为5秒连接就被掐断了。第二服务端处理逻辑里有阻塞比如同步调用了另一个接口这个接口卡住了导致SSE响应流一条消息都没发。解决思路在应用层面加心跳。SSE有个空注释行:\n\n可以当作心跳它不会触发前端onmessage但能重置idle timer。服务端可以每隔10秒发一个注释行保活。调整网关、负载均衡、HTTP客户端的读超时时间至少要大于LLM的最长首token延迟。如果用的是Nginx作为反向代理proxy_read_timeout默认只有60秒对于慢思考场景要调大。还有一种情况是客户端的问题。比如你用Python的requests库去读SSE但requests不支持流式响应超时重置如果设置了timeout30它指的是整个响应30秒而不是每个块之间30秒。这时候换成httpx的Stream模式或者直接用专门为SSE设计的库。提示如果服务端是Python建议直接用httpx或者openai的SDK去消费上游SSE不要用requests硬解析省很多事。4.2 断线重连与消息错乱幂等设计要靠前端EventSource自带重连机制但这在LLM场景里也可能是个坑。假设用户发了一句话后端生成到一半用户的Wi-Fi闪断了EventSource会自动重连重连后从Last-Event-ID续传。问题在于我们的大部分后端实现并不会真的实现“从第N个token继续生成”因为模型推理是无法中断续跑的。所以实际项目里的处理方式是前端不要完全依赖EventSource的自动重连而要在业务层做幂等。具体来说每次发起请求时生成一个唯一的requestId后端在处理时如果发现同一个requestId已经生成过一部分token要么直接丢弃重连请求要么把已经生成的完整结果一次性返回给前端做补偿。如果你的后端是自己控制的LLM推理还有另一种思路把“正在生成的流”和“已经完成的缓存”分开。请求断开后让后端生成任务在后台继续跑完缓存到Redis里客户端重连时如果发现生成已结束就直接从Redis读完整结果。这样虽然不能真正续传中间状态但至少不会让用户白等。4.3 中文乱码、缓冲器与代理的三角关系中文乱码通常不是SSE的问题而是编码和压缩的问题。流式传输中SSE消息体默认是UTF-8前端解码时也要用UTF-8。如果你在服务端往流里写内容时用了ensure_asciiFalse前端却用ASCII去解自然乱码。另一个非常隐蔽的问题是压缩。有些网关或CDN默认开启gzip压缩压缩本身没问题但如果压缩实现是“攒够一定字节才压缩一次”流式效果就被破坏了前端会等很久才看到一块内容。更麻烦的是gzip压缩是按块处理的如果整段流一起压缩前端必须等所有数据到达后才能解压这就完全失去了流式的意义。解决办法对流式响应的路径关闭压缩或者确保压缩是按chunk进行的。在Nginx里可以针对text/event-stream类型禁用gzipgzip off;或者更精细化地只在流式接口路径上关闭。很多CDN厂商对流式支持得不好如果生产环境用了CDN遇到SSE不工作第一反应该去CDN控制台关掉缓冲和压缩而不是改应用代码。4.4 Agent场景下SSE的“流而不完整”问题现在LLM应用很少是单纯的“一问一答”更多是Agent形态中间会调工具、查知识库、做RAG检索。这意味着流式输出不仅要有文本token还要有工具调用状态、检索进度、错误信息等不同事件类型。如果所有事件都挤在默认的message事件里前端解析起来会非常痛苦而且很难判断“现在这个回答算不算结束”。我的做法是约定一套事件类型体系event类型含义数据示例message最终要展示给用户的文本token{content:你好}status状态更新比如“正在检索知识库”{phase:retrieving,message:正在搜索}tool_call触发了工具调用{name:search,arguments:{\q\:\...\}}error错误信息但不中断连接{code:rate_limit,message:...}done整轮结束附带统计信息{total_tokens:123,latency_ms:8000}前端根据event类型渲染不同的UI。为什么这个设计重要因为Agent场景下一个很大的坑是LLM生成完毕并不等于Agent任务完成。模型可能还要调用第二个工具或者还要等RAG检索结果如果前端把“流结束”当作“回答结束”就会出现用户看到一句话页面已经停止滚动但后台其实还在工作的“假死”状态。在事件里明确区分“流结束”和“任务结束”非常关键。我一般在LLM的token流结束后不立刻发done而是等Agent的整个执行链跑完再发一个包含最终统计的done事件。中间如果有工具调用就用status或tool_call事件通知前端。5. 生产级流式输出的下一步优化代码能跑和线上稳定运行是两回事。把流式输出真正放到生产环境还需要考虑连接生命周期、资源释放、监控告警这些看起来不性感但非常致命的问题。5.1 客户端断开时后端要及时“踩刹车”流式输出最怕的不是慢而是“客户端已经走了服务端还在拼命生成”。用户等得不耐烦关掉了页面或者切换了对话如果后端不检测连接状态LLM还会继续跑完剩余的所有token白白消耗算力和钱。FastAPI里可以通过request.is_disconnected()来检测客户端是否断开我在前面的代码里已经演示过。但要注意这个检测不是实时的它依赖于底层ASGI服务器比如uvicorn的事件循环状态调用一次只能知道那一刻的状态。所以更稳妥的方式是把检测放在每轮生成循环里每拿到一个chunk都检查一次。还有一种情况是客户端断开后生成器抛异常而不是优雅退出。建议在生成器外层包一层try/except确保断开时能关闭上游HTTP连接和释放其他资源。async def event_generator(): try: async for chunk in upstream_stream(): if await request.is_disconnected(): break yield fdata: {chunk}\n\n finally: # 确保关闭上游连接 await upstream_stream.aclose()5.2 监控和日志首token延迟是核心指标流式服务有没有问题不能只看接口成功率。你要关注这些指标TTFTTime To First Token从请求进入后端到第一个token返回给客户端的耗时。这个指标反映了上游模型推理速度和网关转发效率通常希望控制在1-2秒以内。Token吞吐量每秒向客户端推送了多少token。可以反映流式链路有没有被缓冲或拥塞。平均token间隔相邻两个token推送时间的间隔。如果这个间隔忽大忽小说明上游或网络波动严重。Stream断流率已经建立的SSE流在业务正常结束前被断开的比例。断流可能是用户关闭、网络抖动、超时等需要分维度统计。日志方面建议给每个流式请求分配一个requestId在开始、首token、结束、断开这几个关键节点打印日志并带上累计耗时。这样排查问题的时候可以通过一条日志串起整条链路而不是在多个服务里捞上下文。5.3 多路复用与HTTP/2的取舍HTTP/1.1的规范里同一个域名下的并发连接数通常限制在6个。如果你的页面同时有多个SSE流在跑比如一个用于对话一个用于进度通知很容易把浏览器的连接数打满导致其他资源加载变慢。两个解决办法。第一升级到HTTP/2多路复用可以让多个流共用一条TCP连接SSE在HTTP/2下的表现会更稳定。但这要求全链路都支持HTTP/2包括你的反向代理和CDN。第二在应用层做事件路由只维持一个SSE连接通过不同的event类型来区分业务前端用一个EventSource接收所有消息再内部转发给不同的处理器。我个人的建议是对话类的页面维持一个SSE连接就好人多的时候不要为每个功能单独开连接。等你的流式场景复杂到单个连接无法满足时再考虑HTTP/2和更复杂的方案。作为一个被LLM流式输出折磨过很多次的人我最后再分享一个体会SSE本身协议很简单真正的复杂度都在“链路环境”里。每一层——浏览器、Nginx、K8s Ingress、CDN、云厂商的负载均衡——都可能因为默认的缓冲策略、超时配置、压缩逻辑不经意间把你的流式响应变成“假流式”。所以调试的时候不要只盯代码记得看请求经过的每一跳按“客户端 - 网关 - 服务端 - 上游LLM”的顺序逐个排查90%的问题都能定位出来。希望这篇文章能帮你少踩几个坑把流式输出真正用好。