A2UI 技术详解:让 AI Agent 学会“说界面”

A2UI 技术详解:让 AI Agent 学会"说界面"

基于 Google 开源项目 A2UI(Agent-to-User Interface,源码 v0.8 ~ v0.10)的源码分析。 一句话概括:A2UI 是一套让 Agent 用「数据」描述界面、由客户端用「原生组件」渲染的开放协议。安全得像数据,表达力强得像代码。


一、A2UI 是什么?

想象一个场景:你问 AI「帮我订一张明晚的餐厅」。

  • 传统聊天机器人:回你一大段文字「请告诉我日期、人数、菜系......」,你得一行行打字。
  • 理想的 Agent :直接给你弹出一个带日期选择器、人数滑块、菜系单选框的表单,你点几下就提交了。

后者就是 A2UI 想解决的问题------让 Agent 不只是「说话」,还能「画界面」

但这里有个核心难题:Agent(尤其是远程的、跨信任边界的 Agent)怎么把界面安全地交给客户端?

A2UI 给出的答案是:Agent 不发送代码(不安全),也不发送固定图片(不灵活),而是发送一段声明式的 JSON ,描述「我想要什么界面」。客户端拿到这段 JSON 后,用自己本地已经写好、经过审核的组件库去渲染。

scss 复制代码
Agent 说:       "我要一个 Card,里面放一个 Text 写'你好',和一个 Button 写'确认'"
                              ↓  (一段 JSON,纯数据)
客户端做:       用自己的 <Card> <Text> <Button> 组件把它画出来

这就是 A2UI 名字的由来:A2UI = Agent to UI,Agent 到用户界面。


二、为什么需要 A2UI?(它解决的真问题)

生成式 AI 很擅长写文字和写代码,但在「呈现富交互界面」上一直很笨拙。已有的几种做法都有硬伤:

痛点一:直接让 LLM 生成 HTML/JS ------ 不安全

让 LLM 直接吐出 <script> 和 HTML,等于在你的页面里执行一段来路不明的代码。一个被注入的恶意 Agent 可以窃取 Cookie、发起请求、伪造界面。这是 XSS 的温床。

痛点二:固定模板 ------ 不灵活

预先写死一堆界面模板,Agent 只能从里面挑。可对话是千变万化的,模板永远不够用。

痛点三:截图/图片 ------ 不能交互

返回一张界面截图?用户没法点击、没法输入。

A2UI 的设计哲学(源自 README 的四条核心理念)

理念 含义 怎么做到的
安全第一 LLM 生成的内容是数据不是代码 客户端维护一个「组件目录 catalog」,Agent 只能从目录里点名已审核的组件,无法注入任意逻辑
对 LLM 友好 + 可增量更新 界面是「扁平的组件列表」,LLM 容易增量生成 邻接表模型(下文详解),可以边生成边渲染(渐进式渲染)
框架无关 + 可移植 同一份 JSON 能在 Flutter / React / Angular / Lit 上渲染 协议只描述结构和意图,把「怎么画」留给各端的原生实现
灵活可扩展 开发者可注册自定义组件(甚至 iframe 沙箱包裹遗留内容) 开放的「注册表 + 智能包装器」模式,安全策略掌握在开发者手里

一句话:A2UI 让 Agent 生成的界面「安全得像数据,表达力强得像代码」。


三、整体架构(Infographic)

下面这张图描绘了 A2UI 的完整数据流。注意它的核心特征:生成与渲染解耦传输层可插拔

📐 上图的可编辑源文件为同目录下的 A2UI架构图.drawio。用 VS Code 的 Draw.io Integration 插件app.diagrams.net 打开后,可通过 File → Export as → PNG(建议勾选透明背景、2x 缩放)导出为本目录下的 A2UI架构图.png,图片即可在本文中显示。

三个角色记牢就够了:

  1. Agent(服务端):用 LLM 生成符合 catalog 的 A2UI JSON 消息流。
  2. Transport(传输层):把消息流可靠、有序地送过去(最常用 A2A 和 AG-UI)。
  3. Renderer(客户端):解析消息流,用本地原生组件渲染,并把用户交互回传。

四、和同类技术对比

A2UI 不是凭空出现的,它处在「Agent 输出界面」这个赛道上。横向对比一下:

维度 直接生成 HTML/JS MCP(Model Context Protocol) 传统 SDUI(服务端驱动 UI) A2UI
本质 LLM 吐可执行代码 Agent 调工具/取上下文的协议 后端下发界面描述 Agent 下发声明式 UI 的协议
解决什么 让 AI「写网页」 让 AI「用工具」 后端控制 App 界面 让 Agent「画界面」
安全性 ❌ 可执行任意代码,XSS 风险 ✅ 工具受控 ✅ 受控 ✅ 纯数据,只能点名白名单组件
跨框架 ⚠️ 只能 Web 与 UI 无关 ⚠️ 通常绑定特定端 ✅ React/Flutter/Lit/Angular 通吃
增量/流式 ❌ 难 N/A ⚠️ 一般整页下发 ✅ 邻接表,天然支持渐进式渲染
关系 A2UI 的反面教材 互补:MCP 可作为 A2UI 的传输层之一 A2UI 是其「Agent 时代 + LLM 友好」的进化版 ------

几点要澄清的关系:

  • A2UI 不是来取代 MCP 的。 MCP 解决「Agent 怎么用工具/拿数据」,A2UI 解决「Agent 怎么把界面给用户」。两者甚至可以叠加------源码里 MCP 就是 A2UI 的一种可选传输层(A2UI 消息作为 MCP 的 tool output 投递)。
  • A2UI 可以看作 SDUI(Server-Driven UI)的「Agent 时代版本」。 传统 SDUI 由后端工程师写死下发逻辑;A2UI 的下发方是 LLM,所以它的格式特意设计成「扁平列表 + ID 引用」,让 LLM 更容易增量生成。

五、本地怎么跑起来?

A2UI 官方提供了一个「餐厅查找」全栈 Demo,一条命令就能体验端到端流程(Gemini 驱动的 ADK Agent + Lit 渲染器)。

5.1 最快路径:餐厅查找 Demo

bash 复制代码
# 前置:Node.js 18+、uv(Python 包管理器)、一个 Gemini API Key
git clone https://github.com/google/A2UI.git
cd A2UI
export GEMINI_API_KEY="你的_gemini_api_key"

cd samples/client/lit
npm run demo:restaurant

这一条命令会自动:安装依赖 → 构建渲染器 → 启动 Python Agent → 打开浏览器 http://localhost:5173

默认只能用GeminiAPI使用,可以修改A2UI/samples/agent/adk/restaurant_finder/agent.py里的GeminiLiteLlm并配置OpenAI API

5.2 零安装路径

如果只想感受一下,不想装环境:

  • A2UI Composer(可视化编辑器):拖拽生成 A2UI JSON,复制粘贴进任意 Agent 的 prompt 里即可。
  • A2UI Theater:在线分步观看 A2UI 在 Lit / React / Angular 三种渲染器上的流式渲染过程。

5.3 用 CopilotKit 在 React 里跑 A2UI(最快路径)

前面 5.1 是 Lit 渲染器的官方 Demo,但如果你的项目是 React/Next.js ,最省事的方式是借助 CopilotKit

简单说,三者的分工是:A2UI 负责「描述界面」,AG-UI 负责「传输消息」,CopilotKit 则把 AG-UI 落地成开箱即用的 React 集成。它支持的任意 Agent 框架(ADK、LangGraph、CrewAI、Mastra......)都能产出 A2UI 并在 React 里渲染,你不用自己写传输胶水代码。

第一步:用脚手架初始化 ,在交互选项里选择 a2ui

bash 复制代码
npx copilotkit@latest init

第二步:进入项目并安装依赖

bash 复制代码
cd <项目目录>
npm i

第三步:配置 .env,填入大模型的密钥与地址(用 OpenAI 兼容接口为例):

bash 复制代码
# .env
OPENAI_API_KEY=你的_api_key
OPENAI_API_BASE=https://api.openai.com/v1   # 也可指向任意兼容端点

第四步:启动

bash 复制代码
npm run dev

跑起来后,进入http://localhost:3000/,然后和Agent对话用内置 catalog(Text、Card、Button...)就能立刻画出可用界面。下面两段是脚手架已经帮你配好的关键代码,理解一下原理即可:

后端开启 A2UI :脚手架在 CopilotRuntime 里打开了开关,它会自动给 Agent 注入一个 render_a2ui 工具,让 Agent 能产出 A2UI 界面:

ts 复制代码
import {CopilotRuntime} from '@copilotkit/runtime';

const runtime = new CopilotRuntime({
  agents: {default: myAgent},
  a2ui: {injectA2UITool: true}, // 关键:开启 A2UI
});

前端无需额外代码 :只要用 CopilotKitProvider 包住应用,A2UI 渲染器就会自动激活(可选传入主题):

tsx 复制代码
import {CopilotKitProvider} from '@copilotkit/react-core/v2';
import '@copilotkit/react-core/v2/styles.css';

<CopilotKitProvider runtimeUrl="/api/copilotkit" a2ui={{theme: myCustomTheme}}>
  {children}
</CopilotKitProvider>;

进阶:用自己的组件(BYOC, Bring Your Own Components) 。A2UI 真正的威力在于把 catalog 扩展成你自己的 React 组件(你的设计系统、你的数据结构)。一个自定义 catalog 由三部分组成:① 用 Zod 写组件 schema + 自然语言描述(这是 Agent 在系统提示词里「看到」的);② 一一对应的 React 渲染组件(这是用户「看到」的);③ 把 catalog 通过 provider 注册进去。注册后,Agent 就只能用你信任的这些组件来拼界面了。

详细 API 以 CopilotKit 的 ADK + A2UI 文档 为准。

六、实现原理(核心,逐层拆解)

下面进入最硬核的部分。我们从「协议长什么样」一路讲到「客户端怎么把 JSON 变成会动的界面」。

6.1 协议:只有 4 种消息

整个 A2UI 服务端→客户端的协议,核心就 4 种消息 (v0.9/v0.10)。每条消息是一个 JSON 对象,有且仅有下面 4 个键之一:

消息 作用 类比
createSurface 创建一块「画布」(surface),指定它用哪个组件目录、主题 新建一张画纸
updateComponents 往画布里增加/更新一批组件(扁平列表) 在画纸上添加图形
updateDataModel 更新画布的「数据模型」(填充内容) 给图形填上文字数据
deleteSurface 删除整块画布 撕掉画纸

关键设计:结构(组件)和内容(数据)分开传。 改内容时只发 updateDataModel,不用重传整棵组件树------这是高效增量更新的基础。

一次完整交互的时序如下:

sequenceDiagram participant S as Server (Agent) participant C as Client (Renderer) participant U as User S->>C: 1. createSurface(surfaceId="contact_form") S->>C: 2. updateComponents(components=[root, 表单字段...]) S->>C: 3. updateDataModel(path="/contact", value={...}) Note over C: 客户端渲染出表单 U->>C: 4. 用户填写并点击「提交」 C->>S: 5. action(name="submit", context={email: "..."}) Note over S,C: 时间流逝,新数据到达... S->>C: 6. updateComponents / updateDataModel (动态更新) S->>C: 7. deleteSurface(surfaceId="contact_form")

源码中 MessageProcessor.processMessage() 就是这套分发逻辑的实现------它检查消息里有哪个键,分别走 processCreateSurfaceMessage / processUpdateComponentsMessage 等分支,并且严格校验「一条消息只能有一种类型」 ,否则抛 A2uiValidationError

6.2 邻接表模型:为什么界面是「一张扁平的表」?

这是 A2UI 最聪明的设计之一。

普通界面是一棵嵌套的树 (Card 里套 Column,Column 里套 Button......)。但 A2UI 不用嵌套 ,而是把所有组件拍平成一个扁平列表 ,组件之间通过 ID 引用建立父子关系。这就是「邻接表模型」(adjacency list)。

看一个真实例子(来自规范的联系人表单):

json 复制代码
{
  "updateComponents": {
    "surfaceId": "user_profile_card",
    "components": [
      { "id": "root",      "component": "Column", "children": ["user_name", "user_title"] },
      { "id": "user_name", "component": "Text",   "text": "John Doe" },
      { "id": "user_title","component": "Text",   "text": "Software Engineer" }
    ]
  }
}

root 通过 children: ["user_name", "user_title"] 引用两个子组件的 ID,客户端在渲染时再把这张「ID → 组件」的表重新拼成树

flowchart TD subgraph Stream["服务端发来的扁平列表"] A["root: Column, children=[user_name, user_title]"] B["user_name: Text 'John Doe'"] C["user_title: Text 'Software Engineer'"] end subgraph Map["客户端缓冲区 (Map)"] D["Map { root, user_name, user_title }"] end subgraph Tree["渲染出的组件树"] E[Column] --> F["Text: John Doe"] E --> G["Text: Software Engineer"] end A --> D B --> D C --> D D --> Tree

为什么要费这个劲拍平?三个好处:

  1. 顺序无关 :服务端可以任意顺序 发组件,先发 root 还是先发子节点都行。
  2. 渐进式渲染 :只要 root 一到,就能开始画;引用了还没到的子节点就先放占位符,等它来了再补上。这对「LLM 一个 token 一个 token 往外吐」的场景至关重要。
  3. LLM 友好:扁平的列表比深层嵌套的 JSON 更容易让 LLM 正确生成、增量修改。

源码里规则很明确:必须有且只有一个 idroot 的组件 作为树根;在 root 出现之前,其他组件更新会被缓冲、暂不显示。

6.3 数据绑定:JSON Pointer + 作用域

组件的属性可以是写死的字面量 ,也可以是绑定到数据模型的路径。绑定用的是 JSON Pointer(RFC 6901):

json 复制代码
{ "id": "user_name", "component": "Text", "text": { "path": "/user/name" } }

text 不再是固定字符串,而是 { "path": "/user/name" }------它会去数据模型里取 /user/name 的值。当服务端发来 updateDataModel(path="/user/name", value="Jane"),这个 Text 自动更新

作用域机制 让列表渲染变得优雅。当容器(如 List)用模板绑定到一个数组时,会为每个数组元素创建一个子作用域:

json 复制代码
{ "id": "employee_list", "component": "List",
  "children": { "path": "/employees", "componentId": "employee_card_template" } },
{ "id": "name_text", "component": "Text", "text": { "path": "name" } }
  • /employees绝对路径 (以 / 开头),从数据模型根部找。
  • 模板里的 name相对路径 (不以 / 开头),会被解析成 /employees/0/name/employees/1/name......即「当前这一项的 name」。
  • 模板里仍可用绝对路径 /company 访问全局数据。

6.4 响应式数据模型:基于 Signals 的级联通知

这是「双向绑定」和「实时更新」的引擎,源码在 DataModel 类(web_core/src/v0_9/state/data-model.ts)。

它内部维护一个 Map<路径字符串, Signal>(用了 @preact/signals-core)。每个被订阅的 JSON Pointer 路径对应一个 Signal。当某个路径的值变化时,它会级联通知三类相关 Signal

flowchart LR Set["set('/user/name', 'Jane')"] --> Self["① 通知自身<br/>/user/name"] Set --> Anc["② 通知所有祖先<br/>/user, /"] Set --> Desc["③ 通知所有后代<br/>/user/name/*"] Self --> Render["订阅者重渲染"] Anc --> Render Desc --> Render

为什么要通知祖先和后代?

  • 通知祖先 :如果有组件绑定到 /user(整个对象),那它的子字段 /user/name 变了,绑 /user 的组件也该刷新。
  • 通知后代 :如果整个 /user 被替换,那绑 /user/name 的组件也得跟着更新。

源码里 notifySignals()batch()(批处理,避免重复渲染)依次更新 自身 → 祖先链 → 所有后代路径。updateSignal() 还会对数组/对象做浅拷贝[...val] / {...val}),确保 Signal 检测到引用变化从而触发更新。

双向绑定 就建立在此之上:TextField 绑定到 /formData/email,用户每敲一个字,客户端立即 把新值写回数据模型的该路径;任何也绑定到这个路径的 Text 标签都会实时同步。注意------这一切都是本地的,不发网络请求 。只有当用户触发某个 action(比如点提交按钮)时,才把数据通过 context 打包发回服务端。

6.5 GenericBinder:靠「读 Schema 形状」自动绑定

这是渲染器里最精妙的一块(web_core/src/v0_9/rendering/generic-binder.ts),它回答了一个关键工程问题:

客户端怎么知道某个组件属性该「订阅数据」、该「当成点击动作」、还是该「展开成子组件列表」?难道要为每个组件硬编码?

A2UI 的答案是 。它用一个叫 scrapeSchemaBehavior 的函数去**「刮取」组件的 Zod Schema 的形状**,自动判断每个属性属于哪一类行为:

flowchart TD Schema["组件的 Zod Schema"] --> Scrape["scrapeSchemaBehavior<br/>(检查 Schema 形状)"] Scrape --> D["DYNAMIC:含 path → 订阅数据模型"] Scrape --> A["ACTION:含 event → 包装成 () => void 回调"] Scrape --> S["STRUCTURAL:含 componentId+path → 展开子组件列表"] Scrape --> Ck["CHECKABLE:checks → 跑校验, 注入 isValid/errors"] Scrape --> St["STATIC:原始值, 原样返回"]

判断逻辑非常直接:

  • 属性的 schema 是个 Union,且某个分支包含 event 字段 → 判定为 ACTION(用户动作)。
  • 包含 path不含 componentId → 判定为 DYNAMIC(数据绑定)。
  • 同时含 componentIdpath → 判定为 STRUCTURAL(模板列表)。
  • 属性名叫 checks → 判定为 CHECKABLE(校验)。

GenericBinder 拿到这棵「行为树」后,深度遍历组件的原始 JSON:

  • 遇到 DYNAMIC 就去 DataContext 订阅对应路径,数据一变就更新 prop 并通知重渲染;同时自动生成 setter (如 value 属性自动生成 setValue 方法,供输入框写回数据)------这正是双向绑定的客户端落点。
  • 遇到 ACTION 就返回一个现成的 () => void 闭包,调用时把 context 里的所有 path 解析成实际值,再 dispatchAction 发给服务端。
  • 遇到 STRUCTURAL 就订阅数组路径,把每个元素映射成 { id: 模板组件ID, basePath: /数组/索引 },从而生成列表的每一项。
  • 遇到 CHECKABLE 就响应式地跑每条校验规则,把 isValidvalidationErrors 注入父对象(比如校验不通过时自动禁用按钮)。

这个设计的威力在于:新增一个组件,开发者只要写好它的 Zod Schema,绑定逻辑全自动,无需写任何样板代码。 框架适配层(React/Angular adapter)只需 subscribe() 监听 currentProps 的变化即可。

6.6 安全模型:组件目录(Catalog)+ 客户端注册函数

A2UI 「安全得像数据」的根基是 Catalog(组件目录)

  • 客户端启动时声明自己支持哪些组件、哪些函数(如 requiredemailregexformatCurrency 等)。
  • Agent 只能引用目录里存在的组件和函数,无法注入任意可执行逻辑。
  • 校验/逻辑也是「按名字调用已注册函数 」的方式(FunctionCall),而不是发送代码。例如表单校验:
json 复制代码
"checks": [
  { "call": "required", "args": { "value": { "path": "/contact/email" } },
    "message": "邮箱必填" },
  { "call": "email",    "args": { "value": { "path": "/contact/email" } },
    "message": "请输入合法邮箱" }
]

这里的 requiredemail 都是客户端预先实现并审核过的函数,Agent 只是「点名」调用。即使 Agent 被攻陷,它能做的也仅限于「用白名单组件搭界面」,无法逃逸执行恶意代码。

进阶:你可以定义自己的 catalog,把 Agent 限制在你 App 真正拥有的组件和视觉语言内;甚至通过「智能包装器」把 iframe 沙箱接入 A2UI 的数据绑定体系,安全策略完全掌握在开发者手里。

6.7 服务端:prompt-generate-validate 循环

最后看服务端怎么「生产」这些 JSON。源码 samples/agent/adk/restaurant_finder/agent.py 完整演示了官方推荐的三步循环

flowchart TD P["① Prompt<br/>把 [目标UI + A2UI Schema + 合法示例]<br/>拼进系统提示词"] --> G["② Generate<br/>LLM 流式生成 A2UI JSON"] G --> Stream["边生成边解析<br/>把每个 part 流式发给客户端<br/>(渐进式渲染)"] G --> V["③ Validate<br/>用 jsonschema 校验生成结果"] V -->|通过| Done["发送最终响应 ✓"] V -->|失败| Retry["把错误信息回喂 LLM<br/>'你上次的响应不合法...请重试'"] Retry --> G

源码里的关键点:

  1. 系统提示词由 A2uiSchemaManager.generate_system_prompt() 生成,自动把 JSON Schema 和合法示例塞进去,让 LLM 知道该产出什么格式。
  2. 流式解析A2uiStreamParser 边读 LLM 的 token 流边切出一个个 A2UI 消息 part,立即 yield 给客户端,实现渐进式渲染。
  3. 校验 + 自我修正 :生成完后用 selected_catalog.validator.validate() 做 schema 校验。失败就把具体错误信息拼成新 prompt 回喂 LLM 重试 (源码里 max_retries = 1,即最多两次尝试)。
  4. 能力声明 :Agent 通过 A2A 的 AgentCard 声明自己支持哪些 A2UI 版本和 catalog(get_a2ui_agent_extension),客户端据此握手。

这个「生成→校验→(失败则)带错误重试」的闭环,既利用了 LLM 的生成能力,又用 Schema 兜住了结构正确性------这是让「LLM 画界面」真正可靠落地的工程关键。


七、未来展望

根据官方 Roadmap:

方向 状态
v1.0 规范稳定 🚧 进行中
Web Core Lib ✅ 已完成
Web Components/React/Flutter/Angular 官方渲染器 ✅ 已完成
SwiftUI 渲染器 🚧 计划 Q2
Jetpack Compose 🚧 计划 Q2
Genkit/LangGraph 集成 📋 计划中

八、学习资源


九、总结

A2UI 解决了一个核心问题:让 AI Agent 能够生成富交互 UI,而不只是纯文本

它的设计哲学:

  1. 安全优先:声明式 JSON,白名单组件
  2. LLM 友好:扁平结构,流式支持
  3. 跨平台:一份协议,多端渲染
  4. 可扩展:自定义 Catalog,灵活集成

A2UI 的精髓,可以用它自己的一句话收尾:

「Safe like data, expressive like code」

相关推荐
米小虾2 小时前
2026 年 AI Agent 开发现状:从概念到产线,这些开源项目正在重新定义自动化
人工智能·agent
zandy10112 小时前
Hermes Agent 2026年6月最新安装教程
docker·github·agent
夜焱辰2 小时前
我花了3个月,把一个终端 AI Agent 搬进了浏览器——踩坑全记录
前端·agent
tech_zjf2 小时前
我如何把「会聊天的 AI」做成「会行动、会记忆、会成长」的社交 Agent
agent·全栈
Agilex松灵机器人2 小时前
松灵技术生态|IsaacLab中实现松灵PIPER机械臂键盘遥操作与数据采集教程
agent·强化学习·仿真·具身智能·skill·松灵机器人
searchforAI3 小时前
CC-Switch教程:统一管理Skills、MCP、模型供应商、系统提示词等多项配置
人工智能·gpt·ai·大模型·agent·claudecode
lqqjuly4 小时前
多智能体系统架构(Multi-Agent System Architecture)
系统架构·agent
LienJack4 小时前
《Re0 Build Harness》第五章 演化路径
agent
雪碧聊技术5 小时前
预定义工具Tavily是什么?如何使用?一文详解
agent·tavily·预定义工具