☰
AI Native 流式输出架构:SSE、WebSocket 与 AG-UI 选型及生产落地
2026/10/27 4:15:47 网站建设 项目流程

1. 流式输出为什么成了 AI Native 应用的生死线

做过大模型应用的人都有一个共同体会:用户等 10 秒看到完整答案,和 1 秒内看到第一个字然后逐字蹦出来,体验差距是数量级的。哪怕总耗时完全一样,后者就是让人觉得“快”。这不是心理错觉,而是交互设计里的即时反馈原则在起作用——只要系统在持续给出响应,人脑就会把等待感知压缩掉一大半。

AI Native 流式输出架构要解决的核心问题就一个:让模型生成的内容以尽可能低的延迟、尽可能稳的链路,一段一段推到用户面前。听起来简单,真做起来,从协议选型到断线重连、从背压控制到多端适配,每一步都有坑。SSE、WebSocket、以及这两年逐渐被提及的 AG-UI,本质上都是围绕“怎么把流式数据可靠地送到客户端”这个命题在不同层次上的解法。

这篇文章适合三类人看:一是正在做 AI 对话类产品、被流式输出折腾过的后端和前端;二是准备从零搭一套流式架构、想少走弯路的架构同学;三是对 SSE、WebSocket 这些协议只停留在“会用”层面、想搞清楚底层取舍的开发者。我会把协议演进逻辑、生产环境的真实参数、踩过的坑和排查手法都摊开讲,尽量让你看完能直接抄作业。

先说结论性的判断,方便你带着框架往下读:SSE 是当前 AI 流式输出的默认最优解,WebSocket 适合双向交互密集的场景,AG-UI 则是在协议之上抽象出的一层“面向 Agent 的 UI 通信约定”。三者不是替代关系,而是不同层次的协作。下面逐层拆。

2. 从 SSE 到 AG-UI 的协议演进逻辑

2.1 为什么第一代 AI 应用几乎都选了 SSE

大模型对话有个天然特征:请求是单向的、响应是流式的。用户发一句话,服务端持续推 token,客户端只管接收展示,中间几乎不需要客户端再往服务端塞数据。这个特征和 SSE(Server-Sent Events)的设计初衷高度吻合。

SSE 本质上是基于 HTTP 的长连接,服务端以text/event-stream的 MIME 类型持续往客户端写数据。它有几个别的方案比不了的好处:

  • 协议极简:就是普通 HTTP,不需要握手升级,不需要额外端口,Nginx、网关、CDN 基本都能直接透传。
  • 自动重连:浏览器原生EventSource自带断线重连,服务端还能通过retry字段指定重连间隔。
  • 文本友好:天然按行解析,data:、event:、id:这些字段对传 JSON 特别顺手。
  • 调试方便:curl一下就能看到流,不用装任何客户端工具。

我早期做过一个对比测试,同样的模型输出,SSE 从请求发出到首 token 到达的中位延迟比 WebSocket 低 30~50ms,因为省掉了协议升级那一次往返。对于首字延迟敏感的场景,这个差距是实打实的。

但 SSE 的短板也很明确:单向。服务端能推给客户端,客户端想主动发消息只能另开一个 HTTP 请求。这在纯对话场景够用,一旦涉及打断生成、实时协作、多轮工具调用回传,就开始别扭了。

2.2 WebSocket 补的是哪块短板

WebSocket 是全双工,握手一次之后双方随时可以互发消息。它在 AI 场景里的价值主要体现在三类需求上:

第一类是用户主动打断。用户看到模型答到一半发现方向不对,点“停止生成”,这个信号要立刻传到服务端去中断推理。SSE 下你得再发一个 HTTP 请求,虽然也能实现,但多一次连接建立,且和原流的关联要靠 session id 维护,逻辑更绕。

第二类是多模态实时交互。比如语音对话,音频流要双向传,这时候 WebSocket 几乎是唯一选择。

第三类是Agent 场景下的工具调用回传。Agent 执行到一半需要向用户确认“是否继续”,这个确认信号走 WebSocket 最自然。

代价是什么?WebSocket 需要协议升级(HTTP Upgrade),很多企业网关、负载均衡默认不转发 Upgrade 头,部署时经常卡在这一步。而且它没有 SSE 那种原生重连,心跳机制得自己实现,否则中间设备会在空闲一段时间后悄悄掐断连接,表现为“stream disconnected before completion: idle timeout waiting for sse”这类报错——注意这个报错虽然写着 sse,但本质是空闲超时,WebSocket 一样会遇到。

2.3 AG-UI 到底抽象了什么

AG-UI 不是又一个传输协议,它更像是跑在 SSE 或 WebSocket 之上的一层事件语义约定。核心思路是:把 Agent 运行过程中的各种状态变化,标准化成一组可被前端直接消费的事件类型。

传统做法里,前端拿到的是裸的 token 流,得自己判断“这段是思考、这段是工具调用、这段是最终答案”。AG-UI 把这层判断前置到协议层,事件类型大致分几类:

事件类别典型事件用途
生命周期run_started, run_finished, run_error标记一次 Agent 运行的起止
文本流text_message_start, text_message_content, text_message_end增量文本输出
工具调用tool_call_start, tool_call_args, tool_call_end展示 Agent 调用了什么工具
状态同步state_snapshot, state_delta前后端共享 Agent 状态
交互interrupt, resume人工介入点

这么设计的好处是前端组件可以按事件类型分发渲染,不用为每个 Agent 写一套解析逻辑。它把“流式输出”从“传字符串”升级成了“传结构化状态机”,这是 AI Native 应用和传统聊天机器人最本质的区别之一。

2.4 三者选型的决策树

别纠结哪个“最好”,按场景选:

  • 纯文本对话、单向输出、要快速上线 →SSE,闭眼选。
  • 需要打断、语音、双向高频交互 →WebSocket。
  • 做 Agent 产品、前端要展示思考过程和工具调用 →SSE/WebSocket 打底 + AG-UI 事件层。

我见过不少团队一上来就上 WebSocket,结果 90% 的功能用不上双向能力,反而背上了心跳、重连、网关配置的包袱。先用 SSE 跑通,等真遇到双向需求再换,这个顺序更稳。

3. SSE 生产落地的核心细节与参数

3.1 服务端响应头一个都不能少

SSE 最容易翻车的地方就是响应头。少一个,可能本地好好的,一上生产就被缓冲住,用户等半天一个字都不出。标准配置如下:

Content-Type: text/event-stream; charset=utf-8 Cache-Control: no-cache, no-transform Connection: keep-alive X-Accel-Buffering: no

逐个说为什么:

  • no-transform是防某些中间代理对内容做压缩或改写,压缩会破坏流式特性。
  • X-Accel-Buffering: no是给 Nginx 看的,告诉它别缓冲这个响应。这一条是生产环境最高频的坑,不加的话 Nginx 默认会攒够一个 buffer 才吐给客户端,流式直接变批量。
  • Connection: keep-alive保证长连接不被过早关闭。

如果你用的是 Nginx 反代,还得在 location 里显式关掉缓冲:

location /api/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_set_header Connection ''; chunked_transfer_encoding on; }

proxy_read_timeout默认 60s,模型思考久了或者工具调用耗时长,连接会被 Nginx 掐掉,报错就是那个经典的 idle timeout。设成 3600s 或者按你业务最长耗时来定。

3.2 数据帧格式与心跳设计

SSE 的数据帧格式看着简单,细节不少。一个标准帧:

id: 42 event: message data: {"content": "你好"}

注意末尾那个空行,它是帧结束标志,少了它客户端会一直等下一行。id字段用于断线重连时告诉服务端从哪继续,event用于区分事件类型——这正是 AG-UI 事件层能落地的基础。

心跳方面,SSE 本身没有 ping/pong 机制,但你可以发注释行当心跳:

: keep-alive

以冒号开头的行会被客户端忽略,但能保持连接活跃,防止中间设备因空闲超时断开。建议 15~30 秒发一次,太频繁浪费带宽,太稀疏挡不住某些激进的网关。

3.3 断线重连与 Last-Event-ID

浏览器EventSource断线后会自动重连,并在请求头带上Last-Event-ID。服务端如果实现了断点续传,就能从这个 id 之后继续推。但现实是,大模型生成是没法真正“续传”的——token 是顺序生成的,你没法从中间某个 token 接着算。

所以生产上的常见做法是:重连后要么重新生成(体验差),要么从服务端缓存里把已生成的部分补发,再接着推新的。我一般会在服务端维护一个短期的输出缓冲(比如最近 5 分钟的完整输出),重连时先补历史再续流。这个缓冲用 Redis 存,key 是 session id,过期时间设成会话生命周期。

3.4 背压控制:别让快消费者拖垮慢消费者

流式输出有个隐蔽问题:模型生成速度可能远快于客户端消费速度。如果服务端不管不顾地推,客户端处理不过来,内存就涨。Node.js 里表现为res.write()返回 false,这时候应该暂停推送,等drain事件再继续。

function writeWithBackpressure(res, chunk) { const ok = res.write(chunk); if (!ok) { return new Promise(resolve => res.once('drain', resolve)); } return Promise.resolve(); }

Python 的异步框架里类似,要 await 写操作。这个细节很多 demo 不会写,但生产环境并发一上来就是稳定性问题。

4. WebSocket 心跳机制与双向场景实现

4.1 心跳为什么必须自己写

WebSocket 协议本身有 ping/pong 控制帧,但浏览器端的WebSocketAPI 不暴露发送 ping 的能力,只能靠应用层自己发心跳消息。中间设备(负载均衡、防火墙、NAT)通常有 60~300 秒的空闲超时,超过就静默断开,而且断开时两端可能都收不到通知,表现为连接“假死”。

心跳机制的标准实现是:客户端每隔 N 秒发一个 ping 消息,服务端收到回 pong;同时服务端也维护一个定时器,如果超过 2N 秒没收到任何客户端消息,就主动关闭连接。N 一般取 30 秒。

// 客户端 let heartbeatTimer; function startHeartbeat(ws) { heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } }, 30000); } // 服务端 ws.on('message', (raw) => { const msg = JSON.parse(raw); if (msg.type === 'ping') { ws.lastActive = Date.now(); ws.send(JSON.stringify({ type: 'pong' })); return; } // 业务处理 }); setInterval(() => { ws.clients.forEach(ws => { if (Date.now() - ws.lastActive > 60000) { ws.terminate(); } }); }, 30000);

4.2 打断生成的实现要点

用户点“停止”时,服务端要真正中断推理,而不是只停止推送。以常见的推理框架为例,需要维护一个 abort 控制器,收到停止信号时调用它:

const controllers = new Map(); ws.on('message', (raw) => { const msg = JSON.parse(raw); if (msg.type === 'stop') { const ctrl = controllers.get(msg.sessionId); if (ctrl) ctrl.abort(); } }); // 生成时 const ctrl = new AbortController(); controllers.set(sessionId, ctrl); try { for await (const token of model.stream(prompt, { signal: ctrl.signal })) { ws.send(JSON.stringify({ type: 'token', data: token })); } } catch (e) { if (e.name === 'AbortError') { ws.send(JSON.stringify({ type: 'stopped' })); } } finally { controllers.delete(sessionId); }

关键点:abort 要传到模型推理层,不能只断推送。否则 GPU 还在烧,成本照算,用户却以为停了。

4.3 SSE 与 WebSocket 混用的架构

实际生产里我越来越倾向于一种混合架构:主输出走 SSE,控制信号走一个轻量的 WebSocket 或独立 HTTP 接口。这样既保留了 SSE 的简单和低延迟,又拿到了双向控制能力。控制通道的数据量极小,WebSocket 的维护成本可以接受。

具体分工:

  • SSE 通道:模型 token 流、AG-UI 事件流。
  • 控制通道:停止、重试、切换模型、人工确认。
  • 状态同步:走 Redis 或内存,两个通道共享 session。

这种拆分让每条链路职责单一,排查问题也容易——流断了查 SSE,控制不灵查 WebSocket,互不干扰。

5. AG-UI 事件层的工程化落地

5.1 事件协议设计原则

设计 AG-UI 事件层时,我踩过最大的坑是事件粒度太细。一开始每个 token 都包一个事件对象,结果 JSON 序列化开销比模型推理还大。后来改成批量:文本内容按 20~50ms 或累积到一定字节数再发一帧,事件开销直接降了一个数量级。

事件设计遵循几条原则:

  • 可回放:每个事件带序号,前端能按序重组,丢帧可检测。
  • 自描述:事件类型和 payload 结构固定,前端不用猜。
  • 可扩展:预留自定义事件类型,业务特有事件不污染核心协议。
  • 幂等:同一事件重复到达不产生副作用,方便重连补发。

5.2 前端消费与渲染

前端拿到事件流后,核心是一个状态机。以文本流为例:

const state = { messages: [], currentMessage: null }; function handleEvent(evt) { switch (evt.type) { case 'text_message_start': state.currentMessage = { id: evt.messageId, content: '' }; state.messages.push(state.currentMessage); break; case 'text_message_content': state.currentMessage.content += evt.delta; scheduleRender(); break; case 'text_message_end': state.currentMessage = null; break; case 'tool_call_start': state.currentMessage.toolCall = { name: evt.name, args: '' }; break; case 'tool_call_args': state.currentMessage.toolCall.args += evt.delta; break; } }

scheduleRender用requestAnimationFrame节流,别每个 token 都触发一次 React 重渲染,否则长回答会卡。我一般按帧合并,一帧内多次更新只渲染一次。

5.3 工具调用与思考过程的展示

AG-UI 相比裸 token 流最大的价值,就是能把 Agent 的“内心戏”结构化展示。用户能看到:模型在思考、调用了搜索工具、拿到结果、再组织答案。这种透明感对建立信任很关键。

实现上,工具调用事件和文本事件走同一个流,前端按类型分发到不同 UI 组件。思考过程可以折叠展示,工具调用显示成卡片,最终答案正常渲染。注意工具调用的参数是流式到达的,前端要能处理“参数还没收全”的中间态,别急着解析 JSON,等tool_call_end再解析。

6. 常见问题与排查技巧实录

6.1 流式输出问题速查表

现象可能原因排查方向
本地正常,生产不流式Nginx 缓冲未关检查 proxy_buffering、X-Accel-Buffering
首字延迟高模型预热、网关缓冲分段打点,定位在哪一段卡住
中途断开 idle timeout读超时、心跳缺失调大 proxy_read_timeout,加心跳
断线后内容重复重连未去重用事件序号去重
长回答内存暴涨无背压控制检查 write 返回值,加 drain 处理
中文乱码编码未声明Content-Type 加 charset=utf-8
事件丢失缓冲溢出检查服务端发送队列长度

6.2 分段打点定位延迟

流式链路的延迟可能出在任何一个环节:网关、应用、模型、网络。我的做法是在关键节点打时间戳:请求到达、模型首 token、首帧发出、客户端首帧渲染。四个点一对比,问题在哪一目了然。首 token 慢是模型问题,首帧发出慢是应用缓冲,渲染慢是前端问题。

6.3 几个反直觉的坑

第一个坑:res.flushHeaders()要显式调用。有些框架不会自动把响应头发出去,导致客户端一直等不到流开始。Node.js 里发第一个数据前调一下,确保头部先走。

第二个坑:HTTP/2 下 SSE 行为不同。HTTP/2 多路复用,SSE 流和普通请求共享连接,某些代理对 HTTP/2 的流控处理有 bug,会导致流被莫名暂停。遇到诡异问题可以试试强制降级到 HTTP/1.1。

第三个坑:移动端后台会断流。App 切到后台,系统可能冻结网络,回来时连接已断。移动端要监听visibilitychange,回前台时主动重连并补拉历史。

第四个坑:日志别把整个流打出来。长回答的完整日志会撑爆日志系统,只记首尾和事件计数即可。

6.4 压测与容量估算

流式连接是长连接,容量估算和普通 HTTP 完全不同。一个 SSE 连接在生成期间会一直占着,假设平均生成 10 秒,单机要支撑 1000 QPS 的并发生成,就需要约 10000 个并发连接。这个量级下,文件描述符、内存、连接数上限都要提前调。

Linux 下几个关键参数:

ulimit -n 65535 # /etc/sysctl.conf net.core.somaxconn = 65535 net.ipv4.tcp_max_syn_backlog = 65535

压测时用工具模拟慢消费者,专门测背压路径,这条路径平时不触发,一触发就是事故。

7. 我在实际项目中的几点体会

流式架构这东西,协议选型只占 20% 的工作量,剩下 80% 都在处理边界情况:断线、超时、背压、重连、多端一致性。我见过太多团队花大力气选协议,结果上线后被 Nginx 缓冲和心跳超时搞得焦头烂额。

如果让我给一个最实用的建议:先把 SSE 跑通,把响应头、Nginx 配置、心跳、重连这四件事做扎实,再考虑要不要上 WebSocket 和 AG-UI。这四件事做不好,换什么协议都白搭。

另外,AG-UI 这类事件层的价值会随着 Agent 复杂度上升而放大。如果你的产品只是简单问答,裸 token 流足够;一旦涉及多步推理、工具调用、人工介入,早点把事件层抽象出来,后期改造成本能省很多事。我自己的项目是在第二个 Agent 功能上线时才补的事件层,回头重构前端的成本比一开始就设计高得多。

最后分享一个排查小技巧:遇到流式问题,先用curl -N直接打后端接口,绕过所有前端和网关。如果 curl 能看到流,问题在链路中间;如果 curl 也卡,问题在后端。这一步能帮你快速砍掉一半的排查范围。

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

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

立即咨询