LLM流式输出与SSE:从原理到生产环境落地全解析 📅 发布时间:2026/9/12 11:11:30 👁 浏览次数: 我最初接触流式输出这个概念是在做大模型应用落地的时候。当时老板提了个需求让网页像ChatGPT一样一个字一个字往外蹦不要等十几秒出整段结果。我第一反应是WebSocket结果越做越别扭后来才发现这里面的最佳实践其实是一套老掉牙的协议——SSE全称Server-Sent Events服务端推送事件。这篇文章就围绕“LLM流式输出”和“SSE”展开梳理清楚Token流式的底层逻辑、实际工程怎么落地、以及我在生产环境里踩过的坑希望能给正在搞大模型应用、搞Agent、搭RAG服务的朋友一点参考。1. 为什么大模型输出非要“流式”用户等待心理与首Token延迟1.1 非流式输出的致命体验先看一个最基本的场景。你调用一个LLM接口假设模型推理需要8秒才能完整生成一段200字的回答。如果走非流式接口意味着用户发出请求后页面要白屏或者转圈8秒然后整段200字一次性出现。这里有两个痛点。第一是用户的耐心问题业内有个“2-5-10秒”经验法则2秒以内反馈用户觉得流畅5秒以内还能接受超过10秒用户大概率直接关页面。第二是心理反馈问题哪怕总耗时一样流式输出让用户看到内容在动、在生成用户对等待的容忍度会大幅提升。这就像看综艺节目里的答题场景观众不怕等怕的是没有进度感。1.2 首Token延迟比总耗时更重要在大模型应用里有一个专门的性能指标叫TTFTTime To First Token也就是从发出请求到收到第一个Token的时间。为什么这个指标被反复提及因为LLM的推理过程是自回归的首Token之前要做预填充Prefill要对整个用户输入做一次完整的注意力计算这个阶段没有办法吐字耗时是必付的成本。而首Token之后进入解码Decode阶段每生成一个Token都需要一次前向计算这个过程的耗时理论上和生成长度成正比。所以你会发现如果总耗时是10秒里面可能3秒是TTFT剩下7秒是逐Token解码。1.3 流式交互带来的产品形态创新流式输出不只是“好看”它直接改变产品形态。比如Agent流式输出中间状态工具调用的进度、思考过程、多步推理的结果都能实时展示给用户。再比如流式输出还能实现“用户打断”机制用户在生成过程中发现问题可以直接点停止节省时间和Token成本。不能流式的场景就只能干等用户在整个等待过程中是黑盒状态。这也是为什么现在评测一个LLM应用流式支持已经成为默认项不是加分项。2. SSE协议拆解它和WebSocket、轮询到底差在哪2.1 SSE本质上还是一个HTTP请求很多人听到SSE第一反应是“又引入了一个新协议”。其实不是SSE是建立在HTTP之上的没有新的传输层协议。它做的事情很简单客户端发起一个普通的HTTP请求服务端收到后不结束响应而是持续地一块一块往回写数据。这里的关键点在于“响应不结束”。传统的HTTP请求-响应模型是请求发出响应回来连接关闭。SSE打破了这个模型的后半段服务端把响应报文分成多个数据块chunk持续发送但整个HTTP响应一直没有terminated。2.2 SSE报文格式分行业务、注释与事件类型SSE的报文格式非常简洁核心是text/event-stream这个MIME类型。服务端往响应体里写的数据格式如下id: 1 event: message data: 第一行数据 id: 2 event: message data: 第二行数据这里有几个规则data:开头的是数据行多个连续的data:行会被客户端解析为一条消息的不同行用换行符拼接。event:指定事件类型如果省略默认是message事件。id:用于断线重连时的Last-Event-ID追踪。一个空行两个换行符表示一条消息的结束。以冒号开头的行是注释行服务端可以用来发心跳包保持连接不被中间设备断开。实际一个大模型流式返回的SSE报文长这样data: {choices: [{delta: {role: assistant}, index: 0}]} data: {choices: [{delta: {content: 你好}, index: 0}]} data: {choices: [{delta: {content: }, index: 0}]} data: [DONE]OpenAI兼容接口就是这么干的每个chunk里带一个delta增量最后用一个data: [DONE]标记整个流结束。2.3 与WebSocket、传统轮询的对比我整理了一张对比表直接看差异维度SSEWebSocket轮询传输层HTTP默认TCPHTTP方向服务端到客户端单向双向双向靠多次请求模拟协议复杂度低文本格式较高有握手、帧格式低自动重连原生支持需自己实现需自己实现穿透代理/防火墙容易常规HTTP端口偶尔被策略阻断容易适用场景大模型生成、实时通知、股票行情聊天、在线游戏、协同编辑低频轮询大模型输出天然是“单向”的主要是服务端往客户端推Token客户端顶多发个停止信号。这种场景用SSE最合适因为双向通信里客户端真正主动发数据的场景少得可怜引入WebSocket反而白白增加了复杂度。3. 从零实现怎么用Python手动搭一个LLM流式服务端3.1 为什么不用现成框架先跑一遍现在FastAPI、Flask这些框架对StreamingResponse都有不错的支持但在实际动手之前建议先用Python自带的标准库写一个最原始的HTTP流式服务理解底层原理。只有把底层原理吃透了后面用框架出问题时你才知道该去哪里查。下面这个例子用Python内置的http.server模块实现一个伪造的LLM流式接口不依赖任何第三方库import json import time from http.server import BaseHTTPRequestHandler, HTTPServer class LLMStreamHandler(BaseHTTPRequestHandler): def do_POST(self): if self.path ! /v1/chat/completions: self.send_error(404) return content_length int(self.headers.get(Content-Length, 0)) body self.rfile.read(content_length) self.send_response(200) self.send_header(Content-Type, text/event-stream; charsetutf-8) self.send_header(Cache-Control, no-cache) self.send_header(Connection, keep-alive) self.send_header(X-Accel-Buffering, no) self.end_headers() response_text 你好这是一个流式输出的示例。 for char in response_text: chunk { choices: [ { delta: {content: char}, index: 0 } ] } event fdata: {json.dumps(chunk, ensure_asciiFalse)}\n\n self.wfile.write(event.encode(utf-8)) self.wfile.flush() time.sleep(0.1) self.wfile.write(bdata: [DONE]\n\n) self.wfile.flush() def log_message(self, format, *args): pass if __name__ __main__: server HTTPServer((0.0.0.0, 8899), LLMStreamHandler) server.serve_forever()这里有几个细节值得注意。第一Content-Type必须是text/event-stream这是SSE协议的核心标识客户端靠它判断这是个流式响应。第二Cache-Control: no-cache必须设置否则中间缓存代理可能把整个流缓冲起来导致客户端迟迟收不到数据。第三每次写入后必须flush()否则数据滞留在应用层缓冲区里不会真正推给客户端。第四X-Accel-Buffering: no是给Nginx用的如果服务端前面挂了Nginx这个头能告诉Nginx关闭缓冲相关细节后面再展开。3.2 Django/Flask/FastAPI里的流式写法实际项目里大家不太会用标准库裸写HTTP服务都是用框架。我分别说一下三个主流Python框架的做法。FastAPI的写法最优雅直接返回StreamingResponsefrom fastapi import FastAPI from fastapi.responses import StreamingResponse import json import asyncio app FastAPI() async def generate(prompt: str): # 模拟LLM生成过程 for token in [我, 是, 一, 个, 大, 模, 型]: chunk {choices: [{delta: {content: token}, index: 0}]} yield fdata: {json.dumps(chunk, ensure_asciiFalse)}\n\n await asyncio.sleep(0.05) yield data: [DONE]\n\n app.post(/v1/chat/completions) async def chat_completions(prompt: str): return StreamingResponse( generate(prompt), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, } )Flask用make_response加Response也可以不过因为Flask默认是同步WSGI流式部分建议用生成器函数from flask import Flask, Response, request import json import time app Flask(__name__) def generate(prompt: str): for token in [我, 是, 一, 个, 大, 模, 型]: chunk {choices: [{delta: {content: token}, index: 0}]} yield fdata: {json.dumps(chunk, ensure_asciiFalse)}\n\n time.sleep(0.05) yield data: [DONE]\n\n app.route(/v1/chat/completions, methods[POST]) def chat_completions(): data request.get_json() prompt data.get(prompt, ) return Response( generate(prompt), content_typetext/event-stream; charsetutf-8, headers{ Cache-Control: no-cache, X-Accel-Buffering: no, } )Django从3.2开始支持StreamingHttpResponse写法类似不赘述。3.3 阻断式生成与流式生成的本质差异可能有人疑惑同一个模型怎么一会能流式一会不能流式区别不在模型本身而在于调用方式。如果模型API本身不暴露流式接口那服务端就只能等完整结果生成完再一次性推给客户端这时候你哪怕给客户端返回text/event-stream也没用因为服务端没有增量数据可以发。所以做流式的第一个前提是底层模型API支持流式。OpenAI兼容接口的做法是参数里加stream: true返回的就是SSE流。而很多自建模型框架比如llama.cpp的server、vLLM、TGI都原生支持流式输出。它们内部在生成Token的同时就会通过SSE增量返回。4. 消费端实战浏览器原生EventSource与fetch流式解析4.1 EventSource的局限浏览器端最省事的方案是用EventSource它原生支持SSE自动重连体验极佳const eventSource new EventSource(/api/llm-stream); eventSource.onmessage function(event) { if (event.data [DONE]) { eventSource.close(); return; } const chunk JSON.parse(event.data); const delta chunk.choices[0].delta.content; if (delta) { outputElement.textContent delta; } }; eventSource.onerror function(event) { console.error(SSE连接出错, event); };但注意EventSource有两个硬伤。硬伤一是它只能发GET请求没法自定义请求头。这意味着很多API鉴权方式比如Authorization头、自定义token头都用不了只能通过URL query参数把token拼进去。这在生产环境里极其别扭一般不建议用EventSource去连那些带鉴权的大模型网关。硬伤二是它是浏览器原生API没法用在Node.js后端做服务端转发。4.2 fetch ReadableStream逐块解析更通用的做法是用fetch加ReadableStream来消费SSEasync function streamChat(messages) { const response await fetch(/api/llm-stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer your-token }, body: JSON.stringify({ messages }) }); if (!response.ok) { throw new Error(HTTP ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(); 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\n); buffer lines.pop(); for (const line of lines) { for (const part of line.split(\n)) { if (part.startsWith(data:)) { const data part.slice(5).trim(); if (data [DONE]) return; try { const chunk JSON.parse(data); const content chunk.choices?.[0]?.delta?.content; if (content) { handleContent(content); } } catch (e) { console.warn(解析失败, data); } } } } } }这里有个容易被忽略的细节decoder.decode(value, { stream: true })。HTTP传输是按字节流的一个中文字符可能被拆分到两个TCP包里到达所以解码时必须带stream: true否则多字节字符在被切断的边界上会解码成乱码。4.3 Node.js后端转发SSE的注意点如果我们在做Agent网关最典型的链路是“浏览器 - 后端Node.js - 上游LLM API”后端需要把上游的SSE流转发给浏览器。Node.js里用fetch同样可以消费上游的流式响应const upstreamResponse await fetch(https://api.llm.example.com/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey}, }, body: JSON.stringify({ model, messages, stream: true }), signal: AbortSignal.timeout(120000) }); return new Response(upstreamResponse.body, { headers: { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, Connection: keep-alive, } });Node.js 18以后原生fetch返回的response.body就是Web标准ReadableStream作为新Response的body直接扔出去即可。这里唯一要注意的是超时设置LLM生成时间长超时阈值不能设太短一般建议120秒以上。5. 鉴权、超时与断线重连生产环境绕不开的三个问题5.1 SSE请求的鉴权怎么处理前文提到浏览器EventSource不能自定义请求头所以生产环境里通常有两种做法。第一种是把token放在query string里例如/api/llm-stream?access_tokenxxx。做法简单token会出现在Nginx access log和浏览器历史里有泄漏风险只适合短生命周期的一次性token。第二种是客户端用fetch流式请求后端自己的业务接口业务接口校验完登录态后由后端代为调用上游LLM API。上游API的密钥保存在后端环境变量里浏览器永远接触不到。这种模式权限收敛、安全可控我强烈推荐。如果担心登录态被恶意前端盗用去刷上游API可以在后端业务接口里做限流同一用户在单位时间内最多发起多少次流式请求。这个在上游LLM网关层做也行但如果你用别人的API未必有配额接口自己做更靠谱。下面是常见的SSE鉴权与传输链路对比模式前端拿到什么上游密钥暴露面适用场景前端直连上游LLM上游API Key或临时token大内部工具、demo前端连业务后端后端转发上游请求自己的会话凭证无生产环境标准做法5.2 before completion: idle timeout waiting for SSE这个报错在接入一些大模型API的SDK或者网关时经常出现含义很直接在流式响应还没有结束之前连接处于空闲状态的时间超过了超时阈值。这种情况通常有两种原因。一种是模型在预填充阶段耗时过长TTFT超过了网关设置的idle timeout。比如用户上传了一个超长文档模型要先处理数千Token的上下文这个阶段可能就要几秒甚至几十秒网关等不到第一个Token就报错了。另一种是模型输出临时中断比如生成过程中服务端在做工具调用function calling思考了一段时间没有往外传Token前端/网关把这个静默期误判成了空闲超时。解决思路或者说常规优化手段包括这几个调大idle timeout比如从默认的30秒调到120秒。服务端主动发心跳注释行。SSE规范里以冒号开头的注释行会被客户端忽略但能有效“刷新”连接的活跃状态让网关不把它判定为空闲。服务端每隔15秒发一个: keep-alive\n\n就能绕过大部分中间超时限制。检查是不是模型输入过长导致预填充阶段过久必要时限制单次上下文长度。在应用层收集完整个流式响应后将结果写入日志。如果从日志看模型端确实长时间无输出优先排查模型推理侧的瓶颈。5.3 断线重连与动态游标续传SSE自带断线重连机制浏览器原生EventSource断了会自动重连。但如果我们在做后端转发或者自己实现消费端就得自己设计重连策略。重度方案是配合Last-Event-ID头实现增量续传。服务端每发出一个事件都带id字段客户端重连时在请求头里带上最后收到的id服务端根据这个id决定从哪里继续推。在LLM流式场景里这个方法用得不多因为很多模型API本身不支持从任意位置恢复生成。主流做法是断线后重新发起一次请求把已经生成的内容当作对话历史的一部分传给模型。不过这样做会导致生成结果可能变化所以更稳妥的方案是——前端把已生成的部分先存着重连后新流里的内容追加显示重复的部分用算法去重等整个流结束再做最终展示。如果对一致性要求极高那就走“客户端请求取消后模型服务端缓存已生成Token重连后根据句柄续推”的定制方案。这个方案实现成本较高一般只有大厂在核心产品里才做。6. 服务端框架与网关层的SSE支持Nginx缓冲问题6.1 反向代理层如何正确透传SSE一旦服务端前面挂了Nginx坑就来了。默认情况下Nginx会对上游响应做缓冲proxy_buffering这意味着Nginx会先攒够一定量数据再统一发给客户端。对于SSE流式响应这个行为会直接导致客户端长时间收不到数据直到服务端结束整个流才一股脑收到。解法是关闭Nginx的proxy缓冲同时关闭对SSE响应的gzip压缩location /api/llm-stream { proxy_pass http://backend_server; proxy_set_header Connection ; proxy_http_version 1.1; proxy_cache off; proxy_buffering off; gzip off; proxy_read_timeout 180s; proxy_send_timeout 180s; chunked_transfer_encoding on; }几个配置逐条说。proxy_buffering off是最关键的关掉缓冲让上游数据直接透传。proxy_http_version 1.1必配因为HTTP/1.0不支持长连接SSE需要保持连接不中断。proxy_read_timeout要设大因为LLM生成过程中可能出现较长的静默期。gzip off是因为流式响应是逐步到达的压缩这个行为本身会引入缓冲和延迟而且Nginx默认在数据量小的时候不会压缩但为避免极端情况直接禁用。6.2 网关对idle timeout的判定逻辑网关层通常根据“连接闲置时间”来判断要不要断开连接。如果某条连接Tag是请求处理中但一直没有任何字节流动超过阈值就会被断掉。这里需要明白网关看到的是字节流不是业务事件。只要数据还在流动不管流动的是业务数据、data: [DONE]还是注释行网关都会认为连接是活跃的。所以“发心跳注释行”这个技巧本质是在利用网关对数据是否流动的判定逻辑。6.3 云环境里更稳的单机处理思路如果你的服务部署在K8s里前面还挂了一层云负载均衡CLB/SLB经常出现流的连接被云LB掐断。要尽量满足这几个条件云LB的“空闲超时”调到最大值。确保请求路径里每一层LB、Nginx、Ingress、应用都不缓冲SSE响应。应用层定期发心跳注释行覆盖全网关。实测下来把心跳间隔设为15-20秒配合LB空闲超时60秒以上基本能稳定跑完正常的LLM长对话。7. 断点排查一个流式接口不吐字的完整排障思路这部分我把自己真实排查过一次的完整链路写出来当时是一个Dify里接入外部LLMSSE流半天不吐字的问题。第一个环节先确认请求有没有到达后端。看后端日志如果有请求记录但没有输出说明问题在后端逻辑或模型调用。如果后端根本没有请求记录问题在网关、Ingress或路由配置。第二个环节用curl直接请求后端的内网地址绕过所有代理层。命令长这样curl -N --no-buffer -X POST http://backend:8000/v1/chat/completions \ -H Content-Type: application/json \ -d {prompt: 你好, stream: true}-N --no-buffer是curl关闭缓冲的关键参数。如果这里能看到逐步吐字说明后端本身没问题锅在反向代理。第三个环节逐层检查Nginx配置是否关掉了proxy_buffering是否存在gzip缓冲是否限制读取超时。这块最常见的问题都是配置写错位置没生效。第四个环节如果curl都通了但浏览器里不吐字重点检查前端解析逻辑。常见问题有两个一是没有正确处理UTF-8流式解码的边界中文字符被拆包导致JSON.parse报错整个流崩掉二是用EventSource去打POST接口请求方式不匹配直接404。第五个环节看SSL层或协议层有没有干扰。有些WAF产品会拦截text/event-stream的Content-Type或者认为长时间不关闭的响应是异常流量。跳过WAF测试一下基本能定位。8. 流式输出背后的模型推理机制与并发考量8.1 Token是如何一个接一个生成的刚才一直在聊工程层的流式机制这里补一下模型层面的原理。大模型生成文本的过程是自回归的模型每次只生成一个Token然后把这个Token拼到已有序列后面再预测下一个Token如此反复直到遇到终止符或达到最大长度。因此“流式输出”对模型推理来说并不是额外的特殊功能模型本来就是“一个Token一个Token”往外吐的只是有些API把中间过程藏起来了一次性返回全部结果。流式接口本质上只是把这个逐Token生成的过程实时暴露给了调用方。理解这一点非常重要它会帮助你快速判断问题到底出在哪里——如果模型在预填充、排队、显存不够那么所有问题都会集中体现在TTFT变长而不是流式输出本身失效。8.2 并发请求与流式连接数的折中流式输出还有一个工程隐患并发连接数。如果每个用户的每个聊天请求都是一条长连接挂着网关的连接数、后端线程数、内存占用都会成倍上涨。一个中间的查询或生成任务可能持续几十秒一百个并发用户就可能压垮单机服务。在做并发预估时不要把“请求数/秒”当作唯一指标要同时估算“峰值并发连接数”和“峰值流阅持续时间”。建议为每个用户的并行流数设上限一般单用户同时最多2-3个流式生成任务就够了。如果真的有超大规模并发需求可以考虑把SSE封装成消息队列消费模式让前端从消息队列拉取增量事件。8.3 基于Token速率做服务端限速流式输出本质上也是一种消耗上游Token的手段。有些模型API是按Token计费的一个用户狂发请求生成超长文本成本会非常夸张。服务端可以做Token速率限制也就是对用户的Token消耗速率做统计超阈值后直接掐断或降级为普通模式。实现方式并不复杂在应用层记录每个用户最近N秒内的输出Token总量用滑动窗口做统计超过阈值就在流里插入一个特殊事件通知前端“你被限流了”。这种限流没法精确控制模型内部的解码过程但能确保账单不会失控。9. 大模型流式生态现状与选型建议9.1 各家框架的SSE兼容性现在主流的大模型推理框架比如vLLM、TGIText Generation Inference、llama.cpp、Ollama、SGLang基本上都原生支持SSE格式的流式输出。OpenAI兼容接口已经成了事实标准很多模型网关和开源项目直接对齐这个协议。在RAG增强LLM链路里StreamingResponse也很常见。RAG服务先做检索把检索到的文档片段塞进上下文然后交给LLM流式生成。这部分会对首次响应延迟有影响因为检索本身也是TTFT的一部分。9.2 千万别在原生产品里绕过SSE硬造轮子我见过一些项目为了“灵活”直接把模型推理的原始Token流用WebSocket裸传给前端。这就导致前端要自己处理二进制帧、自己实现错误重传、自己实现连接状态管理工作量翻倍却没有任何收益。除非有双向实时交互需求比如AI语音对话里需要同时上行音频下行文本否则SSE永远都是大模型流式输出的首选。9.3 从“能跑通”到“能上线”要补的功课一个流式接口demo能跑通和生产能上线中间隔着不少工程细节列一下我日常会重点检查的事项日志里有没有记录完整请求和响应摘要方便事后审计。监控里有没有TTFT、Token吞吐、连接时长、断连率这些指标。服务端异常时有没有给客户端返回一个明确的事件而不是直接断开连接。客户端有没有做“停止生成”按钮有没有处理服务端异常断开的情况。流式接口有没有做限流和鉴权防止被人刷接口。10. 最后再分享几个我在实际项目中常用的调试技巧第一个技巧用curl调试真实LLM流式接口时加上-N参数后如果发现数据不是逐段出现而是一下子全出来基本可以断定中间有缓冲层在作怪。第二个技巧如果前端看不出来到底有没有收到数据别瞎猜直接在Chrome开发者工具的Network面板里看SSE请求在响应标签页里能实时看到数据流的情况而且能区分是网络层没收到还是JS解析层出了问题。第三个技巧很多大模型API的流式输出在最后会附带usage消费统计但注意它不是标准SSE事件格式要特殊解析。调试时记得把原始响应体的最后几行打出来看看很多SDK在上层就把这个字段吞了导致你想看Token消耗却看不到。第四个技巧处理中英文混合内容时流式解码务必用TextDecoder的stream: true参数并且保持一个跨多次回调的buffer变量。这个细节决定了你的应用在生成过程中会不会出现乱码或JSON解析崩溃。第五个技巧测试SSE断线重连时不要只在本地模拟。真实网络里运营商NAT超时、公司防火墙、云LB各项策略都会影响连接存活时间有条件的话在不同网络环境里各测一轮。流式输出加上SSE看起来是个小的协议知识点但在大模型应用里直接决定用户体验和工程稳定性。这篇文章从协议原理、服务端实现、客户端消费、生产环境排障四个角度把整个链路串了一遍希望能帮你少走点弯路。