☰
SSE 流式输出实战:AI 对话打字机效果与断连排查
2026/9/26 9:07:30 网站建设 项目流程

1. 为什么大模型对话总在"打字",而不是"等一整段"

第一次接触大模型接口的人,几乎都会有一个疑问:为什么我调用接口之后,返回的不是一整段完整的回答,而是一个字一个字往外蹦?这个"打字机效果"背后,最普遍、最朴素、也最容易被忽视的协议,就是SSE(Server-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,而是用fetch+ReadableStream手动解析流,这样既能发 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 对话有个刚需:用户看到回答不对,想立刻停止生成。这时候如果只是前端停止渲染,服务端还在傻傻地跑,既浪费算力又浪费钱。正确做法是用AbortController:

const 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
客户端是浏览器且想少写代码SSE

AI 对话和 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 交互链路基本就能扛住生产环境的考验了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询