有价值的边界:一份 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 规范或实现之前,先把已经过测试的确切边界记录下来:
{
"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,或把用户引向一个不安全的动作。
渲染器应当拒绝一份无效契约,而不是去猜测并修复它。它的准入校验至少应包括:
- 结构版本与消息类型;
- 最大字节数、组件数量、嵌套深度、更新频率和可保留的界面数量;
- 允许出现的组件名,以及每个组件各自的属性结构;
- 有界的文本、图片尺寸、富文本子集、本地化键和数据引用;
- URL 协议、主机白名单、重定向策略和媒体获取策略;
- 唯一标识符、合法的父子关系和环路检测;
- 界面的归属、过期时间,以及与当前会话的关联关系。
这是纵深防御。JSON Schema 可以校验数据的形状,却无法判断某个价格是否最新、某个账户是否属于当前用户,或者某个按钮是否适合用于敏感操作。
一个小而由产品自己掌控的组件目录
从一个刻意收窄的目录开始,例如 Text、List、Notice、Select、TextInput、Button 和 ConfirmationSummary。每一项都应有明确的无障碍契约和数据契约,不要把任意属性直接透传给底层框架组件。
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、角色、价格、目的地、审批状态或授权标记。组件名、一个结构合法的字段、一条提示词指令,乃至模型的一次拒答,都不能决定访问权限。
设计动作的生命周期
一次安全的交互,必须区分「提议」和「副作用」:
使用一枚短生命周期、不透明的动作令牌,由服务端把它绑定到主体、租户、界面、允许的动作以及规范化后的参数上。在每一次状态迁移时都重新核验状态。一次完成的界面更新,并不能证明邮件、付款、删除或预约恰好只执行了一次。
对于长时间运行的工作,明确暴露诸如 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 的结构定义能校验一次付款或删除吗?
不能。结构定义校验的是形状,有时也能校验取值范围。动作服务必须在执行时,依据权威记录去校验身份、租户、所有权、当前状态、金额、审批、幂等和业务不变量。
最小可用的生产上线范围是什么?
从一个只读界面开始:一个精简的组件目录、由服务端过滤过的数据、严格的载荷限制、无障碍的固定控件,以及可观测的拒绝路径。只有在授权、确认和恢复这几条契约都经过测试之后,再去增加动作意图。
延伸阅读
- A2A 协议:Agent 间任务、信任与生产边界
- MCP 协议:生产环境的工具与资源边界
- MCP Apps:交互式 Tool UI 边界
- A2UI、AG-UI 与 Vercel AI SDK 对比
- Prompt Injection 攻击与防御指南