1. 为什么流式输出成了 AI Native 应用的生死线
做过大模型应用的人都有一个共同体会:用户对等待的容忍度,在 ChatGPT 出现之后被彻底重置了。以前一个接口转三秒,用户觉得正常;现在你让他在输入框里干等三秒才看到第一个字,他已经在考虑关掉页面了。这不是用户变挑剔了,而是流式输出把"响应速度"这件事从"总耗时"重新定义成了"首字延迟"。
我最早做 AI 应用的时候,用的是最朴素的方式:前端发一个请求,后端等模型把整段话生成完,一次性返回 JSON。功能上没毛病,但体验上就是灾难。一段五百字的回答,模型生成要八到十二秒,用户盯着 loading 转圈,中途没有任何反馈,很多人直接刷新页面重来。后来改成流式,同样的模型、同样的耗时,用户感知却完全变了——第一个字一两秒就蹦出来,后面的内容像打字机一样往外冒,哪怕总时长没变,用户也会觉得"这玩意儿挺快"。
这就是AI Native应用和传统 Web 应用在交互范式上的根本差异。传统应用是"请求-响应"的离散模型,一次交互就是一个完整的结果;AI 应用是"持续生成"的连续模型,结果本身是一个随时间展开的过程。你的架构如果还停留在"攒完再发"的思路里,那不管模型多强,体验都会差一截。
而支撑这种连续交互的底层技术,就是流式输出架构。从最早的SSE(Server-Sent Events),到 WebSocket 的双向通信,再到这两年逐渐成型的AG-UI协议,这条演进路线背后其实是三个问题的不断升级:怎么把 token 稳定地推给前端、怎么在推送过程中保持连接可靠、怎么让流式数据不只是"文本"而是"可交互的界面事件"。
这篇文章我想把这条线完整捋一遍。不是教科书式的协议对比,而是我在实际项目里踩过的坑、做过的取舍、以及最后沉淀下来的一套能直接抄作业的方案。如果你正在做 AI 对话产品、Agent 应用,或者任何需要"边生成边展示"的功能,这里面的东西应该能帮你少走不少弯路。
2. 流式输出的三种技术路线与选型逻辑
2.1 SSE:最简单也最容易踩坑的方案
SSE 本质上就是一个"长连接的单向广播"。客户端发一个普通的 HTTP 请求,服务端把Content-Type设成text/event-stream,然后保持连接不关闭,持续往里面写数据。每条消息以data:开头,以两个换行符结束,浏览器端的EventSource会自动帮你解析。
它最大的优势是简单。不需要额外的协议握手,不需要引入 WebSocket 库,服务端就是往一个 HTTP response 里写字符串,前端就是监听onmessage。对于"服务端单向推送 token"这个场景,SSE 几乎是天然契合的。
但简单的东西往往藏着细节。我列几个实际项目里一定会遇到的问题:
第一个坑是缓冲。很多人写完 SSE 发现前端要等好几秒才一次性收到一大段,而不是逐字冒出来。这十有八九是中间有代理或者框架在缓冲。Nginx 默认会缓冲响应,需要显式关掉:
location /api/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; }后端框架也有类似问题。比如某些 Python 框架默认会等 response 完整才 flush,需要手动调用 flush 或者用生成器逐条 yield。Node.js 里如果用 Express,记得res.flushHeaders(),并且不要用任何会做压缩的中间件——gzip 会把你的流式数据重新攒成块。
第二个坑是超时。热搜词里那个stream disconnected before completion: idle timeout waiting for sse我太熟悉了。模型思考时间长一点,中间十几秒没吐 token,连接就被中间层掐断了。这个问题的根源是各层都有 idle timeout:负载均衡器、反向代理、网关、甚至浏览器。解决办法是在应用层加心跳,即使没有真实 token,也定期发一个注释行:
: keep-aliveSSE 规范里以冒号开头的行是注释,客户端会忽略,但能让连接保持活跃。我一般设 15 秒发一次心跳,比大多数中间层的 30 秒或 60 秒超时都短,稳。
第三个坑是重连。EventSource自带重连机制,断线后会自动重发请求。这在普通推送场景是好事,但在 AI 对话里是灾难——重连意味着重新触发一次模型生成,用户会看到内容重复或者计费翻倍。所以生产环境我通常不用原生EventSource,而是用fetch+ReadableStream手动解析,这样能完全控制重连逻辑,也能带上自定义 header(EventSource不支持自定义 header,这是个硬伤)。
2.2 WebSocket:双向能力带来的复杂度
WebSocket 和 SSE 的核心区别是双向。SSE 只能服务端推,WebSocket 两边都能随时发。那什么时候真的需要双向?
我的判断标准很简单:如果用户在生成过程中需要"打断"或者"追加指令",那就需要 WebSocket。比如用户看到模型答到一半发现方向不对,想点个"停止"或者补一句"换个角度",这种场景 SSE 就很别扭——你只能关掉连接再开一个新的,而 WebSocket 可以直接在同一个连接里发一条控制消息。
但 WebSocket 的代价是明显的。首先是连接管理,你得自己维护连接池、处理断线重连、做心跳保活。热搜词里"websocket 心跳机制实现"被反复搜,就是因为这是绕不过去的坎。一个典型的心跳实现是这样的:
// 客户端心跳 let heartbeatTimer = null; let pongTimeout = null; function startHeartbeat(ws) { heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); pongTimeout = setTimeout(() => { console.warn('pong 超时,主动重连'); ws.close(); }, 5000); } }, 25000); } ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'pong') { clearTimeout(pongTimeout); return; } // 处理正常业务消息 };服务端收到ping要立刻回pong,客户端如果 5 秒内没收到pong就认为连接已死,主动关闭重连。这个"双向确认"比单纯发心跳更可靠,因为能区分"连接还在但服务端卡死"和"连接正常"两种情况。
其次是水平扩展。SSE 本质是 HTTP,天然能被负载均衡器按普通请求分发;WebSocket 是有状态的持久连接,一旦连上就绑定了某台服务器。如果你的生成任务可能跨机器(比如模型推理在 A 机器,业务逻辑在 B 机器),就得引入 Redis Pub/Sub 或者消息队列做跨节点转发,复杂度直接上一个台阶。
所以我的实际选型经验是:纯对话展示用 SSE,需要中途交互用 WebSocket。不要因为 WebSocket "看起来更高级"就无脑上,大部分 AI 对话场景 SSE 完全够用,而且省掉一大堆连接管理的麻烦。
2.3 AG-UI:流式数据从"文本"到"事件"的跃迁
前面两种都是传输层的方案,解决的是"怎么把字节推过去"。但 AI Native 应用发展到今天,光推文本已经不够了。
想象一个场景:模型在回答过程中要调用工具查天气,然后根据结果继续回答。如果只推文本,前端只能显示"正在查询..."这种模糊状态,用户不知道背后发生了什么。再比如模型要生成一个表格、一个按钮、一个可点击的选项,纯文本流根本表达不了。
这就是AG-UI这类协议要解决的问题。它把流式输出的内容从"文本片段"升级成了"结构化事件"。一个典型的 AG-UI 事件流大概长这样:
event: run_started data: {"runId": "abc123", "threadId": "t1"} event: text_message_start data: {"messageId": "m1", "role": "assistant"} event: text_message_content data: {"messageId": "m1", "delta": "今天"} event: text_message_content data: {"messageId": "m1", "delta": "北京"} event: tool_call_start data: {"toolCallId": "tc1", "toolName": "get_weather"} event: tool_call_args data: {"toolCallId": "tc1", "delta": "{\"city\":"} event: tool_call_end data: {"toolCallId": "tc1"} event: text_message_end data: {"messageId": "m1"} event: run_finished data: {"runId": "abc123"}注意这里的关键变化:每个事件都有类型和语义。前端不再只是"收到一段文字就追加到气泡里",而是能根据事件类型做不同的渲染——文本事件追加到消息气泡,工具调用事件显示一个可折叠的"正在调用 XX 工具"卡片,状态事件更新顶部的进度指示器。
这种设计带来的最大好处是前后端解耦。以前前端要理解"这段文本里[TOOL:weather]是什么意思",得写一堆正则去解析;现在协议层就把语义定义清楚了,前端只负责按事件类型渲染,后端换模型、换工具、换编排逻辑,前端几乎不用改。
AG-UI 目前还在演进中,不同实现细节有差异,但核心思想是一致的:把流式输出从"字节流"抽象成"事件流"。这也是我认为 AI Native 应用架构接下来一定会走的方向——因为 Agent 的行为越来越复杂,纯文本协议承载不了这么多语义。
3. 一套能扛住生产的流式架构长什么样
3.1 分层设计:把"生成"和"传输"拆开
我见过很多项目把模型调用和 SSE 推送写在一个函数里,模型吐一个 token 就直接往 response 里写。这种写法在 demo 阶段没问题,但一上生产就各种问题:想加个日志得改核心逻辑,想换个模型得重写推送,想做多路复用根本无从下手。
我的做法是强制分层,中间用一个事件通道隔开:
[模型/Agent 层] --事件--> [事件总线] --订阅--> [传输层] --SSE/WS--> [前端]模型层只负责产生事件,它不知道外面是 SSE 还是 WebSocket,也不知道有几个消费者。传输层只负责把事件序列化后推给客户端,它不关心事件是模型生成的还是工具产生的。中间的事件总线可以是一个内存队列,也可以接 Redis 做跨进程分发。
这个设计的好处,我在一个多端项目里体会特别深。同一个对话,Web 端用 SSE,移动端用 WebSocket,还有一个后台任务在消费同样的事件流做审计日志。如果按传统写法,我得写三套推送逻辑;分层之后,三个消费者只是订阅同一个事件通道,模型层一行代码都不用改。
事件的数据结构我一般定义成这样:
interface StreamEvent { id: string; // 事件唯一 ID,用于断点续传 type: EventType; // 事件类型,决定前端如何渲染 timestamp: number; payload: unknown; // 具体内容,结构由 type 决定 } type EventType = | 'run.started' | 'message.delta' | 'message.completed' | 'tool.started' | 'tool.delta' | 'tool.completed' | 'run.failed' | 'run.finished';id这个字段很关键。SSE 协议原生支持id:字段,客户端断线重连时会带上Last-Event-IDheader,服务端可以根据这个 ID 把断线期间的事件补发回去。这就是断点续传的基础。我在做长文档生成的时候,一次生成可能几分钟,中间网络抖一下很常见,有了这个机制用户几乎无感。
3.2 背压处理:别让快生产者拖垮慢消费者
流式系统里一个容易被忽视的问题是背压(backpressure)。模型生成 token 的速度可能很快,比如每秒几十个;但客户端网络慢,或者前端渲染卡顿,消费速度跟不上。如果中间没有缓冲控制,事件就会在内存里堆积,量大了一个进程就 OOM 了。
SSE 场景下这个问题相对好处理,因为 HTTP 的 TCP 窗口本身就是天然的背压信号——客户端不读,服务端的 write 就会阻塞。但要注意别在服务端做无界缓冲,比如先把所有事件塞进一个数组再统一发,那就等于放弃了流式。
WebSocket 场景要更小心,因为ws.send()是异步的,如果不管返回值一直发,底层缓冲区会涨。我的做法是监控ws.bufferedAmount,超过阈值就暂停生产:
async function pushEvent(ws, event) { const MAX_BUFFER = 1024 * 1024; // 1MB while (ws.bufferedAmount > MAX_BUFFER) { await new Promise(r => setTimeout(r, 10)); } ws.send(JSON.stringify(event)); }这个"等缓冲区降下来再发"的逻辑,本质上就是手动实现背压。虽然简单,但能避免很多莫名其妙的崩溃。
3.3 错误处理:流式场景下的失败比你想的复杂
传统请求失败很干脆:要么成功返回,要么报错。流式请求的失败是部分失败——前面已经推了一千个字,推到一半模型报错了,这时候怎么办?
我的处理原则是:已经推出去的内容不撤回,用事件明确告知失败位置和原因。具体做法是发一个run.failed事件,带上已经完成的部分和错误信息,前端把错误提示挂在消息末尾,而不是清空整个气泡。用户至少能看到已经生成的内容,体验上比"啪一下全没了"好得多。
还有一种失败是静默失败:连接还在,但服务端已经不推数据了。这种最难排查,因为前端看起来一切正常,就是没内容。我的经验是在协议层加超时——如果 N 秒内没收到任何事件(包括心跳),客户端主动判定超时并重连。这个 N 一般设 30 到 60 秒,比中间层的 idle timeout 短一点,确保是客户端先发现异常。
4. 实操:从零搭一个带断点续传的 SSE 服务
4.1 服务端实现要点
我用 Node.js 写一个最小可用的例子,把前面说的关键点都串起来。核心是一个事件缓冲区加一个推送循环:
const clients = new Map(); // clientId -> { res, lastEventId } function handleSSE(req, res) { const clientId = req.query.clientId; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', // 关键:告诉 Nginx 别缓冲 }); res.flushHeaders(); clients.set(clientId, { res, lastEventId: req.headers['last-event-id'] || null }); // 心跳 const heartbeat = setInterval(() => { res.write(': ping\n\n'); }, 15000); req.on('close', () => { clearInterval(heartbeat); clients.delete(clientId); }); } function sendEvent(clientId, event) { const client = clients.get(clientId); if (!client) return; client.res.write(`id: ${event.id}\n`); client.res.write(`event: ${event.type}\n`); client.res.write(`data: ${JSON.stringify(event.payload)}\n\n`); }几个细节值得展开说。X-Accel-Buffering: no这个 header 是给 Nginx 看的,比改 Nginx 配置更灵活,因为它是按响应生效的。res.flushHeaders()要显式调用,否则某些框架会等 body 才开始发 header,前端就一直卡在连接建立阶段。
断点续传的实现依赖事件缓冲区。我一般保留最近 5 分钟的事件,客户端重连时带上Last-Event-ID,服务端从这个 ID 之后开始补发:
function replayEvents(clientId, lastEventId) { const events = eventBuffer.getSince(lastEventId); for (const event of events) { sendEvent(clientId, event); } }这里有个坑:补发的事件和实时事件可能重叠。如果补发还没结束,新的实时事件就来了,顺序会乱。我的做法是补发期间先把实时事件暂存,补发完再按序推送。虽然增加了一点复杂度,但能保证前端看到的事件流是严格有序的。
4.2 前端解析:别用 EventSource
前面说过EventSource不支持自定义 header,而且重连逻辑不可控。我推荐用fetch+ReadableStream手动解析:
async function streamChat(prompt, onEvent) { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ prompt }), }); 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 以 \n\n 分隔事件 const parts = buffer.split('\n\n'); buffer = parts.pop(); // 最后一段可能不完整,留到下次 for (const part of parts) { const event = parseSSE(part); if (event) onEvent(event); } } } function parseSSE(raw) { const lines = raw.split('\n'); const event = { type: 'message', data: '', id: null }; for (const line of lines) { if (line.startsWith('event:')) event.type = line.slice(6).trim(); else if (line.startsWith('data:')) event.data += line.slice(5).trim(); else if (line.startsWith('id:')) event.id = line.slice(3).trim(); } if (!event.data) return null; try { event.payload = JSON.parse(event.data); } catch { event.payload = event.data; } return event; }这里最容易出错的是分块边界。reader.read()返回的 chunk 不保证按事件边界切分,可能一个事件被切成两半,也可能两个事件挤在一个 chunk 里。所以必须维护一个buffer,用\n\n切分,最后一段不完整的留到下一轮。这个逻辑我调了很久才稳定,新手特别容易在这里翻车。
4.3 参数选择:超时、心跳、缓冲的取值依据
这些参数没有标准答案,但有几个经验值可以参考。心跳间隔我一般设15 秒,理由是大多数云负载均衡器的 idle timeout 是 60 秒,15 秒留了足够余量,同时不会太频繁浪费带宽。客户端超时设45 秒,比心跳间隔的三倍略短,能容忍偶尔丢一两个心跳。
事件缓冲区保留时长设5 分钟,因为用户断线重连通常发生在几秒到几十秒内,5 分钟足够覆盖绝大多数场景,同时内存占用可控。如果做的是长文档生成这种可能断线几分钟的场景,可以延长到 15 分钟,但要配合事件落盘,不能全放内存。
背压阈值我设1MB,这是 WebSocket 缓冲区的经验值。太小会导致频繁暂停影响吞吐,太大则失去背压的意义。SSE 场景其实不太需要手动背压,TCP 窗口会帮你处理,但如果你在服务端做了事件聚合(比如攒 10 个 token 发一次),那就要注意聚合缓冲区的大小。
5. 生产环境那些文档不会告诉你的坑
5.1 常见问题速查表
我把这些年遇到的高频问题整理成一张表,方便对照排查:
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 前端一次性收到全部内容 | 中间层缓冲 | 检查 Nginx、框架、压缩中间件 | 关 buffering,禁用 gzip |
| 生成到一半连接断开 | idle timeout | 看各层超时配置 | 加心跳,缩短心跳间隔 |
| 断线重连后内容重复 | 重连触发重新生成 | 检查重连逻辑 | 用 Last-Event-ID 续传 |
| 事件顺序错乱 | 补发与实时事件竞争 | 检查补发逻辑 | 补发期间暂存实时事件 |
| 内存持续增长 | 事件缓冲区无上限 | 监控缓冲区大小 | 加 TTL 和容量上限 |
| 部分客户端收不到 | 负载均衡粘性问题 | 检查连接分发 | 用 clientId 做一致性哈希 |
| 中文乱码 | 分块切断多字节字符 | 检查解码方式 | 用 TextDecoder 的 stream 模式 |
5.2 三个我踩过的真实坑
第一个坑是 gzip。有次上线后发现流式效果没了,前端要等十几秒才一次性显示。查了半天,最后发现是某个中间件默认开了 gzip 压缩。压缩算法需要攒够一定数据才能有效压缩,所以它会把流式数据缓冲起来。关掉 gzip 后立刻恢复正常。这个坑的隐蔽性在于,本地开发环境没开压缩,只有生产环境有,所以特别容易漏。
第二个坑是 HTTP/2。我们为了性能上了 HTTP/2,结果发现 SSE 在某些客户端上表现异常。原因是 HTTP/2 的多路复用会让多个流共享一个 TCP 连接,如果其中一个流阻塞,可能影响其他流。而且 HTTP/2 的流控机制和 SSE 的长连接配合起来有些微妙。后来我们对流式接口单独走 HTTP/1.1,问题就消失了。这不是说 HTTP/2 不能用,而是流式场景要特别测试。
第三个坑是移动端后台。移动端 App 切到后台后,系统会挂起网络连接,SSE 直接断掉。用户切回来发现内容停在半路。这个问题的解法是在 App 层监听前后台切换,切回前台时用Last-Event-ID主动重连续传。如果没有断点续传机制,这个场景基本无解。
5.3 监控指标:流式系统该看什么
传统接口看 QPS、延迟、错误率就够了,流式系统还得加几个专属指标。首字延迟(TTFT,Time To First Token)是最重要的用户体验指标,它直接决定用户觉得"快不快"。token 吞吐率反映生成速度,突然下降可能是模型服务出问题了。连接存活时长能反映断线频率,如果平均存活时间很短,说明心跳或超时配置有问题。事件积压量反映背压情况,持续增长说明消费跟不上生产。
我一般把这几个指标做成看板,首字延迟设 P95 告警,超过 3 秒就查。这个阈值是根据用户感知定的——超过 3 秒,用户就会开始怀疑是不是卡住了。
6. 从 SSE 到 AG-UI,架构演进背后的思考
回头看这条演进路线,其实每一步都是被需求推着走的。最早只要能把 token 推过去就行,SSE 够了;后来要支持打断和交互,WebSocket 上场;再后来 Agent 行为复杂了,纯文本表达不了,就有了 AG-UI 这类事件协议。
我的判断是,未来的 AI Native 应用,流式协议一定会往"事件化"和"可组合"方向走。因为 Agent 不再是简单的"问-答",而是"规划-调用工具-观察-再规划"的循环,每一步都需要给用户可见的反馈。文本流承载不了这种复杂度,必须有结构化的协议。
但我也想说,不要为了追新而过度设计。如果你的应用就是简单的对话,SSE 加个心跳和断点续传,能稳定跑很久。AG-UI 这类协议的价值在复杂 Agent 场景才体现得出来,简单场景上它只会增加前后端的对接成本。技术选型永远要看自己的实际需求,而不是看哪个词更热。
最后分享一个我自己的习惯:每次做流式功能,我都会先写一个"最笨的版本"——不用任何框架,就是裸的 HTTP 加字符串拼接,把整条链路跑通,确认每个环节的数据流向。等这个版本稳定了,再往上加抽象、加协议、加分层。这样做的原因是,流式系统的 bug 往往藏在层与层之间的缝隙里,抽象越多,排查越难。先把地基打牢,上面的楼才盖得稳。