最近在梳理生成式AI应用的工程链路时,我发现自己和团队花了整整一个迭代去处理同一个问题:模型输出的token怎么才能又快又稳地到达用户屏幕,同时还能在执行工具调用时把结果同步给前端。这个问题看着简单,但一旦涉及Agent、MCP、长任务写入,传统的HTTP请求-响应模型就撑不住了。于是我们顺着SSE走到了AG-UI。这篇文章会从SSE的边界讲起,说清楚AG-UI解决了哪些真实痛点,然后给出一套可以直接参考的生产级流式输出架构,包括MCP工具流式输出内容到文件的落地细节,希望给正在做AI应用前后端、搭建Agent平台,或者准备把AI能力接入业务系统的同学一些可复用的经验。
1. 为什么要重新设计流式输出:SSE时代的边界与局限
1.1 从轮询到长连接:SSE如何成为AI流式输出的第一站
聊AG-UI之前,得先回到SSE。SSE的全称是Server-Sent Events,中文常叫“服务端单向推送”。它的工作方式很简单:浏览器通过EventSource发起一个HTTP长连接,服务端用text/event-stream格式持续返回事件,客户端一直接收,直到连接关闭。相比WebSocket,它最大的优势是基于HTTP,天然支持自动重连、消息ID追认和缓存语义,不需要额外握手协议,部署和调试都很轻。
这正好贴合大模型应用初期的诉求。模型输出是单向、松散的文本流,客户端不需要频繁上行消息,只需要不断接收增量。于是SSE几乎成了AI流式输出的默认起点。早期实现思路千篇一律:后端调模型SDK拿到流式chunk,拼成data: {"delta":"hello"}\n\n发出去,前端用fetch reader边读边渲染。
这个阶段能跑通,但离“架构设计”还有距离。第一个暴露的问题是消息格式没有统一标准。SSE协议本身只定义了data字段,里面塞什么完全由业务自己说了算。团队A用data: {"type":"token","value":"..."},团队B用data: {"code":200,"data":{"text":"..."}},前端为了适配不同后端,只能各自写一套“暗号解析器”。一旦Agent系统开始复杂,整个链路就会变成拼维护成本。
1.2 原生SSE在Agent场景中的三个硬伤
随着AI应用从纯聊天走向Agent化,SSE的不足变得非常具体,我归纳成三个硬伤。
第一个是事件语义太弱。Agent场景下,流里不仅要传文本,还要传工具调用参数、工具执行结果、进度信息、错误信息、引用来源。SSE只给了一个data字段,增量文本和工具调用参数混在一起,前端必须自己拼装、判断边界。举个例子,一个write_file工具的参数往往是流式到达的JSON片段,{"path":"/tmp/a.txt","content":"开头几个字"...}可能分成十几次才发完。前端要维护一个缓冲区,一边等参数完整,一边决定何时触发工具调用渲染。这个逻辑写在业务代码里,只会越滚越复杂。
第二个是断线重连的恢复成本高。EventSource确实提供了Last-Event-ID重连机制,但服务端是否记录消费位置、是否支持从中间恢复,完全取决于业务实现。模型流式响应通常是一次性管道,断点之后没法接着生成。更麻烦的是,工具已经执行了一半,客户端断线重连后不知道该不该重新执行,很容易出现重复写文件或者漏掉结果。
第三个是聊天框之外的场景表达不出来。AI Native应用不只是聊天框:自动化工作流、编辑器补全、Agent调用MCP工具写文件、多Agent协作,每一步都是一个事件。SSE本质是单向文本推送,没有状态、没有能力协商、没有消息类型约束。越往后做,越能感觉到“给协议打补丁”不如“引入面向AI交互的协议”。
2. AG-UI到底是什么:面向AI Agent的统一消息协议
2.1 AG-UI的核心消息模型:增量、工具、状态一次说清
AG-UI,全称Agent User Interface,是一套面向“AI Agent与用户界面之间通信”的开放消息协议,近年在Vercel AI SDK生态里被大量讨论和落地。它的核心思路是把一次模型交互拆成多种语义明确的消息类型,包括文本增量、工具输入增量、工具结果、推理过程、完成标记等。消息本身用JSON表示,底层可以用JSON Lines、SSE或WebSocket承载,重点是“事件契约”统一了。
你可以把AG-UI理解成“前端UI能直接消费的数据契约”。服务端不再发一个自造的JSON,而是发送协议约定的事件;前端也不需要针对每家模型服务商写解析逻辑。协议里几个关键消息类型非常贴近AI场景:
- 文本增量(text-delta):模型新生成的一段文本,前端直接追加显示。
- 工具输入增量(tool-input-delta):工具调用的参数以流式片段到达,前端可以边收边渲染。
- 工具结果(tool-result):工具执行完成后返回的结构化结果。
- 推理过程(reasoning):模型思考链或摘要,适合做过程展示。
- 完成标记(finish):本次交互结束,前端可以收尾。
这种拆法最大的价值在于“模型输出与UI更新的形状对齐”。SSE只定义了data,AG-UI则定义了一套可组合的消息类型。前端拿到text-delta就追加文本,拿到tool-result就渲染工具卡片,逻辑清晰,也不会因为模型供应商切换而崩掉。
2.2 相比SSE,AG-UI解决了什么关键问题
对比SSE,AG-UI的改进不在传输层,而在消息契约层。它带来三个直接收益。
第一,前端渲染与后端模型解耦。今天接GPT,明天换Claude,后天接本地模型,只要后端把模型输出规范成AG-UI消息,前端代码几乎不用动。对团队来说,这意味着模型选型切换的边际成本大大降低。
第二,工具调用成为一等公民。在SSE方案里,工具调用往往被塞进自定义字段,前端要自己判断“这是文本还是工具参数”。AG-UI把工具调用拆成增量事件,拼装逻辑可以前置到网关或SDK里,前端只需要按协议处理。这正好衔接MCP工具生态,Agent可以调用写文件、查数据库、发HTTP请求等工具,而每一步都通过标准消息推给前端。
第三,支持多模态与富UI。文本、图片、音频、文件输出都能以标准消息形态出现。对MCP工具来说,写文件时可以把进度实时推给用户,体验从“黑盒等待”变成“可视化执行”。
需要提醒的是,AG-UI目前仍在快速演进,不同实现之间字段存在细微差异。生产落地时建议锁定协议版本,并在网关层做字段适配,避免上下游各自升级导致消息不兼容。
3. 生产级AI Native流式输出架构的分层设计与关键环节
3.1 分层架构:接入层、编排层、模型网关与工具层
重新设计AI Native流式输出架构时,我习惯把它拆成四层,每一层职责单一,避免模型和前端直接耦合。
接入层面向浏览器和客户端,负责序列化与连接生命周期管理。它可以是SSE、WebSocket或AG-UI JSONL,核心任务是把内部事件按客户端协议输出,并处理心跳、断线、取消等底层问题。编排层接收上游消息,维护会话状态,执行思维链和规划。它只处理业务语义,不关心底层是哪个模型。模型网关统一封装各家模型API,负责模型调用、超时、重试和流式增量规范化。工具层就是MCP Server/Client、HTTP工具、内部RPC,负责执行真实动作,比如写文件、查数据库。
AI Native不是把模型API直接暴露给前端,而是围绕这四层提供可观测、可治理的通道。这里最容易被低估的是模型网关。不同模型对工具调用的流式格式差异非常大:有的模型先发工具名称再发参数,有的直接发完整的arguments JSON,有的会用多个增量片段拼成一个JSON。网关如果不做缓冲区归一化,下游AG-UI消息就没法稳定。这块我在项目里花的时间最多,但也是整个架构里性价比最高的部分。
3.2 增量缓冲与背压控制:让流式输出可回放、可限速
流式架构里第二个常见问题是“太快”。模型输出快,并不代表客户端消化得了,也不代表下游写文件跟得上。这里需要两个配套组件:增量缓冲区和背压控制器。
增量缓冲区把模型发来的chunk暂存,再按语义完整性拆分或合并。比如工具调用参数没到齐时,先把片段存在缓冲区,等到可以拼成一个合法JSON后再发tool-input-delta。这能避免前端拿到一堆半截参数却没法处理。背压控制可以理解成一个阀门:当消费者处理速度跟不上时,服务端主动降速或改为低频批量发送,防止连接堆积。实现上,我会在网关层放一个有界队列,队列水位超过阈值就暂停拉取模型流,等下游消费后再继续。与其无脑转发,不如让每一步都有明确消费进度。
另一个容易被忽视的设计是“可回放”。不是所有场景都需要,但生产环境建议至少保留最近N条已发送消息,供客户端断线重连后快速补偿。这和SSE的Last-Event-ID思路一脉相承,只是到AG-UI下消息类型更多,补偿时需要“会话快照+增量事件”一起处理。
3.3 工具调用的流式写入:MCP工具输出内容到文件的落地方案
这是最近被问得很多的一个场景,特别是Cherry Studio这类客户端支持自定义MCP服务后,“用MCP工具流式输出内容到文件”成了高频需求。简单说,就是Agent在生成内容的同时,调用一个write_file之类的MCP工具,把流式内容分块写入目标文件,并把进度反馈给前端。
完整链路一般长这样:
- Agent规划后产生一个
write_file工具调用,参数里包含目标路径和内容来源。 - 服务端先发送
tool-input-delta,前端立刻显示“准备写入文件”。 - 工具层开始接收模型文本流,按固定块大小追加写入文件。每写完一块,通过MCP协议返回进度,服务端把进度包装成AG-UI消息发给前端。
- 全部写入完成,工具返回包含文件路径、大小、最终状态的
tool-result。
实操中有两个容易翻车的点。第一是幂等性:如果网络中断、客户端重连后重复请求,工具会不会重复写文件?我习惯给每次工具调用生成唯一runId,写文件前先检查目标文件是否存在同名锁文件,存在就拒绝或续写。第二是并发控制:多个Agent实例同时写同一个路径,必须避免互相覆盖。可以引入文件锁,或者在路径里注入会话ID。配置MCP Server时,超时参数也要调大,长文件写入不可能秒级返回。
3.4 超时、重连与幂等:生产环境的稳定性保障
流式接口和普通接口不一样,不能用“请求超时5秒”来管理。一个Agent可能规划10秒、调工具20秒、写文件30秒,但用户看到的内容一直是连续推进的。所以超时要拆成“空闲超时”和“总时限”两类。
空闲超时指超过多少秒没有任何事件返回,就判定链路卡死;总时限是整个请求允许的最大时长,达到后服务端发标准错误消息并关闭流。这两类超时最好分别配置,不能混为一谈。客户端重连时,我建议带上会话ID和最后一条消费序号,服务端根据序号补偿漏掉的事件。如果序号已经过期,可以降级为“重建会话”并清空前端旧状态,避免渲染错乱。所有写入类工具调用,入参里一定要加requestId,服务端记录执行状态,重放前先查询状态,防止同一个文件被写两遍。
4. 实操:从SSE升级到AG-UI的落地步骤与核心代码
4.1 服务端实现AG-UI的JSONL流式响应
这里用Node.js的Express写一个最小编程示例,展示AG-UI服务端应该如何组织响应。核心思路是每一行写一个完整JSON对象,客户端按行解析。
app.post("/api/chat", async (req, res) => { // 使用JSON Lines格式,每行一个JSON对象 res.setHeader("Content-Type", "application/x-ndjson"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.flushHeaders?.(); const sessionId = crypto.randomUUID(); res.write(JSON.stringify({ type: "session", sessionId }) + "\n"); const stream = await model.stream({ messages: req.body.messages, tools: [{ name: "write_file", description: "写入文件" }], }); for await (const chunk of stream) { if (chunk.type === "text") { // 把模型增量转成AG-UI文本增量消息 res.write(JSON.stringify({ type: "text-delta", delta: chunk.text }) + "\n"); } if (chunk.type === "tool_call") { const parsed = normalizeToolCall(chunk); if (parsed.ready) { res.write(JSON.stringify({ type: "tool-input-delta", toolCallId: parsed.id, delta: parsed.argsText }) + "\n"); } } } res.write(JSON.stringify({ type: "finish", sessionId }) + "\n"); res.end(); });这段代码展示了从模型chunk到AG-UI消息的基本转换。实际工程里还要处理工具执行循环、错误事件和取消信号,建议把消息序列化封装成独立模块,别在业务回调里拼JSON,后续维护会轻松很多。
4.2 前端如何消费和渲染流式增量
前端侧,fetch本身可以读取流式响应。通过ReadableStream不断按行拆包,根据消息类型分发。核心逻辑不复杂,但要注意渲染方式。
const resp = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ messages }), }); 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 }); let idx; while ((idx = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (!line) continue; const msg = JSON.parse(line); switch (msg.type) { case "text-delta": appendText(msg.delta); break; case "tool-input-delta": showToolProgress(msg); break; case "tool-result": renderToolResult(msg); break; case "finish": finalize(); break; } } }这里的关键是“增量渲染,而不是全量重绘”。文本增量直接追加到当前段落;工具状态放在一个Map里,按toolCallId更新对应卡片。这样能避免大段内容频繁重建DOM,用户滑动时也不会被重置位置。
4.3 与MCP工具联动:流式输出内容到文件的完整示例
需要强调,下面这段是框架无关的伪代码,但覆盖了“流式读取内容+分块写文件+进度上抛+工具结果返回”四个核心环节。
async function runWriteFileTool(toolCallId, params, onProgress) { const targetPath = params.path; const stream = createContentStream(params.contentFrom); let offset = 0; // 先获取文件锁,防止并发覆盖 await acquireFileLock(targetPath); for await (const chunk of stream) { await appendFile(targetPath, chunk); offset += chunk.length; onProgress({ type: "tool-event", toolCallId, stage: "writing", bytesWritten: offset, }); } releaseFileLock(targetPath); return { type: "tool-result", toolCallId, result: { path: targetPath, size: offset, ok: true }, }; }onProgress回调里把进度转成AG-UI消息发到前端,用户就能看到“正在写入XX字节”的实时进度。需要特别注意的是appendFile要保证原子性和序列化,不能并发写同一个文件。如果配置的MCP Server只返回普通JSON而不是流式,也没关系,网关可以把一次返回拆成“开始、进行中、完成”三条协议消息,照样能模拟流式体验。
4.4 新旧协议共存时的兼容策略
生产系统不太可能一夜之间全切AG-UI。比较稳妥的做法是在网关层做协议适配:内部统一用AG-UI风格的事件对象,对外按客户端能力分别输出SSE或AG-UI JSONL。判断逻辑可以很简单,客户端请求头带Accept: application/x-ndjson就返回AG-UI,否则退回SSE。
这样升级成本被控制在网关一个模块,旧客户端不需要改。迁移期间比较容易踩的坑是维护两套协议后,SSE补全逻辑和AG-UI补全逻辑不一致。解决办法是让两套协议共用同一个“事件源”,只在序列化层分流,绝不在业务层分流。同时,协议适配层一定要做监控埋点,记录消息类型、数量、耗时和断线次数,这些数据对后续优化特别有用。
5. 常见问题与排查实录
5.1 断线重连后事件丢失,如何做可靠性兜底
现象很典型:用户弱网环境下,前端EventSource重连后,工具结果丢了,页面一直转圈。排查后发现服务端没有保存Last-Event-ID对应的缓冲区,重连后直接从新请求开始,之前的事件自然找不到。
解决思路是给每个会话分配连续序号,客户端断线带着最后消费序号,服务端从Redis里拉取该序号之后的事件补发。缓冲区可以设置过期时间,比如5分钟,超时返回session_expired,让前端重建会话并提示“历史内容已失效”。这里的关键不是依赖某个协议自带的重连,而是把“可恢复”当成一等需求提前设计。
5.2 工具结果与文本增量乱序,如何处理时序
另一个高频问题是乱序:Agent先调用工具,但文本增量还在继续发,前端一会儿显示文本,一会儿显示工具结果,卡片顺序错乱。根因通常是模型输出本身多路并发,或者网关在转发时存在异步竞争。
我的解决方法是引入“时间线水位线”:每条消息带递增序号,前端收到后先放入待渲染队列,等序号连续时再统一提交渲染。实际不需要等所有消息到齐,只要保证每个消息类型的局部顺序稳定即可。更简单的做法是按工具调用ID分组,同一工具的增量按时间线性展示,不同工具之间允许乱序。这两种方案都能有效减少渲染错乱。
5.3 高并发下SSE连接内存暴涨,怎样做资源治理
压测时遇到过一个典型问题:几千个SSE连接直接把Node进程内存打满。分析后发现三个原因:第一,每个连接都持有一个模型流引用,模型端没有及时关闭;第二,没有设置连接空闲超时,僵尸连接一直挂着;第三,数据发送速度比客户端消费速度快,服务端写缓冲不断堆积。
对应策略是三件事:在网关层加有界队列和背压,队列满了就暂停拉取模型流;给所有连接设置心跳和空闲超时;客户端断线时立刻通过AbortController取消模型流,做级联取消。这套组合下来,内存曲线变得平滑很多。
5.4 监控指标与压测经验:流式架构上线前必查清单
最后分享一份上线前必查的指标清单:
- 首字节时延(TTFB):从请求发起到客户端收到第一个消息的时间,最好小于500ms。
- 平均token间隔:模型流中相邻token的到达间隔,用来判断模型网关是否成了瓶颈。
- 事件积压数:有界队列当前堆积的消息数量,接近阈值要告警。
- 重连率:单位时间断线重连次数,突增通常意味着网关或网络异常。
- 工具成功率与工具耗时:写文件类工具尤其盯P99耗时。
压测别只测“并发连接数”,要测“流持续30秒以上的并发”。一秒钟就结束的请求和多轮工具调用持续1分钟的请求,资源占用完全不是一个量级。用真实Agent场景去压测,数据才有参考价值。上线初期,我习惯给流式接口设置全局最大并发数,超出直接排队,避免雪崩。
我个人在实践中最深的体感是:协议选择只是水面上的部分,真正的工程量在事件契约、缓冲、背压和可恢复性。AG-UI让我少写了很多“前端对暗号”的解析代码,但它不会自动让架构变稳。如果你正打算从SSE升级,建议先把内部消息模型定义清楚,再决定传输层用哪套协议;如果你在做MCP工具服务,优先把文件写入的幂等和进度上报做好,比纠结用什么事件格式更重要。后续如果再扩展,我会把多模态事件和本地缓存补偿也纳入这套架构,持续补课。