Featured image of post GPT-6 的回答里开始长出按钮了:生成式 UI 拆解,以及 A2UI 想当成的那层「标准」!

GPT-6 的回答里开始长出按钮了:生成式 UI 拆解,以及 A2UI 想当成的那层「标准」!

10 月 7 日,OpenAI 开始给 ChatGPT 推 GPT-6,顺手带了一个叫 Intelligent UI 的功能。官方措辞很轻:回答不再只是文字,可以是图表、按钮、表单,甚至是当场生成的一个小工具。

MacRumors 那篇实测里有个例子我印象很深——你问一辆七速自行车,它给你的不是先铺五百字,而是一张可以点的车示意图,点哪个部件、展开哪个部件的说明。有媒体还描述了一段做饭计划:一张食材图、一个「几个人吃」的控件、跟着人数自动重算的用量、一个复制购物清单的按钮、再加一份可勾选的备菜清单。

这东西在消费端是第一次铺到十亿级用户的默认界面里,但它在工程圈不是新概念。它有个名字——生成式 UI(Generative UI)。所以我没急着写「GPT-6 发布」,而是把三样东西翻了一遍:OpenAI 的发布说明、Google 的 A2UI v0.9 规范(a2ui.org 上那一版),还有几篇拆架构的长文。下面把读到的和我自己的判断揉在一起写。

先把三个被混在一起的词分开

「AI 帮你做界面」这件事,其实有三种完全不同的东西,媒体经常混着讲。

概念 本质 例子
AI 生成 UI 代码 在设计期生成代码,人审完再提交 让模型吐出一个 Dashboard.tsx
生成式 UI 运行期、按用户这次的问题,由模型挑选/组合界面 在聊天流里渲染一张天气卡片
自适应 UI 界面按行为/上下文变化,未必经过模型 按用户分群切 A/B 布局

生成式 UI 是中间那个。它的关键不是「模型会写前端」,而是把界面的决定推迟到运行期——传统 UI 每一个屏幕都是开发者提前手搭的,生成式 UI 把这一步交给了模型,而且是每一次提问都重新决定。

一句话的机制定义:生成式 UI = 把工具调用的结果直接接上一个 UI 组件。agent 调了 searchFlights(),系统不再把 JSON 塞回 prompt 让模型用散文「复述」一遍,而是直接渲染一张带真实数据的航班卡片。

三层控制光谱:这一层最容易被忽略的分类

2026 年的各个框架,基本都落在这条光谱上——往右是给 agent 更大的自由度,代价是风险和控制成本。我把它整理成一张表:

模式 agent 返回什么 优点 代价 适合
Static(预置组件) 工具名 + 数据 安全、一致、简单 死板,每个场景都得先造组件 金融、医疗、SaaS
Declarative(声明式,A2UI) JSON 规格(蓝图) 有护栏的灵活、跨端 每个规格要一套渲染器 大多数生产应用
Open-ended(开放式,MCP Apps) HTML / iframe / 小应用 复杂工具能力最强 安全风险、难移植、样式不统一 内部工具、开发者平台

三层的差别,说穿了是**「谁掌握『能出现什么组件』这件事」**。

Static 是开发者说了算:agent 只能在你的组件库里挑,永远画不出你没建的东西。Open-ended 是 agent 说了算:它直接把整个界面(HTML、iframe、甚至一个嵌进去的小应用)丢过来,前端只是个容器。Declarative 在中间——agent 交的是一份描述「要什么」的 JSON 蓝图,前端拿自己的组件目录去渲染。agent 不交代码,只交意图;呈现的控制权还在前端手里。

顺便分清一个经常被搞错的层:AG-UI 不是界面格式。它是一个事件与状态协议,坐在三层模式下面——负责工具生命周期(started → streaming → finished/failed 的信号)、把用户的点击/表单提交路由回去、以及实时同步 agent 状态和 UI。正是有了这一层,一个运行时(比如 CopilotKit)才能同时支持三种模式。

A2UI 到底是一份什么样的规范

Declarative 这一档,现在最像「公共标准」的是 Google 的 A2UI(Agent-to-User Interface),v0.9 是 2026 年 4 月发的。它的哲学就一句:从 demo 到生产,需要干净的关注点分离。agent 生成一份 UI 规格,客户端用自己的组件目录渲染。agent 不需要知道你在用 React 还是 Flutter,前端也不需要因为 agent 想展示新东西就加组件。

我去读了它现在的规范页,先说一个很多人没注意到的现状:v0.9.1 是当前稳定版,v1.0 还在 release candidate,v0.8 已经标成 legacy。也就是说这层标准自己还在动。

消息信封:现在有四个,不是一个

每一条从服务端流下来的 JSON,都是「一个信封,里面恰好只有一个键」。现在的规范里这四个键是:

消息 作用 v0.8 时代的旧名
createSurface 创建一个新的界面表面,开始渲染 beginRendering
updateComponents 增改界面树上的组件 surfaceUpdate
updateDataModel 往已声明的组件里喂数据 dataModelUpdate
deleteSurface 销毁一个表面 —

这里有个值得记的细节:那篇在网上传得很广的 GenUI 长文,用的还是 v0.8 的三个旧名(surfaceUpdate / dataModelUpdate / beginRendering)。规范把消息名整套改掉了,二手文章还没追上。 这本身就是「生态还在变」的证据——你要真拿一篇文章去接 SDK,很可能对不上版本。

组件树是「邻接表」

A2UI 的界面模型很有意思:它不是嵌套的树,而是一个扁平的组件数组 + 靠 id 引用拼出结构,也就是邻接表。容器组件(Row、Column、List、Card)用属性指向子组件的 id,客户端把全部组件存进一个 Map,渲染时再重建树。

这条设计带来一个直接的好处:服务端可以乱序发组件。只要 id 为 root 的那个组件到了,客户端就能先把树画出来,剩下的边到边补。规范里明确写着——组件树里必须有且仅有一个 id 是 root 的组件,在它出现之前,其它更新都不会有可见效果,会被先缓冲着。

还有两个约束值得记:surfaceId 和 catalogId 一旦创建就固定,想改只能删掉重建;surfaceId 在渲染器整个生命周期里必须全局唯一。规范甚至专门提了子 agent 的坑——多个 subagent 一起管 surface 时,要么给 surfaceId 加 agent 名前缀,要么强制用 UUID,否则会撞。

下面这段是真从规范里抄出来的 updateComponents(我截了一部分),一个联络表单:

{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "contact_form_1",
    "components": [
      { "id": "root", "component": "Card", "child": "form_container" },
      { "id": "form_container", "component": "Column",
        "children": ["header_row", "name_row", "email_group", "submit_button"] },
      { "id": "email_field", "component": "TextField", "label": "Email",
        "value": { "path": "/contact/email" },
        "checks": [
          { "call": "required", "args": { "value": { "path": "/contact/email" } },
            "message": "Email is required." },
          { "call": "email", "args": { "value": { "path": "/contact/email" } },
            "message": "Please enter a valid email address." }
        ] },
      { "id": "submit_button", "component": "Button", "child": "submit_button_label",
        "variant": "primary",
        "action": { "event": { "name": "submitContactForm",
          "context": { "formId": "contact_form_1" } } } }
    ]
  }
}

数据用另一个信封喂进去:

{ "version": "v0.9",
  "updateDataModel": { "surfaceId": "contact_form_1", "path": "/contact",
    "value": { "email": "[email protected]", "subscribe": true } } }

几个能看出设计取舍的点:

  • 结构和数据是分开的。 updateComponents 声明「有哪些组件」,updateDataModel 单独喂值。这样刷新数据不用重建整棵树——它是流式渲染能顺滑的地基。
  • 数据绑定用 JSON Pointer(RFC 6901),"path": "/contact/email" 就是标准指针。A2UI 只对它做了一处扩展:为了让列表模板能渲染,允许不以下划线开头的相对路径——这是对严格 RFC 6901 的一次有意偏离。
  • 交互分两种。 action.event 是把事件发回服务端(服务端动作);action.functionCall 是在客户端执行本地函数(比如 openUrl)。输入组件是双向绑定的。

集成是一段五步的 Python

Google 的 devblog 给了「Hello World」式的集成,真的很短:

pip install a2ui-agent-sdk
# 1. 定义目录(用基础的,或者带你自己的)
my_catalog = CatalogConfig.from_path(
    name="<MY_CATALOG_NAME>",
    catalog_path="file:///path/to/catalog.json",
)

# 2. 初始化 schema 管理器(管 A2UI 的版本)
schema_manager = A2uiSchemaManager(version="0.9", catalogs=[my_catalog])

# 3. 生成系统提示词
system_instruction = schema_manager.generate_system_prompt(
    role_description="You are a helpful assistant great at generating UI...",
)

# 4. 初始化你的 LLM agent
my_agent = AnyAgentFrameworkLLMAgent(instruction=system_instruction)

# 5. 执行并流式吐 UI
def handle_turn(user_query):
    llm_response = my_agent.respond(user_query)
    selected = schema_manager.get_selected_catalog()
    return parse_response_to_parts(llm_response, selected.validator)

SDK 还承诺了三件生产级的事:版本协商(按客户端能力动态挑版本)、动态目录(运行时按用户权限/设备约束切不同目录)、弹性流式(边生成边解析并「修复」LLM 输出,让客户端在 JSON 还没闭合时就能开始画)。

「目录」本身是一份 JSON Schema,而且传输是解耦的

A2UI 里的目录(Catalog)不是一张组件清单那么简单,它是一份 JSON Schema 文档。规范要求:一份目录定义要同时写 $id(给 JSON Schema 工具用)和 catalogId(给 A2UI SDK 做目录协商用),而且两个值要设成同一个 URI。catalogId 虽然习惯写成 URI 的样子,但它只是个字符串标识,不要求真能解析到某个资源——它存在的全部意义,是「让客户端和服务端对同一个目录达成共识」。规范里的基础目录(Basic Catalog)只是入门的起点,它自己都建议:绝大多数生产应用应该定义自己的目录,去对齐自家设计系统。

另一个常被忽略的设计是传输解耦。A2UI 不绑定任何传输层,规范原话是「A2UI over MCP、WebSockets、REST、AG-UI、A2A,或者随便你想用的什么」。同一份界面规格,换传输不用重写——这也是它敢叫「框架无关标准」的底气。

v0.9 最关键的一处变化:prompt-first

这条我觉得比消息改名重要得多。A2UI 从 v0.8 的 structural-output 路线,换成了 prompt-first——把 schema 直接塞进 prompt 当上下文,而不是指望模型走结构化输出。

规范自己列了得失。好处有两个:schema 不再受 structured output 格式的约束,能写得更丰富更可读;schema 被拆成独立的模块(common_types.json、catalogs/basic/catalog.json、server_to_client.json),更好维护。

代价它也没藏:模型不再被 schema 硬约束了,所以校验必须自己做,而且要够狠。 规范原话大意是,这需要健壮的错误处理和纠正——系统得能发现出入、尝试修复再渲染,或者直接让模型重试。

安全:declarative 为什么比 executable 稳

这一段我认为是团队选型时最该抄下来的。A2UI 的防御是叠起来的四层:

  1. 数据不是命令。 agent 的输出是一种声明式数据格式,没有 eval(),没有内嵌脚本。
  2. 目录白名单。 agent 只能引用预先批准的目录里的组件——它没法凭空造一个 <script>。
  3. 目录可按权限切。 同一个 agent,未登录用户看到的是最小目录,管理员看到的是完整目录。
  4. 每个规格进场前都过 Schema Manager 校验。

对比一下 Open-ended 那档(MCP Apps):agent 返回的是任意 HTML / iframe,等于把 XSS 和界面伪装(UI-redressing)的入口直接递给了模型——尤其是当内容里混进了不受信任的用户输入时。

我的立场很明确:任何碰到敏感数据或不可逆动作(支付、删除、改权限)的流程,都别让它跑 Open-ended。 老老实实待在 Static 或 Declarative,目录卡紧一点。「模型很聪明所以没事」不是安全模型。

我的三个判断

拆完规范,说三个我自己的结论——不是转述厂商的说法。

第一,这一层的胜负手不是模型能力,是「目录(catalog)」。 生成式 UI 谁都能做,难的是「默认能出现哪些组件」这件事由谁定义。目录是这层的词汇表——你定义了目录,就等于定义了所有人用生成式 UI 时的默认积木。这跟 MCP 那边 tools registry 之争是同一个结构:协议是免费的,目录是权力。谁先把默认目录做成事实标准,谁就拿到了分发权。

第二,OpenAI 和 Google 走的是两条不同的路,而两条路的护城河都在分发,不在技术。 OpenAI 的 Intelligent UI 是 native 目录 + 编译器:自家一套可流式渲染的原生组件,配一个「边生成边编译」的管道(据 MacRumors 报道,图表可以先画出坐标轴、数字随后到),闭环、可控,但封闭。Google 的 A2UI 是 开放目录 + 任意渲染器:React、Flutter、Angular、Lit 四个官方渲染器,agent 不用知道你在用什么端。两条路线最终都会靠自家产品的分发碾压——ChatGPT 铺十亿用户,Android/Chrome 后台就替你铺 A2UI。创业公司在这条光谱上只剩两个切口:垂直领域的目录(比如专门给金融、医疗做的组件集),和某个端的渲染器。做通用协议层,你没有分发。

第三,什么情况下它会变成泡沫,条件很清楚。 生成式 UI 的核心卖点是「感知延迟」——200ms 出现的骨架,比 2 秒后出现的完整段落感觉快得多,哪怕总时间没变。但 v0.9 的 prompt-first 把「校验」的成本推给了你:模型输出不再被 schema 约束,你得自己 heal、重试。如果校验和重试的开销,把流式渲染省下来的那点感知延迟吃回去了,卖点就塌了。 我暂时不认为这会普遍发生——但这是这条路线最该盯的数据,而不是 benchmark 分数。

如果你现在就要选,这是我会用的顺序

一句话版决策树:流程碰敏感数据或不可逆动作 → Static(预置组件、目录卡死);需要跨端(web + 移动 + 桌面)→ Declarative / A2UI(JSON 规格、共享目录);工具复杂到必须要深度定制 UI → 才考虑 Open-ended,而且只在内部环境用。

2026 年更靠谱的做法不是二选一,是叠层:用 A2UI 管跨端组件,用 AG-UI 管事件与状态,用 MCP 管工具接入。三层各管一段,谁也不用假装自己能管全部。

(举个已经落地的组合:Oracle 最近上的 Agent Spec,就是把它自己的 Agent Spec + AG-UI + A2UI 叠在一起——Agent Spec 定义「跑什么」,AG-UI 承载「交互怎么走」,A2UI 定义「用户碰到的界面长什么样」。堆栈里任何一层都能换实现,体验不变。这种「各管一段」的分工,是这层生态开始成型的信号。)

写在最后

我写完这篇的时候,还在想一个没答案的问题:生成式 UI 会不会让「界面」这个词本身消失?

过去我们说的界面,是开发者提前搭好、所有人共享的一层壳。生成式 UI 把它变成了一次性的、为这一次对话临时长出来的东西——用完就扔。这对产品是好事,对「设计系统」这门手艺却未必:如果每次都是模型现搭,那品牌一致性、可访问性这些东西,靠谁保证?

我的答案是目录。目录就是那个「不许模型自由发挥」的边界——它既是安全阀,也是设计系统的延续。谁能把自己的设计系统变成 agent 的默认目录,谁就在生成式 UI 时代保住了自己的壳。这一仗,现在刚刚开打。


相关阅读

By AI博士 万戈