本文深入解析 Google 开源的 A2UI 协议,探讨其核心架构、数据流设计以及为何它是 LLM 生成 UI 的最佳实践。
一、A2UI 是什么?
A2UI (Agent-to-User Interface) 是 Google 于 2025 年开源的声明式 UI 协议。它解决了一个核心问题:
如何让 AI Agent 安全地跨信任边界发送富交互 UI?
传统的 Agent 交互往往是纯文本对话,效率低下。而直接让 LLM 生成 HTML/JS 代码又存在严重的安全风险。A2UI 提供了一个中间方案:Agent 发送声明式 JSON 描述 UI 意图,客户端使用自己的原生组件渲染。
安全性:像数据一样安全
表达力:像代码一样丰富
二、核心设计理念
2.1 三层解耦架构
A2UI 的核心哲学是将三个关键元素解耦:
┌─────────────────────────────────────────────────────────┐
│ A2UI 三层架构 │
├─────────────────────────────────────────────────────────┤
│ 1. 组件树 (Structure) - Agent 提供的抽象 UI 结构 │
│ 2. 数据模型 (State) - 动态填充 UI 的应用状态 │
│ 3. 组件目录 (Catalog) - 客户端定义的可信组件映射 │
└─────────────────────────────────────────────────────────┘
这种设计带来的好处:
- 安全性:Agent 只能使用客户端预定义的组件,无法注入恶意代码
- 灵活性:同一份 UI 描述可在不同框架(Angular/Flutter/React)上渲染
- 高效性:数据变更无需重发整个 UI 结构
2.2 邻接表模型 vs 嵌套树
这是 A2UI 最精妙的设计之一。传统 UI 描述使用嵌套 JSON 树:
json
//传统嵌套结构 - LLM 难以一次性生成正确
{
"type": "Column",
"children": [
{
"type": "Text",
"text": "Hello"
},
{
"type": "Row",
"children": [
{"type": "Button", "child": {"type": "Text", "text": "Cancel"}},
{"type": "Button", "child": {"type": "Text", "text": "OK"}}
]
}
]
}
A2UI 采用扁平的邻接表:
json
//A2UI 邻接表结构 - LLM 友好,支持增量生成
{
"surfaceUpdate": {
"components": [
{"id": "root", "component": {"Column": {"children": {"explicitList": ["greeting", "buttons"]}}}},
{"id": "greeting", "component": {"Text": {"text": {"literalString": "Hello"}}}},
{"id": "buttons", "component": {"Row": {"children": {"explicitList": ["cancel-btn", "ok-btn"]}}}},
{"id": "cancel-btn", "component": {"Button": {"child": "cancel-text", "action": {"name": "cancel"}}}},
{"id": "cancel-text", "component": {"Text": {"text": {"literalString": "Cancel"}}}},
{"id": "ok-btn", "component": {"Button": {"child": "ok-text", "action": {"name": "ok"}}}},
{"id": "ok-text", "component": {"Text": {"text": {"literalString": "OK"}}}}
]
}
}
邻接表的优势:
| 特性 | 嵌套树 | 邻接表 |
|---|---|---|
| LLM 生成难度 | 高(需一次性正确嵌套) | 低(逐个组件生成) |
| 增量更新 | 困难 | 简单(按 ID 更新) |
| 流式传输 | 不支持 | 原生支持 |
| 错误恢复 | 整体失败 | 单组件失败不影响其他 |
三、协议消息类型详解
A2UI 定义了 4 种服务端到客户端的消息类型:
3.1 surfaceUpdate - 定义 UI 结构
json
{
"surfaceUpdate": {
"surfaceId": "booking-form",
"components": [
{
"id": "title",
"component": {
"Text": {
"text": {"literalString": "预订餐厅"},
"usageHint": "h1"
}
}
},
{
"id": "date-picker",
"component": {
"DateTimeInput": {
"value": {"path": "/reservation/date"},
"enableDate": true,
"enableTime": true
}
}
}
]
}
}
关键点:
surfaceId:标识 UI 区域,支持多个独立 Surfacecomponents:扁平组件列表,通过 ID 引用建立父子关系- 组件属性支持字面值 或数据绑定
3.2 dataModelUpdate - 填充数据
json
{
"dataModelUpdate": {
"surfaceId": "booking-form",
"path": "/reservation",
"contents": [
{"key": "date", "valueString": "2025-12-20T19:00:00Z"},
{"key": "guests", "valueInt": 2},
{"key": "restaurant", "valueMap": [
{"key": "name", "valueString": "川味轩"},
{"key": "rating", "valueNumber": 4.8}
]}
]
}
}
设计亮点:
- 数据与 UI 结构分离,修改数据无需重发组件定义
- 支持嵌套数据结构(valueMap)
- 类型安全(valueString/valueInt/valueBoolean/valueNumber)
3.3 beginRendering - 触发渲染
json
{
"beginRendering": {
"surfaceId": "booking-form",
"root": "title",
"catalogId": "https://github.com/google/A2UI/.../standard_catalog_definition.json"
}
}
为什么需要这个消息?
- 防止"闪烁":客户端缓冲组件,等待明确信号再渲染
- 指定根组件:从哪个组件开始构建树
- 指定组件目录:告诉客户端使用哪套组件定义
3.4 deleteSurface - 清理 UI
json
{
"deleteSurface": {
"surfaceId": "booking-form"
}
}
四、完整数据流解析
下面是一个完整的餐厅预订场景数据流:
┌──────────────────────────────────────────────────────────────────┐
│ A2UI 数据流 │
└──────────────────────────────────────────────────────────────────┘
用户: "帮我预订明晚7点的餐厅,2人"
│
▼
┌─────────────────┐
│ AI Agent │ ← 接收用户请求,调用 LLM
│ (Python/Java) │
└────────┬────────┘
│ 生成 A2UI JSON (JSONL 流)
▼
┌─────────────────────────────────────────────────────────────────┐
│ {"surfaceUpdate": {"surfaceId": "booking", "components": [...]}}│
│ {"dataModelUpdate": {"surfaceId": "booking", "contents": [...]}}│
│ {"beginRendering": {"surfaceId": "booking", "root": "form"}} │
└─────────────────────────────────────────────────────────────────┘
│ 通过 SSE/WebSocket/A2A 传输
▼
┌─────────────────┐
│ Client App │ ← 解析 JSONL,构建组件缓冲
│ (Angular/Lit) │
└────────┬────────┘
│ 收到 beginRendering 后
▼
┌─────────────────┐
│ A2UI Renderer │ ← 从 root 开始递归构建组件树
│ │ ← 解析数据绑定,查询 WidgetRegistry
└────────┬────────┘
│
▼
┌─────────────────┐
│ Native UI │ ← 渲染为原生组件(Material/Cupertino等)
│ (用户可见) │
└────────┬────────┘
│ 用户点击"确认预订"按钮
▼
┌─────────────────────────────────────────────────────────────────┐
│ {"userAction": { │
│ "name": "confirm_booking", │
│ "surfaceId": "booking", │
│ "context": {"date": "2025-12-20T19:00", "guests": 2} │
│ }} │
└─────────────────────────────────────────────────────────────────┘
│ 通过 A2A 消息发送回 Agent
▼
┌─────────────────┐
│ AI Agent │ ← 处理用户操作,可能更新 UI 或完成任务
└─────────────────┘
4.1 用户点击"确认预订"后发生了什么?
这是一个关键问题:A2UI 只负责 UI 层,真正的业务逻辑由 Agent 决定。
当用户点击按钮后,Client 会将 userAction 发送回 Agent。Agent 收到后有多种处理方式:
┌─────────────────────────────────────────────────────────────────┐
│ 用户点击"确认预订"后的处理流程 │
└─────────────────────────────────────────────────────────────────┘
userAction 到达 Agent
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 方案 A │ │ 方案 B │ │ 方案 C │
│ 模拟预订 │ │ 调用 API │ │ 委托子Agent│
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
直接返回确认UI 调用餐厅真实API 通过 A2A 委托
(Demo 场景) (MCP/HTTP) 专业预订 Agent
方案 A:模拟预订(Demo 场景)
当前示例代码采用的就是这种方式------Agent 直接生成确认 UI,不调用真实预订系统:
python
# Agent 收到 userAction 后,LLM 根据 Prompt 生成确认界面
# 这是 Demo 演示用,没有真实预订
# Prompt 中的指令:
# "For confirming a booking: use the CONFIRMATION_EXAMPLE template"
生成的确认 UI:
json
{
"surfaceUpdate": {
"surfaceId": "confirmation",
"components": [
{"id": "confirm-title", "component": {"Text": {"text": {"path": "title"}}}},
{"id": "confirm-details", "component": {"Text": {"text": {"path": "bookingDetails"}}}}
]
}
},
{
"dataModelUpdate": {
"surfaceId": "confirmation",
"contents": [
{"key": "title", "valueString": "预订成功!"},
{"key": "bookingDetails", "valueString": "川味轩 | 2人 | 明晚7点"}
]
}
}
方案 B:调用真实 API(生产场景)
在真实生产环境中,Agent 需要调用外部服务完成预订。这可以通过以下方式实现:
方式 1:Agent 内置 Tool(函数调用)
python
# 在 Agent 中定义预订工具
def book_restaurant(
restaurant_id: str,
date: str,
time: str,
guests: int,
tool_context: ToolContext
) -> str:
"""调用餐厅预订 API"""
response = requests.post(
"https://api.restaurant.com/bookings",
json={
"restaurant_id": restaurant_id,
"datetime": f"{date}T{time}",
"party_size": guests
},
headers={"Authorization": f"Bearer {API_KEY}"}
)
return response.json()
# Agent 配置
agent = LlmAgent(
tools=[get_restaurants, book_restaurant], # 添加预订工具
instruction="当用户确认预订时,调用 book_restaurant 工具..."
)
方式 2:通过 MCP (Model Context Protocol) 调用
python
# Agent 通过 MCP 连接到餐厅预订服务
mcp_client = MCPClient("restaurant-booking-server")
# MCP 服务器暴露的工具
# - create_booking(restaurant_id, datetime, guests)
# - cancel_booking(booking_id)
# - get_availability(restaurant_id, date)
result = await mcp_client.call_tool(
"create_booking",
{
"restaurant_id": "chuanwei-001",
"datetime": "2025-12-20T19:00:00",
"guests": 2
}
)
方案 C:委托专业子 Agent(多 Agent 协作)
在复杂的多 Agent 系统中,主 Agent 可能将预订任务委托给专业的预订 Agent:
┌─────────────────────────────────────────────────────────────────┐
│ 多 Agent 协作预订流程 │
└─────────────────────────────────────────────────────────────────┘
┌──────────────┐ A2A 协议 ┌──────────────────┐
│ 主 Agent │ ──────────────────>│ 预订专业 Agent │
│ (对话协调) │ │ (OpenTable集成) │
└──────────────┘ └────────┬─────────┘
│
│ 调用真实 API
▼
┌──────────────────┐
│ OpenTable API │
│ 或其他预订平台 │
└──────────────────┘
python
# 主 Agent 通过 A2A 协议委托任务
async def delegate_booking(booking_details: dict):
a2a_client = A2AClient("https://booking-agent.example.com")
response = await a2a_client.send_message({
"message": {
"role": "user",
"parts": [{
"kind": "data",
"data": {
"task": "create_booking",
"details": booking_details
}
}]
}
})
return response
4.2 A2UI 的职责边界
理解这一点很重要:
┌─────────────────────────────────────────────────────────────────┐
│ 职责分离 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ A2UI 协议负责: │
│ ├── UI 结构描述 (surfaceUpdate) │
│ ├── 数据绑定 (dataModelUpdate) │
│ ├── 用户交互事件传递 (userAction) │
│ └── 渲染控制 (beginRendering) │
│ │
│ A2UI 协议不负责: │
│ ├── 业务逻辑执行(预订、支付等) │
│ ├── 外部 API 调用 │
│ ├── 数据持久化 │
│ └── 身份认证 │
│ │
│ 业务逻辑由 Agent 通过以下方式实现: │
│ ├── 内置 Tools(函数调用) │
│ ├── MCP 服务器 │
│ ├── A2A 委托给专业子 Agent │
│ └── 直接 HTTP/gRPC 调用 │
│ │
└─────────────────────────────────────────────────────────────────┘
简单来说:A2UI 是 UI 层协议,业务逻辑由 Agent 自行决定如何实现。
五、标准组件目录
A2UI v0.8 定义了以下标准组件:
| 类别 | 组件 | 说明 |
|---|---|---|
| 布局 | Row, Column, List | 排列子组件 |
| 展示 | Text, Image, Icon, Video, AudioPlayer, Divider | 展示内容 |
| 交互 | Button, TextField, CheckBox, DateTimeInput, Slider, MultipleChoice | 用户输入 |
| 容器 | Card, Tabs, Modal | 组织内容 |
组件示例:动态列表
json
// 使用 template 渲染动态列表
{
"surfaceUpdate": {
"components": [
{
"id": "restaurant-list",
"component": {
"List": {
"children": {
"template": {
"dataBinding": "/restaurants",
"componentId": "restaurant-card-template"
}
}
}
}
},
{
"id": "restaurant-card-template",
"component": {
"Card": {"child": "card-content"}
}
}
]
}
}
六、组件目录协商机制
这是 A2UI 安全模型的核心:Agent 如何知道可以使用哪些组件?
6.1 协商流程
┌─────────────────────────────────────────────────────────────────┐
│ 组件目录协商流程 │
└─────────────────────────────────────────────────────────────────┘
步骤 1: Agent 在 Agent Card 中声明支持的目录
↓
┌─────────────────────────────────────────────────────────────────┐
│ { │
│ "name": "Restaurant Finder", │
│ "capabilities": { │
│ "extensions": [{ │
│ "uri": "https://a2ui.org/a2a-extension/a2ui/v0.8", │
│ "params": { │
│ "supportedCatalogIds": [ │
│ "https://github.com/google/A2UI/.../standard_catalog",│
│ "https://my-company.com/custom_catalog" │
│ ], │
│ "acceptsInlineCatalogs": true │
│ } │
│ }] │
│ } │
│ } │
└─────────────────────────────────────────────────────────────────┘
步骤 2: Client 在每条消息中声明自己支持的目录
↓
┌─────────────────────────────────────────────────────────────────┐
│ { │
│ "metadata": { │
│ "a2uiClientCapabilities": { │
│ "supportedCatalogIds": [ │
│ "https://github.com/google/A2UI/.../standard_catalog" │
│ ], │
│ "inlineCatalogs": [ │
│ { │
│ "catalogId": "my-app:custom-charts", │
│ "components": { │
│ "PieChart": { "type": "object", "properties": {...}}│
│ } │
│ } │
│ ] │
│ } │
│ }, │
│ "message": { "prompt": { "text": "找餐厅" } } │
│ } │
└─────────────────────────────────────────────────────────────────┘
步骤 3: Agent 选择双方都支持的目录,在 beginRendering 中指定
↓
┌─────────────────────────────────────────────────────────────────┐
│ { │
│ "beginRendering": { │
│ "surfaceId": "main", │
│ "catalogId": "https://github.com/google/A2UI/.../standard", │
│ "root": "root-component" │
│ } │
│ } │
└─────────────────────────────────────────────────────────────────┘
6.2 LLM 如何被约束只使用已知组件?
关键在于 Prompt Engineering + JSON Schema 约束:
python
# Agent 开发者在调用 LLM 时,将组件目录作为 Schema 约束传入
# 1. 加载客户端支持的组件目录
catalog = load_catalog("standard_catalog_definition.json")
# 2. 构建包含组件定义的 JSON Schema
resolved_schema = {
"properties": {
"surfaceUpdate": {
"properties": {
"components": {
"items": {
"properties": {
"component": {
# 这里只包含目录中定义的组件类型
"properties": catalog["components"]
}
}
}
}
}
}
}
}
# 3. 使用 Structured Output 模式调用 LLM
response = llm.generate(
prompt="生成一个餐厅预订表单",
response_schema=resolved_schema # LLM 只能输出符合 Schema 的 JSON
)
约束机制:
| 层级 | 约束方式 | 说明 |
|---|---|---|
| LLM 层 | JSON Schema / Structured Output | 现代 LLM(GPT-4、Gemini)支持强制输出符合 Schema 的 JSON |
| Agent 层 | Prompt 中包含组件目录 | 告诉 LLM 可用的组件类型和属性 |
| 协议层 | 目录协商 | Client 声明支持的目录,Agent 只能选择其中之一 |
| 渲染层 | 组件白名单 | Client 渲染器只渲染已注册的组件类型 |
6.3 如果 Agent 发送了未知组件会怎样?
json
// Agent 错误地发送了一个不存在的组件
{
"surfaceUpdate": {
"components": [
{
"id": "evil",
"component": {
"ScriptExecutor": { //不在目录中
"code": "alert('hacked')"
}
}
}
]
}
}
Client 的处理方式:
- 忽略未知组件 :渲染器在 WidgetRegistry 中找不到
ScriptExecutor,跳过该组件 - 显示占位符:渲染一个错误提示组件
- 发送错误消息 :通过
error消息通知 Agent
json
// Client 返回错误
{
"error": {
"type": "unknown_component",
"componentId": "evil",
"componentType": "ScriptExecutor",
"message": "Component type 'ScriptExecutor' is not in the supported catalog"
}
}
6.4 自定义组件的安全扩展
如果业务需要自定义组件(如图表、地图),流程如下:
┌─────────────────────────────────────────────────────────────────┐
│ 自定义组件安全流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Client 开发者实现自定义组件(本地代码,完全可控) │
│ class PieChartComponent { render(data) { ... } } │
│ │
│ 2. 在 WidgetRegistry 中注册 │
│ registry.register("PieChart", PieChartComponent) │
│ │
│ 3. 定义组件 Schema,加入自定义目录 │
│ { "PieChart": { "properties": { "data": {...} } } } │
│ │
│ 4. 在 a2uiClientCapabilities 中声明支持该目录 │
│ │
│ 5. Agent 现在可以安全地使用 PieChart 组件 │
│ │
└─────────────────────────────────────────────────────────────────┘
安全保证:自定义组件的实现代码在 Client 侧,Agent 只能传递数据参数,无法注入逻辑。
七、安全模型总结
┌────────────────────────────────────────────────────────┐
│ A2UI 安全边界 │
├────────────────────────────────────────────────────────┤
│ Agent 侧(不可信) │ Client 侧(可信) │
│ ───────────────── │ ───────────────── │
│ • 生成 JSON 描述 │ • 定义组件目录 │
│ • 只能引用已知组件类型 │ • 实现组件渲染逻辑 │
│ • 无法执行任意代码 │ • 控制样式和行为 │
│ • 受 JSON Schema 约束 │ • 验证数据绑定 │
└────────────────────────────────────────────────────────┘
关键安全特性:
- 声明式数据:Agent 发送的是数据,不是代码
- 组件白名单:只能使用客户端预定义的组件
- 目录协商:双向声明,取交集
- Schema 约束:LLM 输出受 JSON Schema 强制约束
- 无 eval/innerHTML:客户端渲染器不执行任意字符串
- 数据绑定验证:路径解析在客户端控制
八、总结
A2UI 通过精巧的协议设计,解决了 AI Agent 生成 UI 的核心挑战:
| 挑战 | A2UI 解决方案 |
|---|---|
| 安全性 | 声明式 JSON + 组件白名单 |
| LLM 生成难度 | 邻接表模型 + 流式传输 |
| 跨平台 | 抽象组件 + 客户端渲染 |
| 性能 | 数据/结构分离 + 增量更新 |
如果你正在构建 AI Agent 应用,A2UI 值得深入研究。它代表了 Agent UI 领域的最佳实践。
参考资料: