比较边界,而不是品牌名称
关于 Agent 界面的讨论,常常把 A2UI、AG-UI 和某个 AI 框架运行时塞进同一张功能表里,然后选出一个赢家。这种框定掩盖了真正的工程决策。声明式的 UI 契约、事件协议和框架自有的渲染器,其实位于不同的边界上:
| 边界 | 它回答的问题 | 它不能提供 |
|---|---|---|
| UI 契约 | 什么样的受限界面描述可以被渲染? | 身份、授权、业务事实 |
| 事件协议 | 事件、状态与生命周期更新如何流动? | 安全渲染或副作用权限 |
| 框架运行时 | 这个产品如何渲染并协调界面代码? | 与每一个客户端或服务的互操作 |
| 动作服务 | 这个主体此刻能否对这个对象执行这个副作用? | 一个由模型选定的策略 |
与这些生态相关的名称、消息类型、传输方式、SDK 和支持矩阵都会变化。先把某个规范或软件包版本固定下来,并在你自己的部署中测试它,然后再把某项能力当作「已经可用」。
{
"ui_contract": "pinned-spec-or-schema-commit",
"event_profile": "pinned-transport-profile",
"framework_runtime": "package-and-revision",
"renderer_catalog": "product-catalog-revision",
"supported_clients": ["tested-client-and-version"],
"action_api": "server-contract-revision",
"checked_at": "recorded-time"
}
这份记录比一个市场排名更有用:它告诉运维者,当某个依赖发生变化时,必须重新测试哪些东西。
先从用户任务出发
只有当一个 Agent 界面能让某个用户任务更容易理解或完成时,它才是合理的。它不会仅仅因为模型「能描述组件」就变得合理。
| 用户任务 | 有用的界面 | 不安全的捷径 |
|---|---|---|
| 比较返回的选项 | 带来源和刷新时间的只读卡片 | 把生成的排序或价格当作权威事实 |
| 优化一次搜索 | 取值受限的固定筛选项 | 让模型选择任意查询字段 |
| 审阅一份草稿 | 产品自有的编辑器 + 明确的保存 | 从一条聊天回复直接写入记录 |
| 请求一个外部副作用 | 摘要、信息披露和确认 | 让一个按钮去执行模型选定的动作 |
解释性内容优先用文本;稳定、高影响的工作流优先用固定产品流程;只有当受限的自由度确实能改善体验时,才使用生成式契约。
三种实现风格
声明式 UI 契约
A2UI 式的做法传递的是一份关于组件和数据的描述,而不是可执行的浏览器或原生代码。它的价值来自一个产品自有的渲染器,把一个小而经过审阅的目录映射到本地组件上。
它可以在独立运营的多个 Agent 服务与客户端之间支撑起一条交换边界,但它不会让一个远程 Agent 变得可信:未知组件、富文本、URL、数据引用和动作,仍然需要严格的准入控制。
事件与状态协议
AG-UI 式的做法关注投递:进度事件、文本增量、生命周期迁移、用户输入、状态更新、重连和取消。它可以承载一份 UI 契约、一段文本,或应用自定义的载荷。它并不规定哪种界面可以安全渲染,也不授予接收方执行某个工具调用的权限。
应当把顺序、重复、重连行为、背压、重放和事件留存,都当作部署契约中显式的一部分。一条流式传输通道并不等于持久化的工作流状态。
框架自有的运行时
React 或其他框架运行时,可以把渲染代码、服务端数据访问和客户端交互都保持在同一个产品代码库里。当客户端和部署都处在同一个所有权边界之下时,这往往是最简单的选择。
但它也带来了框架和运行时上的耦合。不要仅仅因为模型能选择一个工具,就暴露任意的组件生成能力或服务端函数。把模型的输出约束在产品定义的意图之内,并把凭据、对象查找和执行都留在应用服务之后。
使用一张决策矩阵
基于关于你自己系统的证据来选择,而不是一个永久性的「最佳」标签:
| 问题 | 更适合 UI 契约的场景 | 更适合事件协议的场景 | 更适合产品运行时的场景 |
|---|---|---|---|
| 独立的客户端/服务 | 你需要一份共享、版本化的载荷 | 你需要生命周期上的互操作 | 一个产品拥有全部客户端 |
| 投递行为 | 载荷可以被简单地获取或更新 | 流式、续传和中断很重要 | 框架传输能满足实测需求 |
| 渲染 | 多个渲染器必须消费同一份目录 | 渲染仍由应用自行定义 | 一套经过审阅的组件系统就够了 |
| 升级所有权 | 结构兼容性被显式治理 | 事件兼容性被显式治理 | 一个团队能同步升级运行时与客户端 |
| 安全边界 | 载荷不可信且被严格校验 | 事件不可信且绑定到会话 | 服务端动作由产品掌控并授权 |
把一个产品运行时与一个小型的声明式目录组合起来,是合理的;只用一条事件流而不用声明式 UI,同样是合理的。除非某一层确实解决了一个真实的所有权或兼容性问题,否则不要把每一层都堆上去。
共同的安全模型
没有任何一种方案会把模型输出变成权威。一棵生成出来的组件树、一个事件名、一次 JSON Schema 校验通过的结果、一条提示词指令,或一段工具标注,都不能授予权限。
接收方必须:
- 认证调用方,并把界面绑定到当前会话;
- 校验契约版本、字节大小、组件数量、嵌套深度、更新频率和允许出现的属性;
- 转义文本,并约束图片、富内容、URL、回调和数据引用;
- 在服务端用租户与对象校验来解析数据;
- 只把一个动作映射到一个经过审阅的应用意图;
- 在执行时,重新授权这个精确的动作和当前的对象状态;
- 把确认和幂等键绑定到主体、租户、动作和规范化后的参数上;
- 记录脱敏后的策略与生命周期证据。
from dataclasses import dataclass
@dataclass(frozen=True)
class UiIntent:
name: str
action_token: str
confirmation_id: str | None
def accept_intent(session, intent, tokens, inventory):
if intent.name not in {"select_offer", "request_confirmation"}:
return {"status": "rejected", "reason": "unknown_intent"}
offer = tokens.resolve(intent.action_token, subject_id=session.subject_id)
if offer is None or not inventory.is_available(offer.id):
return {"status": "rejected", "reason": "stale_or_unavailable"}
return {"status": "accepted", "offer_id": offer.public_id}
这是一个应用层策略片段,而不是任何协议或 SDK 的实现。任何有实质后果的写入,都需要一个独立、幂等的接口,在副作用发生之前立即重新校验用户、对象、参数、确认步骤和业务不变量。
传输与渲染都需要失败语义
一个吸引人的演示,往往会遗漏用户最容易注意到的那些情况:一次重复点击、一块过期的屏幕、一次在副作用之后发生的重连,或者在会话进行到一半时发生的渲染器升级。
在上线之前,先定义清楚下面这些:
| 关注点 | 需要记录的契约 |
|---|---|
| 事件投递 | 顺序、去重标识、重连游标、超时、背压 |
| 界面生命周期 | 创建、替换、过期、清理、不受支持客户端的兜底 |
| 数据新鲜度 | 来源、观测时间、刷新行为、过期数据的展示策略 |
| 动作 | 确认、取消、幂等、结果未知时的响应 |
| 兼容性 | 结构/事件版本协商、迁移夹具、回滚 |
| 隐私 | 脱敏、留存、删除传播、租户隔离 |
一次完成的渲染,并不能证明一次副作用已经完成。一条完整走完的事件流,也不能证明所有客户端都观察到了同一个状态。
无障碍与可信的呈现
生成式界面仍然是产品界面。组件目录必须规定语义化角色、标签、键盘导航、增量更新后的焦点、对比度、缩放、减少动效、本地化、复数处理,以及从右到左(RTL)布局。
同时保护用户免受界面层的提示注入和钓鱼攻击:
- 把 Agent 生成的内容与产品自有的导航和安全控件区分开;
- 对来自外部的声明,展示其来源和新鲜度;
- 把凭据、付款和权限控件保留给固定组件;
- 只通过一套经过审阅的策略去打开外部目标;
- 对破坏性或对外可见的动作,要求用户做出清晰、可理解的确认。
无论载荷是经由声明式契约、事件协议还是框架运行时到达的,这些控制都同样重要。
测试你的选择
一次采用,应当以一份测试计划收尾,而不是一张架构图:
| 测试 | 要暴露出来的失败 |
|---|---|
| 契约模糊测试 | 未知属性、循环树、超大载荷、不安全 URL |
| 事件重放 | 重复事件、重连后的缺口、乱序更新 |
| 授权 | 跨租户令牌、过期对象、模型提供的标识符 |
| 副作用恢复 | 执行后超时、重复确认、取消竞态 |
| 无障碍 | 仅用键盘的路径、屏幕阅读器更新、超长的本地化文本 |
| 升级 | 旧版客户端、不兼容的目录、渲染器回滚 |
| 隐私 | 遥测中出现敏感载荷、从已缓存界面中删除 |
在有代表性的任务上,度量用户完成率、澄清请求次数、拒绝率、恢复时长和无障碍失败。不要把一份通用的厂商对比,当作某个架构适合你的用户的证据。
A2UI、AG-UI 与框架运行时可以共存
一份 UI 契约可以通过一个事件协议来传递;一个产品运行时可以渲染一个严格的目录;一个远程 Agent 可以提议一个界面,而由宿主应用来掌控渲染器和动作服务。这些都是组合层面的选择,而不是「某一个生态会取代其他生态」的预测。
关于底层的 UI 契约边界,参阅 A2UI:构建安全的 Agent 驱动界面契约。关于服务间的委派,参阅 A2A 协议:Agent 间任务、信任与生产边界。关于工具与资源的边界,参阅 MCP 协议:生产环境的工具与资源边界。
常见问题
事件流可以承载一份声明式 UI 载荷吗?
可以,前提是部署定义清楚了消息信封、结构版本、大小限制、会话绑定、校验行为和客户端兼容性。接收方仍然必须把载荷当作不可信输入,并且只渲染它已批准的目录。
声明式契约比框架运行时更安全吗?
它们应对的是不同的风险。一个严格的声明式目录,可以减少载荷边界上的任意代码执行;一个产品自有的运行时,可以安全地渲染经过审阅的组件。两者都需要服务端授权、数据过滤、URL 管控、可观测性和恢复行为。
团队现在应该标准化到某一种方案上吗?
先标准化你自己产品能够治理的那些契约:身份传播、动作授权、审计事件、无障碍、载荷限制和升级测试。只有在固定了某个外部协议或运行时的版本、并证明它与你所支持的客户端兼容之后,才去采用它。
应该记录哪些日志?
记录脱敏后的契约版本、界面与动作标识符、策略决策、生命周期类别、延迟和关联 ID。不要默认去存储原始提示词、UI 载荷、私密数据或隐藏的模型推理过程。