A2UI探索随笔-技术选型

A2UI 落地:技术选型------客户端 GenUI,服务端 Genkit TS

前几天在 DeepSeek 网页端问了个问题。答案还在一个字一个字往外蹦的时候,我没盯着页面,而是打开了 DevTools 的 Network 面板------那条还在传输的响应里,一行行 data: 开头的 JSON 正在滚出来。

那一刻我突然反应过来:所谓"流式输出",在传输层从来就不是打字机效果,而是一行一行的结构化帧。前端把每一帧拆开、增量渲染,才拼出了"AI 在打字"的观感。

A2UI 做的事,可以粗着理解成把"模型的输出是结构化帧"这件事标准化了:帧里装的不再是聊天文本的增量,而是界面本身------先给画布,再给组件,再给数据。思路与 Server-Driven UI 一脉相承:界面描述由服务端下发,客户端照着渲染,不掺私货。

上一篇《A2UI 初想:Agent 想画 UI,先得有一块画板》里,我们把 A2UI 的生命周期过了一遍:createSurface 开画布、updateComponents 上组件、updateDataModel 填数据、deleteSurface 收摊。文末留了个问题:真要跑一个 demo,两端各用什么框架?这篇把选型定下来。

总周知,如果在Deepseek网页端正在输出LLM返回的Text stream的时候打开DevTools会看到

先对齐版本:1.0 刚 GA,各端默认还是 v0.9

写作这天是 2026-11-14,A2UI 1.0 在三天前(11-11)刚刚 GA。1.0 带来了双向函数调用、无障碍、扩展机制这几个大件,配套的 Agent SDK 也给出了 Python / TS / Dart 三个语言版本。

但有个现实要注意:各端渲染器目前默认跑的还是 v0.9(2026-04-17 发布,React 端的 @a2ui/react 就是那时候落地的)。所以这篇以及后面的 demo,都基于 v0.9 的 envelope 和 basic catalog------这是当下"真能跑起来"的最大公约数。

官方 Roadmap 上排着 Protocol v1.1、Templates、Agent SDK、Evals,协议还在快速演进。这一点会直接影响下面的选型逻辑:客户端别自研渲染器,跟着官方实现走。

选型总览

结论就两句话:

  • Genkit TS 负责 Agent 侧:提示词怎么编排、tool 怎么调、A2UI 消息怎么流式吐给客户端;
  • GenUI 负责客户端侧:怎么在 catalog 白名单内安全地渲染界面、用户的操作怎么作为 action 回传给 Agent。

一个负责"想",一个负责"画",中间靠一条 JSONL 帧流对话。

成本也要先说透:这套组合不是"TS 全栈"------服务端 TypeScript,客户端 Dart,是实打实的双语言。之所以接受,是因为生态刚好长成这样:AI 服务端生态(模型 SDK、SSE、Genkit 插件)最厚的是 TS;而 Flutter 端的官方 A2UI 实现只有 GenUI。如果你们本来就是 Flutter 客户端加 Node 服务端,这套选型几乎零额外成本;如果不是,双语言的维护成本需要提前掂量,别被"一种语言通吃"的幻觉带偏。

客户端:为什么是 GenUI

先看官方渲染器矩阵(a2ui.org):React、Angular、Lit、Flutter、SwiftUI、Compose 六端都有官方实现,其中 React 的 @a2ui/react 已随 v0.9 落地。Flutter 这一格,填的是 GenUI------仓库在 github.com/flutter/genui,Flutter 官方组织之下,也是目前 Flutter 端唯一的官方 A2UI 框架。

它把客户端这条链路整条包了:解析 envelope、按 catalog 校验、渲染组件、path 到 dataModel 的数据绑定、action 回传。你交给它一个 surfaceId 和一个消息流,它还你一个能跑的 Flutter 界面。旁边还有个 genui_a2a 包,把 A2A(Agent-to-Agent)协议那侧的对接也做了。

不选它的话,备选项大概长这样:

方案 协议支持 安全边界 成本
flutter_markdown + 提示词约束 没有 A2UI 概念,全靠模型自觉 无组件级白名单,界面形态不可控 看似最小,天花板最低
按 spec 自研渲染器 从零实现 envelope、catalog 校验、流式更新、action 回传 自己把关,容易漏 最大,且要跟协议版本赛跑
GenUI 官方实现,跟 spec 一起演进 catalog 白名单天然继承 接上就能跑

再补一个降低心理门槛的事实:basic catalog 的布局体系和 CSS Flexbox 是同一套思想。Row 和 Column 是 Flutter 的两个基础布局 widget,主轴用 justify 排,交叉轴用 align 排:

basic catalog / Flutter CSS Flexbox
Row / Column flex-direction: row / column
justify justify-content
align align-items

写过前端的读者看到 basic catalog 的 Column,五分钟就能对上号。整个 basic catalog 一共 12 个基础组件(Text、Button、CheckBox、Column、Row 这些),对照官方实现指南过一遍,一下午能记全。

服务端:为什么是 Genkit TS

先纠正一个容易搞错的认知:A2UI 的服务端支持,并不是 Genkit TS 独有。Genkit 的 TS、Go、Dart、Python 四个语言运行时都有 a2ui 支持,envelope 层面字节级兼容;Google ADK 也在 2026 年 3 月给了官方 A2UI 集成。所以"别家没有 UI 概念"这类说法不成立------选哪个,都能把 A2UI 消息吐对。

真正的差异在中间件的完整度。@genkit-ai/a2ui 这个 TS 中间件,把"让模型正确输出 A2UI"的三步脏活全部自动化了:

  1. catalog 注入:把 catalog 里有哪些组件、各有哪些属性,自动写进 system prompt,不用手搓提示词;
  2. 拦截 + 校验:模型输出里的 a2ui 代码块被中间件截下来,按 catalog 白名单校验,名单外的组件直接挡掉;
  3. 转流:校验通过的组件描述转成 application/a2ui+json 数据流,按 SSE 的节奏推给客户端。
能力 Genkit TS Genkit Go Genkit Python Google ADK
A2UI 支持 有(@genkit-ai/a2ui) 有 有(0.12 起) 有(2026-03 官方集成)
envelope 兼容 字节级 字节级 字节级 字节级
catalog 注入 → 拦截校验 → 转流的整链路中间件 全自动 无等价物,相关环节自己实现 无等价物,相关环节自己实现 无等价物,相关环节自己实现

表里的"无等价物"不等于"做不到",只是没有打包好的中间件,这几步要自己写。差别不在"能不能",在"要写多少"。

什么时候不该选 TS:如果你的服务端已经全押 Python(比如深度绑在 ADK 的编排生态上),为了 A2UI 换语言不值当,ADK 的集成够用,中间那几步自己搭就是。选型这件事,诚实比站队重要。

跑一遍:三帧 JSONL 点亮一个 TODO

选型定了,看点真东西。下面用一个最小的 TODO demo,模拟 Agent 吐出的完整一轮:三帧------画布、组件、数据,正好是上一篇生命周期里的前三条(deleteSurface 这帧 demo 用不上)。

先对齐传输形态:SSE 里一个 event 携带一帧,帧的 data 是单行 JSON,一行一个对象------这才是 JSON Lines 的本意。DevTools 里看到的样子大致是(data: 是 SSE 的帧前缀):

jsonl 复制代码
data: {"messageType":"createSurface","surfaceId":"todo-demo","catalogId":"https://a2ui.org/specification/v0_9/basic_catalog.json"}

后面两帧同款形式,只是 payload 更长。下面按帧拆解,代码为了可读做了展开排版,但请记住:线上每一帧都是压成一行的。

第 1 帧 · createSurface:开画布

jsonl 复制代码
{
  "messageType": "createSurface",
  "surfaceId": "todo-demo",
  "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
}

三个字段各司其职:messageType 声明这帧干什么;surfaceId 给画布起名,后续每一帧都带着它,客户端才知道往哪块画布上画;catalogId 则声明"这块画布只准用这个 catalog 里的组件"------白名单的开关就在这。客户端收到这帧,先加载 catalog 定义,把安全的渲染环境备好,此时一个组件都还没画。

有个容易被这个 URL 骗到的地方:这个 catalogId 实测 404。catalogId 本质是一个 URI 标识符,协议并不保证它可以被直接下载,客户端通常内置或预加载了 catalog 定义。v1.0 起,catalog 路径换成了新格式(如 a2ui.org/specification/v1_0/catalogs/basic/catalog.json)。demo 里保留 v0.9 写法,因为各端默认还跑在 v0.9 上。

第 2 帧 · updateComponents:上组件

jsonl 复制代码
{
  "messageType": "updateComponents",
  "surfaceId": "todo-demo",
  "components": [
    { "id": "root", "type": "Column", "justify": "start", "align": "stretch",
      "children": ["title", "subtitle", "todo_list", "actions"] },

    { "id": "title", "type": "Text", "variant": "h2", "text": { "path": "/title" } },
    { "id": "subtitle", "type": "Text", "variant": "caption", "text": { "path": "/subtitle" } },

    { "id": "todo_list", "type": "Column", "justify": "start", "align": "stretch",
      "children": ["item_0", "item_1", "item_2"] },

    { "id": "item_0", "type": "Row", "justify": "start", "align": "center",
      "children": ["check_0", "label_0"] },
    { "id": "check_0", "type": "CheckBox", "value": { "path": "/todos/0/done" } },
    { "id": "label_0", "type": "Text", "text": { "path": "/todos/0/label" } },

    { "id": "item_1", "type": "Row", "justify": "start", "align": "center",
      "children": ["check_1", "label_1"] },
    { "id": "check_1", "type": "CheckBox", "value": { "path": "/todos/1/done" } },
    { "id": "label_1", "type": "Text", "text": { "path": "/todos/1/label" } },

    { "id": "item_2", "type": "Row", "justify": "start", "align": "center",
      "children": ["check_2", "label_2"] },
    { "id": "check_2", "type": "CheckBox", "value": { "path": "/todos/2/done" } },
    { "id": "label_2", "type": "Text", "text": { "path": "/todos/2/label" } },

    { "id": "actions", "type": "Row", "justify": "start", "align": "center",
      "children": ["btn_add_wrap", "btn_reset_wrap"] },
    { "id": "btn_add_wrap", "type": "Button", "child": "btn_add",
      "action": { "event": { "name": "todo.add" } } },
    { "id": "btn_add", "type": "Text", "text": "加一条" },
    { "id": "btn_reset_wrap", "type": "Button", "child": "btn_reset",
      "action": { "event": { "name": "todo.reset" } } },
    { "id": "btn_reset", "type": "Text", "text": "重置" }
  ]
}

这一帧信息量最大,分三层看。

第一层:没有树,只有清单。components 是个扁平列表,父子关系全靠 children 里的 ID 引用------root 并不"包含" title,root 只是"认识" title。这不是炫技,是为流式和局部更新设计的:任何一帧 updateComponents 都可以只带发生变化的组件,比如只替换 item_1 一个,客户端按 ID 局部替换,不用重画整棵树。嵌套的组件树想做这件事,得先解决"第 47 层某个属性变了怎么表达"的问题;扁平列表加 ID 引用,天生没有这层烦恼。

第二层:值全是钩子。所有 Text 和 CheckBox 的取值都不是字面量,而是 {"path": "..."}------标题文案、每条 todo 的文字、CheckBox 勾没勾,此刻全是悬空的引用。数据在哪?下一帧才到。这一帧先把骨架搭起来,把钩子埋下去。

第三层:写法和官方几乎一致。这份 payload 是照着 basic catalog 的语义写的:Text 用 h2 / caption 这类 variant,Button 是 child 加 action.event,CheckBox 的 value 是 path 引用,Column / Row 带 justify 和 align。可以拿官方实现指南逐字段核对,完全对得上号。

第 3 帧 · updateDataModel:填数据

jsonl 复制代码
{
  "messageType": "updateDataModel",
  "surfaceId": "todo-demo",
  "dataModel": {
    "title": "A2UI 上手清单",
    "subtitle": "三件事,走完一个闭环",
    "todos": [
      { "id": "t0", "label": "读懂 createSurface", "done": true },
      { "id": "t1", "label": "接住 SSE 流式推送", "done": false },
      { "id": "t2", "label": "把 action 回传给 Agent", "done": false }
    ]
  }
}

第 2 帧埋下的钩子,在这帧全部兑现:/title、/subtitle、/todos/N/label、/todos/N/done 逐一落位,界面从骨架变成活的。

一个容易看走眼的地方:/todos/1/done 里的 1 不是"第一条"。数组下标从 0 数起,它指向 todos 的第二个元素 t1------"接住 SSE 流式推送"。CheckBox 勾没勾,读的就是这个字段。

往后的任何状态变化,Agent 只需要再发一帧 updateDataModel。比如用户勾掉第二条,action 回传,Agent 回一帧只把 /todos/1/done 改成 true,组件树一帧都不用重发。结构和数据分离,各走各的更新通道------这是 A2UI 里我最喜欢的一个设计。

结尾:这个 demo 就是路线图

这个 TODO 的三条待办不是随手编的,它就是本系列的路线图:

  1. 读懂 createSurface (已完成):前两篇干的正是这件事------协议的生命周期与消息语义,见上一篇。
  2. 接住 SSE 流式推送(下一篇):Genkit TS 实战。用 @genkit-ai/a2ui 中间件,把上面这三帧真正流出来,跑通 catalog 注入、拦截校验、转流的全过程。
  3. 把 action 回传给 Agent(再下一篇):点一下"加一条",看 action.event 怎么回到 Agent,Agent 又怎么用一帧 updateDataModel 把界面改回来------闭环收口。

三帧 JSONL,画布、组件、数据,一层层点亮。A2UI 迷人的地方就在这里:Agent 的思考和用户的界面之间,隔着的不是一段谁也说不清的提示词,而是一份可以逐字段校验的协议。


相关推荐
前端繁华如梦17 分钟前
# 从 73000 行压缩代码到 TypeScript 模块化:我把一个 Three.js FPS 游戏 1:1 复刻了一遍
前端
Penge66621 分钟前
React Context 详解:从 Props 传递到跨组件状态共享
前端
Penge66630 分钟前
React 事件处理:bind(this) 原理与实际开发常用写法
前端
ZhengEnCi41 分钟前
小白用 AI 做官网完全指南:不用框架的六格清单,双击就能打开
前端·html·ai编程
zhanlang02171 小时前
HTML 零基础入门到企业实战【第五章 5.2】图片路径精讲:绝对路径、相对路径、网络路径(新手必踩坑点)
前端·html
夕水1 小时前
我用trae重构了ew-color-picker,一个插件化架构的现代颜色选择器
前端·javascript·开源
Penge6661 小时前
React JSX 核心语法:从入门到业务开发实战
前端
zhanlang02172 小时前
HTML 零基础入门到企业实战全套教程【第五章 5.5 综合案例:图文影音展示页面开发】
前端·html
zhanlang02172 小时前
HTML 零基础入门到企业实战全套教程【连载 5.3】audio、video 音频视频标签|兼容格式、自动播放踩坑、企业规范
前端·html