引言:Agent 时代的 UI 困境
想象这样一个场景------你对一个 AI 助手说:"帮我订一张明天晚上 7 点的两人桌。" 如果 Agent 只能回复文本,接下来将是:
vbnet
用户: "帮我订一张明天晚上7点的两人桌"
Agent: "好的,请问几位用餐?"
用户: "两位"
Agent: "请问哪天?"
用户: "明天"
Agent: "什么时间?"
用户: "晚上7点"
Agent: "有什么忌口吗?"
...(五六个回合后终于订完)
更好的方式是:Agent 直接生成一个表单------日期选择器、时间选择器、人数输入框、提交按钮,一步搞定。但传统方案(Agent 返回 HTML/JS 塞进 iframe)笨重、割裂、不安全。
A2UI(Agent-to-User Interface) 就是为此而生的 Google 开源协议:Agent 发送声明式 JSON 描述界面意图,客户端用自己的原生组件渲染。安全如数据,表达如代码。
一、A2UI 全景架构图
先来一张图看全貌------A2UI 的核心是把 UI 生成和 UI 执行彻底解耦:
scss
┌──────────────────────────────────────────────────────────────┐
│ 用户 (User) │
│ 输入:"帮我找纽约的中餐馆" │ 看到原生渲染的卡片列表 │
└───────────────┬──────────────────────────────▲───────────────┘
│ 文字请求 │ 原生 UI
▼ │
┌───────────────────────────────────────────────────────────────┐
│ 客户端应用 (Client App) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 传输层 │ │ A2UI 渲染器 │ │ 组件目录 │ │
│ │ (Transport) │──▶│ (Renderer) │◀──│ (Catalog) │ │
│ │ A2A/WS/SSE │ │ Lit/Angular │ │ Button, Card... │ │
│ └──────┬──────┘ │ /Flutter │ └──────────────────┘ │
│ │ └──────────────┘ │
└─────────┼────────────────────────────────────────────────────┘
│ JSON 消息流 (JSONL)
│
┌─────────▼─────────────────────────────────────────────────────┐
│ AI Agent (后端) │
│ ┌───────────────┐ ┌──────────────────┐ │
│ │ 业务逻辑 │───▶│ A2UI 生成器 │ │
│ │ (Tools/API) │ │ (LLM 生成 JSON) │ │
│ └───────────────┘ └──────────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ Gemini / GPT │ │
│ │ 等 LLM 模型 │ │
│ └─────────────────┘ │
└────────────────────────────────────────────────────────────────┘
关键洞察:Agent 永远不会执行代码或操控 DOM。它只能从客户端预批准的"组件目录"中选取组件来组合界面------就像只能用菜单上的菜来点餐,不能自己跑进厨房。
二、三分钟理解核心概念
2.1 五个关键词
css
┌─────────────────────────────────────────────────────────────┐
│ A2UI 五大核心概念 │
├─────────────┬───────────────────────────────────────────────┤
│ Surface │ 画布/容器,承载一组组件(如一个表单、一个卡片) │
│ Component │ UI 元素(Button, Text, Card, TextField...) │
│ Data Model │ 应用状态,组件通过路径绑定到它 │
│ Catalog │ 组件目录,定义 Agent 能用哪些组件 │
│ Message │ JSON 消息(创建画布/更新组件/更新数据/删除画布) │
└─────────────┴───────────────────────────────────────────────┘
2.2 邻接表模型:为什么是扁平列表而非嵌套树?
这是 A2UI 最独特的设计。传统 UI 描述用嵌套 JSON 树,但 LLM 生成深层嵌套时极易出错、难以流式传输。A2UI 把组件展平为一个列表,通过 ID 引用建立父子关系:
bash
传统嵌套树(LLM 容易搞乱括号) A2UI 邻接表(扁平 + ID 引用)
───────────────────────── ──────────────────────────
{ components: [
"Column": { { id: "root", → Column, children: ["title","btn"] },
"children": [ { id: "title", → Text, text: "Hello" },
{ "Text": { "Hello" } }, { id: "btn", → Button, child: "btn-text" },
{ "Button": { { id: "btn-text",→ Text, text: "OK" }
"child": { ]
"Text": { "OK" }
}
}}
]
}
}
层层嵌套,一个括号没对上就全废了 所有组件平铺,随时增量发送、按 ID 更新
2.3 数据绑定:结构与状态分离
组件定义"长什么样",数据模型定义"展示什么内容"。两者通过 JSON Pointer 路径连接:
vbnet
组件结构 数据模型
┌──────────────┐ ┌──────────────────┐
│ Text │ │ { │
│ text: ────────┼───path───────────▶│ "user": { │
│ path: │ "/user/name" │ "name":"Alice"│
│ "/user/name"│ │ } │
└──────────────┘ └──────────────────┘
│
当数据模型更新为 "Bob" 时 ──────────────────┘
Text 自动显示 "Bob",无需重发组件定义!
三、消息生命周期图解
以一个完整的餐厅预订流程为例,看 A2UI 消息如何流转:
bash
用户 客户端 Agent
│ │ │
│ "订两人桌" │ │
│ ──────────────────────────▶│ │
│ │ 将用户消息转发给 Agent │
│ │ ─────────────────────────────▶│
│ │ │
│ │ ① createSurface │
│ │◀─ (创建画布,指定 Catalog)──── │
│ │ │
│ │ ② updateComponents │
│ │◀─ (标题+人数框+日期框+按钮)── │
│ 看到表单渐进式渲染 │ │
│◀───────────────────────── │ ③ updateDataModel │
│ │◀─ (日期="明天", 人数="2") ──── │
│ │ │
│ 修改人数为 "3" │ │
│ ──────────────────────────▶│ 本地数据模型自动更新 │
│ │ /reservation/guests = "3" │
│ │ │
│ 点击「确认预订」 │ │
│ ──────────────────────────▶│ │
│ │ ④ action │
│ │ ─(name:"confirm",context)───▶│
│ │ │
│ │ ⑤ deleteSurface │
│ 看到"预订成功"确认界面 │◀─ + 新 surface (确认卡片) ── │
│◀───────────────────────── │ │
四、实战示例:由浅入深
🟢 入门级:Hello World --- 一张静态信息卡
适合人群:想快速了解 A2UI JSON 长什么样的开发者
这是最简单的 A2UI 示例------展示一张带标题和描述的卡片,没有交互,没有数据绑定,纯静态内容。
json
// 消息 1:创建画布
{
"version": "v0.9",
"createSurface": {
"surfaceId": "hello-card",
"catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
}
}
// 消息 2:定义组件
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "hello-card",
"components": [
{
"id": "root",
"component": "Card",
"child": "content"
},
{
"id": "content",
"component": "Column",
"children": ["title", "desc"]
},
{
"id": "title",
"component": "Text",
"text": "👋 欢迎使用 A2UI",
"variant": "h1"
},
{
"id": "desc",
"component": "Text",
"text": "这是一张由 Agent 生成的卡片,渲染为你应用的原生组件。"
}
]
}
}
解读如下------整个过程只需两条消息。createSurface 告诉客户端"我要创建一个画布,用基础组件目录"。updateComponents 发送四个组件:Card 是容器,Column 纵向排列子组件,两个 Text 分别是标题和正文。所有组件平铺在一个列表里,通过 child 和 children 引用彼此的 ID。
渲染效果示意:
css
┌──────────────────────────┐
│ ┌──────────────────────┐ │
│ │ 👋 欢迎使用 A2UI │ │ ← h1 标题
│ │ │ │
│ │ 这是一张由 Agent │ │ ← 正文描述
│ │ 生成的卡片... │ │
│ └──────────────────────┘ │
└──────────────────────────┘
Card 容器
🟡 进阶级:带数据绑定的用户资料卡
适合人群:需要理解数据绑定、响应式更新的前端/全栈开发者
这个示例展示数据绑定的核心能力------组件不写死内容,而是绑定到数据模型的路径。当数据变化时,UI 自动刷新。
json
// 消息 1:创建画布
{
"version": "v0.9",
"createSurface": {
"surfaceId": "profile",
"catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
}
}
// 消息 2:定义组件(结构)
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "profile",
"components": [
{
"id": "root",
"component": "Card",
"child": "layout"
},
{
"id": "layout",
"component": "Column",
"children": ["avatar", "name", "email", "role"]
},
{
"id": "avatar",
"component": "Image",
"url": { "path": "/user/avatar" },
"fit": "cover"
},
{
"id": "name",
"component": "Text",
"text": { "path": "/user/name" },
"variant": "h2"
},
{
"id": "email",
"component": "Text",
"text": { "path": "/user/email" }
},
{
"id": "role",
"component": "Text",
"text": { "path": "/user/role" },
"variant": "caption"
}
]
}
}
// 消息 3:填充数据
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": "profile",
"path": "/user",
"value": {
"name": "Sarah Chen",
"email": "sarah@techco.com",
"role": "Product Designer",
"avatar": "https://example.com/sarah.jpg"
}
}
}
关键点在于,组件中的 { "path": "/user/name" } 就是数据绑定语法。渲染器看到它会去数据模型中读取 /user/name 的值来显示。当 Agent 后续发送新的 updateDataModel 把 /user/name 改成 "Bob Lee" 时,名字自动变化,不需要重新发送组件定义。这就是结构与状态分离带来的高效更新。
javascript
组件定义(不变) 数据模型(可随时更新)
┌──────────────────┐ ┌────────────────────────┐
│ Text │ │ { "user": { │
│ text: │─bindTo──▶│ "name": "Sarah" │──▶ 显示 "Sarah"
│ path:/user/name│ │ } │
└──────────────────┘ └────────────────────────┘
│ Agent 发送数据更新
┌────────▼───────────────┐
│ { "user": { │
│ "name": "Bob" │──▶ 自动显示 "Bob"
│ } │
└────────────────────────┘
🟡 进阶级:带表单交互的餐厅预订
适合人群:需要理解双向绑定和 Action 机制的开发者
这是官方 Demo 的核心场景------Agent 生成一个预订表单,用户填写后提交,Agent 收到数据进行处理。
json
// 消息 1:创建画布
{
"version": "v0.9",
"createSurface": {
"surfaceId": "booking",
"catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
}
}
// 消息 2:定义表单组件
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "booking",
"components": [
{
"id": "root",
"component": "Column",
"children": ["title", "img", "party-size", "datetime", "dietary", "submit-btn"]
},
{
"id": "title",
"component": "Text",
"text": { "path": "/title" },
"variant": "h2"
},
{
"id": "img",
"component": "Image",
"url": { "path": "/imageUrl" }
},
{
"id": "party-size",
"component": "TextField",
"label": "用餐人数",
"value": { "path": "/partySize" },
"textFieldType": "number"
},
{
"id": "datetime",
"component": "DateTimeInput",
"label": "日期和时间",
"value": { "path": "/reservationTime" },
"enableDate": true,
"enableTime": true
},
{
"id": "dietary",
"component": "TextField",
"label": "饮食要求",
"value": { "path": "/dietary" }
},
{
"id": "submit-btn",
"component": "Button",
"child": "submit-text",
"variant": "primary",
"action": {
"event": {
"name": "submit_booking",
"context": {
"restaurant": { "path": "/restaurantName" },
"partySize": { "path": "/partySize" },
"time": { "path": "/reservationTime" },
"dietary": { "path": "/dietary" }
}
}
}
},
{
"id": "submit-text",
"component": "Text",
"text": "确认预订"
}
]
}
}
// 消息 3:填充初始数据
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": "booking",
"path": "/",
"value": {
"title": "预订 - 西安名吃",
"restaurantName": "西安名吃",
"imageUrl": "https://example.com/xian.jpg",
"partySize": "2",
"reservationTime": "",
"dietary": ""
}
}
}
这里有三个关键交互机制值得注意。
双向绑定 ------TextField 的 value 绑定到 /partySize,用户输入 "4" 时,本地数据模型立即更新为 {"partySize": "4"},完全在客户端本地完成,没有网络请求。
Action 的 context ------Button 的 action.event.context 定义了提交时要携带哪些数据。每个 key 的 value 用 path 指向数据模型,客户端在点击时解析出当前值。
当用户点击"确认预订",客户端发送的消息如下:
json
{
"version": "v0.9",
"action": {
"name": "submit_booking",
"surfaceId": "booking",
"sourceComponentId": "submit-btn",
"timestamp": "2026-03-18T19:30:00Z",
"context": {
"restaurant": "西安名吃",
"partySize": "4",
"time": "2026-03-19T19:00:00Z",
"dietary": "不吃辣"
}
}
}
Agent 端 Python 处理代码类似:
python
if action_name == "submit_booking":
restaurant = context.get("restaurant")
party_size = context.get("partySize")
time = context.get("time")
# 让 LLM 处理
query = f"用户预订了 {restaurant},{party_size} 人,时间 {time}"
response = await llm.generate(query)
🔴 高级:动态列表 + 模板渲染
适合人群:需要高效渲染大量数据的架构师和高级开发者
当 Agent 返回一组搜索结果时,不需要为每条结果分别定义组件------用一个模板 + 数据数组即可自动渲染:
json
// 组件定义:一个模板驱动的列表
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "search-results",
"components": [
{
"id": "root",
"component": "Column",
"children": ["result-header", "result-list"]
},
{
"id": "result-header",
"component": "Text",
"text": "为你找到以下餐厅:",
"variant": "h2"
},
{
"id": "result-list",
"component": "List",
"children": {
"componentId": "restaurant-card",
"path": "/restaurants"
},
"direction": "vertical"
},
{
"id": "restaurant-card",
"component": "Card",
"child": "card-layout"
},
{
"id": "card-layout",
"component": "Row",
"children": ["card-img", "card-info"]
},
{
"id": "card-img",
"component": "Image",
"url": { "path": "/imageUrl" },
"fit": "cover"
},
{
"id": "card-info",
"component": "Column",
"children": ["card-name", "card-rating", "card-detail"]
},
{
"id": "card-name",
"component": "Text",
"text": { "path": "/name" },
"variant": "h3"
},
{
"id": "card-rating",
"component": "Text",
"text": { "path": "/rating" },
"variant": "caption"
},
{
"id": "card-detail",
"component": "Text",
"text": { "path": "/detail" }
}
]
}
}
// 数据模型:一个数组,有多少项就渲染多少张卡片
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": "search-results",
"path": "/restaurants",
"value": [
{
"name": "西安名吃",
"detail": "正宗手拉面,香辣可口",
"rating": "★★★★☆",
"imageUrl": "https://example.com/xian.jpg"
},
{
"name": "韩朝",
"detail": "地道四川菜",
"rating": "★★★★☆",
"imageUrl": "https://example.com/han.jpg"
},
{
"name": "红农场",
"detail": "现代中餐,农场直供",
"rating": "★★★★☆",
"imageUrl": "https://example.com/red.jpg"
}
]
}
}
核心原理是作用域路径 。模板中的 { "path": "/name" } 不是指向全局根路径,而是自动限定到当前数组项。第一张卡片的 /name 解析为 /restaurants/0/name,即 "西安名吃";第二张解析为 /restaurants/1/name,即 "韩朝"。
ini
数据:/restaurants = [ {name:"西安名吃"}, {name:"韩朝"}, {name:"红农场"} ]
│ │ │
模板自动实例化 ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 🖼️ 西安名吃 │ │ 🖼️ 韩朝 │ │ 🖼️ 红农场 │
│ ★★★★☆ │ │ ★★★★☆ │ │ ★★★★☆ │
│ 正宗手拉面 │ │ 地道四川菜 │ │ 现代中餐 │
└─────────────┘ └─────────────┘ └─────────────┘
新增一项到数组 → 自动多渲染一张卡片,无需修改组件定义!
🔴 高级:多 Agent 编排(Orchestrator)
适合人群:构建企业级多 Agent 系统的架构师
在真实的企业场景中,一个主协调器(Orchestrator)管理多个专业子 Agent,每个子 Agent 负责自己领域的 UI。这是仓库里 samples/agent/adk/orchestrator 示例所展示的架构:
scss
┌───────────────────┐
用户问题 │ Orchestrator │
───────────────── ▶│ (主协调 Agent) │
│ │
│ ① 意图识别 │
│ "找中餐" → 路由到 │
│ 餐厅 Agent │
└──┬──────┬─────┬──┘
│ │ │
┌───────────────┘ │ └───────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 餐厅查找 Agent │ │ 联系人查找 Agent │ │ 数据图表 Agent │
│ (port 10003) │ │ (port 10004) │ │ (port 10005) │
│ │ │ │ │ │
│ 返回:餐厅列表 UI │ │ 返回:联系人卡片 │ │ 返回:图表 UI │
│ (A2UI JSON) │ │ (A2UI JSON) │ │ (A2UI JSON) │
└────────────────────┘ └───────────────────┘ └──────────────────┘
Orchestrator 需要处理两个关键安全问题:
Surface 所有权映射------当子 Agent 创建 Surface 时,Orchestrator 记录"这个 surfaceId 属于哪个子 Agent"。当用户在 UI 上操作触发 Action 时,Orchestrator 根据 surfaceId 把请求路由回正确的子 Agent。
数据模型隔离 ------当 sendDataModel: true 启用时,客户端会在每条消息元数据中附带所有 Surface 的数据模型。Orchestrator 必须在转发给子 Agent 前剥离其他 Agent 的数据,否则会导致跨 Agent 的数据泄露。
css
客户端发来的元数据(包含所有 Surface 的数据):
┌──────────────────────────────────────┐
│ a2uiClientDataModel: { │
│ surfaces: { │
│ "restaurant-list": {...}, ◀─── 属于餐厅 Agent
│ "contact-card": {...}, ◀─── 属于联系人 Agent
│ "sales-chart": {...} ◀─── 属于图表 Agent
│ } │
│ } │
└──────────────────────────────────────┘
│
Orchestrator 必须 strip
│
▼ 转发给餐厅 Agent 时只保留:
┌──────────────────────────────────────┐
│ a2uiClientDataModel: { │
│ surfaces: { │
│ "restaurant-list": {...} │ ✅ 只有自己的数据
│ } │
│ } │
└──────────────────────────────────────┘
🔴 高级:自定义组件 Catalog
适合人群:需要扩展 A2UI 到特定业务领域的团队
标准 Catalog 只有通用组件。如果你需要地图、图表、股票行情等,就需要自定义 Catalog:
json
{
"$id": "https://mycompany.com/catalogs/dashboard/v1/catalog.json",
"components": {
"allOf": [
{ "$ref": "basic_catalog.json#/components" },
{
"SalesChart": {
"type": "object",
"description": "交互式销售数据图表",
"properties": {
"chartType": {
"type": "string",
"enum": ["bar", "line", "pie"],
"description": "图表类型"
},
"data": {
"description": "绑定到数据模型的图表数据路径"
},
"title": {
"type": "string",
"description": "图表标题"
}
},
"required": ["chartType", "data"]
},
"GoogleMap": {
"type": "object",
"description": "显示指定位置的 Google 地图",
"properties": {
"latitude": { "type": "number" },
"longitude": { "type": "number" },
"zoom": { "type": "integer", "default": 14 }
},
"required": ["latitude", "longitude"]
}
}
]
}
}
然后 Agent 就可以这样使用自定义组件:
json
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "dashboard",
"components": [
{
"id": "root",
"component": "Column",
"children": ["chart", "map"]
},
{
"id": "chart",
"component": "SalesChart",
"chartType": "bar",
"data": { "path": "/sales/quarterly" },
"title": "Q4 销售数据"
},
{
"id": "map",
"component": "GoogleMap",
"latitude": 31.2304,
"longitude": 121.4737,
"zoom": 12
}
]
}
}
整个协商流程如下:
bash
客户端 Agent
│ │
│ "我支持这些 Catalog": │
│ [basic_catalog, dashboard/v1] │
│ ────────────────────────────────────────▶ │
│ │
│ Agent 选择最佳匹配 │
│ dashboard/v1 ✅ │
│ │
│ createSurface: │
│ catalogId: "dashboard/v1" │
│ ◀──────────────────────────────────────── │
│ │
│ 此后该 Surface 只能用 │
│ dashboard/v1 中定义的组件 │
五、v0.8 vs v0.9 差异速查表
两个版本的核心差异一图了然。如果你是新项目,建议直接用 v0.9;如果要维护旧代码,参考此表迁移。
css
v0.8 (稳定版) v0.9 (草案版)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
组件格式: 组件格式:
"component": { "component": "Text",
"Text": { "text": "Hello"
"text": {"literalString":"Hello"}
} ← 更扁平、更少 token
}
子组件: 子组件:
"children": { "children": ["a", "b"]
"explicitList": ["a", "b"]
} ← 标准数组
数据更新: 数据更新:
[{"key":"name","valueString":"Alice"}] {"name": "Alice"}
← 标准 JSON 对象
画布创建: 画布创建:
beginRendering + surfaceUpdate createSurface (含 catalogId)
← 显式目录协商
按钮样式: 按钮样式:
"primary": true "variant": "primary"
← 更灵活的枚举
Action 格式: Action 格式:
{"name": "submit"} {"event": {"name": "submit"}}
← 支持 event/functionCall 区分
版本标识: 版本标识:
无 每条消息含 "version": "v0.9"
六、安全模型图解
A2UI 的安全是多层防御体系,这是它区别于传统 iframe 方案的核心优势:
javascript
┌────────────────────────────────────────────────────────────┐
│ 安全防御层级 │
├────────────────────────────────────────────────────────────┤
│ │
│ 第 1 层:声明式格式 ─ 不是代码,是数据 │
│ ──────────────────────────────────────── │
│ Agent 发送的是 JSON 描述,不是 HTML/JS │
│ 客户端永远不会 eval() 任何 Agent 内容 │
│ │
│ 第 2 层:组件目录白名单 ─ 只能用"菜单上的菜" │
│ ──────────────────────────────────────── │
│ Agent 只能请求 Catalog 中预定义的组件 │
│ 未知组件类型直接被忽略或降级为占位符 │
│ │
│ 第 3 层:双端 Schema 验证 ─ Agent 端 + 客户端都检查 │
│ ──────────────────────────────────────── │
│ Agent 端:发送前验证 JSON 是否合法 │
│ 客户端:接收后再验证一次,不合法就报错给 Agent │
│ │
│ 第 4 层:VALIDATION_FAILED 反馈 ─ LLM 自我纠正 │
│ ──────────────────────────────────────── │
│ 客户端告诉 Agent "你的 JSON 第X处不对" │
│ Agent 据此修正并重新生成 │
│ │
│ 第 5 层:Orchestrator 数据隔离 ─ 多 Agent 不互相窥探 │
│ ──────────────────────────────────────── │
│ 必须剥离其他 Agent 的数据模型后再转发 │
│ │
└────────────────────────────────────────────────────────────┘
七、与同类方案的对比一览
css
A2UI MCP Apps AG UI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
本质 UI 描述格式 预构建 HTML 传输协议
(iframe)
渲染方式