编程 给 AI Agent 装上"界面表达能力":A2UI 声明式生成式 UI 协议深度实战,从 catalog 安全模型到手搓流式渲染引擎(附 TS/Go 完整代码)

2026-07-10 06:43:56 +0800 CST views 23

给 AI Agent 装上"界面表达能力":A2UI 声明式生成式 UI 协议深度实战,从 catalog 安全模型到手搓流式渲染引擎(附 TS/Go 完整代码)

当大家还在争论"Agent 该不该自己写前端代码"时,Google 已经把答案写进了一份规范里:让 Agent 描述 UI,而不是构建 UI。本文带你从协议原理、安全模型一路打到工程落地,用 TypeScript 手搓一个支持流式渲染、带白名单校验的 A2UI 渲染引擎,并用 Go 写一个会"说 UI 语言"的 Agent 后端,最后补上 AG-UI 风格的 SSE 传输层与一整套性能优化清单。

一、背景:Agent 为什么必须学会"说 UI 的语言"

2024 年底到 2025 年,围绕 AI Agent 的一批开放协议密集出现,目标很明确:给不断"生长"的 Agent 生态建立一套可通用、可协作的语言。Anthropic 的 MCP 解决"Agent 怎么调用工具",Google 的 A2A 解决"Agent 之间怎么协作",CopilotKit 的 AG-UI 解决"Agent 与前端怎么实时通信"。但有一个缺口长期没人补:Agent 怎么把复杂的多步操作,变成用户眼前一个可点击、可填写的交互界面?

传统的对话式 Agent 只能输出文本或语音。普通人问"今天天气怎么样",聊两句没问题;可一旦进入企业级场景——比如"帮我订一桌两人位的餐厅"——纯对话的体验会迅速崩塌:日期、时间、人数,需要一轮一轮地来回确认,每加一个约束就多一次往返,冗长且易错。

更合理的形态是一个交互式表单:用户选日期、选时间、点"确认预订",一步到位。问题来了:这个表单由谁定义?

目前的"生成式 UI"方案(如 CopilotKit 早期能力)有个隐性天花板——UI 的主导权仍在前端。前端根据 Agent 返回的状态或数据,用预先写好的组件动态渲染(比如把天气结果渲染成卡片)。换句话说,Agent 并不"理解"界面本身,它只是吐数据,UI 长什么样由前端工程师提前定死。

要让 Agent 真正参与 UI 构建,会撞上四个硬核挑战:

  1. 动态性:UI 元素要随上下文即时生成,某种程度上该由 AI 决定"此刻该出现什么"。
  2. 安全性:绝不能执行 AI 输出的前端代码,否则 XSS、代码注入直接破门。AI 应该"描述 UI",而不是"构建 UI"。
  3. 跨平台:同一个 Agent 可能服务 Web、App、桌面多端,技术栈各不相同,能否"一次生成,多端呈现"?
  4. 流式与状态:能否像流式文本一样,让用户实时看到 UI 逐步成形?表单数据、进度状态能否与后端 Agent 状态局部同步?

A2UI(Agent-to-User Interface) 就是 Google 针对上述问题的回答。它目前处于预览阶段(稳定版 v0.8、草稿版 v0.9,2026 年 7 月仍在快速演进),核心思想非常克制:用一份声明式的 JSON"UI 蓝图",让 Agent 描述界面应该"长什么样、能做什么",而真正怎么画、怎么执行,永远由宿主前端掌控。

二、核心概念:A2UI 到底定义了什么

一句话:A2UI 是一种声明式的生成式 UI 规范,允许 AI Agent 用 JSON 数据描述 UI 布局与组件。 Agent 不再局限于输出字符串,而是返回一份"组件树的描述",前端用本地已有的组件库把它渲染成可交互界面。

官方文档有一句定调的话值得所有做 Agent UI 的人贴在显示器上:

"Running arbitrary code generated by an LLM may present security risk. A2UI is declarative data format, not executable code."

围绕这句话,A2UI 展开了四组设计原则,每一组都直击上一节的挑战。

2.1 安全的声明式设计 + catalog 白名单

A2UI 定义的纯数据格式,而非可执行代码。它引入了一个关键概念:可信组件库(catalog)。所有可渲染组件(如 ButtonCardTextDateTimeInput)都必须事先在 catalog 中注册。Agent 只能"请求使用"这些白名单组件,无法注入任意脚本或超范围元素。

所以 A2UI 传递的不是"如何操作 DOM",而是一张 UI 蓝图:告诉前端该用哪些已核准的组件、它们的属性和布局关系。安全边界始终在客户端。

2.2 LLM 友好 + 流式生成

协议结构在设计时明确考虑了 LLM 的生成特性。UI 描述采用扁平化的 JSON 列表结构(v0.9 进一步完善),而不是深度嵌套的树。这让 LLM 可以逐步、流式地生成:先输出根组件的占位描述,再在后续输出中补充子组件或细节,也可以只对某一部分做增量更新。

于是 UI 可以"一边生成、一边展示",用户无需等待完整 JSON 憋出来才开始渲染。

2.3 跨框架、跨平台的可移植性

A2UI 彻底解耦"界面结构"与"具体实现"。Agent 只发组件结构和绑定数据,不含任何与 React、Flutter 绑定的细节。同一个 A2UI JSON,在 Web 端映射为 HTML Button,在 Flutter 中映射为 ElevatedButton,在 iOS 上对应 SwiftUI 的 Button——各端复用统一的 Agent 输出,又保持原生体验。

2.4 状态与数据绑定(dataModel)

除了结构,还需要机制把数据绑到组件上(比如把 Agent 检索到的多家餐厅名展示在列表里)。A2UI 引入 dataModel 消息:Agent 可以单独发数据更新消息("某个按钮要显示 xxx 文字"),前端据此刷新,避免每次重发整份 UI 描述。当用户点击按钮、填完表单,前端把交互转成标准化 userAction 事件回传给 Agent,形成完整的双向闭环。

2.5 四种核心消息

A2UI 用 JSON Lines(每行一条独立消息)传输,总共四类消息,理解这四类就理解了协议骨架:

消息作用
surfaceUpdate创建/更新某块界面区域(surface)的组件
dataModelUpdate更新绑定到组件的数据,无需重发结构
beginRenderingAgent 发出"可以开始首次渲染"的信号,携带 catalogId
userAction用户在前端的交互(点击/提交)回传给 Agent

一个 surfaceUpdate 的真实示例(基于官方餐厅预订 Demo):

{
  "surfaceUpdate": {
    "surfaceId": "main",
    "components": [
      { "id": "root", "component": { "Column": { "children": { "explicitList": ["header", "date_input", "confirm_btn"] }, "distribution": "start", "alignment": "stretch" } } },
      { "id": "header", "component": { "Text": { "text": { "literalString": "预订餐厅桌位" }, "usageHint": "h1" } } },
      { "id": "date_input", "component": { "DateTimeInput": { "label": { "literalString": "选择日期和时间" }, "value": { "path": "/reservation/datetime" }, "enableDate": true, "enableTime": true } } },
      { "id": "confirm_btn", "component": { "Button": { "child": "confirm_text", "action": { "name": "confirm_reservation" }, "primary": true } } },
      { "id": "confirm_text", "component": { "Text": { "text": { "literalString": "确认预订" } } } }
    ]
  }
}

注意三个关键点:组件 IDheaderdate_input)用于后续引用与更新;组件类型通过对象键声明(TextButton…);数据绑定通过 value: { "path": "/reservation/datetime" } 指向数据模型字段。

Agent 接着用 dataModelUpdate 把数据灌进去(界面与数据更新彻底分离):

{
  "dataModelUpdate": {
    "surfaceId": "main",
    "contents": [
      { "key": "reservation", "valueMap": [ { "key": "datetime", "valueString": "2025-12-18T19:00" } ] }
    ]
  }
}

等描述足够完整,Agent 发出 beginRendering,前端才进行首次渲染——并且通过 catalogId 锁定这次渲染所用组件库的来源:

{
  "beginRendering": {
    "surfaceId": "main",
    "root": "root",
    "catalogId": "https://my-company.com/a2ui/v0.8/my_custom_catalog.json"
  }
}

用户选好时间、点"确认预订",前端回传 userAction

{
  "userAction": {
    "name": "confirm",
    "surfaceId": "main",
    "context": { "details": { "datetime": "2025-12-16T19:00:00Z" } }
  }
}

三、架构分析:A2UI 在 Agent 协议栈里的位置

理解 A2UI,必须把它放进整个协议组合栈里看。真实的智能客服系统往往是这样协同的:

  • MCP:Agent 通过它访问客户数据库、查订单;
  • A2A:Agent 协同外部数据分析 Agent;
  • AG-UI:保持前端与后端 Agent 之间的状态同步(通信层);
  • A2UI:把复杂流程呈现为可交互界面(表达层)。

关键区分在 A2UI vs AG-UIA2UI 定义 UI 的结构、组件、数据绑定;AG-UI 定义这些描述如何以事件流在前后端之间安全、实时地传输。 AG-UI 不关心"你传的是什么",所以它能承载 A2UI,也能承载 OpenAI 的 Open-JSON-UI,甚至你自创的格式。两者天然互补,而非竞争。

传输层通常走 SSE(Server-Sent Events),A2UI 消息以 JSON Lines 一行条推送,前端边收边解析。而 catalogId 的引入是一次漂亮的"纵深防御":即便恶意或故障的 Agent 想塞进未授权组件,客户端也只会加载 catalog 白名单内的实现——执行的不是 Agent 给的代码,而是开发者事前审核过的组件

四、代码实战:手搓一套安全的流式渲染引擎

光讲协议不过瘾。下面我们用 TypeScript 实现渲染器 + 安全校验层,用 Go 实现一个会吐 A2UI 的 Agent 后端,最后用 Node/Express 补上 AG-UI 风格的 SSE 传输层。整套代码可运行、可拼接。

4.1 TypeScript:消息与组件类型

先把协议收敛成类型,这是一切的基础:

// A2UI 消息与组件类型定义(基于官方 v0.8/v0.9 示例整理)
export type StringValue =
  | { literalString: string }
  | { path: string };

export type ComponentDef =
  | { Text: { text: StringValue; usageHint?: 'h1' | 'h2' | 'body' | 'caption' } }
  | { Button: { child?: string; action?: { name: string }; primary?: boolean } }
  | { Column: { children: { explicitList: string[] }; distribution?: 'start' | 'center' | 'end' | 'spaceBetween'; alignment?: 'start' | 'stretch' | 'center' } }
  | { Row: { children: { explicitList: string[] } } }
  | { Card: { child: string; elevation?: number } }
  | { DateTimeInput: { label: StringValue; value: { path: string }; enableDate?: boolean; enableTime?: boolean } }
  | { TextField: { label: StringValue; value: { path: string }; placeholder?: string } };

export interface ComponentNode { id: string; component: ComponentDef; }

export interface SurfaceUpdate { surfaceUpdate: { surfaceId: string; components: ComponentNode[] }; }
export interface DataModelEntry { key: string; valueMap?: { key: string; valueString: string }[]; }
export interface DataModelUpdate { dataModelUpdate: { surfaceId: string; contents: DataModelEntry[] }; }
export interface BeginRendering { beginRendering: { surfaceId: string; root: string; catalogId: string }; }
export interface UserAction { userAction: { name: string; surfaceId: string; context?: Record<string, any> }; }

export type A2UIMessage = SurfaceUpdate | DataModelUpdate | BeginRendering | UserAction;

4.2 安全核心:catalog 白名单校验层

这是整篇文章最重要的一段代码——fail-closed 的默认拒绝。任何不在 catalog 中的组件类型、任何未授权属性,直接抛错,绝不静默放行。

// 可信组件白名单(catalog)。只有在此注册的组件/属性才允许渲染。
interface CatalogEntry { allowedProps?: string[]; }
const CATALOG: Record<string, CatalogEntry> = {
  Text:          { allowedProps: ['text', 'usageHint'] },
  Button:        { allowedProps: ['child', 'action', 'primary'] },
  Column:        { allowedProps: ['children', 'distribution', 'alignment'] },
  Row:           { allowedProps: ['children'] },
  Card:          { allowedProps: ['child', 'elevation'] },
  DateTimeInput: { allowedProps: ['label', 'value', 'enableDate', 'enableTime'] },
  TextField:     { allowedProps: ['label', 'value', 'placeholder'] },
};

// 校验单个组件节点:默认拒绝,只放行白名单。
export function validateComponent(node: ComponentNode, catalog: Record<string, CatalogEntry> = CATALOG): void {
  const entries = Object.entries(node.component);
  if (entries.length !== 1) {
    throw new Error(`[A2UI Security] 组件 ${node.id} 必须恰好包含一个类型键`);
  }
  const [type, props] = entries[0] as [string, Record<string, unknown>];
  if (!(type in catalog)) {
    throw new Error(`[A2UI Security] 拒绝未知组件类型: ${type}(不在 catalog 白名单中)`);
  }
  const allowed = catalog[type].allowedProps ?? [];
  for (const key of Object.keys(props)) {
    if (!allowed.includes(key)) {
      throw new Error(`[A2UI Security] 拒绝组件 ${type} 的未授权属性: ${key}`);
    }
  }
}

工程经验:校验要放在传输边界,而不是渲染时才想起来。 收到每条 surfaceUpdate 的第一时间就跑 validateComponent,再决定要不要进渲染队列。配合前端的 Error Boundary,能做到"一条脏消息,只废一块 surface,绝不拖垮整页"。

4.3 React 渲染器:流式解析 + 数据绑定 + 事件回传

渲染器把消息流逐步累积成组件注册表(按 id 索引),数据与结构分离存储,遇到 beginRendering 才产出首屏。所有文本以 React 文本节点渲染,绝不使用 dangerouslySetInnerHTML,从根上堵死 XSS。

import React, { useMemo } from 'react';

const getByPath = (data: Record<string, any>, p: string): any =>
  p.split('/').filter(Boolean).reduce((acc, k) => (acc == null ? acc : acc[k]), data);

function buildElement(
  node: ComponentNode,
  registry: Map<string, ComponentNode>,
  data: Record<string, any>,
  onAction: (name: string, ctx: any) => void,
): React.ReactNode {
  const [type, props] = Object.entries(node.component)[0] as [string, any];
  const resolve = (v: StringValue): string =>
    'literalString' in v ? v.literalString : String(getByPath(data, v.path) ?? '');

  switch (type) {
    case 'Text':
      return <span className={`a2ui-text ${props.usageHint ?? 'body'}`}>{resolve(props.text)}</span>;
    case 'Button':
      return (
        <button className={props.primary ? 'a2ui-btn primary' : 'a2ui-btn'}
          onClick={() => onAction(props.action?.name ?? node.id, {})}>
          {props.child ? buildElement(registry.get(props.child)!, registry, data, onAction) : null}
        </button>
      );
    case 'Column':
      return <div className="a2ui-col">{props.children.explicitList.map((id: string) =>
        <React.Fragment key={id}>{buildElement(registry.get(id)!, registry, data, onAction)}</React.Fragment>)}</div>;
    case 'Row':
      return <div className="a2ui-row">{props.children.explicitList.map((id: string) =>
        <React.Fragment key={id}>{buildElement(registry.get(id)!, registry, data, onAction)}</React.Fragment>)}</div>;
    case 'Card':
      return <div className="a2ui-card">{buildElement(registry.get(props.child)!, registry, data, onAction)}</div>;
    case 'DateTimeInput':
      return <label className="a2ui-field">{resolve(props.label)}
        <input type="datetime-local" value={resolve(props.value)} readOnly /></label>;
    case 'TextField':
      return <label className="a2ui-field">{resolve(props.label)}
        <input type="text" placeholder={props.placeholder ?? ''} readOnly /></label>;
    default:
      return null;
  }
}

export function A2UIRenderer({ messages, onAction }: {
  messages: A2UIMessage[];
  onAction: (name: string, ctx: any) => void;
}) {
  const { registry, data, rootId } = useMemo(() => {
    const map = new Map<string, ComponentNode>();
    const model: Record<string, any> = {};
    let root: string | null = null;
    for (const msg of messages) {
      if ('surfaceUpdate' in msg) {
        for (const c of msg.surfaceUpdate.components) {
          validateComponent(c);          // 每收到一条就校验,脏消息直接抛错
          map.set(c.id, c);
        }
      } else if ('dataModelUpdate' in msg) {
        for (const entry of msg.dataModelUpdate.contents) {
          model[entry.key] = (entry.valueMap ?? []).reduce(
            (acc: any, m) => { acc[m.key] = m.valueString; return acc; }, {});
        }
      } else if ('beginRendering' in msg) {
        root = msg.beginRendering.root;
      }
    }
    return { registry: map, data: model, rootId: root };
  }, [messages]);

  if (!rootId) return <div className="a2ui-loading">等待 Agent 输出 UI…</div>;
  const root = registry.get(rootId);
  if (!root) return null;
  return <div className="a2ui-surface">{buildElement(root, registry, data, onAction)}</div>;
}

前端的消费方式极简——把 SSE 收到的每一行 JSON.parse 后塞进 messages 数组即可,React 会自动做增量协调:

const [messages, setMessages] = useState<A2UIMessage[]>([]);
const source = new EventSource('/a2ui/stream');
source.onmessage = (e) => setMessages((prev) => [...prev, JSON.parse(e.data) as A2UIMessage]);

<A2UIRenderer messages={messages}
  onAction={(name, ctx) => fetch('/a2ui/action', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userAction: { name, surfaceId: 'main', context: ctx } }),
  })} />

4.4 Go:一个会"说 UI 语言"的 Agent 后端

后端 Agent 的职责是产出 A2UI JSON Lines。下面这段 Go 程序完整复现了餐厅预订场景的三段消息,用 fmt.Println 一行一条,天然就是 JSON Lines:

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type strVal struct {
	Lit  string `json:"literalString,omitempty"`
	Path string `json:"path,omitempty"`
}
type node struct {
	ID        string         `json:"id"`
	Component map[string]any `json:"component"`
}

func surfaceUpdate(sid string, comps ...node) map[string]any {
	return map[string]any{"surfaceUpdate": map[string]any{"surfaceId": sid, "components": comps}}
}
func dataModel(sid, key string, fields map[string]string) map[string]any {
	vm := []map[string]any{}
	for k, v := range fields {
		vm = append(vm, map[string]any{"key": k, "valueString": v})
	}
	return map[string]any{"dataModelUpdate": map[string]any{"surfaceId": sid, "contents": []map[string]any{{"key": key, "valueMap": vm}}}}
}
func beginRendering(sid, root, catalog string) map[string]any {
	return map[string]any{"beginRendering": map[string]any{"surfaceId": sid, "root": root, "catalogId": catalog}}
}
func emit(m map[string]any) {
	b, _ := json.Marshal(m)
	fmt.Println(string(b)) // JSON Lines:一行一条 A2UI 消息
}

func main() {
	const sid = "main"
	const catalog = "https://my-company.com/a2ui/v0.8/my_custom_catalog.json"

	// 1) 根布局:Column[header, date_input, confirm_btn]
	emit(surfaceUpdate(sid,
		node{ID: "root", Component: map[string]any{"Column": map[string]any{
			"children":    map[string]any{"explicitList": []string{"header", "date_input", "confirm_btn"}},
			"distribution": "start", "alignment": "stretch"}}},
		node{ID: "header", Component: map[string]any{"Text": map[string]any{"text": strVal{Lit: "预订餐厅桌位"}, "usageHint": "h1"}}},
		node{ID: "date_input", Component: map[string]any{"DateTimeInput": map[string]any{
			"label": strVal{Lit: "选择日期和时间"}, "value": strVal{Path: "/reservation/datetime"}, "enableDate": true, "enableTime": true}}},
		node{ID: "confirm_btn", Component: map[string]any{"Button": map[string]any{
			"child": "confirm_text", "action": map[string]any{"name": "confirm_reservation"}, "primary": true}}},
		node{ID: "confirm_text", Component: map[string]any{"Text": strVal{Lit: "确认预订"}}},
	))

	time.Sleep(150 * time.Millisecond)

	// 2) 数据绑定(无需重发整棵组件树)
	emit(dataModel(sid, "reservation", map[string]string{"datetime": "2025-12-18T19:00"}))

	// 3) 渲染信号
	emit(beginRendering(sid, "root", catalog))
}

4.5 AG-UI 风格 SSE 传输层

最后用 Node/Express 把"后端产消息"和"前端收消息 + 回传操作"接起来,这就是 AG-UI 所定义的通信通道:

import express from 'express';
import { spawn } from 'child_process';

const app = express();

// 前端订阅:后端 Agent 以 JSON Lines 持续产出 A2UI,经 SSE 推送
app.get('/a2ui/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.flushHeaders();

  const agent = spawn('go', ['run', 'agent.go']);
  agent.stdout.on('data', (buf) => {
    for (const line of buf.toString().split('\n')) {
      if (!line.trim()) continue;
      res.write(`event: a2ui\ndata: ${line}\n\n`); // AG-UI 事件通道包裹 A2UI 消息
    }
  });
  req.on('close', () => agent.kill());
});

// 前端回传:用户交互转成 userAction 事件送回 Agent
app.post('/a2ui/action', express.json(), (req, res) => {
  const action = req.body as UserAction;
  console.log('收到用户操作:', action.userAction.name, action.userAction.context);
  res.json({ ok: true });
});

app.listen(3000, () => console.log('A2UI transport on :3000'));

到此,一条完整链路打通:Agent(Go)吐 A2UI → AG-UI(SSE)传输 → 渲染器(TS)安全渲染 → 用户操作经 userAction 回传 Agent

五、性能优化:别让"生成式"变成"卡顿式"

声明式 UI 听着优雅,落地时稍不留神就会卡。结合上面的实现,给你一张可直接抄的优化清单:

5.1 流式解析,渐进渲染

JSON Lines 的最大价值是"边收边画"。前端不要等所有消息到齐再渲染,收到 beginRendering 就立刻出首屏,后续 surfaceUpdate 增量补组件、dataModelUpdate 灌数据。上面 A2UIRendereruseMemo 把消息累积成注册表,React 的 keyed reconciliation 保证只更新变化的节点,不会整页重绘。

5.2 catalog 校验结果缓存

validateComponent 对同一 (catalogId, 组件类型) 的结果是确定的。用一个 Map<catalogId, Set<type>> 缓存白名单,配合"校验一次后给组件打 validated 标记",避免每个 surfaceUpdate 重复遍历属性。注意:cache key 必须绑定 catalogId,不同 catalog 不能复用。

5.3 结构 / 数据分离,禁止重发整棵树

这是 A2UI 最被低估的性能点。表单有 50 个字段、需要刷新其中 3 个?发 3 条 dataModelUpdate 即可,绝不要重发 50 个组件的 surfaceUpdate。传输体积能差一两个数量级,首屏也更稳。

5.4 增量更新按 id 定位

渲染器用 Map<id, node> 存储组件,更新某个组件只需 map.set(id, newNode) 后触发局部重渲染。如果前端用细粒度状态库(Zustand/Jotai),可以把每个 id 映射成一个原子,做到"改一个、刷一个",把协调成本压到最低。

5.5 XSS 与注入的三道防线

  1. 声明式:Agent 永远给数据,不给代码;
  2. 白名单:catalog 拒绝一切未知组件/属性(默认拒绝);
  3. 文本节点:渲染时所有字符串走 React 文本节点,禁用 dangerouslySetInnerHTML,catalog 里也不放"原始 HTML"类组件。

三者叠加,即使 LLM 抽风输出了 <script>,也只会被当成普通文本显示,不可能执行。

5.6 渲染预算与 DoS 防护

Agent 可能(因幻觉或攻击)吐出上万组件。生产环境必须设上限:单个 surface 的组件数、嵌套深度、单条消息字节数都要有硬顶,超限直接截断并报错。配合 catalogId 锁定组件来源,能把"恶意 Agent"的危害面压到最小。

5.7 多 surface 并发

beginRendering 里的 surfaceId 支持一个对话挂多块界面(比如主区表单 + 侧边进度条)。渲染器应为每个 surface 维护独立注册表,避免互相污染;传输层用 SSE 的 event 字段区分不同 surface 的事件流。

六、总结与展望

A2UI 的出现,标志着 Agent 从"只会说话"进化到"会表达界面"。它和 MCP、A2A、AG-UI 分层协作,补齐了 Agent 应用栈里最关键的一环——让 AI 的输出以直观、可交互的形式呈现,而不是一段需要人脑二次解析的文本。

对工程师而言,落地的关键不在"多炫",而在"多稳":

  • 安全是第一位:catalog 白名单 + 声明式 + 文本节点,三件套缺一不可;
  • 流式是体验位:JSON Lines + 渐进渲染,让用户感知到"AI 在实时搭界面";
  • 分离是性能位:结构归结构、数据归数据,别让每次小更新都重发整棵树。

生态侧也在快速补位:CopilotKit 已内置 A2UI 渲染器,并提供可视化的 A2UI Composer(拖拽生成 A2UI JSON 直接喂给 Agent);高德联合千问开源的 AGenUI,则把 A2UI 做到了 iOS/Android/HarmonyOS 三端原生。可以说,2026 年 Agent UI 的"协议战国时代"已经开启。

当然也要泼盆冷水:A2UI 目前仍是 v0.8/v0.9 的预览阶段,消息格式还会调整;LLM 生成 UI 的可靠性、设计令牌(design token)的一致性、以及"Agent 该不该过度干预布局"这类产品哲学,都还没定论。但只要"让 Agent 描述 UI、让前端掌控渲染"这条主线不走偏,方向就是对的。

当年 Web 标准催生了整整一代创新;今天这些 Agent 协议,很可能正在酝酿下一次。现在理解并实践它们,就是为下一代 AI 应用提前占位。


参考资料:Google A2UI 规范(v0.8 稳定 / v0.9 草稿,2026-07)、CopilotKit AG-UI 协议与 A2UI 渲染器、高德×千问 AGenUI 开源框架。文中餐厅预订示例 JSON 整理自官方 Demo。

复制全文 生成海报 A2UI AG-UI 生成式UI Agent 声明式UI 前端架构

推荐文章

LLM驱动的强大网络爬虫工具
2024-11-19 07:37:07 +0800 CST
php内置函数除法取整和取余数
2024-11-19 10:11:51 +0800 CST
PHP如何进行MySQL数据备份?
2024-11-18 20:40:25 +0800 CST
Vue3中如何处理SEO优化?
2024-11-17 08:01:47 +0800 CST
Vue3 实现页面上下滑动方案
2025-06-28 17:07:57 +0800 CST
PyMySQL - Python中非常有用的库
2024-11-18 14:43:28 +0800 CST
程序员茄子在线接单