创建日期:2026-10-20 | 最近更新:2026-10-20 基准:A2UI v0.9.1,
common_types.json(DataBinding/FunctionCall/ChildList)与a2ui_protocol.md的 Data model 章节。完整消息示例已过 schema 校验。
数据绑定:结构是结构,状态是状态
A2UI 把 UI 拆成两层:
- 组件树:长什么样。由
updateComponents维护。 - 数据模型:显示什么。由
updateDataModel维护,用户输入也会写它。
两层通过 JSON Pointer 连接。这一篇讲清楚四件事:路径怎么解析、作用域怎么嵌套、双向绑定的边界在哪、sendDataModel 为什么能把 Agent 做成无状态。
1. 每个 surface 一份数据模型
数据模型是每个 surface 独立的 JSON 对象。它可以是任意形状:
{
"user": {
"name": "Alice",
"email": "alice@example.com"
},
"cart": {
"items": [
{ "name": "Widget", "price": 9.99, "quantity": 2 }
],
"total": 19.98
}
}
组件不持有业务状态,只持有「指向状态的路径」。所以:
- 改数据 → 所有绑定该路径的组件自动更新;
- 改组件 → 不影响数据;
- 同一个路径可以被多个组件绑定,它们天然同步。
这就是「结构与状态分离」的实际收益。
2. JSON Pointer:从根开始找
绑定路径用 RFC 6901 JSON Pointer:
| 路径 | 含义 |
|---|---|
/user/name | 对象属性 |
/cart/items/0 | 数组下标,从 0 开始 |
/cart/items/0/price | 嵌套路径 |
给定:
{
"user": { "name": "Alice" },
"items": ["Apple", "Banana"]
}
/user/name→"Alice"/items/0→"Apple"/items/1→"Banana"
A2UI 对 RFC 6901 做了一个扩展:支持不以 / 开头的相对路径,用于模板列表的作用域。规范原文:
A2UI extends JSON Pointer to support Relative Paths that do not start with a forward slash
/. This is a deviation from strict RFC 6901 to support template-based list rendering.
所以路径分两类:
- 绝对路径:以
/开头,永远从数据模型根解析; - 相对路径:不以
/开头,只在集合作用域里有意义。
3. 动态值:字面量、绑定、函数调用
组件属性不是所有时候都写死。common_types.json 定义了 DynamicString / DynamicNumber / DynamicBoolean / DynamicStringList,它们都接受三种形态:
| 形态 | 写法 | 何时求值 |
|---|---|---|
| 字面量 | "Hello"、42、true、["a","b"] | 不变 |
| 数据绑定 | { "path": "/user/name" } | 数据变化时重算 |
| 函数调用 | { "call": "formatDate", "args": { ... }, "returnType": "string" } | 参数变化时重算 |
三个例子:
{
"id": "title",
"component": "Text",
"text": "Welcome"
}
{
"id": "username",
"component": "Text",
"text": { "path": "/user/name" }
}
{
"id": "today",
"component": "Text",
"text": {
"call": "formatDate",
"args": {
"value": "2026-10-20T00:00:00Z",
"format": "yyyy-MM-dd"
},
"returnType": "string"
}
}
DataBinding 本身只有一个字段 path,且 additionalProperties: false。所以 { "path": "/user/name", "fallback": "匿名" } 这种写法是非法的。
还有一个通用类型 DynamicValue:除了字符串/数字/布尔/数组,也接受 DataBinding 和 FunctionCall。它主要用在 action.context 这种「值可以是任意 JSON」的地方。
4. 作用域:模板里的路径相对于谁
这是 A2UI 数据绑定最容易写错的地方。
根作用域
默认所有组件都在根作用域。绝对路径(以 / 开头)永远从根解析,不管组件嵌套多深。
集合作用域
当一个容器用 ChildList 的模板形态绑定数组时,会为每个元素创建一个子作用域:
{
"id": "employee_list",
"component": "List",
"children": {
"path": "/employees",
"componentId": "employee_card"
}
}
数据:
{
"company": "Acme Corp",
"employees": [
{ "name": "Alice", "role": "Engineer" },
{ "name": "Bob", "role": "Designer" }
]
}
模板组件:
{
"id": "employee_card",
"component": "Column",
"children": ["name_text", "role_text"]
}
{
"id": "name_text",
"component": "Text",
"text": { "path": "name" }
}
{
"id": "role_text",
"component": "Text",
"text": { "path": "role" }
}
解析结果:
| 组件 | 路径 | 第 0 项解析为 | 第 1 项解析为 |
|---|---|---|---|
name_text | name | /employees/0/name → Alice | /employees/1/name → Bob |
role_text | role | /employees/0/role → Engineer | /employees/1/role → Designer |
想从模板里访问根作用域
用绝对路径:
{
"id": "company_text",
"component": "Text",
"text": { "path": "/company" }
}
它会解析到 Acme Corp,不受当前是第几个员工影响。
两条作用域规则
- 相对路径只在集合作用域里有明确定义。规范只定义了两种解析:根作用域下的绝对路径、集合作用域下的相对路径;它没有定义根作用域里的相对路径该怎么解析。我的建议是:所有非模板绑定都写绝对路径(以
/开头),不要把「根作用域相对路径」当成可依赖的行为。 - 数组下标必须是非负整数。规范明确说:对指向数组的路径段使用非数字下标是错误。
5. 非字符串值怎么变成字符串
当组件需要显示字符串,而绑定到的值是数字、布尔、对象或数组时,客户端要转换:
| 值类型 | 转换结果 |
|---|---|
| 数字 / 布尔 | 标准字符串表示 |
null / undefined | 空字符串 "" |
| 对象 / 数组 | JSON 字符串化 |
这条规则在 formatString 插值和直接显示时都适用。它保证了不同渲染器至少在同一份数据上得到同样的文本。
6. 双向绑定:本地立即写,网络不参与
输入类组件(TextField、CheckBox、Slider、ChoicePicker、DateTimeInput)与数据模型之间是双向绑定。
读与写的契约
- 读(Model → View):组件渲染时从绑定路径读值;服务端发
updateDataModel后,组件重渲染。 - 写(View → Model):用户一输入,客户端立即把新值写回本地数据模型。
规范强调这个写是同步的:数据模型在任何 action.context 解析之前就已经写完了。所以不存在「刚打完字就点提交,提交的却是旧值」的竞态。
一处 schema 与语义的缝(我的推断,非规范逐字):
TextField.value等字段的 schema 允许字面量字符串,但双向绑定的「写」需要一个可写路径。合理的做法是:字面量用于只读展示或初始值,真正要收集输入时绑定{ "path": "..." }。规范没有逐字规定「输入组件必须绑定 path」,这条是从读写契约推导出的实现建议。
反应式
因为数据模型是单一事实源,两个组件绑定同一个路径时会自动同步:
TextField绑定/user/name;Text也绑定/user/name;- 用户每敲一个字符,
Text实时跟着变。
不需要额外的「刷新」消息。
网络边界
双向绑定只发生在客户端本地。用户输入(击键、勾选、拖动滑块)不会自动发网络请求。只有发生 action 时,客户端才会:
- 解析
action.context里的路径; - 把解析后的值放进
action消息; - 发给 Agent。
所以:
- 不需要为每次击键做防抖;
- 网络不会被输入噪音打满;
- Agent 看到的是「点击那一刻」的完整快照,而不是一串中间状态。
表单提交模式
{
"id": "name_field",
"component": "TextField",
"label": "Name",
"value": { "path": "/booking/name" }
}
用户输入 Alice → 本地 /booking/name 立即变成 "Alice"。
提交按钮:
{
"id": "submit_btn",
"component": "Button",
"child": "submit_label",
"action": {
"event": {
"name": "confirm_booking",
"context": {
"name": { "path": "/booking/name" }
}
}
}
}
点击时,客户端解析 /booking/name 得到 "Alice",发送:
{
"version": "v0.9.1",
"action": {
"name": "confirm_booking",
"surfaceId": "booking",
"sourceComponentId": "submit_btn",
"timestamp": "2026-10-20T10:00:00Z",
"context": { "name": "Alice" }
}
}
这条消息已通过 client_to_server.json 校验。
7. 服务端改数据:upsert、删除、整体替换
updateDataModel 的语义在上一篇讲过,这里从数据绑定视角再强调一次:
// 只改一个字段:其他绑定不受影响
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "booking",
"path": "/booking/name",
"value": "Bob"
}
}
// 删除字段:绑定到它的组件得到 undefined
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "booking",
"path": "/booking/name"
}
}
// 整体替换:所有绑定重新求值
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "booking",
"value": {
"booking": { "name": "Bob" }
}
}
}
工程建议:
- 能改路径就别整体替换。整体替换会制造一整轮无效重算,也容易覆盖客户端刚写入的输入。
- 数据按域组织:
user、cart、ui分开,路径更稳定。 - 展示值尽量在 Agent 侧算好:货币、日期这类格式,如果 Agent 能确定格式,直接发格式化后的字符串,减少客户端函数差异。但这会牺牲一点本地化的灵活性,按产品需要取舍。
8. sendDataModel:把完整状态随每条上行消息带走
默认情况下,Agent 只能看到 action.context 里明确列出的字段。如果表单有 20 个字段,逐个写进 context 很啰嗦。
createSurface 里可以打开同步:
{
"version": "v0.9.1",
"createSurface": {
"surfaceId": "booking",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json",
"sendDataModel": true
}
}
打开后,客户端在发给创建该 surface 的服务端的每条消息的 metadata 里,附带该 surface 的完整数据模型。A2A 绑定里的形状是:
{
"version": "v0.9.1",
"surfaces": {
"booking": {
"booking": {
"name": "Alice",
"guests": 2
}
}
}
}
放在 A2A 消息的 metadata.a2uiClientDataModel 里。对应的 schema 是 client_data_model.json。
什么时候发
- 只在客户端主动发消息时发:
action、用户查询等。 - 被动输入不触发网络:打字、勾选只改本地状态,不会单独发同步包。
- 发送的是点击/提交那一刻的快照:客户端保证先写完本地模型,再打包。
为什么有用
- Agent 可以无状态:不需要为每个会话维护一份表单状态,每次交互都收到完整上下文。
- 不用手动映射字段:省掉把 20 个输入逐个写进
context的工作。 - 支持「口头提交」:用户说「就这样,提交吧」,Agent 从 metadata 里拿到当前完整表单,直接执行。
安全边界
这是协议里少见的、需要显式设计的地方:
- 数据模型只发给创建该 surface 的服务端,不能广播给其他 Agent。
- 多 Agent 编排时,Orchestrator 必须在转发前剥离不属于目标子 Agent 的 surface。规范把这写成强制要求:否则一个天气子 Agent 可能读到银行 surface 的状态。
- Python SDK 提供了
A2uiSubagentMap.strip_unowned_surfaces_from_data_model之类的工具;自己实现时也要做同样的事。
一句话:sendDataModel 很方便,但它把「UI 状态」变成了「网络载荷」,按最小可见性原则裁剪是使用者的责任。
9. 一个完整的模板列表例子
组件:
{
"version": "v0.9.1",
"updateComponents": {
"surfaceId": "team_demo",
"components": [
{
"id": "root",
"component": "Column",
"children": ["company_text", "employee_list"]
},
{
"id": "company_text",
"component": "Text",
"text": { "path": "/company" },
"variant": "h2"
},
{
"id": "employee_list",
"component": "List",
"children": {
"path": "/employees",
"componentId": "employee_card"
},
"direction": "vertical"
},
{
"id": "employee_card",
"component": "Column",
"children": ["name_text", "role_text"]
},
{
"id": "name_text",
"component": "Text",
"text": { "path": "name" },
"variant": "h3"
},
{
"id": "role_text",
"component": "Text",
"text": { "path": "role" },
"variant": "caption"
}
]
}
}
数据:
{
"version": "v0.9.1",
"updateDataModel": {
"surfaceId": "team_demo",
"value": {
"company": "Acme Corp",
"employees": [
{ "name": "Alice", "role": "Engineer" },
{ "name": "Bob", "role": "Designer" }
]
}
}
}
渲染结果:
Acme Corp
Alice
Engineer
Bob
Designer
company_text 用绝对路径 /company,在模板外;name_text / role_text 用相对路径,随模板实例解析。增加或删除 employees 数组元素,列表自动增删,不需要重发组件。
10. 本篇自检清单
- 非模板绑定一律用绝对路径(以
/开头)。 - 模板里的相对路径只指向当前元素,想访问全局就写绝对路径。
-
DataBinding只有path一个字段。 - 需要收集输入时,
value绑定真实路径;字面量只当只读展示/初始值(推断性建议,见 §6 的说明)。 - 不要指望用户输入自动同步到服务端;同步只发生在
action。 - 改数据优先用细粒度路径,不要动不动整体替换。
- 打开
sendDataModel时,多 Agent 场景必须裁剪 surface。 - 数组路径下标必须是非负整数。
下一篇:动作与函数——「用户点了一下之后」发生了什么:action 的两种形态、客户端上行 payload 的五个必备字段、checks 的正确结构,以及 14 个基础函数和 prompt → generate → validate 的闭环。