创建日期:2026-10-20 | 最近更新:2026-10-20 基准:A2UI v0.9.1,协议正文
specification/v0_9_1/docs/a2ui_protocol.md,schemaspecification/v0_9_1/json/server_to_client.json。文中的完整消息示例逐条过了 Draft 2020-12 校验;含...占位符的片段仅作结构示意。
消息与生命周期:四条消息撑起一个 Surface
A2UI 的服务端到客户端只有四条消息。真正需要吃透的不是字段表,而是三个问题:
- 信封长什么样:为什么一条消息只能有一个消息键?
- 状态怎么变:
createSurface→updateComponents→updateDataModel→deleteSurface,哪些顺序是硬约束? - 流式到达时怎么办:组件、数据、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: false | schema 校验失败 |
所以下面两条都是非法的:
// 错:一条消息里塞两种操作
{
"version": "v0.9.1",
"createSurface": { "surfaceId": "main", "catalogId": "..." },
"updateComponents": { "surfaceId": "main", "components": [] }
}
// 错:漏掉 version
{
"createSurface": { "surfaceId": "main", "catalogId": "..." }
}
为什么这么设计?因为消息之间没有事务。一条消息只做一件事,客户端才能逐条应用、逐条报错、继续处理后面的消息。A2A 绑定里甚至明确写了:一个消息列表不是事务单元,某条失败就记录该条错误,继续处理剩余消息。
2. createSurface:先建画布,再谈内容
作用:创建一块 surface,并锁定它使用的 Catalog。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
surfaceId | string | 是 | 当前活跃 surface 内唯一 |
catalogId | string | 是 | Catalog 标识符;看起来像 URI,但不要求可访问 |
theme | object | 否 | 必须通过该 Catalog 的 theme schema |
sendDataModel | boolean | 否 | 为 true 时,客户端在后续每条上行消息的 metadata 里附带完整数据模型;默认 false |
几个容易忽略的语义:
- surface 必须先创建。在
createSurface之前发updateComponents或updateDataModel是错误。 surfaceId和catalogId创建后不可变。想换 Catalog,只能deleteSurface再重新createSurface。- 重复创建是错误。对已存在的
surfaceId再发createSurface,必须先删除。 - root 不在
createSurface里。这是和 v0.8beginRendering最大的区别:v0.8 用root字段指定根组件,v0.9 改成「某个组件的id必须叫root」。
v0.9.1 相对 v0.9 放宽了一点:surfaceId 不再要求「渲染器生命周期内全局唯一」,只要求「当前活跃 surface 中唯一」。删除后可以复用同一个 ID。
3. updateComponents:扁平列表,ID 连边
作用:向指定 surface 添加或更新组件。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
surfaceId | string | 是 | 目标 surface |
components | array | 是 | 至少一个组件对象 |
组件对象是扁平的:
{
"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,树结构由客户端自己重建。这带来三个直接后果:
- 顺序无关。父组件可以先于子组件到达。
- 可增量更新。再次发送同一个
id就是更新,不会重复创建。 - 可引用尚未到达的组件。客户端应当优雅处理:先占位,等目标到达再补上。
root 约定
规范要求:组件树中恰好有一个组件的 id 是 root。它可以在任意一条 updateComponents 里到达。
root出现之前,其他组件更新「没有可见效果」,客户端先缓冲。root出现之后,客户端从它开始递归建树;遇到不存在的引用就跳过该分支,而不是整棵树失败。
这条约定替代了 v0.8 的 beginRendering.root。它更省字段,但也把「谁是根」变成了一个必须由生成侧保证的约定。
4. updateDataModel:按 JSON Pointer 改状态
作用:更新 surface 的数据模型。组件树不用重发,绑定了对应路径的组件会自动重渲染。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
surfaceId | string | 是 | 目标 surface |
path | string | 否 | JSON Pointer;省略或为 / 表示整个模型 |
value | any | 否 | 要写入的值;省略表示删除该路径 |
语义是严格的 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"}}
逐条看:
- 创建:锁定 basic catalog。此时还没有任何组件,客户端只知道「有一块空 surface」。
- 组件:
root是Card,它的唯一孩子是form。form是Column,孩子按顺序排列。两个TextField分别绑定/contact/name和/contact/email;后者带两条校验。按钮的动作是submitContactForm,上下文里带上了邮箱路径。 - 数据:一次性写入
/contact对象。两个输入框立刻显示John和john@example.com。 - 删除:整块 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 消息和语义,不规定怎么传。传输层必须满足四条:
- 可靠、有序:A2UI 是有状态的(先创建再更新),乱序会破坏状态。
- 消息分帧:能区分一条条完整消息,例如 JSONL 的换行、WebSocket 帧、SSE event。
- 能带 metadata:
sendDataModel和 capabilities 都依赖 metadata。 - 可选的双向通道:下行是单向流,但
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 字段(会和官方文档页有几处出入)。