一句话梗概:想象一下,你跟AI说"给我显示附近的餐厅",它不仅能找出答案,还能即时"画"出一个精美的卡片列表界面------这不是科幻,这是Google A2UI协议在.NET Blazor中的真实落地。
引子:那个让前端工程师夜不能寐的问题
作为一个写了十年代码的老油条,我见过太多"需求又双叒叕改了"的深夜。产品经理拿着原型图冲过来:"这个列表能不能改成卡片?"、"能不能根据用户权限动态显示按钮?"、"这个表单字段下周可能要加10个..."
每次听到这些,我的第一反应都是:得,今晚又要通宵改JSX/Razor代码了。
但如果我告诉你,有一种技术能让AI Agent直接生成UI,你只需要告诉它"我要什么",它就能返回一个完整的、可交互的、符合你应用风格的界面,你信吗?
这就是Google在2024年底推出的A2UI协议 (Agent to UI)想要解决的核心问题。而我最近把玩的这个开源项目------A2UI for .NET Blazor,更是把这套理念在.NET生态里落地得明明白白。
今天,咱们就撕开这层技术的面纱,看看它到底是如何运作的,以及为什么我认为它可能会改变我们构建应用的方式。
第一章:不是玄学,是声明式UI的终极进化
1.1 先搞清楚一个问题:什么是A2UI?
简单来说,A2UI是一个JSON协议。它定义了AI Agent如何用声明式数据描述用户界面,而不是生成可执行代码。
听起来很抽象?我们来看个对比:
传统方式(危险⚠️):
// AI 返回这段代码让你执行
eval(`
const btn = document.createElement('button');
btn.onclick = () => {
fetch('/api/delete-everything'); // 💀危险!
};
`)
A2UI方式(安全✅):
{
"component": {
"Button": {
"child": "btn-text",
"action": { "name": "delete_item" }
}
}
}
看出区别了吗?第一种方式AI可以让你的浏览器执行任意代码(想想看,如果AI被黑了呢?),而第二种方式只是告诉你"这里应该有一个按钮,点击后触发delete_item动作"。至于这个动作具体做什么,完全由你的应用代码控制。
这就是声明式数据 vs 可执行代码的本质区别。Google的工程师把这个理念总结为三大核心价值:
-
安全性(Security): 声明式数据,无代码执行风险
-
原生体验(Native Experience): 使用应用自己的UI框架渲染,继承应用样式和性能
-
可移植性(Portability): 同一份JSON可以在Web、移动、桌面平台渲染
1.2 为什么说它是"终极进化"?
回想一下UI技术的演进路径:
命令式UI (jQuery时代)
↓
声明式UI (React/Vue/Blazor)
↓
组件化UI (Ant Design/Material UI)
↓
AI生成UI (A2UI) ← 我们在这里
每一次演进都在解决一个核心问题:如何用更少的代码表达更复杂的界面。
-
jQuery时代:手动操作DOM,写100行代码实现一个列表
-
React时代:声明式描述状态,写30行代码实现同样的列表
-
组件化时代:直接用
<List />组件,5行代码搞定 -
A2UI时代:跟AI说"显示联系人列表",0行UI代码
是的,你没看错,0行UI代码。你只需要维护业务逻辑和数据,UI完全由AI动态生成。
第二章:深入技术架构------这玩意儿到底怎么跑起来的?
好,吹牛X的话说完了,现在该硬核分析了。咱们来拆解一下A2UI在.NET Blazor中的完整架构。
2.1 架构全景图:五层模型
┌─────────────────────────────────────────────────────────┐
│ 用户层 (User) │
│ "显示附近的餐厅" │
└────────────────────┬────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────┐
│ 客户端层 (Client) │
│ A2AClientService + EventDispatcher │
│ 负责:发送查询、接收JSON、分发用户动作 │
└────────────────────┬────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────┐
│ Agent层 (AI LLM) │
│ MockA2AAgent / Gemini / GPT │
│ 负责:理解意图、生成A2UI JSON响应 │
└────────────────────┬────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────┐
│ 处理层 (Processing) │
│ MessageProcessor + DataBindingResolver │
│ 负责:解析JSON、构建组件树、管理数据模型 │
└────────────────────┬────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────┐
│ 渲染层 (Rendering) │
│ A2UISurface + A2UIRenderer + 15+组件 │
│ 负责:动态渲染Blazor组件、处理用户交互 │
└─────────────────────────────────────────────────────────┘
这个架构最精妙的地方在于:职责分离。每一层只关心自己该干的事,Agent只负责生成JSON,它完全不知道也不关心这个JSON最终会被渲染成什么样子。
2.2 核心类详解:五大金刚
金刚1: MessageProcessor------JSON的翻译官
这是整个系统的心脏。它接收AI返回的JSON消息,解析成内部数据结构,维护着所有Surface(渲染表面)的状态。
public class MessageProcessor
{
// 维护所有 Surface 的字典,key是surfaceId
private readonly Dictionary<string, Surface> _surfaces = new();
// 事件:当Surface更新时通知所有订阅者
public event EventHandler<SurfaceUpdatedEventArgs>? SurfaceUpdated;
// 核心方法:处理三种消息类型
public void ProcessMessage(ServerToClientMessage message)
{
if (message.BeginRendering != null)
HandleBeginRendering(message.BeginRendering);
else if (message.SurfaceUpdate != null)
HandleSurfaceUpdate(message.SurfaceUpdate);
else if (message.DataModelUpdate != null)
HandleDataModelUpdate(message.DataModelUpdate);
}
}
关键设计点:
-
Surface隔离:每个Surface有独立的组件树和数据模型,互不干扰
-
事件驱动:使用.NET事件机制,当数据变化时自动通知UI更新
-
增量更新:SurfaceUpdate只更新变化的组件,不是全量替换
我特别喜欢它处理BeginRendering的方式:
private void HandleBeginRendering(BeginRenderingMessage message)
{
var surface = GetOrCreateSurface(message.SurfaceId);
// 清除旧组件,开始新的渲染周期
surface.Components.Clear();
surface.RootComponentId = message.Root;
surface.IsReadyToRender = true; // 🔥关键:设置渲染就绪标志
OnSurfaceUpdated(message.SurfaceId); // 触发更新事件
}
这个IsReadyToRender标志解决了一个微妙的问题:在组件定义还没完全加载时,不要尝试渲染。这避免了"闪烁"和不完整UI的问题。
金刚2: A2UISurface------Blazor中的渲染画布
这是用户在Razor页面中实际使用的组件:
<A2UISurface SurfaceId="my-surface" />
它的实现非常巧妙:
@if (Surface != null && Surface.IsReadyToRender && !string.IsNullOrEmpty(Surface.RootComponentId))
{
<div class="a2ui-surface">
<A2UIRenderer SurfaceId="@SurfaceId" ComponentId="@Surface.RootComponentId" />
</div>
}
else
{
<div class="a2ui-surface-debug">
<!-- 显示调试信息 -->
</div>
}
@code {
protected override void OnInitialized()
{
// 🔥关键:订阅Surface更新事件
MessageProcessor.SurfaceUpdated += OnSurfaceUpdated;
LoadSurface();
}
private void OnSurfaceUpdated(object? sender, SurfaceUpdatedEventArgs e)
{
if (e.SurfaceId == SurfaceId) // 只响应自己的更新
{
LoadSurface();
InvokeAsync(StateHasChanged); // 通知Blazor重新渲染
}
}
}
注意到那个InvokeAsync(StateHasChanged)了吗?这是Blazor的核心机制。因为SurfaceUpdated事件可能在非UI线程触发,必须用InvokeAsync调度回UI线程,否则会抛异常。
这里还有一个细节:调试模式。当Surface还没准备好时,它会显示详细的诊断信息,包括组件数量、根组件ID等。这对开发调试太有用了!
金刚3: A2UIRenderer------递归渲染引擎
这是最复杂也最精彩的部分。它负责把组件树递归渲染成实际的Blazor组件:
@code {
[Parameter] public required string SurfaceId { get; set; }
[Parameter] public required string ComponentId { get; set; }
private void RenderComponent(RenderTreeBuilder builder, ComponentNode node)
{
// 🔥核心:根据组件类型动态选择Blazor组件
var componentType = node.Type switch
{
"Card" => typeof(A2UICard),
"Button" => typeof(A2UIButton),
"Text" => typeof(A2UIText),
"Column" => typeof(A2UIColumn),
"Row" => typeof(A2UIRow),
"List" => typeof(A2UIList),
// ... 15+组件映射
};
// 动态构建组件并传递参数
builder.OpenComponent(0, componentType);
builder.AddAttribute(1, "SurfaceId", SurfaceId);
builder.AddAttribute(2, "ComponentId", ComponentId);
builder.AddAttribute(3, "Properties", node.Properties);
builder.CloseComponent();
}
}
这里用到了Blazor的RenderTreeBuilder API,这是一个非常底层的接口,允许你在运行时动态构建组件树。大多数Blazor开发者一辈子都不会碰这个API,因为太底层了。
但这正是实现动态UI的关键:你无法在编译时知道要渲染什么组件,必须在运行时根据JSON数据决定。
金刚4: DataBindingResolver------数据绑定的魔法师
A2UI支持数据绑定,比如这样的JSON:
{
"Text": {
"text": { "path": "user.name" }
}
}
这个"path": "user.name"会自动从数据模型中取值。DataBindingResolver就是负责解析这些路径的:
public class DataBindingResolver
{
private readonly MessageProcessor _messageProcessor;
public object? ResolveBoundValue(
string surfaceId,
Dictionary<string, object> boundValue,
string? dataContextPath = null)
{
// 支持两种绑定方式
if (boundValue.ContainsKey("literalString"))
return boundValue["literalString"]; // 字面值
if (boundValue.ContainsKey("path"))
{
var path = boundValue["path"].ToString();
// 从数据模型中取值
return _messageProcessor.GetData(surfaceId, path, dataContextPath);
}
}
}
这里有个巧妙的设计:dataContextPath参数。它用于支持相对路径绑定。
比如在List组件中,每个列表项都有自己的数据上下文:
/contacts/contact1 ← 这是第一个联系人的上下文
/contacts/contact2 ← 这是第二个联系人的上下文
当你在模板中写"path": "name"时,它会自动解析为/contacts/contact1/name或/contacts/contact2/name,取决于当前是哪个列表项。
金刚5: EventDispatcher------用户交互的信使
当用户点击按钮、输入文本时,需要把事件发送回Agent。EventDispatcher负责这个:
public class EventDispatcher
{
// 全局事件:任何地方都可以订阅
public event EventHandler<UserActionEventArgs>? UserActionDispatched;
// 组件调用这个方法触发事件
public void DispatchUserAction(UserAction action)
{
UserActionDispatched?.Invoke(this, new UserActionEventArgs(action));
}
}
在你的页面代码中:
protected override void OnInitialized()
{
EventDispatcher.UserActionDispatched += OnUserAction;
}
private async void OnUserAction(object? sender, UserActionEventArgs e)
{
// 用户点击了按钮!
var actionName = e.Action.Name; // 比如 "book_restaurant"
var context = e.Action.Context; // 比如 {"restaurantId": "123"}
// 发送给Agent处理
await A2AClient.SendActionAsync(actionName, context);
}
这个设计用了观察者模式,实现了完全的解耦:组件不需要知道谁在监听事件,页面也不需要知道事件从哪个组件来。
2.3 消息流转:一次完整的交互过程
让我们跟踪一个完整的用户交互:用户输入"显示联系人"→AI生成列表→用户点击"查看"按钮。
第一步:用户输入查询
┌──────────────────────────────────────────────────────┐
│ 用户在聊天框输入: "显示联系人" │
│ │
│ A2UIDemo.razor: │
│ await SendQuery("显示联系人") │
│ ↓ │
│ A2AClientService.SendQueryAsync(query, surfaceId) │
└──────────────────┬───────────────────────────────────┘
│
第二步:Agent处理查询 ↓
┌──────────────────────────────────────────────────────┐
│ MockA2AAgent.ProcessQueryAsync() │
│ query.Contains("联系人") ? GetContactListExample() │
│ │
│ 返回三条消息: │
│ 1. BeginRendering: { surfaceId, root: "root" } │
│ 2. SurfaceUpdate: { components: [...9个组件] } │
│ 3. DataModelUpdate: { contacts: [...3条数据] } │
└──────────────────┬───────────────────────────────────┘
│
第三步:处理JSON消息 ↓
┌──────────────────────────────────────────────────────┐
│ MessageProcessor.ProcessMessages() │
│ │
│ BeginRendering → 创建Surface,设置root │
│ SurfaceUpdate → 解析9个组件,存入Components字典 │
│ DataModelUpdate → 解析联系人数据,存入DataModel │
│ │
│ 触发事件: SurfaceUpdated(surfaceId) │
└──────────────────┬───────────────────────────────────┘
│
第四步:UI渲染 ↓
┌──────────────────────────────────────────────────────┐
│ A2UISurface 收到事件 │
│ LoadSurface() │
│ InvokeAsync(StateHasChanged) → Blazor重新渲染 │
│ │
│ A2UIRenderer 开始递归渲染: │
│ 根组件(Column) → List → 遍历contacts数据 │
│ → 为每条数据克隆Card模板 → 渲染Text和Button │
│ │
│ 用户看到: │
│ ┌────────────────────────┐ │
│ │ 张三 │ │
│ │ 高级工程师 [查看] │ │
│ ├────────────────────────┤ │
│ │ 李四 │ │
│ │ 产品经理 [查看] │ │
│ └────────────────────────┘ │
└──────────────────┬───────────────────────────────────┘
│
第五步:用户点击按钮 ↓
┌──────────────────────────────────────────────────────┐
│ A2UIButton: │
│ 用户点击 → @onclick触发 → OnButtonClick() │
│ ↓ │
│ EventDispatcher.DispatchUserAction() │
│ action: { name: "view_contact", │
│ context: { contactName: "张三" } } │
└──────────────────┬───────────────────────────────────┘
│
第六步:处理用户动作 ↓
┌──────────────────────────────────────────────────────┐
│ A2UIDemo.OnUserAction(): │
│ 收到事件 → 构造查询 "用户点击了: view_contact (张三)" │
│ ↓ │
│ 再次调用 SendQuery() → 进入新一轮循环... │
└──────────────────────────────────────────────────────┘
注意到这个流程的精妙之处了吗?
-
单向数据流:数据永远从Agent→Processor→Renderer流动,从不反向
-
事件上报:用户动作通过EventDispatcher上报,而不是直接修改数据
-
状态隔离:每个Surface有独立状态,多个Surface可以同时存在
-
增量更新:只更新变化的部分,不是每次都重建整个UI
这就是为什么这套架构能支撑复杂的交互:它把数据流 和控制流完全分离开了。
第三章:组件库------15个组件撑起一个UI宇宙
A2UI定义了一套标准组件目录,这个.NET实现支持了15+组件。让我们看看其中几个代表性的。
3.1 基础组件:Text、Button、Card
这三个是最基础的,几乎所有UI都会用到:
<!-- A2UIText.razor -->
@inherits A2UIComponentBase
@code {
private string GetTextContent()
{
var textProp = GetProperty("text");
if (textProp is Dictionary<string, object> boundValue)
{
// 解析绑定值
var resolved = DataBindingResolver.ResolveBoundValue(
SurfaceId, boundValue, DataContextPath);
return resolved?.ToString() ?? "";
}
return "";
}
private string GetUsageHint()
{
return GetProperty("usageHint")?.ToString() ?? "body";
}
}
@if (GetUsageHint() == "h1")
{
<h1 class="a2ui-text-h1">@GetTextContent()</h1>
}
else if (GetUsageHint() == "h2")
{
<h2 class="a2ui-text-h2">@GetTextContent()</h2>
}
else
{
<p class="a2ui-text-body">@GetTextContent()</p>
}
A2UIButton的实现更有趣,因为它需要处理用户点击:
<button class="a2ui-button @(IsPrimary() ? "primary" : "")"
@onclick="OnButtonClick">
<A2UIRenderer SurfaceId="@SurfaceId" ComponentId="@GetChildId()" />
</button>
@code {
private async Task OnButtonClick()
{
var action = GetProperty("action") as Dictionary<string, object>;
if (action == null) return;
var actionName = action["name"]?.ToString();
var context = ParseActionContext(action);
// 触发事件
EventDispatcher.DispatchUserAction(new UserAction
{
Name = actionName,
SourceComponentId = ComponentId,
SurfaceId = SurfaceId,
Context = context
});
}
}
注意<A2UIRenderer SurfaceId="@SurfaceId" ComponentId="@GetChildId()" />,这行代码递归渲染了按钮的子组件(通常是Text)。这就是为什么你可以嵌套任意深度的组件。
3.2 布局组件:Column、Row、List
布局组件负责排列其他组件。Column(垂直布局)和Row(水平布局)的实现几乎一样:
<!-- A2UIColumn.razor -->
<div class="a2ui-column" style="@GetStyle()">
@foreach (var childId in GetChildren())
{
<div class="a2ui-column-item">
<A2UIRenderer SurfaceId="@SurfaceId"
ComponentId="@childId"
DataContextPath="@DataContextPath" />
</div>
}
</div>
@code {
private List<string> GetChildren()
{
var children = GetProperty("children") as Dictionary<string, object>;
if (children?.ContainsKey("explicitList") == true)
{
return ((JsonElement)children["explicitList"])
.EnumerateArray()
.Select(e => e.GetString()!)
.ToList();
}
return new List<string>();
}
}
List组件是最复杂的,因为它支持模板和数据绑定:
private List<string> GetChildren()
{
var children = GetProperty("children") as Dictionary<string, object>;
// 方式1:显式子组件列表
if (children?.ContainsKey("explicitList") == true)
{
return ParseExplicitList(children["explicitList"]);
}
// 方式2:模板+数据绑定
if (children?.ContainsKey("template") == true)
{
var template = children["template"] as Dictionary<string, object>;
var templateId = template["componentId"]?.ToString();
var dataBinding = template["dataBinding"]?.ToString();
// 从数据模型获取列表数据
var listData = MessageProcessor.GetData(SurfaceId, dataBinding);
if (listData is Dictionary<string, object> dict)
{
var result = new List<string>();
foreach (var key in dict.Keys)
{
// 为每个数据项克隆模板
var clonedId = CloneTemplate(templateId, key);
result.Add(clonedId);
}
return result;
}
}
return new List<string>();
}
这里的关键是CloneTemplate:它会复制模板组件,生成新的组件ID,并设置正确的数据上下文路径。
比如,对于这个JSON:
{
"List": {
"children": {
"template": {
"componentId": "card-template",
"dataBinding": "/contacts"
}
}
}
}
如果/contacts有3条数据,CloneTemplate会创建3个新组件:
-
card-template__contact1(上下文路径:/contacts/contact1) -
card-template__contact2(上下文路径:/contacts/contact2) -
card-template__contact3(上下文路径:/contacts/contact3)
这样,模板中的"path": "name"就能自动解析到正确的联系人姓名。
3.3 输入组件:TextField、CheckBox、Slider
输入组件需要支持双向绑定。TextField的实现:
<div class="a2ui-textfield">
@if (!string.IsNullOrEmpty(GetLabel()))
{
<label>@GetLabel()</label>
}
<input type="text"
value="@GetCurrentValue()"
@oninput="OnInputChanged"
placeholder="@GetPlaceholder()" />
</div>
@code {
private async Task OnInputChanged(ChangeEventArgs e)
{
var newValue = e.Value?.ToString() ?? "";
// 更新数据模型
var textProp = GetProperty("text") as Dictionary<string, object>;
if (textProp?.ContainsKey("path") == true)
{
var path = textProp["path"].ToString();
MessageProcessor.SetData(SurfaceId, path, newValue, DataContextPath);
}
// 可选:触发change事件给Agent
EventDispatcher.DispatchDataChange(new DataChangeEvent
{
Path = path,
NewValue = newValue,
SurfaceId = SurfaceId
});
}
}
这里有个有趣的问题:什么时候应该通知Agent?
-
每次按键都通知:实时性好,但会产生大量请求
-
失去焦点时通知:减少请求,但延迟高
-
防抖(debounce)通知:平衡实时性和性能,推荐方案
实际项目中,你可能需要根据具体场景选择策略。
3.4 高级组件:Modal、Tabs、MultipleChoice
这些组件涉及更复杂的交互。Modal(模态框)需要管理自己的显示/隐藏状态:
@if (IsOpen())
{
<div class="a2ui-modal-overlay" @onclick="OnOverlayClick">
<div class="a2ui-modal-dialog" @onclick:stopPropagation>
<div class="a2ui-modal-header">
<h3>@GetTitle()</h3>
<button @onclick="CloseModal">×</button>
</div>
<div class="a2ui-modal-body">
<A2UIRenderer SurfaceId="@SurfaceId"
ComponentId="@GetChildId()" />
</div>
</div>
</div>
}
@code {
private bool IsOpen()
{
var isOpenProp = GetProperty("isOpen") as Dictionary<string, object>;
var resolved = DataBindingResolver.ResolveBoundValue(
SurfaceId, isOpenProp, DataContextPath);
return resolved is bool b && b;
}
private void CloseModal()
{
// 更新数据模型,关闭模态框
var isOpenProp = GetProperty("isOpen") as Dictionary<string, object>;
if (isOpenProp?.ContainsKey("path") == true)
{
var path = isOpenProp["path"].ToString();
MessageProcessor.SetData(SurfaceId, path, false, DataContextPath);
}
}
}
@onclick:stopPropagation这个指令很关键:它阻止了点击对话框内容时关闭模态框(只有点击遮罩层才关闭)。
第四章:实战场景------看看它能干什么
理论讲完了,该看看实际能干什么活了。
4.1 场景一:智能客服系统
想象你在做一个客服聊天机器人:
用户: "我想退货"
传统做法:
-
写死一个退货表单页面
-
用if-else判断显示哪个页面
-
每次需求变化(比如增加字段)都要改代码
A2UI做法:
// Agent根据上下文动态生成表单
public async Task<List<ServerToClientMessage>> HandleReturnRequest(Order order)
{
// 如果是衣服,显示尺码不合适选项
// 如果是电子产品,显示质量问题选项
// 如果超过7天,显示警告信息
var formFields = DetermineFieldsBasedOnContext(order);
return GenerateA2UIForm(formFields);
}
Agent可以根据订单类型、时间、用户等级等因素,动态决定显示什么字段、什么提示语、什么按钮。完全不需要前端代码改动。
4.2 场景二:数据可视化大屏
用户: "显示今天的销售数据"
Agent返回:
-
如果数据<=5条:返回Card列表
-
如果数据6-20条:返回Table组件
-
如果数据>20条:返回分页Table+筛选器
这个决策逻辑完全在Agent端,前端只需要渲染它返回的组件。
更炫的是,Agent可以根据数据特征选择可视化方式:
-
趋势数据 → 折线图组件
-
分类对比 → 柱状图组件
-
占比分析 → 饼图组件
4.3 场景三:个性化推荐界面
每个用户看到的界面可以完全不同:
public async Task<List<ServerToClientMessage>> GenerateHomePage(User user)
{
var components = new List<ComponentDefinition>();
// 新用户:显示引导教程
if (user.IsNewUser)
components.Add(CreateTutorialCard());
// VIP用户:显示专属优惠
if (user.IsVIP)
components.Add(CreateVIPDealsCard());
// 根据浏览历史推荐商品
var recommendations = await GetRecommendations(user.BrowsingHistory);
components.Add(CreateProductList(recommendations));
return BuildA2UIMessages(components);
}
关键是:这些逻辑都在后端Agent,前端完全不需要知道有哪些用户类型、有哪些推荐规则。
4.4 场景四:低代码平台
这是我觉得最有潜力的应用场景:
用户(业务人员): "帮我创建一个客户管理表单,包括姓名、手机、地址、备注"
AI Agent:
-
理解需求
-
生成包含4个TextField的表单
-
生成保存按钮
-
生成表单验证逻辑
-
返回A2UI JSON
前端: 直接渲染,不需要开发人员介入
业务人员可以通过自然语言描述需求,AI直接生成可用的界面。如果不满意,继续对话调整:"把备注改成多行文本框"、"增加一个上传附件的按钮"。
这不就是对话式低代码吗?
第五章:集成真实LLM------从Mock到生产
好,到现在你肯定会问:这些例子都是Mock的,怎么接入真实的LLM?
5.1 接入Google Gemini
public class GeminiA2AAgent
{
private readonly GenerativeModel _model;
private readonly string _systemPrompt = @"
You are a UI generation AI. You generate user interfaces using A2UI JSON protocol.
When user asks for UI, respond with JSON array containing:
1. beginRendering message with surfaceId and root component ID
2. surfaceUpdate message with all components
3. (optional) dataModelUpdate message with data
Available components: Card, Column, Row, Text, Button, Image, Icon, List, TextField, CheckBox, DateTimeInput, Slider, Divider
Example response:
[
{
""beginRendering"": {
""surfaceId"": ""demo"",
""root"": ""root-card""
}
},
{
""surfaceUpdate"": {
""surfaceId"": ""demo"",
""components"": [
{
""id"": ""root-card"",
""component"": {
""Card"": { ""child"": ""text1"" }
}
},
{
""id"": ""text1"",
""component"": {
""Text"": {
""text"": { ""literalString"": ""Hello"" },
""usageHint"": ""h1""
}
}
}
]
}
}
]
Respond ONLY with valid JSON. No explanations.
";
public GeminiA2AAgent(string apiKey)
{
_model = new GenerativeModel(apiKey, "gemini-2.0-flash-exp");
}
public async Task<List<ServerToClientMessage>> ProcessQueryAsync(string query)
{
var prompt = $"{_systemPrompt}\n\nUser request: {query}";
var response = await _model.GenerateContentAsync(prompt);
var text = response.Text;
// 提取JSON(LLM可能会在JSON前后加说明文字)
var jsonMatch = Regex.Match(text, @"\[[\s\S]*\]");
if (!jsonMatch.Success)
throw new Exception("LLM response is not valid JSON");
var json = jsonMatch.Value;
// 反序列化为A2UI消息
var messages = JsonSerializer.Deserialize<List<ServerToClientMessage>>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
);
return messages ?? new List<ServerToClientMessage>();
}
}
在Program.cs中注册:
builder.Services.AddScoped<GeminiA2AAgent>(sp =>
new GeminiA2AAgent(builder.Configuration["Gemini:ApiKey"]!));
builder.Services.AddScoped<A2AClientService>();
5.2 提高LLM输出质量的技巧
真实场景中,LLM输出的JSON可能不完美。这里有几个技巧:
技巧1:提供完整的Schema
在Prompt中包含完整的A2UI JSON Schema,最好带注释:
Component structure:
{
"id": "unique-component-id", // Required: must be unique
"component": {
"ComponentType": { // Only ONE type per component
"property1": value,
"property2": value
}
}
}
BoundValue can be:
{ "literalString": "text" } // Static text
{ "path": "data.field" } // Bind to data model
{ "path": "/absolute/path" } // Absolute path
技巧2:使用Few-Shot Examples
在Prompt中提供2-3个完整示例:
Example 1: Simple card
[
{ "beginRendering": {...} },
{ "surfaceUpdate": {...} }
]
Example 2: List with data binding
[...]
Example 3: Form with validation
[...]
Now generate UI for user request: [用户请求]
技巧3:增加验证和修复
public class RobustA2AAgent
{
private GeminiA2AAgent _agent;
public async Task<List<ServerToClientMessage>> ProcessQueryAsync(string query)
{
var messages = await _agent.ProcessQueryAsync(query);
// 验证消息完整性
ValidateMessages(messages);
// 修复常见问题
FixCommonIssues(messages);
return messages;
}
private void ValidateMessages(List<ServerToClientMessage> messages)
{
// 检查1: 必须有BeginRendering
if (!messages.Any(m => m.BeginRendering != null))
throw new Exception("Missing BeginRendering message");
// 检查2: 所有引用的组件ID都存在
var allComponentIds = new HashSet<string>();
foreach (var msg in messages.Where(m => m.SurfaceUpdate != null))
{
foreach (var comp in msg.SurfaceUpdate!.Components)
{
allComponentIds.Add(comp.Id);
}
}
// 检查3: root组件必须存在
var beginMsg = messages.First(m => m.BeginRendering != null);
var rootId = beginMsg.BeginRendering!.Root;
if (!allComponentIds.Contains(rootId))
throw new Exception($"Root component '{rootId}' not found");
// 更多验证...
}
private void FixCommonIssues(List<ServerToClientMessage> messages)
{
// 修复1: 统一surfaceId
var surfaceId = messages.First(m => m.BeginRendering != null)
.BeginRendering!.SurfaceId;
foreach (var msg in messages)
{
if (msg.SurfaceUpdate != null)
msg.SurfaceUpdate.SurfaceId = surfaceId;
if (msg.DataModelUpdate != null)
msg.DataModelUpdate.SurfaceId = surfaceId;
}
// 修复2: 补充缺失的catalogId
var beginMsg = messages.First(m => m.BeginRendering != null);
if (string.IsNullOrEmpty(beginMsg.BeginRendering!.CatalogId))
beginMsg.BeginRendering.CatalogId = "org.a2ui.standard@0.8";
}
}
技巧4:使用Structured Output
如果你用的是支持Structured Output的LLM(如OpenAI GPT-4),可以强制它输出符合Schema的JSON:
var response = await client.GetChatCompletionsAsync(
new ChatCompletionsOptions
{
Messages = { ... },
ResponseFormat = ChatCompletionsResponseFormat.JsonObject,
Functions = new[] { a2uiJsonSchema } // 定义A2UI的JSON Schema
}
);
这样可以大大减少格式错误。
第六章:性能优化------让它飞起来
动态UI渲染天生比静态UI慢,但我们可以通过优化把性能损失降到最低。
6.1 组件缓存:避免重复创建
public class MessageProcessor
{
// 缓存已解析的组件节点
private readonly Dictionary<string, ComponentNode> _componentCache = new();
private ComponentNode ParseComponentDefinition(ComponentDefinition def)
{
var cacheKey = $"{def.Id}_{GetHash(def.Component)}";
if (_componentCache.TryGetValue(cacheKey, out var cached))
return cached;
var node = new ComponentNode
{
Id = def.Id,
Type = def.Component.Keys.First(),
Properties = def.Component[def.Component.Keys.First()] as Dictionary<string, object>
};
_componentCache[cacheKey] = node;
return node;
}
}
6.2 虚拟滚动:处理大列表
当List组件有成百上千条数据时,全部渲染会卡顿。解决方案是虚拟滚动:
<!-- A2UIList.razor -->
@using Microsoft.AspNetCore.Components.Web.Virtualization
<Virtualize Items="@GetAllChildren()" Context="childId">
<div class="list-item">
<A2UIRenderer SurfaceId="@SurfaceId"
ComponentId="@childId"
DataContextPath="@GetDataContextForChild(childId)" />
</div>
</Virtualize>
Blazor的Virtualize组件只渲染可见区域的元素,滚动时动态加载,可以轻松处理10万+数据。
6.3 增量更新:只改变的部分
这是MessageProcessor的核心优化:
private void HandleSurfaceUpdate(SurfaceUpdateMessage message)
{
var surface = GetOrCreateSurface(message.SurfaceId);
// 不是清空再重建,而是增量更新
foreach (var componentDef in message.Components)
{
var componentNode = ParseComponentDefinition(componentDef);
// 如果组件已存在,比较是否真的变化了
if (surface.Components.TryGetValue(componentDef.Id, out var existing))
{
if (ComponentEquals(existing, componentNode))
continue; // 没变化,跳过
}
surface.Components[componentDef.Id] = componentNode;
}
OnSurfaceUpdated(message.SurfaceId);
}
6.4 防抖和节流:减少无效渲染
public class EventDispatcher
{
private readonly Dictionary<string, Timer> _debounceTimers = new();
public void DispatchDataChangeDebounced(DataChangeEvent evt, int delayMs = 300)
{
var key = $"{evt.SurfaceId}_{evt.Path}";
// 取消之前的定时器
if (_debounceTimers.TryGetValue(key, out var timer))
{
timer.Dispose();
}
// 创建新的定时器
_debounceTimers[key] = new Timer(_ =>
{
DataChangeDispatched?.Invoke(this, evt);
_debounceTimers.Remove(key);
}, null, delayMs, Timeout.Infinite);
}
}
用户在输入框连续输入时,只在停止300ms后才触发事件,避免每次按键都调用Agent。
6.5 预加载和预测:提前准备UI
public class SmartA2AClient
{
// 预测用户下一步操作
private async Task PrefetchLikelyUIs()
{
// 比如用户在看餐厅列表,预测他可能点"查看详情"
// 提前让Agent生成详情页UI,缓存起来
var likelyQueries = PredictNextQueries();
foreach (var query in likelyQueries)
{
_ = Task.Run(async () =>
{
var messages = await _agent.ProcessQueryAsync(query);
_uiCache[query] = messages;
});
}
}
}
这是一个高级优化,类似浏览器的Link Prefetch。
第七章:安全考量------别让AI搞砸你的应用
动态UI虽然灵活,但也带来安全风险。必须考虑以下几点:
7.1 输入验证:不信任任何AI输出
public class SecureMessageProcessor : MessageProcessor
{
private readonly HashSet<string> _allowedComponentTypes = new()
{
"Card", "Button", "Text", "Column", "Row", "List",
"TextField", "CheckBox", "Image", "Icon", "Divider"
};
protected override void HandleSurfaceUpdate(SurfaceUpdateMessage message)
{
// 验证1: 组件类型必须在白名单中
foreach (var comp in message.Components)
{
var type = comp.Component.Keys.FirstOrDefault();
if (!_allowedComponentTypes.Contains(type))
throw new SecurityException($"Disallowed component type: {type}");
}
// 验证2: 检查URL是否指向可信域名
foreach (var comp in message.Components)
{
ValidateUrls(comp);
}
// 验证3: 限制组件数量,防止DOS攻击
if (message.Components.Count > 1000)
throw new SecurityException("Too many components");
base.HandleSurfaceUpdate(message);
}
private void ValidateUrls(ComponentDefinition comp)
{
// 如果是Image或Video组件,验证URL
if (comp.Component.ContainsKey("Image") || comp.Component.ContainsKey("Video"))
{
var props = comp.Component.Values.First() as Dictionary<string, object>;
var urlProp = props?["url"] as Dictionary<string, object>;
var url = urlProp?["literalString"]?.ToString();
if (url != null && !IsUrlSafe(url))
throw new SecurityException($"Unsafe URL: {url}");
}
}
private bool IsUrlSafe(string url)
{
// 只允许HTTPS
if (!url.StartsWith("https://"))
return false;
// 检查域名白名单
var allowedDomains = new[] { "yourdomain.com", "cdn.yourdomain.com" };
var uri = new Uri(url);
return allowedDomains.Any(d => uri.Host.EndsWith(d));
}
}
7.2 权限控制:不同用户看到不同UI
public class AuthorizedA2AAgent
{
public async Task<List<ServerToClientMessage>> ProcessQueryAsync(
string query,
User user)
{
var messages = await _baseAgent.ProcessQueryAsync(query);
// 根据用户权限过滤组件
FilterByPermissions(messages, user);
return messages;
}
private void FilterByPermissions(List<ServerToClientMessage> messages, User user)
{
foreach (var msg in messages.Where(m => m.SurfaceUpdate != null))
{
var update = msg.SurfaceUpdate!;
// 移除用户没权限看的组件
update.Components = update.Components
.Where(comp => CanUserSeeComponent(comp, user))
.ToList();
}
}
private bool CanUserSeeComponent(ComponentDefinition comp, User user)
{
// 比如删除按钮只有管理员能看
if (comp.Component.ContainsKey("Button"))
{
var props = comp.Component["Button"] as Dictionary<string, object>;
var action = props?["action"] as Dictionary<string, object>;
var actionName = action?["name"]?.ToString();
if (actionName == "delete_item" && !user.IsAdmin)
return false;
}
return true;
}
}
7.3 防止XSS:永远Escape用户输入
<!-- A2UIText.razor -->
@{
var text = GetTextContent();
// Blazor默认会转义,但要确保不使用@((MarkupString)text)
}
<p class="a2ui-text">@text</p> <!-- ✅ 安全 -->
<!-- <p>@((MarkupString)text)</p> ❌ 危险! -->
第八章:从实验到生产------工程化的思考
好了,技术细节讲得差不多了。现在该聊聊如何把这玩意儿用到真实项目中。
8.1 渐进式采用策略
不要一开始就全面铺开。推荐这样的路线:
第一阶段(1-2周): Proof of Concept
-
选一个非核心功能,比如"帮助中心"页面
-
用Mock Agent,不接真实LLM
-
验证技术可行性和团队接受度
第二阶段(1个月): 小范围试点
-
接入真实LLM
-
选2-3个适合的场景(推荐列表、表单生成)
-
A/B测试,对比传统方式
第三阶段(3个月): 扩大范围
-
总结最佳实践
-
建立组件库和Prompt模板库
-
培训更多开发者
第四阶段(6个月+): 全面推广
-
作为标准技术栈
-
建立内部平台
8.2 团队协作模式
A2UI改变了前后端的协作方式:
传统模式:
产品经理 → 设计师 → 前端开发 → 后端开发
PRD 原型图 UI实现 接口开发
A2UI模式:
产品经理 → Prompt工程师 → Agent开发 → 后端开发
PRD 提示词设计 Agent逻辑 数据接口
↓
自动生成UI
前端工程师的角色变了:
-
从"实现UI"变成"维护组件库"
-
从"写业务代码"变成"优化渲染性能"
-
从"对接后端接口"变成"设计Agent协议"
8.3 监控和调试
生产环境必须有完善的监控:
public class ObservableMessageProcessor : MessageProcessor
{
private readonly ILogger _logger;
private readonly IMetrics _metrics;
public override void ProcessMessage(ServerToClientMessage message)
{
var stopwatch = Stopwatch.StartNew();
try
{
base.ProcessMessage(message);
_metrics.RecordHistogram("a2ui.message_processing_duration_ms",
stopwatch.ElapsedMilliseconds);
_logger.LogInformation(
"Processed {MessageType} in {Duration}ms",
GetMessageType(message),
stopwatch.ElapsedMilliseconds
);
}
catch (Exception ex)
{
_metrics.IncrementCounter("a2ui.processing_errors");
_logger.LogError(ex,
"Failed to process message: {Message}",
JsonSerializer.Serialize(message)
);
throw;
}
}
}
关键指标:
-
Agent响应时间: P50/P95/P99延迟
-
JSON解析成功率: 有多少响应格式正确
-
组件渲染时间: 从收到JSON到UI显示的时间
-
用户交互延迟: 点击按钮到收到新UI的时间
-
错误率: 各种异常的发生频率
8.4 降级方案
当Agent服务挂了怎么办?必须有Plan B:
public class ResilientA2AClient
{
private readonly GeminiA2AAgent _primaryAgent;
private readonly MockA2AAgent _fallbackAgent;
private readonly ICache _cache;
public async Task<List<ServerToClientMessage>> SendQueryAsync(string query)
{
// 1. 尝试从缓存读取
var cacheKey = GetCacheKey(query);
if (_cache.TryGet(cacheKey, out List<ServerToClientMessage> cached))
return cached;
try
{
// 2. 调用主Agent (LLM)
var messages = await _primaryAgent.ProcessQueryAsync(query)
.TimeoutAfter(TimeSpan.FromSeconds(5)); // 设置超时
_cache.Set(cacheKey, messages, TimeSpan.FromMinutes(10));
return messages;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Primary agent failed, using fallback");
// 3. 降级到Mock Agent或预定义UI
return await _fallbackAgent.ProcessQueryAsync(query);
}
}
}
第九章:未来展望------这只是开始
A2UI还很年轻,但我认为它代表了一个重要方向。让我大胆预测一下未来。
9.1 多模态UI生成
未来的Agent不仅能理解文字,还能理解图片、语音:
用户(上传一张手绘草图): "帮我实现这个界面"
Agent:
-
用视觉模型识别草图中的组件布局
-
生成对应的A2UI JSON
-
返回高保真界面
9.2 实时协作UI
多人同时操作一个A2UI Surface:
public class CollaborativeA2AClient
{
private readonly HubConnection _hubConnection; // SignalR
public async Task JoinCollaborationSession(string sessionId)
{
await _hubConnection.SendAsync("JoinSession", sessionId);
// 订阅其他用户的操作
_hubConnection.On<UserAction>("UserActionReceived", action =>
{
// 实时更新UI
EventDispatcher.DispatchUserAction(action);
});
}
}
想象一下,多个用户同时在一个Agent生成的表单上填写数据,实时看到彼此的修改。
9.3 自适应UI
Agent根据设备、网络、用户习惯自动优化UI:
-
移动端: 简化布局,减少组件数量
-
慢网络: 优先加载核心内容,延迟加载图片
-
老年用户: 放大字体,增加点击区域
-
高频用户: 显示快捷操作,隐藏新手引导
9.4 语义化组件
未来可能出现更高级的组件:
{
"component": {
"SemanticCard": {
"intent": "product_showcase",
"data": {
"product": {...}
}
}
}
}
Agent只需要指定"这是一个产品展示卡片",客户端根据平台(Web/iOS/Android)和品牌风格自动选择最佳实现。
第十章:总结与思考
写到这里,我们已经深入剖析了A2UI在.NET Blazor中的完整实现。让我总结几个核心观点:
10.1 A2UI解决的核心问题
-
开发效率: 不再需要为每个需求写UI代码
-
灵活性: 需求变化时,改Prompt而不是改代码
-
个性化: 每个用户可以看到完全不同的界面
-
安全性: 声明式数据比执行任意代码安全得多
10.2 A2UI不是银弹
它也有明显的局限性:
-
性能开销: 动态渲染比静态UI慢
-
调试困难: UI不是写在代码里,出问题难定位
-
依赖LLM: Agent质量直接影响用户体验
-
学习曲线: 团队需要时间适应新的开发模式
10.3 适合使用A2UI的场景
-
✅ 需要频繁变化的UI(管理后台、数据看板)
-
✅ 需要深度个性化的应用(推荐系统、智能助手)
-
✅ 低代码/无代码平台
-
✅ 原型快速验证
10.4 不适合使用A2UI的场景
-
❌ 性能要求极高的应用(游戏、视频编辑)
-
❌ UI需要像素级控制(品牌官网、营销活动页)
-
❌ 完全离线的应用(无法调用Agent)
-
❌ 安全要求极高且无法信任AI输出的场景
后记:AI时代的前端工程师
最后,我想聊聊一个很多人关心的问题:A2UI这种技术会让前端工程师失业吗?
我的答案是:不会,但会转型。
就像当年从jQuery到React,前端工程师的工作重心从"操作DOM"变成了"管理状态"。A2UI带来的转变是从"实现UI"到"定义组件系统和交互规范"。
未来的前端工程师可能更像"UI基础设施工程师":
-
设计和维护高质量的组件库
-
优化渲染引擎性能
-
定义Agent和客户端的协议
-
保障UI的安全性和可访问性
这实际上是更高层次的抽象。就像我们不再手写汇编,而是写高级语言,但系统工程师仍然需要优化编译器和运行时。
技术的演进从来不是取代人,而是让人专注于更有价值的工作。
相关资源
-
项目地址 : A2UI for .NET Blazor
-
Google A2UI官方 : github.com/google/a2ui
-
在线Demo : 体验地址
-
技术交流: 欢迎加入讨论群
致谢
感谢Google开源A2UI协议,感谢.NET和Blazor团队提供强大的基础设施,感谢所有为这个项目贡献代码和想法的开发者。
也感谢你耐心读到这里。如果这篇8000+字的长文对你有帮助,欢迎分享给更多人。
让我们一起探索AI驱动UI的未来! 🚀
关于作者
一个在.NET生态摸爬滚打十年的老码农,见证了从WebForms到MVC到Blazor的演进。最近在研究AI如何改变软件开发,欢迎交流。
版权声明
本文采用 CC BY-NC-SA 4.0 协议,转载请注明出处。
附录A: 完整示例代码
完整的可运行示例代码请访问项目仓库:
git clone https://github.com/your-repo/A2UI.Blazor.git
cd A2UI.Blazor/samples/A2UI.Sample.BlazorServer
dotnet run
访问 https://localhost:5001/a2ui-demo 即可体验。
附录B: A2UI JSON完整Schema
详见项目文档 A2UI_COMPONENTS_JSON_GUIDE.md,包含所有组件的详细说明和示例。
附录C: 性能基准测试
在典型硬件(Intel i7-12700K, 32GB RAM)上的性能数据:
| 指标 | 数值 |
|---|---|
| JSON解析时间(100组件) | 2-5ms |
| 首次渲染时间(100组件) | 50-80ms |
| 增量更新时间(10组件变化) | 5-15ms |
| 内存占用(1000组件) | ~15MB |
| List虚拟滚动(10000条数据) | 流畅60fps |
附录D: 常见问题FAQ
Q: A2UI支持哪些前端框架?
A: 官方支持Lit(Web Components)、Angular、React、Flutter。社区有Vue、Blazor等实现。
Q: 能否在Blazor WebAssembly中使用?
A: 可以。需要注意的是服务注册改用AddSingleton而不是AddScoped。
Q: 如何处理复杂的表单验证?
A: 可以在TextField组件中使用validationRegexp属性,或者在Agent端验证后返回错误提示UI。
Q: 支持自定义组件吗?
A: 支持。实现A2UIComponentBase,在A2UIRenderer中注册映射即可。
