
看了十篇A2UI介绍文章,九篇在讲"声明式UI好厉害"。嗯,我知道它厉害,但我的Spring Boot项目到底怎么接?JSON往哪发?状态怎么同步?安全怎么搞?——这才是后端工程师真正关心的问题。
上一篇我们聊了A2X协议族的关系辨析(A2A vs AG-UI vs A2UI vs MCP),理清了"谁是谁"的问题。今天往下走一步——A2UI这个协议,后端到底怎么接?
这不是一篇"A2UI是什么"的科普文。这是一篇工程化落地指南——从协议消息格式,到Spring Boot / Python的代码实现,到生产环境的安全和性能优化。
目标读者:已经知道A2UI是什么、正在考虑或已经开始对接的后端工程师。
很多人第一次看A2UI协议,容易搞混后端到底负责什么。先画个清楚的边界:
用户操作 → 前端(Renderer) → 传输层(AG-UI/A2A/SSE) → 后端(Agent)
↓
LLM / 业务逻辑
↓
后端生成A2UI JSON → 传输层 → 前端Renderer → 原生UI组件后端的核心职责就三件事:
职责 | 具体内容 | 对应消息类型 |
|---|---|---|
定义UI结构 | 告诉前端"渲染什么组件、怎么布局" | surfaceUpdate |
管理数据状态 | 提供组件绑定的数据、响应状态更新 | dataModelUpdate |
处理用户事件 | 接收按钮点击等回调,返回新的UI或数据 | Action回调 |
用人话说:后端就是"甲方"——画设计稿(组件描述)、提供数据(状态)、响应交互(事件)。前端是"施工队"——按图纸用自己的材料(原生组件)盖房子。
这里有一个心智模型需要切换:
维度 | 传统REST API | A2UI模式 |
|---|---|---|
返回值 | 纯数据JSON | UI结构 + 数据JSON |
前端角色 | 自己决定怎么渲染 | 按后端描述渲染 |
交互控制 | 前端主导 | Agent主导 |
状态管理 | 前后端各管各的 | 通过dataModel同步 |
UI变更 | 需要前端发版 | Agent动态生成,无需发版 |
最后一条是关键——A2UI的核心价值之一是:Agent可以根据上下文动态生成不同的UI,前端不需要重新部署。 你的订单Agent发现用户是VIP?直接返回一个带专属折扣卡片的界面。不需要前端改一行代码。
A2UI协议的后端产出就是JSONL流(每行一个JSON消息)。一共四种消息类型:
消息类型 | 作用 | 发送时机 |
|---|---|---|
createSurface | 创建渲染画布,指定组件目录 | 首次创建UI |
surfaceUpdate | 定义/更新UI组件树 | 需要新增或修改组件 |
dataModelUpdate | 更新绑定数据 | 数据变化时 |
beginRendering | 通知前端开始渲染 | 组件和数据就绪后 |
发送顺序非常重要:createSurface → surfaceUpdate → dataModelUpdate → beginRendering
一个完整的JSONL流示例:
{"version":"v0.9.1","createSurface":{"surfaceId":"booking","catalogId":"https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json"}}
{"version":"v0.9.1","updateComponents":{"surfaceId":"booking","components":[{"id":"title","component":"Text","text":"预订餐厅"},{"id":"date-input","component":"DateTimeInput","label":"选择日期","enableDate":true,"path":"/booking/date"},{"id":"submit","component":"Button","child":"submit-text","action":{"event":{"name":"confirm_booking"}}}]}}
{"version":"v0.9.1","updateDataModel":{"surfaceId":"booking","path":"/booking","value":{"date":"2026-07-02","guests":2}}}
{"version":"v0.9.1","beginRendering":{"surfaceId":"booking","root":"title"}}A2UI的组件结构用的是扁平邻接表(Flat Adjacency List),而不是常见的嵌套树。
嵌套树写法(A2UI不用这种):
{
"Column": {
"children": [
{"Text": {"text": "你好"}},
{"Row": {
"children": [
{"Button": {"text": "取消"}},
{"Button": {"text": "确认"}}
]
}}
]
}
}A2UI的扁平写法:
[
{"id": "root", "component": "Column", "children": ["greeting", "buttons"]},
{"id": "greeting", "component": "Text", "text": "你好"},
{"id": "buttons", "component": "Row", "children": ["cancel-btn", "ok-btn"]},
{"id": "cancel-btn", "component": "Button", "child": "cancel-text"},
{"id": "ok-btn", "component": "Button", "child": "ok-text"},
{"id": "cancel-text", "component": "Text", "text": "取消"},
{"id": "ok-text", "component": "Text", "text": "确认"}
]为什么要扁平化?三个工程原因:
用人话说:嵌套树就像让LLM写一篇结构完美的学术论文,扁平列表就像让它写一个个独立的便利贴再编号——后者的成功率高得多。
A2UI的数据绑定有两种方式:
静态值(写死的):
{"text": {"literalString": "欢迎光临"}}路径绑定(响应式的):
{"text": {"path": "/user/name"}}路径绑定使用JSON Pointer(RFC 6901)语法。当后端通过dataModelUpdate更新/user/name的值时,所有绑定了这个路径的组件会自动刷新。
这个设计非常巧妙——它把"UI长什么样"和"UI显示什么数据"彻底解耦了。 后端只需要更新数据,不需要重新发送整个UI结构。
Java后端接A2UI,目前最成熟的方案是a2ajava框架 + Spring Boot。
<!-- pom.xml 关键依赖 -->
<dependency>
<groupId>io.github.vishalmysore</groupId>
<artifactId>a2ajava</artifactId>
<version>最新版本</version>
</dependency>项目结构建议:
src/main/java/com/example/
├── agent/ # Agent服务层
│ ├── OrderAgent.java # 订单Agent(核心业务)
│ └── CustomerAgent.java # 客户Agent
├── a2ui/ # A2UI构建器
│ ├── ComponentBuilder.java # 组件构建工具类
│ └── SurfaceFactory.java # 页面工厂
├── config/ # 配置
│ └── A2UIConfig.java
└── controller/ # 入口
└── AgentController.javaa2ajava框架的核心思想是注解驱动——用@Agent和@Action声明Agent能力:
@Service
@Agent(groupName = "orderService",
groupDescription = "处理订单查询和操作")
publicclass OrderAgent implements A2UIDisplay {
@Autowired
private OrderRepository orderRepo;
@Action(description = "查询订单详情")
public Object queryOrder(String orderId) {
Order order = orderRepo.findById(orderId);
// 关键:双模式响应
if (isUICallback(callback)) {
returnbuildOrderDetailUI(order); // A2UI客户端 → 返回UI
}
return order.toString(); // 普通客户端 → 返回文本
}
private Map<String, Object> buildOrderDetailUI(Order order) {
List<Map<String, Object>> components = new ArrayList<>();
// 标题
components.add(createTextComponent("title",
"订单详情 #" + order.getId(), "h2"));
// 订单信息
components.add(createTextComponent("status",
"状态:" + order.getStatus(), "body"));
components.add(createTextComponent("amount",
"金额:¥" + order.getAmount(), "body"));
// 操作按钮
Map<String, String> ctx = Map.of("orderId", "/order/id");
components.add(createButtonComponent("cancel-btn",
"取消订单", "cancelOrder", ctx));
components.add(createTextComponent("cancel-btn_text", "取消订单"));
// 构建完整消息
Map<String, Object> data = Map.of("/order/id", order.getId());
returnbuildA2UIMessageWithData("order_detail", "title", components, data);
}
}这是a2ajava最精妙的设计——同一个@Action方法,根据客户端类型返回不同格式:
@Action(description = "获取用户信息")
public Object getUserInfo(String userId) {
User user = userService.getById(userId);
if (isUICallback(callback)) {
// A2UI客户端:返回带表单的交互界面
return buildUserProfileUI(user);
}
// CLI/API客户端:返回纯文本
return String.format("用户:%s,邮箱:%s", user.getName(), user.getEmail());
}这意味着你不需要为A2UI专门写一套API。 原有的Agent能力直接复用,只需要加一个if分支构建UI。
表单是A2UI最常见的场景。后端需要做三件事:定义表单组件、初始化数据、处理提交。
private Map<String, Object> buildBookingFormUI() {
List<Map<String, Object>> components = new ArrayList<>();
// 表单字段——通过path绑定数据
components.add(createTextFieldComponent("name-input",
"姓名", "/form/name"));
components.add(createTextFieldComponent("phone-input",
"手机号", "/form/phone"));
components.add(createTextComponent("date-label", "选择日期", "body"));
// 提交按钮——context提取表单数据
Map<String, String> formContext = new LinkedHashMap<>();
formContext.put("name", "/form/name");
formContext.put("phone", "/form/phone");
components.add(createButtonComponent("submit",
"提交预订", "submitBooking", formContext));
components.add(createTextComponent("submit_text", "提交预订"));
// 初始化空数据
Map<String, Object> initData = Map.of(
"/form/name", "",
"/form/phone", ""
);
returnbuildA2UIMessageWithData("booking_form", "name-input",
components, initData);
}
// 表单提交处理
@Action(description = "提交预订")
public Object submitBooking(String name, String phone) {
// 参数由框架从context绑定自动提取
Booking booking = bookingService.create(name, phone);
if (isUICallback(callback)) {
returnbuildConfirmationUI(booking); // 返回确认页面
}
return"预订成功:" + booking.getId();
}注意这里的设计模式:Button的context字段把表单数据路径绑定到Action方法的参数名上。 用户点击"提交"时,框架自动从数据模型中提取/form/name和/form/phone的当前值,注入到submitBooking方法的参数中。
Python生态下,Google ADK(Agent Development Kit)是官方推荐的A2UI后端方案:
# agent.py
from google.adk import Agent
from google.adk.a2ui import SurfaceUpdate, DataModelUpdate
agent = Agent(name="booking_agent", model="gemini-2.5-flash")
@agent.tool
defcreate_booking_form():
"""创建预订表单"""
surface = SurfaceUpdate(
surface_id="booking",
components=[
{"id": "title", "component": "Text",
"text": "餐厅预订", "usageHint": "h1"},
{"id": "date-input", "component": "DateTimeInput",
"label": "选择日期", "enableDate": True,
"path": "/booking/date"},
{"id": "guests-input", "component": "TextField",
"label": "用餐人数", "path": "/booking/guests"},
{"id": "submit", "component": "Button",
"child": "submit-text",
"action": {"event": {"name": "confirm_booking"}}},
{"id": "submit-text", "component": "Text",
"text": "确认预订"}
]
)
data = DataModelUpdate(
surface_id="booking",
path="/booking",
value={"date": "2026-07-02", "guests": 2}
)
return [surface, data]如果不想引入ADK,可以直接用FastAPI + SSE手动构建JSONL流:
# server.py
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json
app = FastAPI()
defgenerate_a2ui_stream(user_input: str):
"""生成A2UI JSONL消息流"""
# Step 1: 创建Surface
yield json.dumps({
"version": "v0.9.1",
"createSurface": {
"surfaceId": "main",
"catalogId": "https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json"
}
}) + "\n"
# Step 2: 发送组件
yield json.dumps({
"version": "v0.9.1",
"updateComponents": {
"surfaceId": "main",
"components": [
{"id": "header", "component": "Text",
"text": f"搜索结果:{user_input}", "usageHint": "h1"},
{"id": "result-card", "component": "Card",
"child": "result-content"},
{"id": "result-content", "component": "Text",
"text": {"path": "/result/content"}}
]
}
}) + "\n"
# Step 3: 填充数据(可以异步获取后再发送)
result = search_service.query(user_input)
yield json.dumps({
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "main",
"path": "/result",
"value": {"content": result}
}
}) + "\n"
# Step 4: 通知渲染
yield json.dumps({
"version": "v0.9.1",
"beginRendering": {"surfaceId": "main", "root": "header"}
}) + "\n"
@app.post("/agent/chat")
asyncdefchat(request: dict):
returnStreamingResponse(
generate_a2ui_stream(request["message"]),
media_type="application/x-ndjson"
)这个方案的好处是零依赖、完全可控。 你可以精确控制每条消息的发送时机,比如先发组件结构让前端开始渲染骨架,等数据库查询完成后再发数据填充。
这是A2UI最被低估的特性——它的安全模型是从协议层面设计的,不是事后打补丁。
方案 | Agent发送的内容 | 安全风险 |
|---|---|---|
HTML/iframe | 可执行代码(HTML+JS) | XSS、代码注入、DOM篡改 |
A2UI | 声明式JSON描述 | 无代码执行风险 |
A2UI传输的是组件蓝图("这里放一个按钮"),不是可执行代码("执行这段JavaScript")。前端Renderer只会用自己预审批的原生组件来渲染这些描述。
用人话说:HTML方案是Agent直接给你一把钥匙让你开门,A2UI方案是Agent告诉保安"让这个人进来"——保安(Renderer)会用自己的方式验证和执行。
后端工程化的关键安全实践是定义严格的组件目录:
{
"catalogId": "https://your-company.com/a2ui/catalog/v1",
"components": {
"Text": { "allowed": true, "maxLength": 5000 },
"Button": { "allowed": true, "allowedActions": ["submit", "cancel", "navigate"] },
"TextField": { "allowed": true, "maxDataPaths": ["/form/*"] },
"Card": { "allowed": true },
"Image": { "allowed": true, "allowedDomains": ["cdn.your-company.com"] },
"Script": { "allowed": false },
"IFrame": { "allowed": false }
}
}前端Renderer只渲染目录中allowed: true的组件。 Agent请求渲染Script组件?Renderer直接忽略。请求加载外部域名的图片?被allowedDomains拦截。
检查项 | 实现方式 | 说明 |
|---|---|---|
组件白名单 | Catalog机制 | 只允许渲染预定义组件 |
Action白名单 | 后端校验 | 只处理已注册的Action |
数据路径限制 | 路径前缀校验 | 防止Agent访问越权数据 |
输入验证 | 后端校验 | Action参数必须校验 |
速率限制 | 中间件 | 防止恶意Agent刷UI请求 |
消息大小限制 | 网关层 | 单条A2UI消息限制大小 |
这是后端工程化中最容易纠结的选择。先搞清楚三者的定位:
传输方案 | 定位 | 适用场景 |
|---|---|---|
裸SSE | 最简单,单向流 | 单Agent、简单场景 |
AG-UI | 双向实时协议 | 需要前端→后端事件流 |
A2A | Agent间通信 | 多Agent协作系统 |
你的系统有多个Agent协作吗?
├── 是 → 用A2A作为Agent间通信 + AG-UI/SSE作为前端通信
└── 否 → 前端需要实时推送事件给后端吗?
├── 是 → 用AG-UI(双向SSE + WebSocket)
└── 否 → 用裸SSE(最简单)生产环境推荐:AG-UI作为传输管道 + A2UI作为消息载荷。
原因:
用人话说:AG-UI是管道,A2UI是管道里流的水。管道标准化了,换水龙头(前端框架)不需要改管道。
最重要的性能优化:不要等所有消息生成完再一次性发送。
# ❌ 错误:等全部生成完再发
defgenerate_ui():
components = build_all_components() # 耗时2秒
data = fetch_all_data() # 耗时3秒
return [components, data, render] # 用户等了5秒才看到UI
# ✅ 正确:流式逐步发送
defgenerate_ui_stream():
yieldcreate_surface() # 立即发送
yieldbuild_skeleton_components() # 先发骨架,用户0.1秒看到框架
yieldbuild_detail_components() # 再发细节
data = fetch_data() # 异步获取数据
yielddata_model_update(data) # 数据就绪后发送
yieldbegin_rendering() # 通知渲染效果:用户感知延迟从5秒降到0.1秒。 先看到骨架,再看到数据——和现在主流App的加载体验一致。
# ❌ 全量替换:重新发送整个组件树
yield update_components(surface_id="main", components=ALL_COMPONENTS)
# ✅ 增量更新:只发变化的组件
yield update_components(surface_id="main", components=[
{"id": "status-text", "component": "Text",
"text": "订单已确认"} # 只更新这一个组件
])# ❌ 粗粒度:更新整个数据树
yield update_data_model(path="/", value=entire_data_tree)
# ✅ 细粒度:只更新变化的路径
yield update_data_model(path="/order/status", value="confirmed")
yield update_data_model(path="/order/updated_at", value="2026-07-02T10:30:00Z")优化项 | 方法 | 效果 |
|---|---|---|
流式渲染 | 骨架→内容→数据分步发送 | 感知延迟降低90% |
增量更新 | 只发变化的组件和数据 | 传输量减少80% |
组件复用 | 相同结构用模板+数据绑定 | 减少LLM生成量 |
批量数据更新 | 多个路径合并一条消息 | 减少消息数量 |
组件ID缓存 | 前端缓存已渲染组件 | 避免重复渲染 |
序号 | 坑 | 原因 | 解法 |
|---|---|---|---|
1 | 组件不显示 | 忘发beginRendering消息 | 四步消息缺一不可 |
2 | 数据绑定不生效 | surfaceUpdate在dataModelUpdate之后到达 | 严格按顺序发送 |
3 | 按钮点击没反应 | Action名称前后端不一致 | 统一用枚举/常量管理 |
4 | LLM生成的JSON格式错误 | 嵌套结构太深 | 用扁平邻接表 + Few-Shot |
5 | 组件ID冲突 | 不同Surface用了相同ID | ID加Surface前缀 |
6 | 流式渲染闪烁 | 每个组件单独触发重排 | 用beginRendering批量触发 |
7 | 内存泄漏 | Surface创建后未deleteSurface | 页面离开时清理 |
8 | 跨Agent UI混乱 | 多Agent写同一个Surface | 每个Agent独立SurfaceId |
9 | 中文显示乱码 | JSONL流未指定UTF-8编码 | 响应头加charset=utf-8 |
第4个坑特别值得展开说。 如果你让LLM直接生成A2UI JSON(而不是用代码构建),一定要在System Prompt里给Few-Shot示例,而且用扁平格式。实测下来,GPT-4o和Gemini 2.5生成扁平A2UI的准确率在95%以上,但嵌套格式只有70%左右。

关注点 | 建议 |
|---|---|
水平扩展 | Agent服务设计为无状态,状态放dataModel |
连接管理 | SSE连接加心跳保活,超时30秒自动重连 |
消息限制 | 单条A2UI消息不超过64KB |
监控指标 | 消息延迟、渲染成功率、Action响应时间 |
灰度发布 | 通过Catalog版本控制组件灰度 |
A2UI不是一个难理解的协议——四种消息类型、扁平组件模型、路径数据绑定,核心概念一篇文章讲完了。
但从"理解协议"到"工程落地"之间的距离,远比大多数人想象的要大。 传输层选型、双模式响应设计、安全白名单机制、流式渲染优化、LLM生成准确率调优……这些都是协议文档里不会写的东西。
我的建议是:
A2UI的核心价值不在于"Agent能生成UI"这件事本身,而在于它让UI变成了Agent的输出物,而不是前端团队的交付物。 这意味着:产品经理找Agent改需求,不用排前端的开发工期了。
至于前端工程师会不会失业——放心,总得有人写Renderer。只不过以后写的不是页面,而是"组件积木"。
我是RiemannChow,一个在架构领域摸爬滚打多年的技术人。如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。关注「老周聊架构」,每周深度解读AI和架构的最新趋势。