尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

SSE 流式输出实战:AI 对话打字机效果与断连排查

发布时间:2026/9/26 9:07:35

资讯中心
01
ARTICLE

SSE 流式输出实战:AI 对话打字机效果与断连排查

SSE 流式输出实战:AI 对话打字机效果与断连排查
1. 为什么大模型对话总在打字而不是等一整段第一次接触大模型接口的人几乎都会有一个疑问为什么我调用接口之后返回的不是一整段完整的回答而是一个字一个字往外蹦这个打字机效果背后最普遍、最朴素、也最容易被忽视的协议就是SSEServer-Sent Events服务器推送事件。我在做 AI 应用开发的前两年一直以为流式输出是某种高深的技术直到自己动手抓包看了一遍请求响应才发现它简单得有点反直觉——它本质上就是一条长连接 纯文本分块推送的 HTTP 请求。没有 WebSocket 那么重没有轮询那么浪费浏览器原生支持服务端实现也就几十行代码。可以说今天你看到的几乎所有 AI 对话产品的实时渲染底层跑的都是这套东西。这篇文章想聊的不是SSE 是什么这种教科书定义而是从一个真正做过 AI 交互链路的人的角度把 SSE 在 AI 场景里的选型逻辑、协议细节、踩坑经验、断连排查、以及和 Agent 工作流的配合方式讲透。适合三类人看一是刚入门 AI 应用开发、搞不清流式输出原理的新手二是已经能跑通 Demo、但一遇到stream disconnected before completion就抓瞎的开发者三是想把 SSE 用到非 AI 场景比如实时日志、进度推送的工程师。看完你应该能自己从零手写一个稳定的 SSE 服务端和客户端并且知道哪些坑是必须提前避开的。先说结论SSE 不是什么黑科技它是 HTTP 协议的一个边角料特性但因为足够简单、足够通用反而成了 AI 时代最普遍的一个协议。理解它等于理解了 AI 交互链路的第一层地基。2. SSE 的协议本质一条不肯挂断的 HTTP 请求2.1 它到底和普通请求差在哪普通 HTTP 请求是这样的客户端发一个请求服务端处理完返回一个完整响应连接关闭。一问一答干净利落。但 AI 生成回答需要几秒甚至几十秒如果等全部生成完再返回用户会盯着空白屏幕发呆体验极差。SSE 的思路是服务端返回响应时不设置Content-Length而是设置Content-Type: text/event-stream然后保持连接不关闭持续往这条连接里写数据。客户端每收到一段就渲染一段直到服务端主动发一个结束信号或者关闭连接。这里有个关键点很多人搞混SSE 是单向的只能服务端推给客户端。客户端想发消息得另开一个普通 POST 请求。这跟 WebSocket 的双向通信完全不同。为什么 AI 场景反而更适合单向因为大模型交互的典型模式就是用户问一次模型答一长串推送方向天然是单向的用 WebSocket 属于杀鸡用牛刀还要额外维护心跳、重连、协议握手成本高得多。2.2 数据格式比你想的还简单SSE 的数据格式简单到令人发指就是纯文本每条消息以data:开头以两个换行符\n\n结尾data: {content: 你} data: {content: 好} data: {content: 世界}除了data:还有几个可选字段字段作用是否常用data:消息内容可多行必用event:自定义事件类型偶尔用id:消息 ID用于断线重连定位建议用retry:重连等待毫秒数偶尔用:开头注释行常用来做心跳强烈建议用我实测下来AI 场景里最容易被忽略的是心跳注释行。因为很多网关、负载均衡器、云服务商会在连接空闲 30 到 60 秒后强制断开如果你的模型思考时间较长比如推理模型中间没有数据推送连接就被掐了前端就会报stream disconnected before completion: idle timeout waiting for SSE。解决办法就是服务端每隔 15 到 20 秒发一个: keep-alive\n\n这个注释行客户端会忽略但能保住连接。2.3 为什么浏览器原生就支持这是 SSE 相比 WebSocket 最大的隐藏优势浏览器内置了EventSource对象几行代码就能接const es new EventSource(/api/chat/stream); es.onmessage (e) { const data JSON.parse(e.data); appendToScreen(data.content); }; es.onerror (err) { console.error(连接异常, err); };EventSource会自动处理重连默认 3 秒会自动带上Last-Event-ID头用于断点续传。你不用写一行重连逻辑。当然代价是它不支持自定义请求头也没法发 POST body。所以现在很多 AI 产品干脆不用EventSource而是用fetchReadableStream手动解析流这样既能发 POST又能带鉴权头还能配合AbortController做中断。3. 从零手写一个能跑通的 SSE 链路3.1 服务端别用框架的假流式很多人以为用了某个框架的流式接口就是 SSE 了其实不然。真正的流式必须满足两个条件响应头正确分块 flush。我见过太多假流式——服务端把数据攒在缓冲区里等全部生成完才一次性 flush前端看起来还是唰地一下全出来完全没有打字机效果。以 Node.js 原生写法为例核心就这几行app.get(/api/chat/stream, async (req, res) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.setHeader(X-Accel-Buffering, no); // 关键禁用 Nginx 缓冲 res.flushHeaders(); const heartbeat setInterval(() { res.write(: keep-alive\n\n); }, 15000); try { for await (const chunk of callLLM(req.query.q)) { res.write(data: ${JSON.stringify({ content: chunk })}\n\n); } res.write(data: [DONE]\n\n); } finally { clearInterval(heartbeat); res.end(); } });这里有几个坑必须点出来。第一X-Accel-Buffering: no这个头是给 Nginx 看的不加的话 Nginx 会默认缓冲你的响应流式效果直接消失。第二res.flushHeaders()要显式调用否则某些框架会等到第一次 write 才发头。第三[DONE]这种结束标记是 OpenAI 系接口的约定不是 SSE 标准但大家都这么用客户端好判断。3.2 客户端fetch 流式解析比 EventSource 更实用前面说了EventSource不支持 POST 和自定义头所以生产环境我更推荐fetch手动解析。核心难点在于TCP 分块不等于消息分块。你收到的一个 chunk 可能包含半条消息也可能包含三条半消息。所以必须自己维护一个缓冲区按\n\n切分async function streamChat(prompt, onChunk, signal) { const resp await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt }), signal, }); const reader resp.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 parts buffer.split(\n\n); buffer parts.pop(); // 最后一段可能不完整留到下次 for (const part of parts) { if (!part.startsWith(data:)) continue; const payload part.slice(5).trim(); if (payload [DONE]) return; onChunk(JSON.parse(payload).content); } } }这段代码我用了两年多几乎没改过。decoder.decode(value, { stream: true })这个stream: true参数很关键它能正确处理跨 chunk 的多字节字符比如中文不加的话偶尔会出现乱码。3.3 中断AbortController 是标配AI 对话有个刚需用户看到回答不对想立刻停止生成。这时候如果只是前端停止渲染服务端还在傻傻地跑既浪费算力又浪费钱。正确做法是用AbortControllerconst controller new AbortController(); streamChat(prompt, onChunk, controller.signal); // 用户点停止 stopBtn.onclick () controller.abort();abort()之后fetch 会抛出一个AbortError同时 TCP 连接关闭服务端那边的res会触发close事件你可以在服务端监听这个事件去取消正在进行的模型调用。这一套配合下来才算是一个完整的、不浪费资源的中断链路。我见过不少项目只做了前端中断后端照跑不误账单哗哗涨。4. 那些让人抓狂的断连问题到底怎么排查4.1stream disconnected before completion的完整排查链路这个报错我踩过至少五次每次原因都不一样。分享一套我总结的排查顺序从外到内一层层剥第一层先看是不是网关超时。大多数云负载均衡默认空闲超时是 60 秒。如果你的模型首 token 延迟就超过 60 秒推理模型很常见连接在第一个字节到达前就被掐了。验证方法看服务端日志有没有收到请求、有没有开始 write。如果服务端压根没收到那就是网关层的问题调大超时或者加心跳。第二层看是不是缓冲导致假死。服务端明明在 write但客户端迟迟收不到。八成是中间有代理做了缓冲。除了前面说的X-Accel-Buffering还要检查 CDN、API 网关的缓冲配置。我遇到过一次是某云厂商的 API 网关默认开启响应缓冲关掉之后立刻正常。第三层看心跳有没有生效。如果模型思考时间长中间没有数据即使网关超时调到 300 秒也可能被某些中间设备掐断。加心跳注释行是最稳的兜底方案。第四层看客户端解析逻辑。有时候不是连接断了而是你的解析代码遇到不完整 chunk 抛异常被 catch 之后误报成断连。这种情况加日志把原始 buffer 打出来一看便知。4.2 一个真实案例中文乱码引发的断流有次线上反馈说流式输出到一半突然断日志显示连接正常关闭。排查了半天最后发现是TextDecoder没加{ stream: true }。一个中文字符占 3 个字节如果正好被切在两个 chunk 中间解码就会出问题抛异常后整个循环退出看起来就像断流。加上stream: true之后问题消失。这个坑很隐蔽因为英文测试永远复现不了。4.3 重连与断点续传Last-Event-ID的正确用法SSE 标准里有个id:字段配合客户端的Last-Event-ID请求头可以实现断点续传。服务端每条消息带上递增 ID客户端重连时浏览器自动带上最后收到的 ID服务端据此从断点继续推。但说实话在 AI 对话场景里这个机制用得不多因为大模型生成是一次性的断了很难从中间续。更常见的做法是断连后让用户重新发起或者前端把已收到的内容保留提示生成中断点击重试。别为了用而用理解场景比套用标准更重要。5. SSE 在 Agent 工作流里的进阶玩法5.1 不只是推文本还能推状态SSE 的event:字段在 Agent 场景里特别有用。一个 Agent 执行任务时中间会有很多状态正在思考、正在调用工具、工具返回结果、正在总结。如果只推文本用户看到的就是一段段文字不知道背后发生了什么。用自定义事件就能把过程可视化event: thinking data: {step: 分析用户意图} event: tool_call data: {tool: search, query: ...} event: tool_result data: {summary: 找到 3 条相关结果} event: answer data: {content: 根据搜索结果...}前端根据event类型渲染不同的 UI 组件思考过程折叠显示工具调用显示成卡片最终答案正常渲染。这套玩法现在很多 Agent 产品都在用体验比单纯推文本好太多。5.2 多路复用一条连接推多个任务SSE 是单向长连接理论上你可以用一条连接推送多个任务的状态。做法是给每条消息带上task_id前端按 ID 分发到不同的 UI 区域。这样避免了为每个任务开一条连接减少资源占用。不过要注意一条连接上如果某个任务卡住可能影响其他任务的推送节奏所以更适合轻量、高频、短消息的场景。5.3 和 WebSocket 的选型边界经常有人问Agent 场景到底用 SSE 还是 WebSocket我的判断标准很简单场景特征推荐只需服务端推、客户端偶尔发SSE需要双向实时通信如协作编辑WebSocket需要传二进制如音频流WebSocket想用 HTTP 基础设施鉴权、网关、日志SSE客户端是浏览器且想少写代码SSEAI 对话和 Agent 状态推送99% 的情况 SSE 就够了。WebSocket 的优势在双向和二进制AI 场景基本用不上反而要额外处理握手、心跳、重连得不偿失。6. 生产环境必须注意的几个细节6.1 连接数管理别让长连接拖垮服务SSE 是长连接每个在线用户占一条。如果服务是单机部署几千并发就可能把文件描述符耗尽。几个应对手段一是调大ulimit -n二是用异步非阻塞的运行时Node.js、Go、Python asyncio 都行别用同步阻塞的线程模型三是设置合理的连接上限和超时防止僵尸连接堆积。我一般会给 SSE 连接设一个最大存活时间比如 5 分钟到点主动关闭让客户端重连避免连接泄漏。6.2 鉴权EventSource 的先天缺陷EventSource不能自定义请求头意味着你没法用Authorization: Bearer xxx传 token。常见的绕法有三种一是把 token 放 URL query简单但不安全会进日志二是用 Cookie同源场景可行三是干脆不用EventSource改用 fetch 流式。我推荐第三种虽然多写点解析代码但鉴权、POST、中断全都能搞定长期看更省心。6.3 压缩与性能SSE 是纯文本开启 gzip 能省不少带宽。但要注意压缩会引入缓冲可能破坏流式的实时性。很多服务器默认对text/event-stream不压缩这是对的。如果非要压缩确保用的是流式压缩而不是攒一批再压。实测下来AI 对话这种短消息高频推送的场景压缩收益不大反而增加延迟建议直接关掉。6.4 日志与可观测性长连接的日志和普通请求不一样一次连接可能持续几分钟中间推了几百条消息。如果每条都打日志日志量爆炸。我的做法是连接建立和关闭各打一条中间的消息只记录条数和总字节数异常时才打详细内容。另外给每条连接分配一个 trace id方便串联前后端日志排查问题。7. 我踩过的坑和几条实在建议做 AI 交互链路这几年SSE 相关的坑我基本踩了个遍。最后分享几条掏心窝的经验都是文档里不会写的。第一条永远假设连接会断。不管你的网络多稳、网关配置多好长连接断掉是常态。前端一定要有生成中断的兜底 UI把已收到的内容保留住给用户一个重试按钮。别让用户看到一片空白然后一脸懵。第二条心跳不是可选项是必选项。我早期为了省事没加心跳结果线上各种莫名其妙的断连加了心跳之后世界清净了。15 到 20 秒一次成本几乎为零收益巨大。第三条服务端要能感知客户端断开。用户关掉页面、点停止、网络断了服务端都应该能通过close事件感知到然后取消正在进行的模型调用。这一条直接关系到你的 API 账单别不当回事。第四条别迷信框架的流式。很多框架号称支持流式实际用起来各种缓冲、各种延迟。跑通之后一定要抓包验证确认数据是真的在分块推送而不是攒完一次性返回。验证方法很简单在服务端每次 write 后打时间戳看间隔是不是均匀的。第五条测试要覆盖慢速和中断场景。正常网络下测不出问题一定要模拟慢速网络、模拟中途断网、模拟超长思考时间。我一般用浏览器 DevTools 的网络限速功能把网速调到 3G很多隐藏的缓冲和解析问题立刻就暴露了。SSE 这个东西入门门槛低到几乎为零但要用稳、用好需要理解的细节一点不少。它就像 AI 应用里的水电煤平时感觉不到存在一旦出问题就是全局性的。把上面这些点吃透你手头的 AI 交互链路基本就能扛住生产环境的考验了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。