有价值的边界:一份 UI 契约,而非魔法协议
A2UI 常被用来描述一种 Agent-to-UI 的做法:由 Agent 输出一段声明式描述,再由产品自己持有的客户端用本地代码去渲染它。当 Agent 需要展示一组对比、收集受约束的输入,或呈现某个工作流状态,同时又不应生成 HTML、JavaScript 或原生源码时,这是一条有价值的边界。
它并不是一套完整的应用架构。一段界面描述无法认证用户身份、无法证明某个预订的所有权、无法验证价格、无法批准付款,也无法保证某个动作只发生一次。这些属性都属于界面周围的应用系统。
在采用任何 A2UI 规范或实现之前,先把已经过测试的确切边界记录下来:
{
"ui_contract": "a2ui-spec-release-or-commit",
"transport_profile": "pinned-profile",
"schema_bundle": "catalog-schema-revision",
"renderer": "product-renderer@revision",
"component_catalog": "catalog-version",
"action_api": "server-contract-version",
"accessibility_baseline": "tested-platforms-and-assistive-tech",
"checked_at": "recorded-time"
}
一个生态所发布的消息类型、数据绑定语法、渲染器和 SDK 都可能发生变化。应当以固定的来源和你自己的集成测试来核验它们,不要把博客里的包名、路线图或示例消息直接提升为生产依赖。
何时生成界面才真正有用
对话擅长解释,但不擅长对比、受约束的输入和可见状态。一个旅行助手用表格来帮用户比较可退改的票价,可能确实更清晰;但一个「预订」按钮,不应把模型的建议悄悄变成一次真实购买。
只有当生成的界面能改善某个具体的用户任务时,才去使用它:
| 用户需求 | 合适的契约 | 必须留在 Agent 之外的职责 |
|---|---|---|
| 比较搜索结果 | 带来源标注和刷新时间的只读卡片 | 排序策略、价格来源和信息披露 |
| 选择筛选条件 | 一个基于白名单的固定筛选表单 | 查询授权和服务端校验 |
| 审阅一份草稿 | 可编辑内容 + 明确的保存动作 | 所有权、版本冲突处理和审计 |
| 请求一个重要动作 | 摘要 + 确认步骤 | 对象授权、审批、幂等和执行 |
稳定的产品工作流优先使用固定界面;交互本身没有价值时优先使用文本。动态界面是一种呈现手段,并不能证明某个任务就应该拥有更多自主权。
把每一个 UI 载荷都当作不可信输入
Agent、检索到的文档、工具返回的结果以及远程服务,都可能影响一个 UI 载荷。即便 JSON 语法完全合法,它也可能试图耗尽渲染器资源、伪装成一个可信页面、夹带一个外部 URL,或把用户引向一个不安全的动作。
渲染器应当拒绝一份无效契约,而不是去猜测并修复它。它的准入校验至少应包括:
- 结构版本与消息类型;
- 最大字节数、组件数量、嵌套深度、更新频率和可保留的界面数量;
- 允许出现的组件名,以及每个组件各自的属性结构;
- 有界的文本、图片尺寸、富文本子集、本地化键和数据引用;
- URL 协议、主机白名单、重定向策略和媒体获取策略;
- 唯一标识符、合法的父子关系和环路检测;
- 界面的归属、过期时间,以及与当前会话的关联关系。
这是纵深防御。JSON Schema 可以校验数据的形状,却无法判断某个价格是否最新、某个账户是否属于当前用户,或者某个按钮是否适合用于敏感操作。
一个小而由产品自己掌控的组件目录
从一个刻意收窄的目录开始,例如 Text、List、Notice、Select、TextInput、Button 和 ConfirmationSummary。每一项都应有明确的无障碍契约和数据契约,不要把任意属性直接透传给底层框架组件。
type UiNode =
| {
kind: "Text";
id: string;
text: string;
tone?: "default" | "muted" | "danger";
}
| {
kind: "Button";
id: string;
label: string;
action: { name: "select_offer" | "request_confirmation"; token: string };
};
function isAllowedAction(name: string): name is UiNode["action"]["name"] {
return name === "select_offer" || name === "request_confirmation";
}
这个类型本身并不会让渲染器变安全。实现层仍然必须在运行时解析这份不可信输入、校验完整对象、在合适的位置拒绝未知字段、转义文本,并且只把节点映射到经过审阅的应用代码。绝不要渲染模型产出的 HTML、样式、脚本、事件处理器或任意深链。
数据引用需要作用域
把组件结构和数据分开,可以减少重复的载荷,但引用不等于权限。应当按界面和会话为数据划分命名空间,禁止引用越出当前界面;敏感值只能在服务端为已认证用户过滤之后再解析。
不要仅仅因为某个组件「可能会用到」,就把密钥、原始的工具返回结果、内部标识符或授权声明放进客户端的数据模型里。一个展示用的标识符加上一枚服务端签发的不透明动作令牌,通常比一条账户记录或一个由模型选定的对象 ID 更安全。
渲染不等于授权
设想一位用户正在审阅一份订单提议。Agent 可以渲染一段摘要和一个 request_confirmation 控件;浏览器可以提交这个意图,但它无法证明这位用户是否有权购买,也无法证明价格是否仍然有效。
服务端应当基于可信的会话上下文和权威记录来构造它的决策:
from dataclasses import dataclass
@dataclass(frozen=True)
class ActionIntent:
action: str
token: str
confirmation_id: str | None
def handle_action(session, intent: ActionIntent, offers, confirmations):
if intent.action not in {"select_offer", "request_confirmation"}:
return {"status": "rejected", "reason": "unknown_action"}
offer = offers.resolve_for_subject(intent.token, session.subject_id)
if offer is None:
return {"status": "rejected", "reason": "offer_not_available"}
if intent.action == "select_offer":
return {"status": "accepted", "offer_id": offer.public_id}
confirmation = confirmations.create(
subject_id=session.subject_id,
offer_id=offer.id,
amount=offer.current_amount,
currency=offer.currency,
)
return {"status": "confirmation_required", "confirmation_id": confirmation.public_id}
这段示例是一个应用层策略片段,而不是一个 A2UI SDK,并且刻意省略了付款执行。任何有实质后果的写入都需要一个独立的接口,在副作用真正发生之前,立即重新校验已认证的主体、对象状态、精确参数、与确认步骤的绑定、过期时间、幂等键和业务策略。
绝不要信任模型生成的用户 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、A2A 与 MCP 是不同的边界
Agent-to-UI 契约负责呈现信息并收集受约束的意图。A2A 协议:Agent 间任务、信任与生产边界 可以支持在独立运营的多个 Agent 服务之间做委派;MCP 协议:生产环境的工具与资源边界 则在 AI 应用与工具、资源或提示词之间建立起协议边界。
它们可以共存,但没有任何一个会向另一个授予授权。一个 A2UI 控件可以发起一次应用动作;一个 A2A 任务可以调用一个 MCP 工具;每一条边界仍然需要可信的身份、租户与对象校验、预算控制,以及一套可审计的副作用策略。
常见问题
声明式 UI 比模型生成的 HTML 更安全吗?
当渲染器把一个严格目录映射到经过审阅的本地代码时,它确实能显著降低一类代码执行风险。但它无法自动防御不安全的 URL、欺骗性的标签、数据泄露、拒绝服务或未授权的服务端动作。无论采用哪种描述形式,都要把它当作不可信输入。
为了向前兼容,渲染器应该接受未知组件吗?
默认不应。应当拒绝这份载荷,或渲染一个由产品掌控的安全兜底,报告契约不匹配,并通过一个经过测试的兼容版本把目录和渲染器一起升级。静默地去「解释」未知组件,只会制造出模糊不清的行为,也让审阅更加困难。
UI 的结构定义能校验一次付款或删除吗?
不能。结构定义校验的是形状,有时也能校验取值范围。动作服务必须在执行时,依据权威记录去校验身份、租户、所有权、当前状态、金额、审批、幂等和业务不变量。
最小可用的生产上线范围是什么?
从一个只读界面开始:一个精简的组件目录、由服务端过滤过的数据、严格的载荷限制、无障碍的固定控件,以及可观测的拒绝路径。只有在授权、确认和恢复这几条契约都经过测试之后,再去增加动作意图。