有价值的边界:一份 UI 契约,而非魔法协议

A2UI 是一种基于 JSON Stream 的 Agent-to-UI 协议:Agent 发出声明式 Surface、Component 和 Data Model 消息,产品掌控的 Renderer 再把它们映射为本地 UI 代码。v0.9.1 是当前生产版本;v1.0 仍是 Release Candidate,而且调整了 Envelope 并增加双向 Function Call,因此两个版本不能隐式混用。

它并不是一套完整的应用架构。一段界面描述无法认证用户身份、无法证明某个预订的所有权、无法验证价格、无法批准付款,也无法保证某个动作只发生一次。这些属性都属于界面周围的应用系统。

在采用任何 A2UI 规范或实现之前,先把已经过测试的确切边界记录下来:

json
{
  "ui_contract": "a2ui-v0.9.1",
  "status": "current-production",
  "transport_profile": "ag-ui-1.0-or-other-pinned-binding",
  "schema_bundle": "server_to_client+common_types+catalog",
  "renderer": "product-renderer@revision",
  "component_catalog": "product-catalog@revision",
  "action_api": "server-contract-version",
  "accessibility_baseline": "tested-platforms-and-assistive-tech"
}

一个生态所发布的消息类型、数据绑定语法、渲染器和 SDK 都可能发生变化。应当以固定的来源和你自己的集成测试来核验它们,不要把博客里的包名、路线图或示例消息直接提升为生产依赖。

构建前先固定协议版本

A2UI 的版本选择属于应用契约,而不是 Package Manager 的实现细节。

能力 v0.9.1 Current v1.0 Candidate
核心 Stream createSurface、updateComponents、updateDataModel、deleteSurface 保留生命周期,同时调整 Agent/Renderer 术语
初始 UI 先创建 Surface,再更新 Component 和 Data 可在 createSurface 内携带 Component 与初始 Data
返回通道 由 Transport 定义 Action Channel 增加类型化双向 Function Call 消息
Catalog Component 与 Function Catalog,固定 catalogId 调整 Catalog Map、收紧 Identifier,并增加 Accessibility Attribute
生产姿态 作为当前版本固定并测试 通过 Adapter 隔离,预期 Schema 与行为仍会变化

Transport 必须保持消息顺序和 Framing,Capability Negotiation 决定 Renderer 能消费哪些 Catalog 与版本。某个版本下 Schema 合法的 Payload,并不能证明它符合另一个版本。

何时生成界面才真正有用

对话擅长解释,但不擅长对比、受约束的输入和可见状态。一个旅行助手用表格来帮用户比较可退改的票价,可能确实更清晰;但一个「预订」按钮,不应把模型的建议悄悄变成一次真实购买。

只有当生成的界面能改善某个具体的用户任务时,才去使用它:

用户需求 合适的契约 必须留在 Agent 之外的职责
比较搜索结果 带来源标注和刷新时间的只读卡片 排序策略、价格来源和信息披露
选择筛选条件 一个基于白名单的固定筛选表单 查询授权和服务端校验
审阅一份草稿 可编辑内容 + 明确的保存动作 所有权、版本冲突处理和审计
请求一个重要动作 摘要 + 确认步骤 对象授权、审批、幂等和执行

稳定的产品工作流优先使用固定界面;交互本身没有价值时优先使用文本。动态界面是一种呈现手段,并不能证明某个任务就应该拥有更多自主权。

把每一个 UI 载荷都当作不可信输入

Agent、检索到的文档、工具返回的结果以及远程服务,都可能影响一个 UI 载荷。即便 JSON 语法完全合法,它也可能试图耗尽渲染器资源、伪装成一个可信页面、夹带一个外部 URL,或把用户引向一个不安全的动作。

渲染器应当拒绝一份无效契约,而不是去猜测并修复它。它的准入校验至少应包括:

  1. 结构版本与消息类型;
  2. 最大字节数、组件数量、嵌套深度、更新频率和可保留的界面数量;
  3. 允许出现的组件名,以及每个组件各自的属性结构;
  4. 有界的文本、图片尺寸、富文本子集、本地化键和数据引用;
  5. URL 协议、主机白名单、重定向策略和媒体获取策略;
  6. 唯一标识符、合法的父子关系和环路检测;
  7. 界面的归属、过期时间,以及与当前会话的关联关系。

这是纵深防御。JSON Schema 可以校验数据的形状,却无法判断某个价格是否最新、某个账户是否属于当前用户,或者某个按钮是否适合用于敏感操作。

一个小而由产品自己掌控的组件目录

从一个刻意收窄的目录开始,例如 Text、List、Notice、Select、TextInput、Button 和 ConfirmationSummary。每一项都应有明确的无障碍契约和数据契约,不要把任意属性直接透传给底层框架组件。

go
package main

import (
	"errors"
	"fmt"
	"net/url"
)

type Action struct {
	Name  string
	Token string
}

type Node struct {
	ID     string
	Kind   string
	Text   string
	URL    string
	Action *Action
}

func ValidateNode(node Node, allowedHosts map[string]bool) error {
	if node.ID == "" {
		return errors.New("missing node id")
	}
	switch node.Kind {
	case "Text":
		if len(node.Text) == 0 || len(node.Text) > 500 {
			return errors.New("invalid text")
		}
	case "Link":
		target, err := url.Parse(node.URL)
		if err != nil || target.Scheme != "https" || !allowedHosts[target.Host] {
			return errors.New("unsafe link")
		}
	case "Button":
		if node.Action == nil || node.Action.Token == "" {
			return errors.New("missing action")
		}
		if node.Action.Name != "select_offer" &&
			node.Action.Name != "request_confirmation" {
			return errors.New("unknown action")
		}
	default:
		return errors.New("unknown component")
	}
	return nil
}

func main() {
	node := Node{
		ID:   "confirm",
		Kind: "Button",
		Action: &Action{
			Name:  "request_confirmation",
			Token: "opaque-short-lived-token",
		},
	}
	fmt.Println(ValidateNode(node, map[string]bool{"docs.example": true}))
	// Output: <nil>
}

这个可执行示例是应用准入门禁,不是完整 A2UI Validator。符合规范的实现还必须校验固定版本的 A2UI Envelope 与 Catalog Schema。绝不要渲染模型产出的 HTML、样式、脚本、事件处理器或任意深链。

数据引用需要作用域

把组件结构和数据分开,可以减少重复的载荷,但引用不等于权限。应当按界面和会话为数据划分命名空间,禁止引用越出当前界面;敏感值只能在服务端为已认证用户过滤之后再解析。

不要仅仅因为某个组件「可能会用到」,就把密钥、原始的工具返回结果、内部标识符或授权声明放进客户端的数据模型里。一个展示用的标识符加上一枚服务端签发的不透明动作令牌,通常比一条账户记录或一个由模型选定的对象 ID 更安全。

渲染不等于授权

设想一位用户正在审阅一份订单提议。Agent 可以渲染一段摘要和一个 request_confirmation 控件;浏览器可以提交这个意图,但它无法证明这位用户是否有权购买,也无法证明价格是否仍然有效。

服务端应当基于可信会话上下文和权威记录构造决策。请求只需携带不透明 Action Token 与用户意图;服务端为已认证 Subject 解析 Token,重新加载对象和价格,应用当前 Policy,再返回拒绝或由服务端创建的 Confirmation Record。

任何有实质后果的写入都需要一个独立接口,在副作用真正发生之前,立即重新校验已认证的主体、对象状态、精确参数、与确认步骤的绑定、过期时间、幂等键和业务策略。

绝不要信任模型生成的用户 ID、角色、价格、目的地、审批状态或授权标记。组件名、一个结构合法的字段、一条提示词指令,乃至模型的一次拒答,都不能决定访问权限。

设计动作的生命周期

一次安全的交互,必须区分「提议」和「副作用」:

sequenceDiagram participant U as User participant R as Renderer participant S as Action Service participant O as Authoritative System U->>R: Selects a proposed option R->>S: Intent with opaque action token S->>O: Rechecks subject, object, price, and policy O-->>S: Current authoritative state S-->>R: Confirmation summary or rejection U->>R: Explicitly confirms R->>S: Confirmation ID and idempotency key S->>O: Execute once or report uncertain outcome

使用一枚短生命周期、不透明的动作令牌,由服务端把它绑定到主体、租户、界面、允许的动作以及规范化后的参数上。在每一次状态迁移时都重新核验状态。一次完成的界面更新,并不能证明邮件、付款、删除或预约恰好只执行了一次。

对于长时间运行的工作,明确暴露诸如 pending、needs_confirmation、running、succeeded、rejected、failed 和 outcome_unknown 这样的状态。定义清楚刷新、重连、取消、重复提交和过期时的行为。用户应当能够判断:系统究竟已经产生了副作用,还是暂时无法证明结果。

抵御注入、钓鱼和资源滥用

生成式界面会让不可信的指令看起来像官方内容。一份被检索到的文档可能会试图重命名某个破坏性动作、索要凭据,或塞进一个看起来很合理的客服链接。仅仅是渲染一个目录里的组件,并不会消除这种社会工程攻击面。

在多个层次上建立控制:

  • 标识出屏幕上由 Agent 生成的部分,并保留由产品掌控的导航;
  • 把安全敏感的措辞、身份提示和付款控件,保留给固定的应用组件;
  • 默认把外部内容渲染成文本,附上来源信息,并由用户主动发起「打开」动作;
  • 图片和 URL 只允许经过代理或已验证的白名单,且受尺寸与内容类型的限制;
  • 对破坏性或对外可见的动作,要求用户做出审慎的确认;
  • 不让 Agent 去选择回调 URL、遥测上报地址或权限范围;
  • 对界面的创建和更新做限速,并在不留存敏感原始内容的前提下,度量被拒绝的载荷。

模型输出是输入,不是策略引擎;检索到的文本、工具的标注,以及一个远程 Agent 提出的 UI 提议,同样如此。

无障碍、本地化与隐私都是契约要求

一个只在截图里看起来正确的界面,并不是原生品质的界面。应当把无障碍作为组件目录定义的一部分:

  • 语义化角色、标签、错误提示、焦点顺序和键盘操作;
  • 可见的焦点、足够的对比度,以及缩放和「减少动效」下的表现;
  • 对增量更新提供屏幕阅读器播报,同时避免重复的噪声;
  • 可本地化的消息键、复数规则、日期/数字格式,以及从右到左(RTL)布局;
  • 不只用颜色来传达含义,也不设置没有无障碍替代方案的限时交互。

默认不要把敏感内容送进遥测系统。可以记录脱敏后的契约版本、拒绝类别、组件数量、延迟、动作状态和关联 ID。为面向用户的内容设定留存策略,并确保删除操作能传播到已缓存的界面、分析载荷和产物存储。

像测试 API 一样去测试契约

只测试一个顺利路径下的渲染器,远远不够。为「被接受」「被拒绝」和「历史遗留」的载荷维护版本化的测试夹具,然后覆盖:

测试类别 需要捕获的失败示例
结构与限制 未知组件、循环树、过深的层级、超长文本
渲染安全 类 HTML 文本、意料之外的属性、不安全 URL、更新风暴
授权 跨租户复用令牌、过期的报价、模型提供的账户 ID
副作用 重复确认、执行后超时、取消竞态
无障碍 仅用键盘完成、更新后的焦点、屏幕阅读器标签
本地化 超长翻译、复数处理、RTL 布局、地区相关的金额
滥用与恢复 被提示注入的标签、渲染器回滚、客户端断连

在升级之前,先在固定的规范和受支持的客户端上跑一遍这些测试。为策略决策保留一条不可变的审计事件,但在没有明确、正当的留存依据时,不要把原始对话和 UI 数据写进日志。

A2UI、AG-UI、MCP Apps 与 A2A 是不同边界

这些技术可以组合,但解决的问题并不相同:

边界 主要职责 不能证明什么
A2UI 声明式 Component Tree、Data Model 与 UI Update Transport Delivery、Identity、Authorization 或 Effect 成功
AG-UI 1.0 Agent Backend 与用户应用之间的有序 Runtime Event Stream 动态 Component Tree 应如何渲染
MCP Apps MCP Tool 引用由 Host Sandbox 渲染的交互式 HTML Resource 任意 HTML 都安全,或 Tool Action 已获授权
A2A 独立运营 Agent 之间的 Task 与 Message 交换 Renderer Conformance 或最终用户批准

A2UI 可以运行在 AG-UI、A2A、MCP 或其他有序 Transport 上,也可以承载 MCP App Component,或在 MCP App 中渲染。但这种组合会同时引入声明式 A2UI 边界和 Embedded App Sandbox 边界。每个 Action 仍需可信身份、Tenant 与 Object 校验、预算控制和可审计副作用 Policy。

常见问题

声明式 UI 比模型生成的 HTML 更安全吗?

当渲染器把一个严格目录映射到经过审阅的本地代码时,它确实能显著降低一类代码执行风险。但它无法自动防御不安全的 URL、欺骗性的标签、数据泄露、拒绝服务或未授权的服务端动作。无论采用哪种描述形式,都要把它当作不可信输入。

为了向前兼容,渲染器应该接受未知组件吗?

默认不应。应当拒绝这份载荷,或渲染一个由产品掌控的安全兜底,报告契约不匹配,并通过一个经过测试的兼容版本把目录和渲染器一起升级。静默地去「解释」未知组件,只会制造出模糊不清的行为,也让审阅更加困难。

UI 的结构定义能校验一次付款或删除吗?

不能。结构定义校验的是形状,有时也能校验取值范围。动作服务必须在执行时,依据权威记录去校验身份、租户、所有权、当前状态、金额、审批、幂等和业务不变量。

最小可用的生产上线范围是什么?

从一个只读界面开始:一个精简的组件目录、由服务端过滤过的数据、严格的载荷限制、无障碍的固定控件,以及可观测的拒绝路径。只有在授权、确认和恢复这几条契约都经过测试之后,再去增加动作意图。

延伸阅读

一手来源