跳到主要内容

创建日期:2026-10-20 | 最近更新:2026-10-20 基准:A2UI v0.9.1,协议正文 specification/v0_9_1/docs/a2ui_protocol.md,schema specification/v0_9_1/json/server_to_client.json。文中的完整消息示例逐条过了 Draft 2020-12 校验;含 ... 占位符的片段仅作结构示意。

消息与生命周期:四条消息撑起一个 Surface

A2UI 的服务端到客户端只有四条消息。真正需要吃透的不是字段表,而是三个问题:

  1. 信封长什么样:为什么一条消息只能有一个消息键?
  2. 状态怎么变:createSurface → updateComponents → updateDataModel → deleteSurface,哪些顺序是硬约束?
  3. 流式到达时怎么办:组件、数据、root 都可能乱序或缺失,客户端该渲染什么?

这一篇按这三条线拆。

1. 信封:一条消息,恰好一个键​

server_to_client.json 的顶层用 oneOf 限定了四种消息,并且每种消息都 additionalProperties: false:

{
"version": "v0.9.1",
"createSurface": {
"surfaceId": "main",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
}
}

规则只有三条,但都是硬约束:

规则含义违反会怎样
version 必填值只能是 "v0.9" 或 "v0.9.1"schema 校验失败
恰好一个消息键createSurface / updateComponents / updateDataModel / deleteSurface 四选一oneOf 失败
不允许额外顶层字段每种消息都 additionalProperties: falseschema 校验失败

所以下面两条都是非法的:

// 错:一条消息里塞两种操作
{
"version": "v0.9.1",
"createSurface": { "surfaceId": "main", "catalogId": "..." },
"updateComponents": { "surfaceId": "main", "components": [] }
}
// 错:漏掉 version
{
"createSurface": { "surfaceId": "main", "catalogId": "..." }
}

为什么这么设计?因为消息之间没有事务。一条消息只做一件事,客户端才能逐条应用、逐条报错、继续处理后面的消息。A2A 绑定里甚至明确写了:一个消息列表不是事务单元,某条失败就记录该条错误,继续处理剩余消息。

2. createSurface:先建画布,再谈内容​

作用:创建一块 surface,并锁定它使用的 Catalog。

字段类型必填说明
surfaceIdstring是当前活跃 surface 内唯一
catalogIdstring是Catalog 标识符;看起来像 URI,但不要求可访问
themeobject否必须通过该 Catalog 的 theme schema
sendDataModelboolean否为 true 时,客户端在后续每条上行消息的 metadata 里附带完整数据模型;默认 false

几个容易忽略的语义:

  • surface 必须先创建。在 createSurface 之前发 updateComponents 或 updateDataModel 是错误。
  • surfaceId 和 catalogId 创建后不可变。想换 Catalog,只能 deleteSurface 再重新 createSurface。
  • 重复创建是错误。对已存在的 surfaceId 再发 createSurface,必须先删除。
  • root 不在 createSurface 里。这是和 v0.8 beginRendering 最大的区别:v0.8 用 root 字段指定根组件,v0.9 改成「某个组件的 id 必须叫 root」。

v0.9.1 相对 v0.9 放宽了一点:surfaceId 不再要求「渲染器生命周期内全局唯一」,只要求「当前活跃 surface 中唯一」。删除后可以复用同一个 ID。

3. updateComponents:扁平列表,ID 连边​

作用:向指定 surface 添加或更新组件。

字段类型必填说明
surfaceIdstring是目标 surface
componentsarray是至少一个组件对象

组件对象是扁平的:

{
"id": "submit_btn",
"component": "Button",
"child": "submit_label",
"variant": "primary",
"action": {
"event": { "name": "submit" }
}
}

不是 v0.8 那种:

// v0.8 写法,v0.9 不再使用
{
"id": "submit_btn",
"component": {
"Button": {
"child": "submit_label"
}
}
}

components 是一个邻接表:每个组件只声明自己的 id 和它引用的子组件 id,树结构由客户端自己重建。这带来三个直接后果:

  1. 顺序无关。父组件可以先于子组件到达。
  2. 可增量更新。再次发送同一个 id 就是更新,不会重复创建。
  3. 可引用尚未到达的组件。客户端应当优雅处理:先占位,等目标到达再补上。

root 约定​

规范要求:组件树中恰好有一个组件的 id 是 root。它可以在任意一条 updateComponents 里到达。

  • root 出现之前,其他组件更新「没有可见效果」,客户端先缓冲。
  • root 出现之后,客户端从它开始递归建树;遇到不存在的引用就跳过该分支,而不是整棵树失败。

这条约定替代了 v0.8 的 beginRendering.root。它更省字段,但也把「谁是根」变成了一个必须由生成侧保证的约定。

4. updateDataModel:按 JSON Pointer 改状态​

作用:更新 surface 的数据模型。组件树不用重发,绑定了对应路径的组件会自动重渲染。

字段类型必填说明
surfaceIdstring是目标 surface
pathstring否JSON Pointer;省略或为 / 表示整个模型
valueany否要写入的值;省略表示删除该路径

语义是严格的 upsert + delete:

  • 路径存在 → 更新;
  • 路径不存在 → 创建;
  • value 省略 → 删除该键;
  • 数组元素删除 → 把该下标设为 undefined,保留数组长度(这是规范原文的表述;JSON 本身没有 undefined,实现上通常表现为空洞或 null,跨端时要注意)。

三个典型例子:

// 改一个字段
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "profile",
"path": "/user/name",
"value": "Alice"
}
}
// 删除一个字段(注意没有 value)
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "profile",
"path": "/user/tempData"
}
}
// 整体替换数据模型
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "profile",
"value": {
"user": { "name": "Alice", "role": "Engineer" }
}
}
}

粒度建议:优先改具体路径。整体替换会让所有绑定重新求值,也更容易在流式更新中覆盖掉客户端刚写入的用户输入。

5. deleteSurface:连组件带数据一起删​

{
"version": "v0.9.1",
"deleteSurface": {
"surfaceId": "contact_form"
}
}

删除后,该 surface 的组件和数据模型都应当被移除;之后可以复用同一个 surfaceId 重新创建。

这里有一处官方文档与 schema 描述不一致,值得记录:

  • server_to_client.json 对 deleteSurface 的描述写:该 surfaceId 之前必须发过 createSurface。
  • docs/public/reference/messages.md 却写:「Safe to delete non-existent surface (no-op)」。

我的建议是按严格语义实现:把「删除不存在的 surface」当作错误或至少记日志。理由很简单:规范正文和 schema 是协议契约,reference 页是说明文档;两者冲突时,前者的约束更强。如果你的实现选择宽容处理,也不会破坏互操作,但要明确这是实现策略,不是协议保证。

6. 生命周期与顺序约束​

把四条消息画成状态机:

硬约束只有一条:createSurface 必须最先到达。之后的 updateComponents 和 updateDataModel 谁先谁后都可以:

  • 先发组件、后发数据:客户端先建空壳,数据到达后填充;
  • 先发数据、后发组件:客户端先存数据,组件到达后绑定;
  • 组件分多条到达:每条都增量合并;
  • 不同 surface 的消息互不影响,可以交错。

官方消息参考给了一个「推荐顺序」,但不是硬性要求:

{"version":"v0.9.1","createSurface":{"surfaceId":"main","catalogId":"..."}}
{"version":"v0.9.1","updateComponents":{"surfaceId":"main","components":[...]}}
{"version":"v0.9.1","updateDataModel":{"surfaceId":"main","path":"/","value":{...}}}

注意:v0.8 的 beginRendering 必须排在组件更新之后,用来「防止半成品闪现」;v0.9 没有这个信号,取而代之的是 root 到达即开始渲染。所以 v0.9 的「防闪烁」要靠两条:先缓冲、root 出现后再建树;以及传输层批量提交(见第 8 节)。

7. 一条完整的流:Contact Form​

下面这条流是我按 schema 校验过的精简版(每条都 VALID)。它覆盖了创建、组件、数据、删除四个阶段。

{"version":"v0.9.1","createSurface":{"surfaceId":"contact_form","catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}
{"version":"v0.9.1","updateComponents":{"surfaceId":"contact_form","components":[{"id":"root","component":"Card","child":"form"},{"id":"form","component":"Column","children":["title","name_field","email_field","submit_text","submit_btn"],"justify":"start","align":"stretch"},{"id":"title","component":"Text","text":"# Contact Us","variant":"h2"},{"id":"name_field","component":"TextField","label":"Name","value":{"path":"/contact/name"},"variant":"shortText"},{"id":"email_field","component":"TextField","label":"Email","value":{"path":"/contact/email"},"variant":"shortText","checks":[{"condition":{"call":"required","args":{"value":{"path":"/contact/email"}},"returnType":"boolean"},"message":"Email is required."},{"condition":{"call":"email","args":{"value":{"path":"/contact/email"}},"returnType":"boolean"},"message":"Please enter a valid email."}]},{"id":"submit_text","component":"Text","text":"Send"},{"id":"submit_btn","component":"Button","child":"submit_text","variant":"primary","action":{"event":{"name":"submitContactForm","context":{"email":{"path":"/contact/email"}}}}}]}}
{"version":"v0.9.1","updateDataModel":{"surfaceId":"contact_form","path":"/contact","value":{"name":"John","email":"john@example.com"}}}
{"version":"v0.9.1","deleteSurface":{"surfaceId":"contact_form"}}

逐条看:

  1. 创建:锁定 basic catalog。此时还没有任何组件,客户端只知道「有一块空 surface」。
  2. 组件:root 是 Card,它的唯一孩子是 form。form 是 Column,孩子按顺序排列。两个 TextField 分别绑定 /contact/name 和 /contact/email;后者带两条校验。按钮的动作是 submitContactForm,上下文里带上了邮箱路径。
  3. 数据:一次性写入 /contact 对象。两个输入框立刻显示 John 和 john@example.com。
  4. 删除:整块 surface 移除。

一个真实的坑:规范正文里的示例不一定能过 schema​

同一份 Contact Form,规范正文(a2ui_protocol.md 的 Example Stream)里的 checks 长这样:

{
"checks": [
{ "call": "required", "args": { "value": { "path": "/contact/email" } }, "message": "Email is required." }
]
}

但 common_types.json 的 CheckRule 要求的是:

{
"condition": {
"call": "required",
"args": { "value": { "path": "/contact/email" } }
},
"message": "Email is required."
}

前者把函数调用直接放在 checks 元素上,后者要求包在 condition 里。我用 Draft 2020-12 跑过:规范正文里那条 updateComponents 会因为两个 checks 结构不合法而被判 INVALID;仓库自己的测试样例 test/cases/contact_form_example.jsonl 用的则是正确的 condition 结构。

结论:以 schema 和 test cases 为准,不要直接抄规范正文里的长示例。这不是 A2UI 独有的问题,而是所有「文档 + 机器可读 schema」双轨项目的通病。

8. 流式到达:缓冲、占位、批量提交​

v0.9 明确允许「组件可以引用尚未定义的子组件,也可以引用尚未到达的数据」。客户端应当:

  • 缓冲:root 出现前,组件定义先存进 Map<id, Component>。
  • 占位:数据路径暂时是 undefined 时,按空字符串、空数组或加载态处理,不要报错。
  • 跳过无效引用:从 root 递归时,遇到不存在的子 ID 就跳过该分支,不要整棵树失败。
  • 批量提交:官方性能建议是缓冲约 16ms 再统一渲染,避免逐条消息触发重排。A2A 扩展规范也说:列表不是事务,但渲染器不应在列表处理完之前重绘,以避免中间态闪烁。

这套策略的收益是「渐进渲染」:用户先看到骨架,再看到数据填进来,而不是盯着一个转圈。

9. 传输层:协议不绑定,但有契约​

A2UI 只定义 JSON 消息和语义,不规定怎么传。传输层必须满足四条:

  1. 可靠、有序:A2UI 是有状态的(先创建再更新),乱序会破坏状态。
  2. 消息分帧:能区分一条条完整消息,例如 JSONL 的换行、WebSocket 帧、SSE event。
  3. 能带 metadata:sendDataModel 和 capabilities 都依赖 metadata。
  4. 可选的双向通道:下行是单向流,但 action 需要回程。

A2A 绑定​

A2A 是最常见的绑定。A2UI 消息被编码成 A2A DataPart:

  • metadata.mimeType = "application/a2ui+json"(v0.9.1 统一后的写法);
  • data 是消息数组,不是单条消息;
  • 客户端和 Agent 都必须按顺序处理数组;
  • 单条失败不影响后续,但渲染器应等整批处理完再重绘。
{
"kind": "data",
"metadata": { "mimeType": "application/a2ui+json" },
"data": [
{
"version": "v0.9.1",
"createSurface": {
"surfaceId": "example_surface",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
}
}
]
}

注意:A2A v1.0 才把 data 定义为数组;更早的 A2A 版本期望单个对象。跨版本对接时要确认这一点。

其他可用传输包括 AG-UI、MCP(作为工具输出或资源订阅)、SSE + JSON-RPC、WebSocket、REST(无流式)。协议语义不变,变的只是外面的信封。

10. 本篇自检清单​

  • 每条消息都带 version,且顶层只有一个消息键。
  • createSurface 永远排在某个 surface 的第一条。
  • 组件树里恰好有一个 id: "root"。
  • 更新已有组件用同一个 id,不要新建。
  • updateDataModel 删除字段时省略 value,不要写 null。
  • deleteSurface 之后才复用 surfaceId。
  • 客户端在 root 到达前缓冲,数据缺失时占位而不是崩溃。
  • A2A 场景使用 application/a2ui+json,data 是数组。

下一篇:组件与目录——为什么扁平邻接表比嵌套树更适合 LLM,ChildList 的静态列表和模板怎么区分,以及 18 个基础组件的真实 schema 字段(会和官方文档页有几处出入)。