YAOTU INSIGHTS

MCP Apps返回HTML够用吗?为什么Agent应用还需要A2UI

MCP Apps返回HTML够用吗?为什么Agent应用还需要A2UI
MCP Apps 能直接返回 HTML为什么还需要 A2UI这个问题我最近被问了不少次。很多做 Agent 应用、写 MCP 工具的朋友一看到“返回 HTML 卡片”这个能力就觉得 UI 这关已经过了反正模型能生成页面客户端能渲染页面还要啥自行车。但真把项目跑起来、用户用起来以后就会发现HTML 返回能解决“有个界面”却解决不了“界面到底是什么”这个问题。先说结论MCP Apps 返回 HTML 是一种输出格式A2UI 是一种交互协议。HTML 解决的问题是“结果怎么展示”A2UI 解决的问题是“AI 和界面怎么对话”。前者是一条单向通道后者是双向闭环。理解了这个区别你就能想明白为什么一个能返回 HTML 的 MCP App在实际交付物里仍然需要 A2UI。这篇文章我打算把两边掰开揉碎讲清楚适合三类人看一是正在做 MCP 工具但不想每次都让用户裸看 JSON 的开发者二是想把 Agent 应用做出“正经产品界面”的前端工程师三是对 Agent 交互形态好奇、想搞明白这些热词背后到底是什么的技术爱好者。1. 先搞清楚MCP Apps 返回 HTML 到底解决了什么1.1 所谓 MCP Apps本质是把模型能力变成可调用的“工具界面”在 MCPModel Context Protocol这个体系里“工具”这个词被用得很频繁。模型的对话能力是固定的但模型能调用多少外部能力取决于你给它接了多少钱包、数据库、代码执行器、文档库。传统做法是工具返回一段结构化数据比如 JSON 字符串模型把它当上下文继续推理用户则什么都看不到。MCP Apps 做的事情是把“工具返回结果”这件事升级成“工具返回一份可以给人看的界面”。典型场景就是你调用一个查天气的 MCP 工具它不再返回{city:北京,temp:26,condition:晴}这种冷冰冰的键值对而是直接给你一个带背景色、带天气图标、带城市名排版的 HTML 卡片。客户端拿到的是一段完整的 HTML 字符串用 iframe 或 WebView 一包用户看到的就是一张设计好的卡片。这解决了一个非常实际的问题模型和人类之间的信息传递效率。工具输出 JSON人得靠另一个模型去解读工具输出 HTML人直接就能读懂。从工程角度看它把“前端开发”这件事往前提了一大步——过去你要给一个工具写配套页面现在模型自己就把页面写出来了。1.2 为什么大家会有“HTML 就够了”的错觉我复盘了一圈身边人觉得“HTML 够用”的理由基本集中在三点。第一渲染成本低。客户端拿到 HTML 直接嵌一个 iframe 就能展示不需要维护组件树不需要定义 schema不需要写状态同步逻辑。第二模型生成 HTML 的成功率已经很高了现代 LLM 对 HTML/CSS/JS 的基础语法掌握得很扎实你给它一个“生成好看的今日运势卡片”的指令它输出的页面直接可用。第三演示效果立竿见影你给别人演示一个 MCP App 时HTML 卡片比 JSON 数据有说服力得多。这些理由都没错但它们都指向同一个隐含前提用户只需要看不需要操作。一旦用户需要填表、点按钮、翻页、编辑内容HTML 字符串的局限马上就暴露了。你以为自己拿到了一个“应用”其实你拿到的是一张截图还是带样式的截图。1.3 “能看”和“能用”是两个量级举个例子你写了一个会议纪要 MCP 工具它把每次会议的录音转成结构化纪要并返回一个设计精美的 HTML 页面。页面上有时间轴、有结论、有负责人名单排版整洁配色专业。用户看完觉得很棒然后他问了一句能不能把第三段的结论改成“暂缓执行”这时候你就会发现那个 HTML 页面里的文字根本改不动。它不是可编辑的表单不是拖拽就能调整的文档区块它就是模型生成的一堆渲染好的字符串。用户要么回对话框里敲提示词让模型重新生成一版要么自己复制粘贴到别的地方改。前者要等模型重新输出后者直接脱离了应用场景。这个“能看不能改”的体验就是 HTML 作为 UI 的边界。2. HTML 返回方案的硬伤三层“天花板”挡在前面2.1 第一层静态快照没有状态概念用 MCP 返回 HTML 的整个过程本质上是一次“拍照”。模型根据当前上下文拍了一张页面快照客户端把它铺在屏幕上。这张快照和模型之间没有任何连接模型不知道用户看了多久、点了哪里、输入了什么页面本身也没有保存状态的能力。你做一个分页列表的 MCP 工具模型返回的 HTML 里有分页按钮点击之后怎么办指望 HTML 里的 JavaScript 自己去请求数据那数据从哪来还是重新调用一次 MCP 工具重新调用的话上一页的滚动位置怎么保持用户已填未提交的表单内容怎么保留这些在传统前端里理所当然的状态管理问题在 HTML 返回方案里全部变成了“每次生成都是从头开始”。这不是实现技巧的问题是架构层面的缺陷。HTML 是标记语言它的稳态就是“渲染当前内容”它不承担数据流、组件树、状态管理这些职责。你硬要把这些逻辑塞进模型生成的 HTML 字符串里最后得到的就是一个又大又脆的页面每个交互都得靠内联脚本每个数据变化都要重新找模型。2.2 第二层安全边界不好划模型生成的 HTML 直接渲染进 iframe有一个绕不开的隐患XSS 和注入面。模型生成内容时会把用户输入也拼进页面里用户输入如果包含恶意脚本片段模型又照单全收渲染出来就是注入攻击。你说可以过滤把script标签剥掉。问题是在现代前端里事件处理不只有script一条路img onerror、svg onload、CSS 里的expression()、iframe 的嵌套加载都能变成攻击面。你让普通开发者写一版可靠的过滤规则基本等于要求他熟悉全部浏览器安全漏洞这不现实。A2UI 的思路是“根本不让模型输出可执行代码”。模型只能从白名单组件里挑选 UI 元素比如输入框、按钮、表格、图表组件的行为是客户端写死的模型只能指定属性数据碰不到事件执行体。这就把安全边界从“过滤字符串”变成“类型约束”可行性和可维护性完全不是一个等级。2.3 第三层无法组合、无法演化、无法复用MCP 工具的业务是会演进的。今天你的工具只有查询功能返回一个 HTML 表格就够了明天你要加审批流表格里得加“通过/驳回”按钮后天你要支持批量操作还得加复选框和顶栏工具栏。如果一切都靠模型每次重新生成一份 HTML你会发现每改一次业务你就要重新面对一整页“一次性代码”。更实际的问题是组合。你的应用里可能有十几个 MCP 工具每个工具都返回一份独立的 HTML 页面。用户在一屏里同时看“销售数据”卡片和“客户列表”卡片两个卡片来自不同的 MCP 工具它们的样式体系可能互相打架它们的状态完全独立它们的操作没有任何交集。你想要一个“在客户列表里勾选客户销售数据卡片实时刷新”的联动效果这在 HTML 返回方案里几乎做不出来因为两个 HTML 页面是两个孤岛。这也是我经常打的比方HTML 返回像一张打印好的说明书A2UI 像一个能跟你来回对话的客服。3. A2UI 到底是什么Agent 与 UI 之间的“共同语言”3.1 从概念说起不是又一个前端框架A2UI全称 Agent-to-UI也有人叫它 Agent-to-User Interaction指的不是某个具体框架而是“智能体如何落地为用户界面”这一类方案的统称。它要回答的问题就一个AI 生成的界面如何能像常规前端应用一样具备完整交互能力、状态能力、安全能力以及和模型之间的双向通信能力。所以 A2UI 不是一个“更聪明的 HTML 生成器”。它不追求让 AI 写出更花哨的网页而是追求让 AI 用一套可结构化的方式来描述界面交给客户端去渲染并在运行中保持状态同步。类比一下你就明白了HTML 是“文档”A2UI 是“程序”HTML 模式里 AI 是作者A2UI 模式里 AI 是驱动程序。近几年社区里相关实践非常多。Vercel AI SDK 的 Generative UI 让大家看到模型直接产出 React 组件的可能性OpenAI 的 Apps 框架也在探索工具返回 UI 卡片的标准形态MCP 生态里也出现了专门的 UI extension 协议方向。A2UI 的共识还在演进但核心思路已经比较清楚模型输出的是界面描述不是界面实现客户端负责把描述变成可交互的组件并实时同步状态。3.2 核心机制一声明式描述而不是命令式渲染模型不应该直接写document.getElementById(...).innerHTML ...这种命令式代码也不应该被要求每次拼接一整段 HTML。A2UI 的通行做法是让模型输出一份组件树描述形式上类似 JSON里面描述“这个页面有哪些组件、每个组件什么属性、组件之间什么关系”。举个例子模型要做一个用户信息面板传统的 HTML 方式是div classprofile-card img srcavatar.png classavatar h2张三/h2 p高级前端工程师/p button onclicksendMessage(张三)发消息/button /divA2UI 的描述方式则是{ type: Card, props: { title: 张三, subtitle: 高级前端工程师 }, children: [ { type: Avatar, props: { src: avatar.png } }, { type: Button, props: { label: 发消息, action: sendMessage } } ] }这里最关键的是action字段。它不写“怎么执行”只写“发消息”这个动作的名字。具体发消息的业务逻辑由客户端在自己的事件处理层实现模型只需要声明“这个按钮可触发发消息”。这就把界面描述和界面执行彻底分开了。3.3 核心机制二状态双向同步AI 和前端共享同一份状态A2UI 和静态 HTML 最本质的区别是状态。A2UI 运行时会维护一份全局 UI 状态这份状态模型可以读写前端组件也可以读写。用户输入内容前端把新值同步给模型模型根据新一轮推理结果把组件的属性改掉前端自动重新渲染。这个机制解决了我在 2.1 里说的“拍照”问题。用户的每一次交互都回流到一个统一的状态层模型下次生成内容时能看到这些状态。比如用户正在填写一个很长的表单其中某个字段触发了模型校验逻辑模型判断格式不正确直接更新那个字段下方的错误提示组件而不需要重新生成整个页面。这体验和传统前端开发几乎一样只是“业务逻辑的编写者”从人变成了模型。实现上围绕这块已经有比较成熟的范式。比较常见的是让模型在工具调用里返回ui_state和ui_actions两部分ui_state描述当前界面ui_actions描述允许触发的事件列表。客户端维护状态快照事件触发后调用模型更新函数模型返回新的状态客户端做 diff 再更新 DOM。3.4 核心机制三组件白名单和渲染沙箱A2UI 的另一个关键设计是“有限组件集”。模型能使用的组件由客户端预先注册比如 Button、Input、Table、Chart、Modal、Tabs 这些全部是受控组件。模型不能输出一个“自定义脚本”也不能输出任意script标签因为它根本没有这种能力协议层面就不允许。这个设计的好处是安全边界可以被正式写进架构文档而不是靠“记得过滤特殊字符”这种约定。客户端只管把模型输出映射到白名单组件上非法组件直接忽略并报错。这也让渲染层的实现非常轻本质上就是做一个 JSON 到组件树的映射器可以落在 React、Vue 或任何一个主流框架上。说到这顺带回应一个热词“目前 A2UI 支持最好的 Vue 框架”。从我实际接触的社区项目看Vue 生态确实有一些封装得不错的 A2UI 渲染器模板语法天然适合声明式渲染Vue 的响应式状态系统几乎就是为 A2UI 的“状态驱动 UI”需求量身定做的。如果你团队本来就是 Vue 技术栈做 A2UI 渲染层的成本会比从零开始低很多。4. 正面硬刚把 HTML 返回和 A2UI 放在一起逐项对比对比维度MCP Apps 直接返回 HTML使用 A2UI渲染方式客户端 iframe/WebView 整页渲染客户端按组件树映射到前端框架组件状态管理无每次生成都是新快照有全局 UI 状态AI 与前端共享用户输入不支持或靠内联脚本硬凑原生支持输入实时回流 AI事件回调难做需要自定义消息通道协议原生支持 action 触发安全边界需要过滤用户输入和模型输出白名单组件无任意代码执行页面组合多个 HTML 之间互相隔离组件可组合状态可共享能联动业务演化每加一个功能就要重新生成页面加组件、加 action 即可扩展调试体验看 HTML 源码难定位问题状态可查询、事件可追踪学习成本低会 HTML 就能用中等需要理解一份 UI 描述协议适用场景一次性展示型内容需要长期交互的 Agent 工作台这张表里需要特别注意的是“事件回调”这一行。很多人以为 HTML 里有按钮就是交互但真正的交互是要“有回路的”。用户点了一下按钮这个动作要被模型感知模型要能做出反应反应结果要能推动界面更新。HTML 里的onclick只能调用客户端预置的脚本而这个脚本怎么拿到模型推理的结果靠重新调用一次工具那工具返回的又是新一份 HTML页面又重置了。这个环路在纯 HTML 方案里基本是断的而 A2UI 从协议层面就把它设计通了。5. 实操示例同一个需求两种方案的落地差异这一节我拿一个很常见的场景来走一遍流程——做一个“AI 周报生成助手”。用户输入本周的工作事项AI 生成周报草稿用户能修改草稿、一键润色、调整语气最后复制或导出。5.1 用 MCP 工具返回 HTML 的实现与瓶颈你先注册一个 MCP 工具叫generate_report接收用户的工作事项返回一段 HTML。模型端把周报内容渲染成一篇排版好的文章客户端接到后用 iframe 展示。核心代码长这样def generate_report(notes: str): report_html llm_generate( f把以下工作事项整理成周报并输出完整HTML{notes} ) return { type: html, content: report_html, }这个过程很顺模型生成的 HTML 排版往往很不错。但用户接下来要“改一下第二段的表述”怎么办你只能在 iframe 外面做一个“重新生成”按钮让用户回到对话框重新输入完整要求。用户在 iframe 里做的任何草稿修改都无法被收集用户点“润色”按钮更没法触发一次模型调用除非你在 HTML 里塞了调用后端的脚本但那样又回到了安全风险和一次性代码的坑里。实测下来这个方案当作“周报预览器”是合格的当作“周报编辑器”是完全不及格的。核心缺乏的是“可编辑”“可反馈”“可再次生成”这三个能力。5.2 用 A2UI 的实现界面状态由模型持续驱动换成 A2UI你会设计一份 UI 描述让模型输出带交互的组件树而不是整段 HTML。表单提交时触发的动作叫preview_report润色按钮触发的动作叫polish_report这些动作都在客户端注册每个 action 后面挂一段业务逻辑。模型首次输出大致是这样的{ type: Page, props: { title: AI 周报助手 }, children: [ { type: TextArea, props: { label: 本周工作事项, value: 修复登录模块的 3 个 bug完成支付流程重构组织团队周会, name: rawNotes } }, { type: Button, props: { label: 生成周报, action: preview_report } }, { type: MarkdownView, props: { content: 等待生成... } } ] }用户把 TextArea 里的文字改掉前端直接把新值同步到 UI 状态。用户点“生成周报”客户端把preview_report动作连带着当前表单值发给模型模型调用 LLM 生成周报内容然后返回一份新的 UI 描述只更新 MarkdownView 的 content其他组件保持原样。用户看完觉得语气太正式点一个“口语化润色”按钮同理触发polish_report模型推理后只更新那段文字。你会发现整个过程里没有任何一次“整页重新生成”每个交互都是局部的、有状态的。而且因为状态是结构化的你还可以把 rawNotes、周报内容、当前语气这些状态存入会话历史下一次对话时模型完全清楚用户之前在改什么。这就是 A2UI 模式的真正价值。5.3 两种方案实测后的体感差异我把同样一个需求用两种方案各做了一遍体会非常明确。HTML 方案大概半天就能跑通而且演示效果唬人A2UI 方案前期要定义 schema、写组件映射、实现状态同步起步慢得多但一旦跑起来后续加功能非常舒服。比如再加一个“导出 PDF”按钮HTML 方案要重新生成一整份带导出脚本的页面A2UI 方案只是加一个 Button 和对应的 action 处理渲染层、状态层完全不用动。6. 常见问题与排查技巧实录这块我把实际开发中遇到的高频问题整理出来方便你踩坑时直接对照。6.1 iframe 里 HTML 样式错乱、图片加载不出来直接返回的 HTML 片段放进 iframe 之后出现样式错乱最常见的原因是响应头里 Content-Type 不正确或者页面里用了相对路径的资源加载不到。排查思路分几步确认返回的 HTML 带base标签指向正确的静态资源根目录确认 iframe 的 sandbox 属性允许加载同源资源必要时加allow-same-origin模型生成的 CSS 如果是style内联标签一般没问题问题往往出在link relstylesheet上外链样式受 iframe 和沙箱策略限制。这类问题建议写一段自动化检查脚本把模型返回的 HTML 里的外部依赖都列出来提前发现资源加载失败。6.2 A2UI 里组件不渲染只显示一个空白块组件空白几乎都是“组件类型未注册”导致的。A2UI 的渲染层靠组件注册表工作模型吐出一个Charts类型但你的注册表里只有Chart映射不到就直接返回空。解决方案是做一个宽松匹配函数先精确匹配再模糊匹配最后走兜底组件比如用“暂不支持”占位块同时把未匹配项打到日志里。另一个常见原因是 props 类型不匹配。模型输出的属性值是布尔型组件却当成对象处理。我一般在渲染层加一层 Schema 校验不符合就直接给默认值同时上报校验错误避免整棵树崩溃。6.3 按钮点击了没反应action 事件不触发这个问题多数出在“动作注册表”没有集中管理。你定义 UI 描述时写了action: preview_report但事件处理层只注册了preview名字对不上点击自然无响应。我的习惯是把所有 action 名抽成一个常量枚举比如ACTIONS.PREVIEW_REPORT客户端注册处理器和模型输出都引用同一份枚举从源头避免字符串拼写错。另外要特别注意异步链路action 触发后模型可能几秒后才返回新状态这段时间要同步禁用按钮防止用户重复点击制造竞态。6.4 状态更新后前端没刷新界面和数据对不上状态不同步先检查你有没有遵循不可变更新的约定。A2UI 状态层一般要求每次更新都返回新的状态对象而不是在原有对象上改。如果你用旧对象的引用去 diff前端会认为没有任何变化。如果数据结构和 diff 算法都没问题再检查渲染层对你的 UI 框架的依赖版本。比如在 Vue 里用 reactive 包状态如果没有按响应式对象的操作规范来更新视图同样不会刷新。排查时可以人为在状态层打一条日志每次模型返回新状态时输出整个对象看看到底是模型没返回新值还是前端没有正确消费。6.5 安全防线两种方案分别要防什么HTML 方案的重点是输入清洗和输出编码。用户输入会进入模型模型可能会原样带回页面里所以要做 HTML 实体编码再用白名单方式过滤危险标签属性。A2UI 方案因为都是结构化数据注入面小很多但要防的是 prompt injection用户输入文本里包含命令片段可能让模型“忘记”系统提示去调用不该调用的 action。这个目前靠两层防护action 注册权限校验 模型侧系统提示词加固谁都不能省。6.6 排查方法速查表症状可能原因先用哪个手段排查HTML 样式错乱Content-Type 错误 / 静态资源缺失打开 iframe 开发者工具看网络面板HTML 页面空白sandbox 限制 / JS 报错去掉 sandbox 试一次再逐个加权限A2UI 组件空白组件未注册 / props 类型不合法查看渲染层未匹配日志action 点击无反应action 名称不一致 / 异步竞态检查动作枚举常量看是否同源状态不刷新违反不可变更新 / diff 失效在状态层加日志输出用户内容触发异常响应prompt injection审查 action 权限校验逻辑7. 选型建议什么时候用 HTML 返回就够什么时候得上 A2UI7.1 用 HTML 返回就够的场景不是所有工具都需要 A2UI。如果你的 MCP 工具定位是“查询型”“展示型”用户看过就走不编辑不反馈那 HTML 返回是最经济的方案。典型例子有查天气、看股票曲线、渲染一篇 Markdown 文档、展示数据库查询结果、预览图片或 SVG。这些场景有一个共同点交互深度很低用户没有“基于这个界面再干点什么”的需求界面本身就是个结果快照用完即走。这时候上 A2UI 反而是过度设计。你要定义 schema、写组件映射、搭状态层纯粹是给自己增加工作量。工具开发者要有这个判断力。7.2 必须上 A2UI 的场景反过来下面这些场景用纯 HTML 基本撑不住建议直接上 A2UI多轮工作流用户需要在一个界面里反复调整参数、查看中间结果、确认下一步操作表单密集型应用比如 Agent 帮你填离职审批流程表单要一步步校验、回显、纠错数据联动仪表盘筛选条件变化多个图表跟着联动刷新可编排的业务台比如客服 Agent边对话边操作工单系统工单状态和对话内容互相影响需要审计和回滚的应用A2UI 的每次状态变更都能记录HTML 则是一笔糊涂账。在这些场景里用户的诉求不是“看一眼结果”而是“和 AI 一起完成一个任务”。一起完成任务就必须有界面反馈回路A2UI 就是为这个回路服务的。7.3 混合策略外层 HTML 壳 内层 A2UI 区最务实的架构实际操作中很多成熟项目不是二选一而是混合使用。外层用 HTML 保证整体视觉表现力渲染静态的说明、图表、排版内容内层把真正需要交互的部分作为 A2UI 组件挂载点比如“参数配置区”“进度追踪区”“结果编辑区”。这样既吃到 HTML 的灵活和丰富又拿到 A2UI 的状态闭环。实现上可以约定一个简单的协议在 HTML 页面里嵌入一个统一定位标签和 props 对象客户端解析 HTML 时遇到这个标签就交给 A2UI 渲染器接管。这类似于你在 Vue 项目里嵌一个 React 子应用边界明确、职责清晰。实测下来这种混合模式在 Agent 应用里磨合得最顺。我个人在实际项目里的体感是HTML 返回适合用来做“一次性交付物”A2UI 适合用来做“AI 原生工作台”。前者是答案后者是过程。真正有长期生命力的 Agent 应用用户不会满足于看一张漂亮的页面他会希望整个界面像同事一样能听懂指令、能自动调整、能一步步陪他把事干完。最后再分享一个小技巧如果你想从 HTML 方案平滑过渡到 A2UI不用一次性推翻重写先把应用里用户反馈最频繁的那个交互点改成 A2UI 组件跑通一次“模型描述组件→用户操作→状态回流→界面更新”的闭环感受一下两者的差异再决定要不要全面迁移。这个决策会比你拍脑袋想清楚得多。