最早接触 AGUI 这个名词,是在研究一个 Rust 编写的 Agent 框架的源码。当时我脑子里最直接的疑问是:LLM 生成的界面,究竟该以什么形态交给前端?过去常见的是让模型吐 Markdown,前端套个预览框;后来不少项目进化到让模型直接吐 HTML;再后来一些 Demo 里甚至让模型生成整段 React 组件代码。听起来都很酷,但只要你真接一个业务场景进去就会发现,这条路越走越窄——安全、性能、交互三方面全是坑。
AGUI 协议和 Data Stream Protocol 提供的是另一种思路:它们不把 Agent 生成的结果当成“代码”,而是当成“数据”。Agent 不直接决定页面上跑什么脚本,只负责描述“这里有一张卡片、那里有一个图表、文本正在追加”。前端通过一条持续打开的流,像瀑布一样把界面组件一块接一块地渲染出来。这篇文章我从接入者的角度,把这条链路从设计动机、协议格式、前端消费到实际踩坑完整过一遍,希望能给正在做 Agent 应用或前端集成的朋友一些实际参考。
1. 为什么让 Agent 手写 HTML 这条路走不通
很多人第一次听到 AGUI 的时候会问:我不是已经有 MCP 了吗?让 Agent 调工具、拿数据,最后把结果拼成 HTML 塞给前端不就行了?这个问题恰恰点中了要害——MCP 解决的是 Agent 和“后端世界”的连接,而 Agent 和“前端界面”之间还缺一层东西。这一节我们先说清楚,为什么 HTML 直出方案在 Agent 场景里基本是死路。
1.1 “执行代码”和“消费数据”天然是两套安全模型
如果前端直接把 Agent 生成的 HTML 塞进页面,哪怕塞进 iframe,本质上也是在执行一段外部输入。HTML 不像 JSON,它自带触发能力:<img>会发出网络请求,<style>会改变页面全局样式,更别提内联<script>和各类事件属性。你的页面只要渲染了这么一段内容,等于把一个完全不受控的执行环境暴露给了模型输出。
你可能说:我限制模型不生成<script>不就行了?但在实际链路里,真正的问题不是模型“主动作恶”,而是模型会被 Prompt 注入影响。只要你的 Agent 在处理用户输入时,把某些外部文本拼进了上下文,就有可能在输出里被诱导生成恶意 HTML。
AGUI 的思路是把风险整体降维:模型输出的不是 HTML,而是一棵组件描述树——某个节点是什么组件、有哪些属性、子节点是什么。前端 SD K只认这份描述,所有渲染动作都在本地受控环境中完成。没有内联脚本,没有资源自动加载,连 style 也只接受白名单内的属性。外部输入的风险从“任意代码执行”降到了“受控数据校验”,这就是协议层带来的根本区别。
1.2 整树替换与增量补丁,决定了流式体验的生死
LLM 的 token 是一个一个蹦出来的,生成一个页面可能要几十轮输出。如果你的方案是“每生成完一段就整树替换一次”,那用户看到的画面会像幻灯片一样不停闪烁。更致命的是,整树替换会破坏所有用户交互状态:滚动位置被重置、输入框的焦点消失、正在展开的折叠面板被收起。
真正可用的 Agent UI 必须是“增量”的。每次模型吐出新内容,前端只需要在原来的组件树上打一个补丁:这里多了一个文本节点,那里更新了一个 props,某个列表尾部追加了几行。这个过程非常像你在写代码时用 Git 提交补丁——不重写整个仓库,只提交变化的部分。
Data Stream Protocol 运送的正是这种补丁。它把界面当成一棵有生命周期的组件树,所有改动都通过流式消息表达。所以你在使用老式“HTML 直出”方案时感受到的闪烁、跳动、状态丢失,本质上不是网络或者渲染引擎的锅,而是方案层面就错了——你根本不该整树替换。
1.3 事件回传与状态分隔,是界面协议必须回答的问题
UI 不只负责展示,用户会和它交互。Agent 生成的表单需要接收输入,按钮需要被点击,图表可能需要 hover 查看详情。如果模型只是给你一段 HTML,事件怎么回传?让前端自己去解析结构然后猜行为吗?显然不现实。
协议层必须把事情说清楚:
- 节点身份要稳定。每个组件在流里有一个唯一 ID,前端靠它定位并做增量更新。
- 事件要结构化。渲染出来的按钮需要一个回调标识,用户点击后前端把结构化消息回传给 Agent,由 Agent 决定下一步动作。
- 状态要划边界。哪些状态属于 Agent(组件结构、内容、业务属性),哪些状态属于前端(表单输入、滚动位置、折叠状态),如果不划清楚,Agent 每次发来一次属性更新,都可能把用户正在填写的表单值直接冲掉。
所以 AGUI 本质上是一次“权力的再划分”:生成归 Agent,渲染归前端,状态管理在协议边界处被严格约束。
2. Data Stream Protocol 里流淌的到底是什么
聊完了为什么,我们进入协议本身。这一节我想把 Data Stream Protocol 拆开,看看一条流里面到底运的是什么形式的“数据”。
2.1 这是一条组件树的增量日志,不是分段的 HTML
我最想强调的一点是:不要把 Data Stream Protocol 理解成“把 HTML 分段送过来”。它更接近一种实时的结构化对象流。底层承载可以用 SSE(Server-Sent Events)、NDJSON,或者 WebSocket,但本质都一样:流上的每一个消息都是一个带有类型字段的 JSON 对象,描述了组件树上的一处操作。
我剥一段很典型的流数据片段出来,方便直观感受:
{"type":"mount","id":"card-1","component":"Card","props":{"title":"分析结果"},"parent":null,"seq":12} {"type":"mount","id":"chart-1","component":"LineChart","props":{"data":[]},"parent":"card-1","seq":13} {"type":"text","id":"title-text","parent":"card-1","text":"正在计算…","seq":14} {"type":"update","id":"chart-1","props":{"data":[1,2,3,4]},"seq":20} {"type":"text","id":"title-text","append":",预计还需 12 秒","seq":21}你可以看到,消息里没有一段“完整页面”,也没有任何“可执行代码”,只有对节点生命周期的操作指令。前端要做的事情很简单:挨个消费这些消息,在自己维护的组件实例树上执行对应操作。这是一个完全确定性的过程——同一条数据流,换任何前端渲染器来消费,结果都应该一样。
2.2 四条核心指令:mount、update、unmount、text
要把一棵动态组件树讲清楚,其实四条指令就够了。下表是我在接入过程中对指令集的总结:
| 指令 | 作用 | 典型载荷 | 注意事项 |
|---|---|---|---|
mount | 新建一个组件节点 | 组件名、props、父节点 ID | 父节点必须已经存在,否则消息要缓存等待 |
update | 更新已有节点属性 | 节点 ID、新的 props 或局部字段 | 不要触发子节点重建 |
unmount | 删除一个节点及其子树 | 节点 ID | 递归释放组件实例和事件绑定 |
text | 操作文本节点内容 | 节点 ID、文本内容或追加片段 | 这是流式场景下最频繁的指令类型 |
四种指令都不复杂,但组合起来就能表达任意复杂的界面变更。关键在于“稳定 ID”:Agent 生成界面时,会给每个可更新的节点分配唯一 ID,前端拿到这个 ID 才能区分“创建新东西”和“更新旧东西”。如果协议里没有这一层约定,增量渲染就无从谈起。
另外要留意的是update语义。Agent 的模型推理是自回归的,它可能在生成某个组件时还没有完全确定这个组件的属性,后续几步才补齐。因此update的设计要考虑“局部更新”能力,不能每次都要求前端提供完整 props。协议里常见的做法是支持字段级合并(类似 React 的setState(partial)),前端收到后把新字段 merge 进旧属性即可。
2.3 文本增量:打字机效果的正确打开方式
H2 2 里最值得单独说一下的是text指令。因为 AI Agent 最常见的输出形态就是文本,而“流式打字机”效果是所有 Agent UI 里最基础的体验。这里最容易犯的错误是:一收到新的流式文本,就整段替换旧文本。
正确的做法是增量追加。协议里专门区分text和append两种载荷,目的就在于此。前端文本组件内部维护一个“已渲染长度”的游标,每次收到append消息,只把新增的字符从游标位置开始渲染,而不是重建整个文本节点。这样打字机效果才可能做到丝滑,不然每个 token 都来一次 React reconciliation,页面迟早卡死。
此外还有一些更刁钻的实现问题:LLM 一次生成可能把一段文字分成多个 token,但这些 token 不一定按完整段落边界切分;有时流里还会出现因为网络分包造成的半行 JSON。这些都要求前端解析器有足够的健壮性,我们留到第三节细聊。
2.4 序列号、乱序与幂等:保证界面永远不错乱
多了一条网络流,就得面对分布式系统常见的问题:消息可能乱序到达,可能重复,可能在连接断开后丢失。组件渲染必须保证结果一致,否则用户看到的就是一个时好时坏的界面。
协议层通常会在每条消息上带一个自增的序列号(seq)。前端消费时可以做两件事:
- 按 seq 维护已应用进度。如果收到的 seq 小于当前游标,直接丢弃,避免重复应用。
- 对乱序到达的消息,暂时放入缓存区,等它的依赖条件满足后再应用。例如某个
mount的父节点还没出现,这条消息就不能立即执行。
这个设计有点像 Git 的 commit hash:所有操作都是确定性的,任何一端只要拥有完整的操作日志,就可以把组件树恢复到一个确定状态。这个特性也为后面的断线重连和快照恢复铺了路。
3. 前端 SDK 怎么把字节流变成真实界面
收到一条条消息和把它们变成用户能看到的界面,中间还隔着一段工程实现。这一节是我觉得最“程序员向”的部分,聊聊 SDK 消费端的渲染器设计。
3.1 解析器:从 ReadableStream 到可分发消息对象
无论底层走 SSE 还是 NDJSON,前端要做的第一件事都是把字节流变成结构化消息。这里有一个隐藏很深的小坑:fetch返回的ReadableStream按任意字节边界分块,也就是说,“一行完整的 JSON”可能被切成两半。
所以我在几个项目里都固定用一种带缓冲的解析模式。下面是一段 TypeScript 简版示例:
async function consumeStream(res: Response) { const reader = res.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 }); const lines = buffer.split("\n"); // 最后一行可能不完整,留到下一轮再处理 buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; const msg = JSON.parse(line); dispatch(msg); } } }核心逻辑就一句话:永远不要把lines数组的最后一项当成一条完整消息去解析,它极可能是半截数据。这种问题是典型的一看就懂、写了才踩的坑。
3.2 渲染器:组件注册表加补丁应用
解析完消息之后,真正的渲染工作才开始。SDK 不能为每个未知组件提供实现,所以它必须让业务方先注册“组件工厂”:一个组件名对应一个前端组件构造函数。可以理解成依赖注入容器:
| 协议里出现的组件名 | 前端注册的实现 |
|---|---|
Card | 你的项目里的Card.vue或Card.tsx |
LineChart | 图表库封装的LineChart组件 |
Button | 业务自定义的Button组件 |
StreamText | 专门为流式文本设计的渲染组件 |
SDK 内部维护一个Map<nodeId, ComponentInstance>。收到mount时就实例化对应组件并挂到父节点下面;收到update时就直接用新 props 调用组件实例的更新方法;收到unmount时就卸载并销毁实例。
在组件实例和 DOM 之外,我还建议另外维护一棵轻量的“描述树”——它只是协议状态的低保真映射,不直接参与 DOM 渲染。遇到乱序消息或者断线重连时,这棵描述树就是前端和 Agent 对齐状态的基础。没有它,排查问题会非常痛苦。
3.3 状态边界:Agent 的 props 和前端 state 必须分家
协议虽然定义了结构和属性,但真实业务中,前端渲染器一定要给自己留一块“自留地”。否则你会遇到一个特别崩溃的现场:Agent 生成了一张表单,用户填到一半,模型因为上下文更新又发来一个update指令,把整个表单的 value 字段重置了。
我参与过的几个项目里,处理方式是协议层明确两类属性:
- 协议 props:由 Agent 全权控制,例如标题文本、图表的数据源、列表的条目内容。这些属性收到
update就直接覆盖。 - 本地 state:由前端独立管理,例如输入框的当前值、折叠面板的展开状态、输入法组合中的文本。凡协议认为属于本地状态的字段,收到更新时应当跳过或做合并,不能直接覆盖。
这个边界没有统一标准,取决于你的业务形态。但有一个通用建议:凡是用户会产生输入的组件,默认都不接受 Agent 的全量属性覆盖。前端 SDK 要提供合并策略配置,让业务方决定哪些 props 字段可以直接覆盖,哪些必须交给前端本地状态。
3.4 渲染性能:高频流式消息下的三条保命策略
LLM 生成速度如果很快,流里可能一秒钟涌入几十条消息。如果每条消息都立刻触发一次 React/Vue 的组件更新,渲染线程会被直接拖垮。实测下来有三条策略最管用:
- 微任务批处理。把同一帧内收到的所有协议消息暂存到队列,等当前 JavaScript 执行栈清空后一次性 commit。这样多条
update可以合并为一次前端渲染。 - requestAnimationFrame 合并 DOM 写入。对频繁的
text append操作,不要每条消息都更新 DOM,而是把待写入的文本片段攒起来,下一帧动画前一次性写入。 - 文本节点单独走“流式容器”组件。这种组件内部不必走框架的响应式更新流程,直接用
document.createTextNode和textContent操作。因为你只需要一个滚动追加的文本视图,没必要让虚拟 DOM 和 diff 算法掺和进来。
三条策略组合使用,基本能把几千条/秒的流式消息平滑消化。记住一句话:协议消息是数据的数字信号,你要做的不是让每个信号都点亮屏幕,而是把它们填进缓冲区,让屏幕按帧率平滑前进。
4. 我在真实接入中踩过的坑与排查过程
理论说再多,不如真跑一遍。这一节我把接入 AGUI 类协议时实际遇到过的几个坑,连同排查过程一起写出来。每个坑都对应一类通用问题,你在自己的项目里几乎必然遇到。
4.1 前端一直接收不到增量:Nginx 悄悄缓冲了 SSE
现象很典型:协议在服务端明明已经按流式输出了,浏览器 Network 面板里也能看到响应头content-type: text/event-stream,但前端的数据要隔几十秒才出现一次,而且一来就是一大坨。完全不是流式体验。
排查过程是这样的:我先在服务端日志确认数据确实在持续发送;然后直接命令行测了一下源站响应,发现源站没有任何问题。问题被锁定在反向代理层。Nginx 默认会对后端响应做缓冲,如果它把每个 chunk 攒到一定量才往客户端吐,那么流式协议就名存实亡。
解决办法是让后端在响应头里显式关闭缓冲:
X-Accel-Buffering: no如果是 Nginx 的proxy_buffering off也可以,但更推荐用响应头控制,这样源站可以独立决定哪些接口需要走流式。这个坑非常典型,几乎每个第一次接流式协议的人都会遇到。排查思路值得记住:先分三段验证——源站输出、代理转发、客户端接收,哪一段丢掉了“流式”两个字,问题就出在哪一段。
4.2 断线重连后界面花屏:seq 游标和快照恢复
流式连接是长连接,必然会被各种情况打断:服务器重启、客户端切后台导致网络切换、甚至公司网关半夜的例行断连。断线重连之后最容易出现的问题,是界面和 Agent 的真实状态对不上。
我遇到的一个具体事故是:用户看着界面正常,但重连后页面突然多出了两个一模一样的卡片,另一处列表却不见了。查协议日志发现,重连后服务端直接从第 0 条 seq 重新推送了完整状态,而前端 SDK 没有做重复mount幂等处理,同一个节点 ID 被创建了两次,导致界面错乱。
后面我们把重连流程改成“快照+增量”两步:
- 重连时,前端先请求一个
snapshot接口,拿到当前组件树的完整快照,快照里带一个snapshot.seq。 - 然后前端只消费
seq > snapshot.seq的流式消息,保证增量从快照之后开始应用。 - 前端 SDK 本身也做幂等保护:遇到重复
mount同 ID 时,先按旧节点unmount再重新挂载。
这样一来,重连造成的唯一影响就是一次短暂的画面刷新,而不是长期错乱。这个“快照+增量”的设计,其实和很多前端状态管理库里的持久化恢复思路是一样的——要恢复一个持续变化的状态,光靠最后一条消息不够,你得有一个当前状态的基线。
4.3 样式注入与外部资源:协议安全边界比想象中更需要守卫
我说过 AGUI 比 HTML 直出安全,但“不直接执行代码”不等于“完全不需要安全措施”。前端渲染器依然要处理外部输入,而外部输入里最容易被利用的路径是样式、URL 和事件回调。
实际发生过的一个 case:模型在一次任务中被注入了一段诱导性内容,随后生成的消息里出现了一个img标签,src指向收款页面的第三方地址。如果前端 SDK 不加校验直接渲染,用户界面就会在不知情的情况下加载外部资源,甚至被引导到伪造的支付页面。
所以渲染器必须做三道防线:
- URL 白名单:
img、link、a的href/src只允许指向固定的 HTTPS 域名,其他一律拦截。 - 样式属性白名单:style 只接受常见样式属性(如颜色、字体、尺寸),拒绝
url()、expression()及任何可能触发外部请求的属性值。 - 事件回调白名单:协议里注册的事件回调 ID 要与前端记录一致,拒绝任何来自消息体的“动态拼接回调路径”。
这套安全边界和防攻击机制,在协议设计就要留好口子。比“事后清洗 HTML”更聪明的地方在于:你在协议层就规定“这里只允许出现白名单里的东西”,从根上消耗了模型的自由发挥空间。这也说明 AGUI 类协议虽然不能完全消除安全风险,但能让风险变得可枚举、可管控。
4.4 调试套件:把协议的每一行都摊开来看
最后一个坑可能不算“坑”,而是一个工程效率问题:流式协议天然是时间敏感的,肉眼很难捕捉瞬间发生的渲染错误。数据过去了就是过去了,等屏幕出问题的时候,你根本不知道是哪条消息导致的。
我自己的做法是给 SDK 插入一个调试中间层,作用是把每一条协议消息都打出来:
- 在
fetch管道里插入一个日志用的TransformStream,把每条 NDJSON 原样输出到控制台。 - 给每个协议节点 ID 加上视觉化边框。就像 React DevTools 里高亮元素一样,点击界面上的组件就能跳到对应节点 ID,反向查它历史收到的所有
update消息。 - 做一份“协议录制/回放”工具。把流式消息保存成一个 JSONL 文件,回放时直接驱动 SDK。这样复现问题完全不用反复调 Prompt、跑模型,而是把同一个协议流一遍一遍地喂给前端,改一版渲染器就跑一次。
这三个工具加在一起,基本能解决 90% 的流式排查困境。尤其协议录制/回放,是我强烈推荐的。模型输出的不确定性让 bug 复现变得极其困难,但协议流是确定的,把它录下来,问题就变成纯前端问题,排查效率高出一个量级。
5. 接入这类协议时,我建议你从哪开始
如果你正在做 Agent 应用,或者准备把现有的前端项目扩展到 Agent 可驱动的形态,我给三点非常实际的建议。
第一,先别着急接 AGUI 完整规范。协议是不断演进的,你的业务不一定需要所有指令。可以先定义一版最小的协议子集:mount、update、unmount、text,四条指令足够覆盖绝大多数展示型、汇报型、生成型界面。把你项目中最常用的十个组件注册进协议,先跑通端到端。
第二,流式体验的瓶颈往往在模型侧,而不在前端侧。如果 Agent 生成的速度本身很慢,再好的 Data Stream Protocol 也救不回来。所以接入之前,先明确一个问题:你的 Agent 是在“一边思考一边输出”,还是“全部生成完再一次性推流”?前者才是流式的意义,后者直接 HTTP POST 就行。
第三,状态边界是方案成功的分水岭。协议接管界面结构,但绝不能接管用户的输入状态。一开始就把“Agent 控制的 props”和“前端控制的本地 state”分成两套体系,后面会省掉无数和用户反馈相关的扯皮。这块设计得越早越好,等界面多了再回头补,代价很高。
我个人的直观感受是,AGUI 协议和 Data Stream Protocol 这类设计,真正改变的并不是某个 API 长什么样,而是把“让模型做前端”这件事,从 Prompt 工程问题变成了工程协议问题。你不再需要哄着模型输出规范代码,你只需要把渲染能力暴露成一张有限的组件清单,让模型像搭积木一样描述界面。一旦这条链路跑通,前端的安全边界会变得异常清楚,性能节奏完全可控,而且界面上一帧一帧的变化,每一处你都能明确知道是哪条消息促成的。这种确定性,才是 Agent UI 真正能走向生产环境的前提。