本文讨论的是一条链路的取舍,不是某个协议的实现教程。文中组件目录与事件形状的写法参照了 A2UI、AG-UI 等公开规范的既有做法1 2,但读完全文不需要先了解它们。
一个已经算完的数据,离上屏还差一次判断
设想一条订单风控流程。后端已经完成了判断:这笔订单被标记为「需人工复核」,原因码是「同卡 90 秒内重复扣款」,涉及两笔流水。数据和结论都齐了。
但「这件事在界面上长什么样」还没有答案,而且它不该由这段业务代码顺手决定。
让业务逻辑自己决定呈现,很快会撞上三件事:
- 同一个业务事实在不同位置要有不同呈现:列表里是一行、详情页是一张卡、通知里是一句话。
- 呈现要随上下文变:有复核权限的人看到「通过 / 驳回」按钮,没有的人只看到状态。
- 每调一次版式,都要动一遍业务逻辑并重新回归:改的是视觉,牵动的却是风控代码。
于是界面决策通常落到两个地方:散进业务代码,或者塞进业务推理的提示词。两种做法都会让「改版式」等价于「改业务」。
三个决定
把这件事拆开,需要下三个决定。
一:后端只算数据与状态,不决定界面。 后端产出的是一份已经算好的业务事实——订单号、异常类型、命中的规则、涉及流水、当前节点。它不知道也不该知道这份事实会被渲染成卡片还是表格。
二:界面决策独立成一层。 这一层接收业务事实和当前可用的组件清单,产出一份界面描述:用哪个组件、填什么参数、怎么排列。它不做业务判断,也不碰数据本身。
三:传输单向,前端按名字查表。 描述经 SSE 流式下发,前端持有一份组件注册表,按描述里的组件名找到真实组件并渲染;用户动作沿另一条通道回到后端。
链路是这样:
查看 Mermaid 源码
flowchart LR
A[业务状态流转] --> B[业务后端<br/>只算数据与状态]
B --> C[界面决策层<br/>只做翻译]
D[组件目录] --> C
C -->|SSE 描述流| E[前端注册表]
E --> F[查表渲染]
F -->|用户动作| B比箭头更重要的是三段之间的边界:后端改业务逻辑不惊动前端,前端改版式不惊动后端,中间的决策层换模型不惊动两边。这正是引入一层额外服务的全部理由——如果它做不到这三件事之一,那它就只是多了一跳。
只做翻译的那一层
这一层的输入输出必须写死,否则它会长成第二个业务层。
输入四样:一份业务事实、这次事件的状态(进入复核 / 已通过 / 超时)、当前可用的组件目录、渲染场景(列表 / 详情 / 通知)。
输出三样:组件名、参数、排列方式。
它有三件不做的事,第三条最容易违反,也最贵:
- 不取数。 数据已经在输入里了,它不需要再查一次库。
- 不判断业务。 "该不该复核"是后端的事,模型只负责"复核这件事该显示成什么"。
- 不重述数据。 只给组件名和参数,不要把值序列化进输出。
第三条违反起来的代价很直观。若让模型把订单号、两笔流水、时间戳都抄进描述,输出 token 随数据体量线性增长——而这些数据本来就在前端手里,模型抄一遍只是让它们多走一趟、还多一份抄错的可能。正确的形状是引用而非复制:
{
"component": "RiskReviewCard",
"props": {
"order_id": "SO-20260929-8417",
"reason_code": "DUPLICATE_CHARGE",
"window_seconds": 90
}
}组件拿着 order_id 自己去取它要展示的细节。这条约束在别处被叫做「元数据优先于数据」3,在流式场景里它同时买到两样东西:更短的生成时间(首帧更快),和更小的抄写错误面。
组件目录:模型能用的积木清单
决策层不能用它没见过的组件。这个约束靠一份目录落实,每个条目四项:
| 字段 | 作用 | 谁来写 |
|---|---|---|
name | 模型认识的字符串,也是前端查表的键 | 人 |
propsSchema | 参数形状,同时用于注入与校验 | 人(可从类型派生) |
description | 模型选它的唯一依据 | 人 |
examples | 可选,几个正确用法 | 人 |
目录有双重身份,这一点决定了它的实现方式:
- 向上,序列化进决策层的提示词——告诉模型"你只有这些积木,不许发明新的"。
- 向下,留在前端当白名单——运行时校验模型给的名字存不存在、参数合不合 schema,不合就丢弃或降级。
一份定义、两处消费,所以它必须单一事实来源,不能手写两份。落地时分成两层:
- 构建期决定"有哪些组件",产出一份基线目录进版本库。好处是确定、可审计、可 review。
- 运行期决定"这次能暴露哪些",按当前页面、登录角色、部署子路径裁剪后再随请求上行。
运行期这一层不能省。前端到底有哪些组件是运行期才知道的:按权限裁掉一部分、按页面只挂载一部分,服务端扫描不出来。目录不是一次性注册,而是随每次请求携带——这也是它与"后端写死的配置"最实际的区别。
四个字段里,真正花时间的是 description,而它恰好是没法从代码自动产生的那一项。类型系统给得出 title: string,给不出"什么时候该用这张卡、它和隔壁那张差在哪"。而模型选组件全靠这段自然语言。所以"从组件自动生成目录"这个诉求里,能自动的部分(schema)不重要,不能自动的部分(描述与示例)才是关键。它更适合被当成一次有意识的设计动作,而不是一条构建流水线。
前端拿到描述之后
前端的处理是三步,每一步都可能否决上一步的结果:
查看 Mermaid 源码
flowchart LR
A[SSE 描述流] --> B[名字在注册表里?]
B -->|否| X[降级显示]
B -->|是| C[参数过 schema?]
C -->|否| X
C -->|是| D[渲染组件]
D -->|用户动作| E[回传事件]校验先于渲染。 名字查不到、参数不合 schema,都直接走降级,不能"尽力渲染"——尽力渲染的结果是把未知参数透传给组件,等于把模型的输出当可信输入。
渲染是查表,不是解释。 前端不需要理解 RiskReviewCard 的业务含义,只需要知道它接收哪些参数。组件长什么样完全由前端和设计 token 决定,模型永远不知道也不需要知道它的颜色和间距。
交互沿另一条通道回去。 组件里的按钮不是普通按钮,它携带一个动作标识(review.approve),前端拦截后回传后端,由后端决定下一步业务状态。模型负责的始终是"显示什么",不是"点了会怎样"。
稳定渲染是一个约束,不是一句承诺
把模型放进渲染链路,最容易被问到的是稳定性。这里必须说清楚:稳定性不来自模型,来自你对它的约束。 具体是三道闸加一条纪律。
第一道:目录白名单。 模型只能从目录里挑名字。这一条同时是安全边界——允许模型自由产出 HTML,等于把 shell 交给它。渲染必须来自你预先批准过的组件集合,没有例外。
第二道:参数校验。 每个组件声明自己的 schema,前端和后端各校验一次。模型给的名字对、参数错,仍然要拦。
第三道:降级路径。 生成失败、校验不过、超时,都要有一条明确的后备:落到预置模板,或者最少呈现(只显示状态文本)。降级不是异常处理,是这条链路上的一等公民——它决定了最坏情况下用户看到什么。
一条纪律:幂等。 这条在业务事件驱动的场景里尤其要紧。SSE 断线重连、事件重放、同一状态变化被触发两次,都会让同一条描述到达前端两次。描述必须带可去重的标识(业务事实的 id + 状态版本),前端据此判断"这条我已经渲染过了"。否则一次网络抖动就会在页面上留下两张一模一样的卡片。
还有一件无法回避的事:同一个输入,两次翻译得到的结果可能不完全一样。 传统做法里描述是代码构造的,同样的输入永远产出同样的字节;换成一个模型,这个保证就没了。要拿回它,只能靠约束收敛(目录足够小、描述足够明确)或者缓存(同一状态版本直接复用上次的描述)。这是这条路线要付的税,不是可以绕过的实现细节。
什么时候不要这样做
这套结构有明确的适用范围,越界的代价通常是延迟和成本,而不是功能缺失。
呈现规则能写完的时候,不要用模型。 如果"这批数据用哪个组件"能用一张表或几行条件写完,那就写代码。确定性的问题是确定性的解法更快、更省、更可测。模型的价值只在"呈现方式依赖上下文、而规则写不完"的地方——前面那张卡在不同页面、不同权限下要变,才是它该出场的时候。
同步交互不要过模型。 用户点击后期望立刻有反馈的场景,不适合在中间插一次生成。适合的是"后端状态变化 → 前端提示"这类允许秒级延迟的推送。
必须逐字节可复现的界面不要过模型。 账单、对账、审计这类要求"同输入同输出"的呈现,要在模型之外另行保证。
这套结构不是凭空来的
把界面描述成数据、由客户端持注册表渲染——这个模式在客户端工程里已经运行了很多年,从大型 App 的首页配置到运营位的 A/B 实验都在用它4。后来被替换掉的那一环,恰好就是本文讨论的这一环:描述由谁产生。 从人写配置换成模型生成,结构没变,变的只是作者和它的约束方式。
所以这篇文章讲的不是新架构,而是一次替换。承认这一点有实际好处:注册表、版本协商、降级组件这些细节都有现成经验可以借,不必从零试错。真正需要自己想清楚的是替换之后新出现的东西——非确定性、生成延迟、以及下面这个在原有模式里不存在的问题:
在那套模式里,界面由运营配置驱动;在对话式 AI 应用里,界面由用户提问驱动。而一个业务系统的界面,应该由后端的状态流转驱动。触发源不是用户,是业务事件——这意味着界面必须在用户没有提问、甚至没有打开页面的情况下被决定。这条链路上多出来的那层服务,本质上就是在处理这件事。
Footnotes
-
A2UI 规范 v0.9 定义了四类服务端到客户端的消息:
createSurface、updateComponents、updateDataModel、deleteSurface;组件用扁平邻接表而非嵌套树表达,便于流式增量生成。本文的"组件目录"对应它的 catalog 概念。 ↩ -
AG-UI 是一套基于 SSE 的事件协议,用
TOOL_CALL_START/TOOL_CALL_ARGS/TOOL_CALL_END/TOOL_CALL_RESULT等事件承载工具调用,并区分服务端工具与前端工具执行。 ↩ -
这个思路常被概括为 metadata-over-data:模型只输出"用哪个组件、要哪些字段",真实数据由前端或后端按引用注入,避免把数据集重新序列化进模型输出。 ↩
-
这套模式通常被称为服务端驱动界面(server-driven UI)。公开的工程实践包括 Airbnb 的 Ghost 平台、Lyft 与 Shopify 的同类系统;其原始动机是绕开移动端的审核周期,让界面变更的部署单位从二进制变成服务端响应。 ↩