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,图片即可在本文中显示。
三个角色记牢就够了:
- Agent(服务端):用 LLM 生成符合 catalog 的 A2UI JSON 消息流。
- Transport(传输层):把消息流可靠、有序地送过去(最常用 A2A 和 AG-UI)。
- 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里的Gemini为LiteLlm并配置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,不用重传整棵组件树------这是高效增量更新的基础。
一次完整交互的时序如下:
源码中 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 → 组件」的表重新拼成树。
为什么要费这个劲拍平?三个好处:
- 顺序无关 :服务端可以任意顺序 发组件,先发
root还是先发子节点都行。 - 渐进式渲染 :只要
root一到,就能开始画;引用了还没到的子节点就先放占位符,等它来了再补上。这对「LLM 一个 token 一个 token 往外吐」的场景至关重要。 - LLM 友好:扁平的列表比深层嵌套的 JSON 更容易让 LLM 正确生成、增量修改。
源码里规则很明确:必须有且只有一个 id 为 root 的组件 作为树根;在 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:
为什么要通知祖先和后代?
- 通知祖先 :如果有组件绑定到
/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 的形状**,自动判断每个属性属于哪一类行为:
判断逻辑非常直接:
- 属性的 schema 是个 Union,且某个分支包含
event字段 → 判定为 ACTION(用户动作)。 - 包含
path但不含componentId→ 判定为 DYNAMIC(数据绑定)。 - 同时含
componentId和path→ 判定为 STRUCTURAL(模板列表)。 - 属性名叫
checks→ 判定为 CHECKABLE(校验)。
GenericBinder 拿到这棵「行为树」后,深度遍历组件的原始 JSON:
- 遇到 DYNAMIC 就去
DataContext订阅对应路径,数据一变就更新 prop 并通知重渲染;同时自动生成 setter (如value属性自动生成setValue方法,供输入框写回数据)------这正是双向绑定的客户端落点。 - 遇到 ACTION 就返回一个现成的
() => void闭包,调用时把context里的所有 path 解析成实际值,再dispatchAction发给服务端。 - 遇到 STRUCTURAL 就订阅数组路径,把每个元素映射成
{ id: 模板组件ID, basePath: /数组/索引 },从而生成列表的每一项。 - 遇到 CHECKABLE 就响应式地跑每条校验规则,把
isValid和validationErrors注入父对象(比如校验不通过时自动禁用按钮)。
这个设计的威力在于:新增一个组件,开发者只要写好它的 Zod Schema,绑定逻辑全自动,无需写任何样板代码。 框架适配层(React/Angular adapter)只需 subscribe() 监听 currentProps 的变化即可。
6.6 安全模型:组件目录(Catalog)+ 客户端注册函数
A2UI 「安全得像数据」的根基是 Catalog(组件目录):
- 客户端启动时声明自己支持哪些组件、哪些函数(如
required、email、regex、formatCurrency等)。 - Agent 只能引用目录里存在的组件和函数,无法注入任意可执行逻辑。
- 校验/逻辑也是「按名字调用已注册函数 」的方式(
FunctionCall),而不是发送代码。例如表单校验:
json
"checks": [
{ "call": "required", "args": { "value": { "path": "/contact/email" } },
"message": "邮箱必填" },
{ "call": "email", "args": { "value": { "path": "/contact/email" } },
"message": "请输入合法邮箱" }
]
这里的 required、email 都是客户端预先实现并审核过的函数,Agent 只是「点名」调用。即使 Agent 被攻陷,它能做的也仅限于「用白名单组件搭界面」,无法逃逸执行恶意代码。
进阶:你可以定义自己的 catalog,把 Agent 限制在你 App 真正拥有的组件和视觉语言内;甚至通过「智能包装器」把 iframe 沙箱接入 A2UI 的数据绑定体系,安全策略完全掌握在开发者手里。
6.7 服务端:prompt-generate-validate 循环
最后看服务端怎么「生产」这些 JSON。源码 samples/agent/adk/restaurant_finder/agent.py 完整演示了官方推荐的三步循环:
源码里的关键点:
- 系统提示词由
A2uiSchemaManager.generate_system_prompt()生成,自动把 JSON Schema 和合法示例塞进去,让 LLM 知道该产出什么格式。 - 流式解析 :
A2uiStreamParser边读 LLM 的 token 流边切出一个个 A2UI 消息 part,立即yield给客户端,实现渐进式渲染。 - 校验 + 自我修正 :生成完后用
selected_catalog.validator.validate()做 schema 校验。失败就把具体错误信息拼成新 prompt 回喂 LLM 重试 (源码里max_retries = 1,即最多两次尝试)。 - 能力声明 :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.org
- GitHub : github.com/google/A2UI
- 在线体验 : a2ui-composer.ag-ui.com
- 规范文档: specification/v0_10/docs/
九、总结
A2UI 解决了一个核心问题:让 AI Agent 能够生成富交互 UI,而不只是纯文本。
它的设计哲学:
- 安全优先:声明式 JSON,白名单组件
- LLM 友好:扁平结构,流式支持
- 跨平台:一份协议,多端渲染
- 可扩展:自定义 Catalog,灵活集成
A2UI 的精髓,可以用它自己的一句话收尾:
「Safe like data, expressive like code」