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 也卡,问题在后端。这一步能帮你快速砍掉一半的排查范围。