从SSE到AG-UI:AI应用流式输出的架构演进与MCP实践

发布时间:2026/10/26 14:45:25
从SSE到AG-UI:AI应用流式输出的架构演进与MCP实践 做 AI 应用开发这几年我越来越确信一件事流式输出在 AI 时代不是可选项而是默认项。无论是对话、Agent、还是编辑器里的 AI 助手用户已经习惯了“打字机效果”——如果请求发出去后界面还在转圈等待超过两秒基本就会被判定为“不可用”。但流式输出这件事本身远不止后端用 SSE 把字符串推给前端那么简单。当你的系统里出现工具调用、多模态内容、富文本渲染、断线重连这些问题时裸 SSE 很快会捉襟见肘。这也是我这段时间从 SSE 逐步走向 AG-UI 的核心原因。这篇文章想把整条链路完整梳理一遍为什么 SSE 是当下最合适的传输底座它的边界在哪AG-UI 这类语义化事件协议解决的是什么问题以及结合 MCP 工具调用的生产实践我在落地过程中踩过哪些坑。适合正在做 AI 应用前后端架构、或者准备把 Agent 能力接入到自己产品里的开发者参考。1. 为什么说流式输出是AI应用的默认项1.1 非流式输出被淘汰的真正原因先聊一个最基本的体验问题。传统 REST 接口的做法是客户端发请求服务端等大模型完整生成完所有 token一次性把 JSON 返回。我测过 GPT 系接口的完整生成时间一段 500 字的回答在模型输出速度是 40 tokens/s 的情况下需要 12 秒以上。如果套上业务系统里的各种中间层、鉴权、网关用户感知的等待时常接近 20 秒。20 秒是什么概念2016 年的移动互联网性能报告中有一组常被引用的数据页面加载超过 3 秒53% 的用户会选择关闭页面。大模型应用虽然不是普通网页但用户的耐心阈值并没有因为“AI”两个字提高多少。用户点击发送之后屏幕上只有一句“正在思考”没有任何增量信息那种焦灼感是非常真实的。更麻烦的是非流式请求的超时问题。大模型生成时间本身就是一个不确定值你没法像处理普通接口那样设置一个合理超时。设短了经常误杀设长了网关、CDN 层又会先断。我见过不少团队最后不得不把超时设到 60 秒甚至更长然后用户就在空白界面干等一分钟——这个体验基本上劝退了所有非技术背景的来访者。而流式输出把“总等待时间”拆成了“首字等待时间 增量显示时间”。首字TTFT通常几百毫秒用户看到文字开始逐个出现大脑会自动预期“它在工作”整体等待感大幅下降。这背后其实是一套心理学机制人脑对持续反馈的容忍度远高于静默等待。1.2 流式体验的核心指标TTFT、TPS与完整性做流式架构一定要先把指标定清楚否则你都不知道后端改成什么样才算达标。我这里沿用业界常用的三件套TTFTTime to First Token从用户发出请求到收到第一个 token 的时间。这个值直接影响用户的第一感知建议控制在 1 秒以内。如果超过 2 秒用户大概率会刷新页面或者再点一次发送。TPSTokens Per Second模型输出速度。这个指标决定“打字机效果”的流畅度。TPS 太低文字逐个蹦用户会觉得卡TPS 太高UI 渲染可能跟不上需要限流缓冲。流式完整性也就是最终用户看到的内容是否和服务端生成的完整 token 序列一致。这个指标在断线重连、事件裁剪场景下特别容易出问题后面我会详细展开。在实际调优时TTFT 和 TPS 往往是矛盾的。TTFT 要求服务端尽快出发送请求到模型最好能透传用户的原始输入不做过多中间加工TPS 则受限于模型本身和网络链路。SSEServer-Sent Events在这种诉求下几乎是天然合适的——它是 HTTP 长连接服务端可以随时把 token 推给客户端不需要客户端轮询也不需要像 WebSocket 那样维护双向通道。1.3 AI Native 不是锦上添花是架构预设“AI Native”这个词被用滥了但我理解它落到架构层面的意思只有一个从设计第一天起就把 token 流作为系统的核心数据形态来对待而不是事后补救。举个例子。传统 Web 架构里渲染是由页面模板 数据 JSON 驱动的状态在客户端。AI 应用反过来——生成状态在服务端LLM 内部客户端只是 token 的消费者。这一反转带来一个连锁变化服务端不能只返回最终结果它必须持续地上报“过程”。过程包括模型正在思考哪个步骤、中间生成了哪些文本、是否要调用某个工具、工具执行到什么阶段、最终结果如何拼装。这些信息不是结构化数据库里的记录而是时间敏感的增量事件。如果架构预设了这种增量事件形态你自然不会去设计一个“等结果返回再渲染”的方案而是从第一行代码开始就用 Event Stream 来组织数据流。这也是我看到很多新项目直接选型 SSE 事件协议的原因——它们不是在“加一个流式功能”而是把整个链路建造成流式友好的。2. SSE最朴素也最稳的AI流式传输基座2.1 轮询、WebSocket与SSE的取舍逻辑当年做消息推送时团队面临三个方案轮询、WebSocket、SSE。我简单列一下当时测试对比的结果对比维度轮询WebSocketSSE连接开销高每次请求都要握手低但需要升级握手低一次握手长连接实时性差有延迟窗口最好好毫秒级推送服务端推送需要轮询才知道支持双向仅服务端到客户端断线重连天然支持重新请求需要自己实现原生支持带断点续传防火墙穿透最好差部分环境拦 upgrade 头好就是普通 HTTP协议复杂度无高低大多数 AI 对话场景是严格单向的客户端发一条用户消息服务端持续回传 AI 的响应。这个语义跟 SSE 完美匹配。WebSocket 的双向能力在 AI 场景里确实有用——比如客户端需要中途打断生成、或者向服务端发送额外的控制指令——但这些需求加一个单独的 REST 接口就够了没必要为 5% 的功能把整个链路复杂度提升一个量级。SSE 还有一个被低估的优势它基于普通 HTTP天然复用现有基础设施。Nginx 网关、负载均衡、鉴权中间件、日志采集体系全部可以直接工作。WebSocket 在这些环节几乎每一步都要特殊处理生产环境的故障排查成本明显更高。2.2 EventSource 的边界条件与变通方案前端对接 SSE 有原生 APIEventSource。用法很简单const es new EventSource(/api/chat/stream); es.onmessage (event) { const data JSON.parse(event.data); // 追加渲染增量数据 }; es.onerror (err) { // 触发自动重连EventSource 内部会处理 };但 EventSource 有两个让很多人抓狂的限制第一个限制只能用 GET 请求。这意味着如果业务需要 POST 传用户消息原生 EventSource 用不了。业界常规变通方案有两种一是把请求参数全部拼在 query string 里服务端能接受就完事二是放弃 EventSource改用fetch配合流式读取const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ messages }) }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); // 按 SSE 格式解析 chunk }第二个限制无法自定义请求头。EventSource 初始化时只能传 URL不能附加 Authorization header。鉴权方案只能退而求其次要么走 CookieSameSite 要配置好要么把 token 放 URL 参数——注意 URL 参数会进日志建议配置脱敏。如果选 fetch 方案同时也会失去两个 EventSource 原生能力自动重连 和 Last-Event-ID 断点续传。这两块需要自己实现后面踩坑清单里详细说。2.3 生产环境里三个绕不开的坑实践是检验真理的唯一标准。我把生产环境里最常遇到的三个问题列一下。代理缓冲问题。这是新手最容易踩的。你的服务端明明已经用res.write()推送了数据但前端等了十几秒才收到第一批内容。原因往往是 Nginx 开启了 proxy buffering把上游数据攒到一定量再统一转发。解决办法location /api/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header X-Accel-Buffering no; proxy_read_timeout 300s; proxy_http_version 1.1; }也可以让后端在响应头里主动加X-Accel-Buffering: noNginx 会遵从。压缩问题。很多人习惯给静态资源开 gzip但流式场景下压缩反而制造麻烦。因为 gzip 是有状态的流式压缩中间结果无法立即发送而且压缩上下文在代理层经常被重置导致前端解压错乱收到乱码。我的建议SSE 链路默认关闭压缩Content-Encoding: identity或者使用分块传输配合逐块压缩。如果传输内容过大优先保证稳定性再考虑压缩收益。中间层缓存问题。所有 CDN、API 网关默认认为 GET 请求是可缓存的如果错误地给 SSE 响应加了缓存头用户连接会拿到一个完整的旧响应完全失去流式效果。解决方案是在响应头显式加Cache-Control: no-cache, no-transform Connection: keep-alive Content-Type: text/event-stream这三样不配好SSE 在复杂的生产网络环境里会变得非常不可控。3. AG-UI把Token流变成UI能理解的事件流3.1 SSE解决不了的问题语义断层SSE 把字节流从服务端搬到了客户端但接下来的问题立刻浮现这些字节流在客户端怎么被识别、拆分和渲染最原始的方案是直接推纯文本。服务端每生成一个 chunk就data:这段内容推给客户端前端收到后塞进一个 div 里。这能跑通但系统很快就撑不住复杂场景生成的内容里既有正文又要穿插代码块、表格、图片前端怎么判断当前渲染模式AI 要调用一个工具比如查天气前端收到一段工具描述文本页面如何把它渲染成可交互的卡片多轮对话时消息如何从连续的流中切成单条记录存储如果一次请求需要更新多个 UI 区域比如答案区 引用来源区 状态区服务端如何表达这种需求纯文本流回答不了这些问题。于是大部分团队开始自研 JSON 事件协议定义type: delta | done | error之类的枚举给每个事件加 id 和 data。问题在于每家定义都不一样前端组件无法沉淀换一条业务线就得重来一遍。这就是语义断层的表现传输层有了统一标准SSE语义层还是一片混战。3.2 AG-UI 的核心设计语言AG-UIAgent-UI Protocol是 Vercel 在 AI SDK 的演进中提出的一套规范目标非常明确为 AI Agent 和 UI 之间的通信定义统一的事件格式。它不替代传输层而是把 SSE/WebSocket 传输的数据封装成结构化的事件流。先看一个简化的事件格式示意{ id: evt_123, command: assistant.message, payload: { role: assistant, parts: [ { type: text, text: 哈尔滨今天的天气是, isFinal: true } ] } }这套设计有几个很关键的点。第一命令模式Command Pattern。每个事件都携带一个command字段代表“服务端想要求客户端做什么”。UI 层可以看到 command 后决定如何渲染不需要自己去猜测文本语义。这跟 Redux 的 action 设计思路一致——事件源是唯一权威UI 是命令的消费方。第二Part 机制。单个消息的内容被拆成多个 part每个 part 有明确的类型text、tool-invocation、image、file 等。这解决了我前面提到的“一段流里既有文本又有工具调用”的难题。工具调用不再是文本的附属而是独立的结构化 part前端可以拿到完整参数渲染成卡片。第三快照Snapshot机制。断线重连时客户端需要恢复完整的消息树而不是从零开始重建。AG-UI 的会话快照字段把所有已完成的消息序列化打包客户端重连后可以一次性拿到当前完整状态再增量接收新事件。这个机制让我重连逻辑的代码量直接砍掉一半。我把 AG-UI 与传统 JSON 事件对流的场景做个对比场景传统方案AG-UI 方案文本增量自定义 delta 字段各家不齐标准 text part工具调用渲染前端需要解析工具描述文本标准 tool-invocation part参数结构化断线恢复自己缓存消息重建状态快照直接同步多页面复用每个团队写一套解析器协议统一组件可沉淀3.3 一次典型对话的 AG-UI 事件序列为了让你直观感受整个链路长什么样我写一个典型的 Agent 对话事件序列简化示意SSE 的data:行里包 JSON。用户问“北京今天适合穿什么”data: {id:1,command:session.created,payload:{sessionId:s_123}} data: {id:2,command:user.message,payload:{role:user,parts:[{type:text,text:北京今天适合穿什么,isFinal:true}]}} data: {id:3,command:assistant.message,payload:{role:assistant,parts:[{type:text,text:我来查一下北京今天的天气。,isFinal:true}]}} data: {id:4,command:assistant.message,payload:{role:assistant,parts:[{type:tool-invocation,toolCallId:call_1,toolName:getWeather,args:{city:北京},state:running}]}} data: {id:5,command:assistant.message,payload:{role:assistant,parts:[{type:tool-invocation,toolCallId:call_1,toolName:getWeather,args:{city:北京},state:completed,result:{温度:5-12℃,风力:3级,天气:晴}}]}} data: {id:6,command:assistant.message,payload:{role:assistant,parts:[{type:text,text:北京今天晴气温 5-12 度风力不大。建议穿薄羽绒服或厚外套加毛衣。,isFinal:true}]}}注意第 4 和第 5 条同一个 tool-invocation part 被推送了两次第一次是 “running” 状态第二次是 “completed” 状态加执行结果。前端看到 running 时可以立刻渲染一个“正在查询天气”的动态卡片等拿到 completed 再把结果填入卡片。用户不会感觉到卡顿因为这个状态本身就是持续可见的。如果沿用裸 SSE 纯文本方案这段流程里的第 4、5 条根本无法表达——前端只会看到一长串拼接文本很难拆出工具调用和最终答案的边界。4. 工具调用场景的流式架构从MCP到落盘实践4.1 工具调用对输出链路的冲击当 AI 应用从纯对话走向 Agent 形态最显著的变化是LLM 的输出不再是单纯文本而是一棵行为树。模型可能多次调用工具、根据结果调整策略、最终生成一段总结。整条链路上的中间过程都需要以流的方式暴露给客户端。这里有个反常识的经验很多人以为工具调用很快其实工具执行往往成为整个链路里最慢的环节。外部 API 调用可能 3-5 秒文件操作可能需要十几秒这些延迟用户是感知得到的。如果架构在工具调用阶段断流——也就是 UI 卡在等待、没有中间反馈——前面的流式优化就前功尽弃了。正确的做法是把工具调用的状态机也流式化pending - running - completed(failed)每个状态变化都推送给前端。这正好与 AG-UI 的 tool-invocation part 设计完全契合。4.2 一个可复现的 MCP 流式落盘方案MCPModel Context Protocol现在已经是 Agent 工具接入的主流协议。MCP 工具调用本身支持 SSE 传输这意味着工具的执行过程可以和服务端推送给客户端的事件流无缝衔接。拿一个比较火的使用场景举例让 AI 通过 MCP 工具把流式输出内容写入本地文件。你可以把它理解为“让 AI 一边作答一边存档”或者更直白一点——AI 生成的长文档不需要等全部写完再一次性保存而是生成一段就落盘一段。具体流程我拆开写客户端建立 SSE 连接发送用户请求。服务端把请求交给 LLMLLM 的 tool calling 机制判断需要调用 MCP 的 filesystem 工具。服务端向 MCP Server比如本地的文件写入服务发起工具调用同时在 SSE 链路里推送tool-invocation事件state 为running。MCP Server 开始执行写文件操作工具内部每完成一个 chunk 写入服务端就更新一次事件状态或推送进度信息。工具执行完毕服务端推送tool-invocation的 completed 状态带上写入结果文件路径、字节数等。LLM 收到工具结果后继续生成最终总结文本文本增量通过 text part 推给前端。在这条链路里MCP 工具的身份是“服务端执行器”SSE 全程把执行过程透传给客户端。用户在 UI 上能看到类似“正在写入文件... 已写入 1234 字节”的进度反馈而不是干等。部分 MCP 客户端比如 Cherry Studio 这类桌面工具已经支持在配置 MCP server 之后把大模型流式输出的内容直接定向到 MCP 文件工具落盘。这种做法的本质就是用 MCP 统一工具协议用 SSE 统一流式通道。两边各司其职组合起来就是一个非常完整的 AI Native 输出链路。4.3 客户端消费状态机的设计思路如果客户端想正确处理上面的流程光靠onmessage拼接字符串是不够的需要一个轻量状态机。我实践下来的设计是维护一个MessageStore结构大致如下interface MessageStore { messages: Mapstring, Message; activeToolInvocations: Mapstring, ToolInvocation; updatePart(messageId: string, partId: string, patch: PartialPart): void; }事件流进来之后按 command 分发user.message/assistant.message新增或更新消息记录text part如果 partId 不存在则新建否则追加文本增量tool-invocation part按 toolCallId 定位记录更新其 state、resultsession.updated用快照整体替换本地状态这个状态机的最大好处是UI 渲染层不感知事件协议细节。组件只需要订阅 MessageStore 的某个消息的 parts 变化其他事情由状态机内部消化。比如我在 React 里会用useSyncExternalStore订阅 store前端组件代码里只剩下声明式的渲染逻辑事件解析逻辑全部集中在 store 层。5. 从SSE到AG-UI的演进路线与踩坑清单5.1 三步走的演进路线不是所有项目都需要一步到位上 AG-UI。我建议按三个阶段渐进每阶段都能独立上线阶段一裸 SSE 文本流。服务端把每个 token chunk 直接推成纯文本data:行前端拼接显示。这一步适合验证模型链路和基本体验代码量最小。但要注意上线前务必定义好错误事件格式和结束标志不然后续兼容很难做。阶段二自研 JSON 事件协议。在 SSE 里包装 JSON定义好事件枚举如text_delta、tool_start、error等、消息 ID、时间戳。大部分业务在这个阶段可以解决 80% 的问题但前提是团队有人力维护协议文档并且接受跨项目复用时可能出现的差异。阶段三AG-UI 标准化。如果团队有多条业务线共享前端组件或者后续要开放能力给第三方开发者建议直接走 AG-UI。标准协议带来的最大收益是生态——你不需要给每个新项目的人重新讲一遍事件格式。演进中我特别强调一点不要频繁改协议。每改一次老客户端全部要兼容数据回放、日志检索规则全要跟着动。协议变更应该像 API 版本管理一样走完整的 release 流程。5.2 兼容与降级策略在实际生产中你没法保证所有客户端都第一时间升级到新协议。我采用过一个比较务实的兼容方案服务端同时支持两套事件格式通过请求头或 query 参数区分客户端能力。GET /api/chat/stream?protocolv1 GET /api/chat/stream?protocolagui服务端在内部保持一个事件模型与 AG-UI 对齐根据协议版本做序列化适配。v1 客户端收到自己的旧格式agui 客户端收到标准格式。这样新老版本可以同时在线迁移过程不再需要停机窗口。如果前端的事件解析逻辑分叉太大也可以用适配器模式写一个AguiToLegacyAdapter把所有 AG-UI 事件转换成旧客户端能理解的格式。这样服务端可以只维护一套输出前端做协议转换。5.3 踩过的坑清单最后把我在生产实践中实际踩过的坑整理成清单这些几乎都是文档里不会写的。断线重连丢消息。SSE 原生支持 Last-Event-ID 重连但前提是你的服务端每条事件都带了合法的id:字段并且事件落库保存。我用 AG-UI 之后服务端会把每个事件写入持久化队列客户端重连时把 Last-Event-ID 带回去服务端从该 ID 之后开始回放。如果当时没保存事件重连之后就只能拿到快照会丢失中间的增量过程。心跳机制必须做。虽然 SSE 规范支持注释行:ping作为保活信号但很多网络中间层会拦截空闲连接。我一般 15 秒发一次心跳注释同时在客户端设置 30-45 秒的静默超时检测超过阈值强制重连。代理层事件撕裂。如果消息体过大Nginx 可能把一次事件拆成多个 packet前端如果直接按\n\n切分事件就会解析失败。正确做法是用TextDecoder配合stream: true累积缓冲每次只消费完整的事件块。这块代码我建议作为公共库沉淀不要每个项目重写。移动端锁屏断连。App 退到后台或者锁屏操作系统可能直接杀掉 SSE 连接。这个无解只能在客户端做前后台切换检测回到前台时立即检查连接状态并重连配合服务端快照恢复状态。多标签页重复消费。同一个用户开多个标签页每个标签页都会建立独立的 SSE 连接同一个事件会被消费多次造成服务端资源浪费和日志重复。我实践中的方案是只允许一个活跃连接用 localStorage 或 BroadcastChannel 做互斥其他标签页通过 BroadcastChannel 接收事件副本。事件语序不是严格有序。跨实例部署时同一会话的请求可能被路由到不同后端实例事件到达顺序偶尔会错乱。我在 AG-UI 事件里额外加了一个 monotonic sequence 字段客户端做严格排序后再写入状态机。这个字段在跨地域部署时特别重要。我在实际使用 AG-UI 规范之前也纠结过“要不要自研一套”这个问题。最终选定标准协议的原因很朴素协议的价值在于连接双方而不在于协议本身有多先进。当你的团队需要入职新人快速接手、需要跨项目复用组件、需要考虑未来接入更多 Agent 客户端时AG-UI 这种行业级规范带来的“约定大于配置”优势会成倍放大。如果你也在做 AI 应用我建议从简单场景开始先把传输链路用 SSE 跑通再逐步把事件语义往标准协议上靠——这条路走下来你会发现整个流式输出的架构会越来越清晰而不是越改越复杂。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询