尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

流式输出+SSE:大模型响应秒出的工程实战

流式输出+SSE:大模型响应秒出的工程实战 手头接了个 LLM 应用的改造任务把原本“用户提交问题 → 前端转圈 30 秒 → 一次性吐出全部回答”的老交互改成基于 LLM 流式输出 SSE 协议的“边生成边显示”模式。改完模型侧把 stream 参数一开满以为很快就能交付结果一上线就被一个诡异问题卡住连接在几十秒内被网关切断日志里反复出现 “before completion: idle timeout waiting for sse”。网上查了一圈发现光会调 /v1/chat/completions 的 stream 参数根本不够得把流式输出与 SSE 之间的关系彻底理顺才能定位问题、设计出真正可用的流式链路。这篇文章就是那段折腾时间的经验整理。适用对象明确正在做 LLM 应用开发、想把流式接入做扎实的工程师后端、前端、全栈都适合看。我会从“为什么必须流式”讲起拆到 SSE 协议格式、链路实现、鉴权方式再重点讲几个生产环境才会遇到的“隐形杀手”。不堆高深理论只讲协议细节和工程落地。1. 为什么大模型响应必须“一点一点挤”流式输出的动机1.1 token 是算出来的不是打包好的很多刚接触 LLM 的开发者会下意识认为模型是在内部把整段回答想好再作为一份完整 JSON 返回。这是对自回归生成最常见的误解。实际上GPT 这类 decoder-only 模型的工作方式是一次只预测下一个 token。给定输入序列模型计算概率分布采样出一个新 token追加进已有序列再做一次推理得到下一个 token如此往复。“你好世界”这句话看起来是整句出现的但它是“你”“好”“”“世”“界”五次推理步骤的结果。第 n 次推理时模型看到的是“你好世”要预测的目标就是“界”。单次推理的延迟通常是十到几十毫秒取决于参数规模、量化方式和硬件水平。我在 A100 上用 vLLM 跑 7B 模型吞吐大致 80-120 tok/s换成本地 MacBook 用 llama.cpp 跑同一个模型直接掉到 5-10 tok/s。这么一算生成一个 300 字的回答GPU 环境大概三秒笔记本环境可能得等一分多钟。这个数字游戏直接带来产品困境如果坚持等模型把整个回答生成完再一次性返回用户在页面上看到的就是几十秒甚至更长的请求黑洞——界面一动不动没人知道服务是不是挂了。卡在这段时间里的每一次操作都可能在用户心里换算成一次差评。流式输出能把“完全等待”变成“边等边看”这是它在 LLM 场景里不可替代的核心价值。1.2 首 token 时间决定用户感知上的等待传统 Web 请求响应时间以秒计用户能接受一两秒的加载。LLM 完整回答动不动十几秒到几十秒两者之间的差距不是加个 loading 动画就能抹平的。流式输出的做法很直接模型每生成一个 token服务端就立刻推给客户端用户看到的是答案在“边写边出”。同样三百字一次性返回时用户的体验是“等了三十秒”流式返回时变成“一秒不到就开始出字”。总时长并没有减少但用户感知到的等待被压缩到了首 token 之前。这个指标有专门名称叫 TTFTTime To First Token。做 LLM 产品时TTFT 往往比总响应时长更影响留存。一个常见经验值TTFT 超过 3 秒用户就开始怀疑服务出了毛病超过 10 秒很多人直接选择关页面重来。这里还要顺便澄清一个误区流式输出并不会降低模型生成速度它只是把同一段生成过程同步透传给用户。后端该等的时间一点没省但体验上从“等一坨数据”变成了“看一段生成”这是完全不同的两种情绪。流式改造的成本极低收益却立竿见影所以几乎成了 LLM 应用的事实标准。1.3 流式不是打字机特效Agent 链路更需要它很多人把流式输出理解成“好看的前端打字机效果”这低估了它。在 Agent 场景中模型常常要经历“多次工具调用 → 观察工具结果 → 继续推理”的循环这个链路实时性要求极高。比如用户问“帮我查一下明天是否适合跑步”模型内部会先决定调天气 API等结果回来再写最终回复。如果这段交互不用流式用户面对的就是规则不明的一次长空白但若把中间步骤也通过流式事件发出来——“正在查询天气数据”“已获取数据正在生成回答”——用户看到的就会是一个在有条理地推进任务的 Agent。在复杂多跳的 Agent 应用里流式不光是体验加分项更是信任基础设施。没有流式用户根本不知道系统是不是已经卡死。搞清楚了“为什么”下一节进入协议本身。2. SSE 协议拆解一个字段、一个事件、一条单向数据流2.1 协议格式比想象中更简单的文本流SSEServer-Sent Events设计上极其轻量。它不是二进制协议而是基于纯文本的 HTTP 流。服务端把响应 Content-Type 设为 text/event-stream然后持续向连接写入特定格式的文本块。每条“事件”由若干字段构成以两个连续换行符\n\n作为结束标记。最小的事件长这样data: {content: 你好}这个块结尾的空行也就是再一个 \n是给客户端判定事件边界用的。一个连接里可以连续发多条事件data: {content: 你好} data: {content: 世界} data: [DONE]SSE 标准中主要涉及四个字段data 是消息主体一个事件里可以有多个 data 行它们会被拼接成一个多行字符串event 字段可以自定义事件类型让前端区分“增量内容”“工具调用”“进度状态”id 字段用于传输事件编号客户端断线重连时可以在 Last-Event-ID 请求头里带回服务端据此从某个位置继续推retry 字段告诉浏览器重连的间隔毫秒数。最容易被忽略、也最实用的是注释行以英文冒号开头的行会被视为注释不触发任何事件。很多网关在连接空闲一段时间后会主动关闭于是大家会定期发送: keep-alive\n\n这种注释事件来维持连接活性。这个技巧在聊超时问题时会派上大用场。2.2 EventSource 与 fetch 流读取两种客户端的边界实现 SSE 客户端大多数人第一个想到的是浏览器原生的 EventSource。它确实封装了自动重连、Last-Event-ID 回传这些底层逻辑用起来很省事const es new EventSource(/api/stream?tokenxxx); es.onmessage (event) { const data JSON.parse(event.data); // 处理增量内容 };但 EventSource 有一个硬伤它只能用 GET 请求且无法自定义请求头。LLM 服务的对话接口往往需要 POST 消息体还要在 Authorization 头里带 token。这两个诉求 EventSource 都满足不了。所以实际项目的流式客户端几乎都用 fetch 加流式读取实现const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ messages }) }); if (!response.ok || !response.body) throw new Error(stream unavailable); 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 }); const events buffer.split(\n\n); buffer events.pop(); // 最后一帧可能不完整保留到下一轮 for (const event of events) { // 解析并渲染 } }这里有个细节很多人会踩必须用 TextDecoder 并把 stream 设为 true否则中文字符的字节被拆到两个 frame 时会出现随机乱码。后面 5.4 会细讲。2.3 SSE 与 WebSocket 的选择题推理场景基本不用纠结在实时通信领域WebSocket 是更常被提到的方案LLM 流式也不是不能用——有些框架确实走 WS。但 SSE 在大多数 LLM 推理场景里是更好的默认选择逻辑可以从三个维度看。第一是方向匹配。LLM 推理的数据流基本是单向的用户一次性提交问题服务端持续回推 token 增量。SSE 天然就是这个模式WebSocket 的双向全双工能力在多数对话场景里根本用不上反而多了一层复杂度。第二是协议复杂度。WebSocket 要先做完 Upgrade 握手再处理帧格式、掩码、心跳保活调试工具的支持也繁琐SSE 踩在普通 HTTP 上curl 直接能看后端一个生成器函数就能实现。第三是基础设施友好度。SSE 走标准 HTTP和 Nginx、网关、CDN 的兼容性比 WebSocket 好得多日志、监控、限流都能复用现有链路。WebSocket 在负载均衡层常常要单独配置长连接和会话保持。两者放在一起很容易决策维度SSEWebSocket传输方向服务端单向推送给客户端全双工双向通信协议基础普通 HTTP 文本流独立帧协议需 Upgrade 握手自动重连原生支持较完整需自行实现自定义请求头EventSource 不支持但 fetch 支持支持复杂度与排查成本低curl 可直接调试较高适用场景LLM 增量输出、订阅通知实时交互、双向消息3. 一条 token 从模型到屏幕的完整链路3.1 推理框架怎么把 token 泼出来先看模型推理框架这一层。vLLM、TGI、llama.cpp 这些框架在流式模式下会通过生成器不断向调用方输出已生成的 token 增量。以 vLLM 的 OpenAI 兼容接口为例请求参数里带stream: true时响应不再是完整的一坨 JSON而是一个持续写回多个增量 JSON 对象的 HTTP 流。OpenAI 兼容流式格式大体长这样data: {id:chatcmpl-xxx,choices:[{index:0,delta:{role:assistant},finish_reason:null}]} data: {id:chatcmpl-xxx,choices:[{index:0,delta:{content:你},finish_reason:null}]} data: {id:chatcmpl-xxx,choices:[{index:0,delta:{content:好},finish_reason:null}]} data: {id:chatcmpl-xxx,choices:[{index:0,delta:{},finish_reason:stop}]} data: [DONE]注意第一帧往往只携带 role用来通知前端接下来是助手消息生成过程中每一帧的 delta.content 就是那个“新 token”结束时先来一个 finish_reason 帧最后再补一个data: [DONE]作为流终止标记。解析时如果直接拿整个 JSON.parse 会挂在 [DONE] 上需要先做判断。3.2 应用后端把生成器翻译成 SSE 响应如果你的应用不直接暴露推理服务而是自建一个业务后端来包装模型调用核心工作就是把内部的生成器转成 SSE 格式逐帧写回 HTTP 连接。FastAPI 里最顺手的方式是 StreamingResponse async generatorfrom fastapi import FastAPI from fastapi.responses import StreamingResponse import asyncio, json app FastAPI() async def fake_llm_stream(text: str): for ch in text: yield json.dumps({delta: {content: ch}}, ensure_asciiFalse) await asyncio.sleep(0.03) app.post(/api/chat) async def chat(messages: list[dict]): async def event_stream(): async for chunk in fake_llm_stream(你好世界): yield fdata: {chunk}\n\n yield data: [DONE]\n\n return StreamingResponse( event_stream(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, }, )这段代码故意写得很薄重点在于三个 headerCache-Control 告诉浏览器和中间缓存不要缓存Connection 保持连接X-Accel-Buffering 是给 Nginx 开的口子告诉它这个响应别缓冲。前面两个容易理解最后一个后面还会再展开。3.3 前端渲染从字节流到逐字刷新的分寸前端拿到 fetch 返回的 response.body 后用 reader.read() 逐块消费。网络传输不会严格按 SSE 事件边界切块一次 read 可能拿到半个事件也可能拿到了三个事件所以必须先把字节流 decode 成字符串再按 \n\n 切分出完整事件并逐一处理。渲染层面同样有讲究。如果每次把新的 delta 追加到 DOM 时都触发一次完整重排300 token 的回答从第 50 个 token 开始就能感到明显卡顿。常见做法是用 textContent 追加增量或者创建独占的文本节点。更精细一点可以配合 requestAnimationFrame 合帧把几毫秒内到达的一批 delta 合并成每一帧只更新一次 DOM。我实测下来合帧对长回答的流畅度提升非常明显实现成本也就十几行。3.4 链路中的隐藏拼接点从模型框架、应用后端到前端浏览器一条 token 实际要过三段推理框架与后端之间的内部通道、后端与浏览器之间的 HTTP 流、浏览器里的字符渲染。任何一段引入额外的缓冲逻辑流式都会“退化”成一段段的小批量输出。我见过的最典型例子是后端为了合并小包把 event_stream 里的逐 token yield 改成攒够 10 个再 yield导致前端一卡一卡地出字。从协议层看这当然是讲得通的但产品交互上就是劣化。做流式的第一守则除了极偶尔的必要聚合不要在生成链路上主动加缓冲。4. 实战给 LLM 流式接口加上一套能过审的鉴权4.1 EventSource 的鉴权困境第 2 节提到了 EventSource 不能自定义请求头这个问题在真实项目里非常致命。后端要求 Authorization Bearer Token但是 new EventSource(url) 根本没有带你 token 的地方。硬要玩可以把 token 拼到查询参数里new EventSource(/api/chat?token${encodeURIComponent(token)})query 参数的最大问题是会出现在访问日志、浏览器历史、网关 access log 里。同一个 token 一旦在这些地方留痕安全性基本等于裸奔。所以我在生产里不会把这种做法当默认选项。如果后端本来就有完整的 Cookie/Session 登录体系可以走 Cookie 方案否则直接上 fetch 流式读取。4.2 三种鉴权姿势对比方案实现成本安全性适用场景自定义 Header fetch低高token 不进 URL大多数 LLM 应用推荐Query 参数带 token极低低日志容易泄漏仅限内网、短时随机 tokenCookie / Session中视实现而定已有统一登录体系的应用我自己落地的是第一种。后端在 FastAPI 里用 HTTPBearer 依赖检查 Authorization 头校验通过后进入流式生成逻辑前端建立 fetch 连接时带上同样的 header。这样安全性和实现成本都在合理区间。4.3 完整的最小闭环示例后端在前面 3.2 的基础上补一层鉴权from fastapi import FastAPI, Depends, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials app FastAPI() bearer HTTPBearer(auto_errorFalse) app.post(/api/chat) async def chat( messages: list[dict], credentials: HTTPAuthorizationCredentials Depends(bearer), ): if not credentials or credentials.credentials ! my_secret: raise HTTPException(status_code401, detailinvalid token) async def event_stream(): async for chunk in fake_llm_stream(你好世界): yield fdata: {json.dumps({content: chunk}, ensure_asciiFalse)}\n\n yield data: [DONE]\n\n return StreamingResponse(event_stream(), media_typetext/event-stream, headers{...})前端完整订阅逻辑就是 2.2 的 fetch 解析再加一个 AbortController 让用户可以点击“停止生成”主动断开。这个“可控中断”是流式 UI 的标配不实现的话用户等长回答时只能干瞪眼。4.4 断流中止、超时兜底与连接健康管理流式接口的健康管理经常被忽略这里给几个亲测有效的参考值首帧超时整个 fetch 连接建立后30 秒内没收到第一个数据事件前端就该提示并中止。数据帧间隔连接中有数据后若 10 秒以上没有新事件多半是模型侧卡了前端可以给出提示等待用户决定是否停止。后端生成超时给生成器包一层 asyncio.wait_for超过 60 秒没有产出就直接断开释放推理资源。这些机制在开发环境看不出差别一旦上了生产同时跑几个慢推理立刻能体会到什么叫“连接烂成一锅粥”。5. 生产环境的隐形杀手超时、代理缓存与重连5.1 亲历的报错before completion: idle timeout waiting for sse文章开头留了个悬念现在正式拆它。“before completion: idle timeout waiting for sse”是客户端/网关节点在等待 SSE 流完成时发现连接空闲超时而被切断后抛出的典型异常。我排查时的第一反应是网关的空闲超时设得太短调完 Nginx 的 proxy_read_timeout 后发现只解决了一半问题。真正麻烦的在更上游的 LLM 推理侧。当时我接了一个量化后的 70B 模型在低配置 GPU 上平均生成一个 token 的间隔超过两秒。如果网关的空闲超时是 30 秒而模型在思考链阶段连续 15 秒没吐出一个 token网关就会认为这个连接“闲着没用”直接给你掐了。SDK 端拿不到完整的 SSE 事件就会抛这个 “before completion”。解决思路有三条按破坏性从低到高排把网关/代理的 read timeout、idle timeout 明确调大建议至少 120 秒以上数值上要覆盖“最坏情况下两个 token 间隔 × 一个合理系数”。在应用后端推送心跳注释事件。SSE 支持: keep-alive\n\n这种注释行网关会把它当成活动数据从而阻止空闲超时触发。这是协议设计上最优雅的解法。优化推理侧换 vLLM 的 continuous batching、换卡、降量化位宽把两次输出之间的空隙压下来。我最后是方案 1 和 2 一起上效果立竿见影。这个案例让我意识到处理 SSE 流式问题光看应用层代码远远不够还要看模型推理性能、看网络链路上每一层对“空闲”的定义。5.2 Nginx 与网关参数流式接口的另一半命运流式接口上面通常还有一层反向代理而 Nginx 默认会对 HTTP 响应做缓冲对 SSE 来说是灾难。它会把后端写入的内容攒到一定大小才转发给客户端于是用户的屏幕看到的不是一行行蹦字而是几秒一坨内容。可以说Nginx 配置不对后端代码写得再好也白搭。这是 Nginx 级的关键配置location /api/chat { proxy_pass http://llm_backend; proxy_buffering off; proxy_cache off; proxy_http_version 1.1; proxy_set_header Connection ; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Host $host; }逐个解释几句proxy_buffering off 必须在 location 里显式关闭proxy_cache off 是为了避免缓存层拦截proxy_http_version 1.1 加上空 Connection 头是为了配合 keep-alive 长连接read/send timeout 都拉到 300 秒别让默认值卡死流。后端的响应头里最好再带 X-Accel-Buffering: no等于给 Nginx 再上一道保险这时候即使外层配置漏了内层也能顶住。我踩过最无语的一次坑是Nginx 配置改好了流式还是不出字排查半天发现是浏览器缓存和内部 CDN 层还在拦截 text/event-stream。所以不止 NginxCDN 对 text/event-stream 也要确认缓存是关闭的这个 MIME 类型的响应就应该被当作“不可缓存”处理。5.3 断线重连与幂等设计浏览器侧的流式连接一旦中断如果客户端简单地把整轮对话重发一遍用户看到的就是内容重新从第一句开始蹦体验非常糟糕。断线重连这件事可以从两个层面处理一是协议层面的续传。SSE 的 id 字段 Last-Event-ID 请求头是为这个设计的服务端给每条事件递增编号前端记录最后一条消费到的 id断线重连时带回服务端若缓存了历史事件就从断点继续推。但要注意真正的 LLM 推理过程通常是无限流式的想在中间保存状态成本很高所以多数项目会放弃精确续传采用下面这个折衷方案。二是交互层面的续传。前端保留已经渲染出来的内容不清理页面只提示“连接断开点击重试”。点击重试时重新发起完整请求但新的 token 从已有内容末尾继续追加。这个方案实现简单用户感知也合理。关键是重试前必须用 AbortController 先把旧流彻底中止否则会出现新旧两条流同时在写内容越拼越长。5.4 中文跨帧乱码一个容易被低估的小问题最后一个高频坑是字符编码。流式传输以字节为单位网络包和 reader.read() 的边界完全随意。比如“你好”的“你”字UTF-8 编码是三个字节可能前两个字节落在一个 frame后一个字节落在下一个 frame。如果前端在每次 read 后直接调用 decoder.decode(value) 而不带流式状态第一个帧解码出的就是乱码。解决办法已经写在 2.2 的示例里了构造 TextDecoder 时传入{ stream: true }。这样 decoder 会记住跨帧未完成的多字节字符等到下一个帧补全后再输出。就这么一个小参数能救掉一大半“中文随机乱码”的 bug我在多语言流式 UI 上至少踩过三次。6. 框架生态LangChain、Dify 还是自研链路6.1 LangChain 的流式回调如果项目里已经用了 LangChain 这类链式框架对流式的抽象一般是回调机制。调用 chain.stream() 或在 LLM 上启用 stream 参数框架会把 token 增量分发给 on_llm_new_token 回调你在这个回调里决定是写日志、转发还是直接透传。一个容易忽略的地方是LangChain 的 stream 只对纯 LLM 生成生效一旦进入 Agent 的工具调用循环模型在思考工具参数和最终回复之间的产出并不只是纯文本 token。实际做 Agent 前端时自定义 SSE 事件类型往往比复用通用文本事件更合适比如单独定义 function_call 类型和 tool_result 类型前端根据事件类型做不同渲染。6.2 Dify 这类平台的流式编排在中大型项目里选择流程平台Dify 这类工具对流式支持已经相当完整。它的接口默认返回 SSE 格式事件里除了最终答案还有工作流执行进度、节点输出等字段。对中小型项目来说用平台接入流式的性价比很高不需要维护推理层、路由层、协议层那一大套东西。我的个人感受是平台把底层细节屏蔽得太干净遇到 5.1 那种超时问题排查路径还是得回到 Nginx、网关和协议层面。如果团队的底层基础没打牢平台上的“流式”反而会成为黑盒出了问题只能干瞪眼。所以哪怕是重度依赖平台SSE 这类底层机制也建议花半天时间读懂。6.3 什么时候必须自研自研与选平台的判断标准本质是“事件模型的自由度”。如果需求只是“LLM 回答走出来”随便哪个方案都行但如果你要精细控制事件类型、让流式接口同时承载“回答内容”“工具调用”“业务状态”“异常提示”这些混合信息还要集成自定义鉴权和权限体系那就值得自研。我自己最后选的就是自研需求是做 Agent 多步骤的流式展示工具调用、进度、最终回复混在一个事件流里Dify 的通用事件字段盖不住业务语义LangChain 的回调链越往深走越难维护。最后落地为 FastAPI 自定义 SSE 事件类型 前端 fetch 流解析代码总量不到四百行链路全可控。把协议握在自己手里之后后面不管是加鉴权、加统计、加限流都是在同一套事件模型上做增量反而省心。7. 调流式接口最顺手的一个小技巧curl 直连给 SSE 接口调 bug 时我强烈推荐在终端里用 curl 直接看流比任何 debugger 都直观curl -N \ -H Authorization: Bearer my_secret \ -H Content-Type: application/json \ -d {messages:[{role:user,content:讲个冷笑话}]} \ http://localhost:8000/api/chat-N 表示禁用 curl 缓冲。回车之后能看到事件帧一条条冒出来想确认“是后端没输出还是前端渲染有问题”半分钟就能定位。想看前几帧的帧结构可以加| head -n 20。把 LLM 流式输出和 SSE 这两层拆开之后整个链路就特别透明模型端只要保证每生成一个新 token 就交出去后端只要保证链路里的每一层都不攒数据前端只要保证拿到增量就立刻渲染。三处都理顺流式应用的核心部分基本就通了。希望这份从踩坑里攒出来的经验能帮你少走几段弯路。
返回列表