跳至主要内容
生成式 UI 的三条禁线

生成式 UI 的三条禁线

去年我在一个内部数据工具里试过让模型直接输出界面。用户在输入框里写「上个月华东区的退货情况」,模型返回一段 JSON,前端按 schema 渲染成筛选器加图表。演示那天效果很好,会议室里有人鼓掌。

三周后这个入口基本被当成了一个普通查询框——大家还是手动点日期,理由是「它每次给的东西都不太一样,不如自己填快」。

那次之后我把这个功能拆了。模型本身没有太大问题;更根本的原因在于,界面这个东西自身的性质和动态生成之间存在冲突。下面是我后来划的几条线。

界面每变一次,用户就要重新学一次

我在自己的工具里做过一个不严谨的对比:同一个后台的筛选器,位置固定的话,大概两三天我就不看它了,手直接过去点;位置随机的话,两周后我还是要先扫一眼找一找。这个差别不是效率上的百分之十,是「要不要动脑」的差别。

这不是什么新发现,几乎所有 GUI 设计原则里都写着。但生成式 UI 的叙事里经常忽略这一条,因为它卖的就是「每次都给你最合适的」。问题在于,「最合适」是从模型的判断出发,不是从用户已经形成的习惯出发。习惯是资产,而且是一种一旦形成就很难贬值、被打破却立刻归零的资产。

可学习性是可以量的:同一任务的完成时间、首次成功率、求助次数、误操作率。生成式界面的收益出现在长尾(第一万种查询),成本出现在高频路径(第一万次查询)。用长尾的收益补贴高频的成本,这个账很难算平。

还有一个常被混淆的点:「生不生成」不是二元的。中间至少有这么几档:

  • 结构固定,内容生成(列表项、摘要、图表类型选择)
  • 字段固定,顺序和显隐生成
  • 骨架固定,文案生成
  • 全部生成

我现在只接受前两档,第三档看场景,第四档只在演示视频里见过好效果。现在很多团队在讨论「用不用生成式 UI」,我觉得更值得讨论的是「生成到哪一层」。粒度选错,比方向选错更难改。

出错代价不对称的地方,别让模型碰

生成对一次省十秒,生成错一次可能要查半小时,还可能把错误的结果当成结论用掉。这种不对称决定了:有些界面哪怕生成成功率是 95%,也不该生成。

我列了三类。

第一类是不可逆操作:删除、转账、发布、权限变更。这些永远静态、永远二次确认、动词永远明确。生成式 UI 可以负责把用户导航到这个静态界面,但不负责画这个界面本身。

第二类是用户察觉不到错的地方。比如模型悄悄改了筛选条件、默认值、时间范围。用户看到的是「数据怎么变了」,不会去怀疑界面。这类错误最难排查,因为报障描述和真实原因隔了一层。

第三类是需要被「指着说」的界面。多人协作时,「你点右上角那个导出」——如果每个人的右上角不一样,这句语言就失效了。这一条很少有人提,但在团队工具里是硬伤。协作的前提是大家看到的是同一个东西,而动态生成恰恰在破坏这个前提。

还有可访问性。tab 顺序、焦点管理、屏幕阅读器标签,生成出来的结构里这些通常靠运气。这不是「以后补一下」的问题,是结构性的——你没法给一个每次都不一样的 DOM 写 aria 规范。

复现不了,就等于不可维护

工单里写「那个灰色的按钮」。你复现不出来。

我对生成式界面的最低要求是三条:同一输入能拿到结构等价的输出(允许文案差异,不允许结构差异);每次生成留档,prompt、schema、渲染器版本、组件映射表都要存;出问题能一键退回上一个版本。

技术上具体是这样的。先生成结果过 schema,再过组件白名单,最后才进渲染器:

TS15 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const UiSchema = z.object({
type: z.enum(["form", "table", "chart"]),
fields: z
.array(
z.object({
name: z.string().regex(/^[a-z_]{2,24}$/),
kind: z.enum(["text", "number", "date", "select"]),
})
)
.min(1)
.max(8),
});

// 白名单之外的意图不硬渲染,降级到最近的静态模板
const FALLBACK = { form: FormTemplate, table: TableTemplate, chart: ChartTemplate };

白名单我控制在十几个组件以内。一开始想做得更宽,后来发现每多一个组件,样式、键盘操作、边界情况都要多验一遍,维护成本是乘出来的。

然后是一致性测试。同一个 prompt 采样 20 次,比较结构指纹:

TS7 行
1
2
3
4
5
6
7
// 指纹只取字段名和类型,忽略文案
const fp = (s) =>
s.fields.map((f) => `${f.name}:${f.kind}`).sort().join("|");

const samples = await Promise.all(Array.from({ length: 20 }, () => generate(prompt)));
const kinds = new Set(samples.map(fp)).size;
// 20 次里出现超过 2 种结构,我当它还不稳定

把 temperature 调低是不够的,它只是降低方差,不是消除。20 次采样、超过 2 种结构就判定不稳定,这个阈值是我自己拍出来的,不是行业标准,但它至少让讨论有个落点,而不是停留在「感觉还行」。

另外一条容易被忽略:失败时的降级必须是静态的。模型超时的时候不要显示「再试一次」,那是最差的中间状态——用户既没拿到界面,也不知道下一步该干什么。降级到一个默认的静态筛选器,哪怕不是最合适的,也好过一个转圈的空白。

离线生成、在线固定

说点正面的。我目前最认可的用法,是让模型在构建时参与,在运行时退出。

具体流程大致是:先从日志里采样用户真实 query;离线跑一批,生成候选界面结构;人工审,挑出值得固化的写成组件;运行时按意图路由到静态组件,路由不中的走通用查询界面兜底。

这样做的结果是每个落点都是人看过的。可评审、可测试、可回滚、可访问性可控。代价是新意图要等一个发布周期,所以兜底路径必须做扎实——大概八成的长尾 query 会落到那个通用界面上,这完全可以接受,因为剩下两成高频的已经固化了。

这个模式牺牲的是「即时适应」,换来的是「可预期」。在绝大多数产品里,可预期比即时适应值钱。真正需要即时适应的场景,我目前只想到三类:开发者自己的调试面板、一次性的数据探索、以及空状态和引导文案。这三类的共同点是错了没关系,重启一下就好,或者根本没人看第二遍。

所以我的判断标准简化成一句话:如果同一个用户在一周内第三次看到同一个意图的界面,它就应该被固定下来。阈值 3 是我拍的,可能是 5,也可能是 7,但先有个数,比「看情况」好推进。

下周我打算去翻那个内部工具的埋点:同一个用户、同一个意图模板的重复渲染率。这个数字如果超过三成,就说明我在为少数场景付全员的成本,那这条线还得往回收。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#交互设计 #工程判断 #生成式UI

评论