聊天框很适合回答"是什么",却不擅长完成"选择时间、填写数量、确认风险、提交订单"这类多步任务。纯文本回答会让用户自己从段落里找参数,前端团队于是又为每一种对话结果手写页面。等业务从酒店预订扩展到报销、运维和客服,页面分支迅速膨胀,Agent仍然只能把结构化意图塞进一段 Markdown。
生成式界面试图解决这个断层,但最直接的做法------让模型输出 HTML、JavaScript 或 React 代码------恰恰把最不可信的生成结果放进了浏览器执行环境。脚本可以读取页面状态、发起网络请求,组件也可能绕过产品的权限、审计和无障碍规范。能"生成一个页面"不等于能安全地进入生产系统。
A2UI采用了另一条路线:Agent不发送可执行代码,而是发送声明式、可验证的界面消息;客户端只把消息映射到自己允许的组件目录。模型可以决定"这里需要一个输入框和确认按钮",但按钮最终用哪个React组件、能触发哪些本地函数、如何校验权限,仍由宿主应用控制。截至2026年10月,A2UI官网将v0.9.1列为当前生产版本,v1.0处于候选状态。本文以v0.9.1的消息模型实现一个可运行的最小闭环,并把版本差异留在协议适配层,而不是散落在业务代码中。
1. 先分清"生成意图"和"执行能力"
A2UI不是远程下发代码,也不是新的前端框架。它描述的是界面意图:创建哪个surface、使用哪个catalog、有哪些组件、组件绑定哪些数据、用户操作后回传什么事件。React、Flutter、Lit或Angular渲染器都可以消费同一类消息,但每个客户端仍使用自己的原生组件和设计系统。
这条边界决定了安全模型。Agent可以请求名为Button的组件,却不能凭空创造DangerouslyEvalScript;它可以声明一个submit_order事件,却不能直接调用数据库。客户端先用catalog判断组件和属性是否合法,再把事件交给服务器;服务器重新做身份、参数和业务状态校验,最终才执行副作用。前端校验改善体验,后端校验守住数据完整性,两者不能互相替代。
一个完整链路可以拆成六层:模型或规则生成A2UI JSONL,协议网关校验版本与大小,surface管理器维护组件树和数据模型,渲染器从白名单目录取组件,用户操作形成action,Agent端事件路由器再进行授权和幂等处理。
业务服务 Agent 协议网关 A2UI客户端 用户 业务服务 Agent 协议网关 A2UI客户端 用户 #mermaid-svg-2O1HYUHRCUSxmAQK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2O1HYUHRCUSxmAQK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2O1HYUHRCUSxmAQK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2O1HYUHRCUSxmAQK .error-icon{fill:#552222;}#mermaid-svg-2O1HYUHRCUSxmAQK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2O1HYUHRCUSxmAQK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2O1HYUHRCUSxmAQK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2O1HYUHRCUSxmAQK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2O1HYUHRCUSxmAQK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2O1HYUHRCUSxmAQK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2O1HYUHRCUSxmAQK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2O1HYUHRCUSxmAQK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2O1HYUHRCUSxmAQK .marker.cross{stroke:#333333;}#mermaid-svg-2O1HYUHRCUSxmAQK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2O1HYUHRCUSxmAQK p{margin:0;}#mermaid-svg-2O1HYUHRCUSxmAQK .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2O1HYUHRCUSxmAQK text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-2O1HYUHRCUSxmAQK .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2O1HYUHRCUSxmAQK .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-2O1HYUHRCUSxmAQK .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-2O1HYUHRCUSxmAQK .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-2O1HYUHRCUSxmAQK #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-2O1HYUHRCUSxmAQK .sequenceNumber{fill:white;}#mermaid-svg-2O1HYUHRCUSxmAQK #sequencenumber{fill:#333;}#mermaid-svg-2O1HYUHRCUSxmAQK #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-2O1HYUHRCUSxmAQK .messageText{fill:#333;stroke:none;}#mermaid-svg-2O1HYUHRCUSxmAQK .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2O1HYUHRCUSxmAQK .labelText,#mermaid-svg-2O1HYUHRCUSxmAQK .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-2O1HYUHRCUSxmAQK .loopText,#mermaid-svg-2O1HYUHRCUSxmAQK .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-2O1HYUHRCUSxmAQK .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2O1HYUHRCUSxmAQK .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-2O1HYUHRCUSxmAQK .noteText,#mermaid-svg-2O1HYUHRCUSxmAQK .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-2O1HYUHRCUSxmAQK .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2O1HYUHRCUSxmAQK .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2O1HYUHRCUSxmAQK .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2O1HYUHRCUSxmAQK .actorPopupMenu{position:absolute;}#mermaid-svg-2O1HYUHRCUSxmAQK .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-2O1HYUHRCUSxmAQK .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2O1HYUHRCUSxmAQK .actor-man circle,#mermaid-svg-2O1HYUHRCUSxmAQK line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-2O1HYUHRCUSxmAQK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JSONL界面消息 已校验的create/update消息 catalog白名单映射并原生渲染 填写并点击确认 action事件+最小context 身份、版本、来源组件已校验 业务参数与权限二次校验 结果或可恢复错误 updateDataModel/updateComponents
这里最重要的不是"模型会画界面",而是能力被分段授予。界面描述不拥有业务权限,客户端事件也不是可信命令。任何一层被诱导生成错误内容,下一层仍有机会拒绝它。
2. v0.9.1消息如何组成一个界面
A2UI消息使用JSON对象表达,并可用JSONL逐行流式传输。v0.9系列的关键服务端消息包括createSurface、updateComponents、updateDataModel和deleteSurface。surfaceId标识一个独立界面区域;catalogId告诉客户端采用哪套组件协议;组件通过稳定的id组成扁平引用关系;数据模型则保存输入值和业务状态。
扁平组件表看上去不如嵌套JSON直观,却适合流式更新。Agent可以先创建骨架,再更新某个文本或按钮,而不必反复发送整棵DOM树。稳定ID也让客户端能够做增量渲染、焦点保持和差异审计。若Agent每次生成随机ID,输入框可能在更新时被重建,用户刚输入的内容和辅助技术焦点都会丢失。因此ID不仅是协议字段,也是一项用户体验契约。
最小消息通常先创建surface,再发送组件。v0.9.1把application/a2ui+json标准化为媒体类型。真实服务可以通过SSE、WebSocket、A2A消息或普通HTTP承载,但传输方式不是A2UI语义本身。不要把"用了SSE"误认为"实现了A2UI",也不要让重连逻辑改写消息含义。
下面是一组预订确认界面。它只使用Card、Column、Text、TextField和Button,并把人数绑定到数据模型。按钮回传稳定事件名,而不是把HTTP地址交给模型。
json
{"version":"v0.9.1","createSurface":{"surfaceId":"booking-42","catalogId":"https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json"}}
{"version":"v0.9.1","updateDataModel":{"surfaceId":"booking-42","path":"/","value":{"partySize":"2","status":"待确认"}}}
{"version":"v0.9.1","updateComponents":{"surfaceId":"booking-42","components":[{"id":"root","component":"Card","child":"form"},{"id":"form","component":"Column","children":["title","size","submit"]},{"id":"title","component":"Text","text":"确认预订信息"},{"id":"size","component":"TextField","label":"用餐人数","value":{"path":"/partySize"}},{"id":"submit","component":"Button","child":"submit_text","action":{"event":{"name":"submit_booking","context":{"partySize":{"path":"/partySize"}}}}},{"id":"submit_text","component":"Text","text":"提交"}]}}
生产系统不应直接相信这一段。catalogId可能指向陌生地址,组件可能超出允许集合,绑定路径可能读取不该暴露的状态,文本也可能大到拖垮客户端。正确姿势是把每一行看成外部输入,先验证再进入surface状态机。
3. 用标准库实现协议入口
为了看清安全边界,下面不用完整SDK,只用Python标准库实现一个小型验证器。它不是完整JSON Schema实现,而是服务边缘的一道廉价防线:限制消息体积、版本、消息类型、surface命名、catalog和组件集合,同时阻止重复组件ID。正式项目仍应在这层之后执行官方schema验证。
python
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Any
VERSION = "v0.9.1"
MAX_LINE_BYTES = 64 * 1024
SURFACE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
ALLOWED_CATALOGS = {
"https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json"
}
ALLOWED_COMPONENTS = {"Card", "Column", "Row", "Text", "TextField", "Button"}
MESSAGE_KEYS = {"createSurface", "updateComponents", "updateDataModel", "deleteSurface"}
class ProtocolError(ValueError):
pass
@dataclass
class Surface:
catalog_id: str
components: dict[str, dict[str, Any]] = field(default_factory=dict)
data: dict[str, Any] = field(default_factory=dict)
def parse_line(line: str) -> dict[str, Any]:
if len(line.encode("utf-8")) > MAX_LINE_BYTES:
raise ProtocolError("单条消息超过大小上限")
try:
message = json.loads(line)
except json.JSONDecodeError as exc:
raise ProtocolError("不是合法JSON") from exc
if not isinstance(message, dict) or message.get("version") != VERSION:
raise ProtocolError("协议版本不受支持")
present = MESSAGE_KEYS.intersection(message)
if len(present) != 1:
raise ProtocolError("每条消息必须且只能包含一种操作")
return message
def require_surface_id(payload: dict[str, Any]) -> str:
surface_id = payload.get("surfaceId")
if not isinstance(surface_id, str) or not SURFACE_RE.fullmatch(surface_id):
raise ProtocolError("surfaceId格式错误")
return surface_id
def apply_message(surfaces: dict[str, Surface], message: dict[str, Any]) -> None:
if "createSurface" in message:
payload = message["createSurface"]
surface_id = require_surface_id(payload)
if surface_id in surfaces:
raise ProtocolError("surface已存在,必须先删除再重建")
catalog_id = payload.get("catalogId")
if catalog_id not in ALLOWED_CATALOGS:
raise ProtocolError("catalog不在白名单")
surfaces[surface_id] = Surface(catalog_id=catalog_id)
return
key = next(iter(MESSAGE_KEYS.intersection(message)))
payload = message[key]
surface_id = require_surface_id(payload)
if surface_id not in surfaces:
raise ProtocolError("surface尚未创建")
if key == "deleteSurface":
del surfaces[surface_id]
return
if key == "updateDataModel":
if payload.get("path") != "/" or not isinstance(payload.get("value"), dict):
raise ProtocolError("示例仅允许整体对象更新")
surfaces[surface_id].data = dict(payload["value"])
return
components = payload.get("components")
if not isinstance(components, list) or len(components) > 100:
raise ProtocolError("组件列表无效或过大")
ids: set[str] = set()
for component in components:
cid = component.get("id") if isinstance(component, dict) else None
kind = component.get("component") if isinstance(component, dict) else None
if not isinstance(cid, str) or not SURFACE_RE.fullmatch(cid):
raise ProtocolError("组件ID无效")
if cid in ids or kind not in ALLOWED_COMPONENTS:
raise ProtocolError("组件重复或类型未授权")
ids.add(cid)
surfaces[surface_id].components.update({item["id"]: dict(item) for item in components})
def demo() -> None:
state: dict[str, Surface] = {}
create = json.dumps({
"version": VERSION,
"createSurface": {"surfaceId": "demo", "catalogId": next(iter(ALLOWED_CATALOGS))},
})
apply_message(state, parse_line(create))
assert state["demo"].catalog_id in ALLOWED_CATALOGS
try:
apply_message(state, parse_line(create))
except ProtocolError:
pass
else:
raise AssertionError("重复surfaceId必须被拒绝")
try:
parse_line('{"version":"v0.8","deleteSurface":{"surfaceId":"demo"}}')
except ProtocolError:
pass
else:
raise AssertionError("旧版本消息应被拒绝")
if __name__ == "__main__":
demo()
这段代码刻意没有"自动兼容所有版本"。协议版本一旦混用,字段名和事件封装可能发生语义错位。更安全的做法是明确协商版本,把旧消息转换成内部统一模型;转换失败就返回可解释错误,而不是猜测字段。v0.8使用过beginRendering、surfaceUpdate和userAction等名称,v0.9系列改为createSurface、updateComponents和action。仅修改字符串而不理解生命周期,会产生难以复现的半兼容状态。
4. 组件目录才是真正的能力边界
catalog不是"组件名称清单"这么简单。它应描述组件可接受的属性、数据绑定、动作、客户端函数和主题约束。客户端只注册经过审核的本地实现。例如名为Link的组件可以强制只打开HTTPS地址,Image可以通过图片代理加载,Button只能触发允许的事件。即使模型输出同名属性,也不能绕过本地实现。
企业通常已经有设计系统,没必要为A2UI复制一套按钮。把现有Button适配成catalog组件即可。这样视觉主题、键盘操作、读屏标签、埋点和灰度策略仍在原组件中。A2UI的价值不是取代前端,而是让Agent在已有能力集合内组合界面。
catalog地址也不能无条件远程获取。若客户端根据Agent给出的任意URL下载schema,就引入了供应链和服务器端请求伪造风险。生产环境可以使用固定版本表,将协议中的catalogId解析到本地打包资源或受控缓存;升级时先做兼容测试,再显式放行新摘要。客户端应拒绝未知catalog,而不是退回"尽量渲染"。
组件树还需要结构约束:最大节点数、最大深度、单段文本长度、图片数量和可绑定路径范围。否则合法schema也能被滥用于资源耗尽。渲染器遇到未知引用时应显示受控错误占位并上报诊断,不能因为一个子节点损坏就执行剩余动作。
5. 数据模型与流式更新怎么处理
结构与状态分离是A2UI的核心。组件声明{"path":"/partySize"}时,它读取的是surface数据模型中的值,而不是把值复制到组件定义。用户修改输入后,客户端更新本地数据;Agent也可以通过updateDataModel刷新状态。这样结构通常保持稳定,频繁变化的进度、表单值和结果可以单独传输。
流式传输必须面对乱序、重复和断线。规范定义消息语义,但业务仍应设计恢复策略。最简单可用的办法是在传输信封外增加单调递增序号和消息ID:客户端只提交连续序号,重复消息按ID去重,缺口触发重取surface快照。不要依赖TCP连接"永远不断";移动网络、反向代理超时和服务扩缩容都会让连接重建。
更新也应当是原子的。一次updateComponents若包含十个节点,验证全部通过后再写入状态,避免前五个节点生效、后一个失败留下半棵树。数据更新需要限制JSON Pointer可达范围。例如客服Agent只能改/draft和/filters,不能写/currentUser/roles。UI状态与身份权限应位于不同存储域,绑定表达式更不能成为修改鉴权数据的通道。
当多个surface共存时,事件必须携带surfaceId与sourceComponentId。服务器不能只按事件名路由,因为另一个已关闭页面可能重放同名提交。删除surface后应撤销其临时令牌并拒绝后续事件;否则旧标签页可能继续操作新会话。
6. 事件回传:客户端提交的是请求,不是事实
A2UI区分本地Function和发给Agent的Event。本地函数适合无高风险副作用的即时行为,例如切换标签、格式化日期或打开经过验证的网址。Event用于需要服务端参与的操作,例如查询库存、创建预订或发起审批。把所有点击都发给Agent会增加延迟,把支付、删除等操作放成本地Function又会失去服务端控制。
action的context是从数据模型中挑选出的最小视图。最小化很重要:提交预订只需要日期、人数和幂等键,不应把整份用户资料、隐藏字段和历史对话一起回传。即使客户端设置sendDataModel,服务器也要按事件白名单重新筛选字段。
下面实现一个事件入口。它验证会话拥有surface、来源组件确实声明了相同事件、参数类型正确,并用幂等键阻止重复提交。示例把存储放在内存里便于运行,生产环境应使用带唯一约束的持久化数据库;否则多进程或重启后幂等状态会丢失。
python
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
class ActionError(ValueError):
pass
@dataclass(frozen=True)
class Principal:
user_id: str
allowed_surfaces: frozenset[str]
def validate_booking(context: dict[str, Any]) -> tuple[int, str]:
size = context.get("partySize")
key = context.get("idempotencyKey")
if isinstance(size, bool) or not isinstance(size, int) or not 1 <= size <= 20:
raise ActionError("partySize必须是1到20之间的整数")
if not isinstance(key, str) or not 8 <= len(key) <= 80:
raise ActionError("幂等键无效")
return size, key
def handle_action(
payload: dict[str, Any],
principal: Principal,
declared_actions: dict[tuple[str, str], str],
seen: set[tuple[str, str]],
create_booking: Callable[[str, int], str],
) -> dict[str, Any]:
if payload.get("version") != "v0.9.1" or not isinstance(payload.get("action"), dict):
raise ActionError("action信封无效")
action = payload["action"]
surface_id = action.get("surfaceId")
component_id = action.get("sourceComponentId")
name = action.get("name")
if surface_id not in principal.allowed_surfaces:
raise ActionError("无权操作该surface")
if declared_actions.get((surface_id, component_id)) != name:
raise ActionError("事件与来源组件不匹配")
if name != "submit_booking" or not isinstance(action.get("context"), dict):
raise ActionError("事件不受支持")
size, key = validate_booking(action["context"])
dedupe_key = (principal.user_id, key)
if dedupe_key in seen:
return {"status": "duplicate", "idempotencyKey": key}
booking_id = create_booking(principal.user_id, size)
seen.add(dedupe_key)
return {"status": "created", "bookingId": booking_id}
def demo() -> None:
principal = Principal("u-7", frozenset({"booking-42"}))
actions = {("booking-42", "submit"): "submit_booking"}
seen: set[tuple[str, str]] = set()
payload = {"version": "v0.9.1", "action": {
"name": "submit_booking", "surfaceId": "booking-42",
"sourceComponentId": "submit",
"context": {"partySize": 2, "idempotencyKey": "request-001"},
}}
creator = lambda user_id, size: f"B-{user_id}-{size}"
assert handle_action(payload, principal, actions, seen, creator)["status"] == "created"
assert handle_action(payload, principal, actions, seen, creator)["status"] == "duplicate"
if __name__ == "__main__":
demo()
幂等检查与写业务数据在真实系统里必须处于同一事务,示例的seen.add不具备这个保证。如果先创建订单、后记录幂等键时进程崩溃,重试仍会创建第二单。数据库唯一键通常比"先查再写"更可靠。对于支付、发布、删除等高风险动作,还应加入明确的人工确认页面、短期挑战令牌和审计记录,不能把模型刚生成的按钮当成用户已理解后果。
7. 从Prompt到界面,不要只有一次模型调用
模型直接生成最终JSON虽然演示效果好,生产质量却不稳定。更稳妥的流程是先让Agent输出领域意图,例如"展示三个可选时段并要求用户确认人数",再由受约束的界面规划器映射到catalog,最后执行schema与策略校验。模型输出使用结构化生成能降低语法错误,但不能替代语义校验。
提示词中应提供目标catalog的解析后schema、少量正确范例、surface预算和禁用能力。不要把整个公司组件库一次塞入上下文;组件越多,选择冲突越多,Token成本也越高。按场景选择一个小catalog,例如预订只暴露表单、日期和确认组件,报表才暴露图表与筛选器。
模型失败时要可恢复。JSON不合法可以请求一次受限修复;组件不受支持应返回结构化验证错误,要求模型只替换问题节点;连续失败则退回预先设计的安全页面或纯文本答复。无限"让模型再试一次"会形成昂贵循环,也可能被恶意输入放大。修复次数、消息总大小和整次任务时长都要有预算。
更关键的是不要让模型决定权限。模型可以建议显示"删除"按钮,服务端仍根据当前用户和资源状态决定是否把该能力加入catalog或直接拒绝动作。最理想的界面是不展示无权限操作,但即使按钮被缓存、伪造或从旧会话重放,后端也必须再次拒绝。
8. 一套可执行的验证清单
协议测试先覆盖确定性行为。用官方schema验证所有服务端和客户端消息;固定一组golden JSONL,升级渲染器时比较组件树、数据模型和事件;对截断行、未知版本、重复ID、悬空引用、超大文本和乱序更新做反例测试。不要只截屏判断"看起来能用",因为焦点顺序、绑定状态和事件context在图片里看不出来。
交互测试要验证真实用户路径:键盘能否完成表单、读屏是否读出标签与错误、流式更新是否抢走焦点、双击提交是否只产生一次业务结果、断线重连是否恢复同一surface。移动端还应检查软键盘、弱网和后台恢复。生成式界面变化多,更需要按语义断言,而不是把每个像素固定死。
安全测试可从四类输入开始。第一类是提示注入,诱导Agent生成不存在的管理组件;第二类是协议畸形,包括深层JSON、巨型数组和循环引用式组件关系;第三类是事件伪造,修改surface、组件、事件名和context;第四类是重放和竞态,包括重复点击、过期会话与并发更新。每次拒绝都应留下原因代码、trace ID和协议版本,但日志默认不要记录完整表单内容。
可观测性至少记录生成、验证、渲染、交互和业务执行五段耗时,以及验证失败类型、组件数量、修复次数和事件结果。不要把完整提示词、身份证号、地址等默认写入trace。排障需要内容时,应显式启用采样、脱敏和访问控制,并设置短保留期。
上线采用影子与灰度更稳妥。先让A2UI生成器离线处理真实但脱敏的请求,只比较它与现有页面能否完成同样任务;再开放给内部用户和低风险场景;最后才触及有资金或不可逆副作用的流程。任何阶段都需要一键退回固定页面,而不是把可用性完全押在模型和流式链路上。
9. 常见故障与排查顺序
"界面空白"首先查看JSONL是否逐行完整、版本是否与渲染器一致、createSurface是否先建立目标surface、根组件是否存在。不要先调Prompt;很多空白只是代理把两行JSON粘成一行,或者catalog版本不匹配。
"输入后刷新丢值"通常是组件ID不稳定、结构更新覆盖本地数据,或绑定路径前后不一致。检查更新前后的surface快照和数据模型差异。结构变更与数据变更分开发送,能让问题更容易定位。
"按钮能点但服务端没响应"要沿action信封查:事件名是否稳定,sourceComponentId是否仍存在,context路径是否解析出值,身份是否拥有surface,幂等键是否被当成重复。如果客户端只记录点击、不记录拒绝原因,最终只能看到"没反应"。
"渲染越来越慢"先统计节点数、更新频率和单条文本长度,再看是否重复发送整棵组件树。A2UI支持增量更新,不代表任何粒度都便宜。高频进度适合更新数据字段,不适合每100毫秒重建组件。为每个surface设置生命周期,任务完成后主动删除,避免后台页面长期积累状态。
"安全扫描没发现问题但仍越权"往往是把schema合法等同于业务合法。schema只能证明字段形状正确,不能证明当前用户有权取消别人的订单。权限判断必须使用服务端可信身份和最新资源状态,不能依赖action context里的userId或role。
10. 用一个报销场景做端到端设计
为了检验前面的原则,可以把"提交差旅报销"作为样板。用户先用自然语言说明出差城市和用途,Agent从票据提取金额后,不应直接创建报销单,而是生成一个确认surface。界面显示识别出的日期、币种、金额、成本中心和票据列表,允许用户修改可编辑字段;财务规则计算出的税额、审批链和员工身份则只读展示,不能从数据绑定写回。
catalog只开放Text、MoneyField、DateField、ChoicePicker、AttachmentList和Button。AttachmentList接收的是后端签发的短期文件引用,而不是任意URL;客户端通过受控下载接口取缩略图。金额字段做格式和范围提示,但服务端仍从字符串按确定的小数规则解析,绝不能用二进制浮点直接记账。成本中心选项由权限服务返回,Agent只能从候选值中选择。
用户点击"提交审批"时,action context只含草稿ID、用户确认后的字段版本与幂等键,不回传完整票据OCR文本。服务端依据访问令牌取得员工身份,读取草稿最新版本,检查客户端版本是否过期,重新计算金额和审批链,最后在一个事务内创建报销与幂等记录。若版本冲突,Agent更新surface显示差异,让用户再次确认,不能静默覆盖财务人员刚修改的字段。
这个例子还能说明本地Function的边界。"预览票据"可以是客户端函数,因为它只打开已授权的本地预览;"提交审批"必须是Event,因为它改变服务器状态;"删除附件"即使在视觉上只是一个小叉号,也属于有副作用事件。行为分类取决于风险,不取决于控件长什么样。
提交成功后,服务器先更新数据模型中的状态与报销编号,再把提交按钮替换为只读结果。若网络在业务提交后、成功消息到达前中断,客户端用相同幂等键重试,服务端返回原有报销编号。这样用户不会因为页面仍显示"提交中"而制造重复单据。最后由Agent删除包含敏感票据的临时surface,客户端也清理内存中的绑定数据。
11. 版本演进与catalog治理
协议版本、catalog版本和业务动作版本是三件事。A2UI从v0.9到v0.9.1可能保持大部分消息形状,组件目录却可能独立增加属性;业务上的submit_booking也可能从第一版两字段变成第二版包含座位偏好。把三个版本混成一个数字,会让回滚变得困难。
建议catalog采用不可变版本地址。发布新版本时生成机器可读差异:新增和删除了哪些组件,哪些属性从可选变必填,哪些客户端函数扩大了能力。客户端可以同时打包两个版本,在灰度期按catalogId选择;服务端只向声明支持的客户端发送新版本。旧catalog到期前统计实际使用量,不能凭发布时间猜测"应该没人用了"。
动作也应拥有稳定schema。事件名保持语义明确,context使用JSON Schema验证,并记录生产者catalog与处理器版本。若动作需要破坏性变更,新增事件名或版本字段,不要让服务器用字段是否存在来猜版本。对高风险动作,服务端返回的确认摘要应来自可信数据重新计算,而不是复述Agent提供的文字。
v1.0候选版引入的新能力不应提前伪装成v0.9.1字段发送。候选规范可能变化,正确做法是建立独立适配器和能力协商,在测试客户端中验证,再决定何时进入生产。支持多个版本并不要求业务代码写满条件分支:入口将各版本转换为内部Surface、Component、Action模型,出口再按协商版本编码即可。
12. 性能优化要从消息预算开始
生成式界面的延迟由模型生成、网络流式传输、验证、状态合并和原生渲染共同组成。只测"首Token时间"会遗漏用户真正看到可交互表单的时刻。至少记录首条有效消息、根组件可见、关键输入可操作和最终稳定四个时间点,再结合trace定位慢点。
最有效的优化通常不是换更复杂的渲染器,而是缩小生成空间。为场景提供小catalog、缓存稳定的系统指令与schema、先流出骨架和必需字段、把大量选项改为受控搜索工具。上千个城市不应直接生成成组件数组;Agent只声明选择器,客户端按输入查询服务器。
数据模型更新要合并高频变化。进度从41%跳到42%无需重建Card和Text,只更新一个路径;连续到达的多条非关键更新可以在一帧内批量应用。另一方面,安全确认和错误提示不能为追求吞吐延迟合并,用户必须看到与即将执行操作一致的最新状态。
缓存也有边界。catalog schema与静态组件实现适合按版本长期缓存,包含用户数据的surface快照只能私有、短期保存。共享缓存键若没有租户和权限维度,可能把一个用户的表单交给另一个用户。任何缓存命中都不能跳过事件阶段的服务端授权。
13. 适用边界与落地建议
A2UI适合结果结构随任务变化、又希望复用本地设计系统的场景,例如Agent表单、动态审批、客服处理卡片和跨端工作流。若页面结构稳定、SEO重要或需要复杂编辑器,传统前端仍更直接。把每个固定页面都改成模型生成,只会增加延迟、成本和不可预测性。
它也不会自动解决业务编排。组件协议负责表达界面,A2A或自有接口负责消息承载,MCP可以负责工具发现和调用,OAuth负责授权,数据库负责一致性。把这些边界画清楚,比争论"一个协议能否包办所有Agent能力"更有意义。
第一版落地可以很小:选择一个低风险流程,冻结一个只有五到十种组件的catalog,实现创建、组件更新、数据更新和删除四类消息,再接通一个无副作用事件。加入schema校验、大小限制、身份绑定和观测后,才逐步开放写操作。这样每扩展一种组件或动作,都有明确的威胁模型和回归用例。
总结
A2UI真正解决的问题,不是让模型"会写前端",而是让Agent用一种受约束的语言表达界面意图。客户端掌握组件实现和本地函数,服务端掌握身份、权限与副作用,协议把结构、状态和事件连接起来。这个分工让动态界面可以跨框架流动,同时避免执行任意模型代码。
一个能上线的闭环至少包含:固定版本和catalog白名单、逐条schema与资源限制、稳定组件ID、数据路径隔离、事件来源校验、业务参数二次验证、幂等事务、可恢复流式状态以及内容最小化的可观测性。做到这些之后,生成式界面才从漂亮演示变成可审计的产品能力。