WIP: Generative UI: 开篇
什么是 Generative UI(以下简称 GenUI)?顾名思义,生成的内容不再只是文本,而是 UI,也就是用户界面。界面本身可以包含文本信息,因此它是文本的超集,能够表达一切文本可以表达的信息。除此之外,它还可以包含图表、业务卡片、交互式内容等可视化元素,用来增强信息表达。
GenUI 的价值在哪里?核心动机是,人类天生对视觉信息更敏感。用视觉方式呈现信息,人对它的印象往往会更深。也有研究表明,视觉刺激比文本刺激更强,这也是为什么短视频 App 往往比长文社区产品更容易提高用户粘性的原因之一:视觉信息的呈现更直接、更自然。如果视觉信息还能交互,用户也会觉得自己的参与感更强,和内容之间也会建立更深的连接。
一个理所当然的想法是:能不能让大模型直接生成图片?但图片生成也有明显问题。首先,图片是静态的,无法交互。其次,图片不方便二次修改。还有一个容易被忽视的问题是,图片通常不是流式生成的,而是在最后一瞬间整体出现。相比之下,GenUI 的某些技术实现可以做到流式渲染 UI。流式渲染一方面能降低延迟,让用户更快看到部分元素并进行理解或使用;另一方面也会带来一种期待感。每一个新出现的 UI 元素,都像下滑时出现的一个短视频。如果这个元素超出了用户预期,就会不断刺激用户的多巴胺分泌。所以,相比一次性看完整个 UI,无数个元素接连出现所累积的总体验可能会更强。
GenUI 现在仍处在一个相对混沌的阶段。面对不同业务场景,它有完全不同的实现方式。如果按触发方式划分,可以分为工具触发和内容触发;如果按格式划分,主要可以分为 JSON 和 HTML。
工具返回 JSON 并渲染成 UI,是比较早期的 GenUI 实现方式,可以在 Vercel AI SDK 中看到类似思路。它出现的时间,可能还早于 Google 那篇 GenUI 论文。原理很简单:工具返回一个结果,通常是 JSON string,前端拿到结果后,将这个 JSON string 渲染成 UI。具体怎么渲染由前端决定,后端不做干预。
比如后端调用工具 get_weather(type=”weather”, location=”shanghai”)。如果不是 GenUI,可能就只是用纯文本返回上海天气。如果是 GenUI 工具,实现方式就有很多种。它可以直接按输入参数原路返回,前端根据 type 访问指定 API,并传入 location,从而拿到 JSON 结果并渲染。也可以由后端在工具调用返回结果前,先去调用 API,让工具直接返回 API 的 JSON 结果,但需要有一个标识,让前端知道这是 GenUI,需要进行渲染。比如:
{
“type”: “weather”,
“data”: “…”
}
这样前端只需要负责渲染,不需要再和另一个 API 做二次交互。
但事情到这里还没有结束。完成工具调用、前端渲染之后,后端还需要决定是否继续下一步。一种方式是,如果发现这是 GenUI 相关工具,后端可以直接停止,这就是 Gemini 的模式。比如在 Gemini 中输入“上海天气”,它只会显示一个天气卡片,不会再附加多余的文字说明。
ChatGPT 则不同。用户咨询天气时,它不仅会展示天气卡片,还会用额外的文字进行解释。这个实现其实也很简单:相关工具调用会返回完整数据,这些数据既可以用于前端渲染 UI,也可以作为额外信息输入给 Agent。Agent 会继续执行工具调用循环,不会因为这是 GenUI 工具就停止。这样做的好处是,卡片之外还能有额外的信息补充;劣势是会产生额外的 token 成本。
到这里还可以继续优化。上面提到的 GenUI 实现方式,都是在工具调用完成后,前端立刻渲染,也就是工具触发渲染。这会带来一个问题:它通常只能显示在回答最上方,而在某些场景下,这并不是最佳体验。
比如用户咨询 A、B 两只股票的对比,理想输出应该是:股票 A 的 K 线图卡片加文字解析,然后是股票 B 的 K 线图卡片加文字解析,而不是多个 K 线图全部堆在最上面,下面再接一大段文字。前者的特性也可以称为图文混排。要实现这个 feature,需要同时优化工具和最终输出。这个思路和 MCP-UI 有些类似:工具的返回结果需要区分数据和 UI。
下面是一种可能的实现方案:
上海和北京天气对比
-> get_weather(shanghai)
-> {“data”: …, “card”: ““}
同时,还需要通过指令让 LLM 理解这个输出。一种方法是使用 system prompt 加 tool loop,只需要在 system prompt 中说明:如果有 card,请把它插入到合适的位置。
另一种方式是单独设置一个 report node。tool_call node 通过增加一个 finish tool 来标记工具调用结束,然后在 report node 中写入类似这样的 prompt:
<卡片使用> |
对于后者,卡片的使用方式和卡片数据被放在一起,指令遵循会更强。但单独使用 report node 无法命中 prefix cache,成本较高。对于前者,劣势是指令被放在 system prompt 中,如果调用卡片的过程发生在比较靠后的位置,模型可能会忽略这条指令,导致不知道如何使用 card 字段的信息。所以前者更考验模型的指令遵循能力;如果模型的指令遵循能力足够强,选择前者会更简洁,成本也更低。