模型回你一墙文字,还是回你一块能点的界面

2026-11-15T07:11:15+08:00 | 8分钟阅读 | 更新于 2026-11-15T07:11:15+08:00

@
模型回你一墙文字,还是回你一块能点的界面

上周让 agent 帮我挑住宿,三个套餐一起给它,它回了我七百多字。第二档含不含早餐,我读了两遍才找着;想改第三档的日期,又得重新描述一遍需求,它重新生成一遍文字。

这类任务里,界面被压成了 markdown 加粗。模型明明知道那是三个可选项,也只能用文字把它们排成行。

Google 在 11 月 11 日把 A2UI 发到 1.0,要解决的就是这件事:让模型不只说“这里有三个选项”,而是直接给出一块能点的界面。同一时间,MCP 那边走的是另一条路。两条路都通,代价不一样。

两条路,差别在谁定布局

先说 MCP Apps。今年 1 月 26 日它成为 MCP 的第一个官方扩展。做法很朴素:服务端注册工具的时候,顺手把界面也声明好。工具描述里挂一个 _meta.ui.resourceUri,指向一个 ui:// 资源,资源里是一段打包好的 HTML 加 JS。宿主把这个资源放进 sandbox iframe 渲染,界面和宿主之间用 postMessage 上的 JSON-RPC 来回传话:界面可以调服务端的工具、可以往对话里发消息、可以悄悄更新模型看到的上文。

也就是说,页面是开发者写的成品,模型只负责把数据填进去。布局固定,模板可以提前缓存、可以人工审,界面上有多少个按钮是确定的。

A2UI 换了主体。模型输出一棵组件树,形式是声明式数据,客户端拿到后用自己的组件库画出来。模型可以临场决定放一张卡片还是两栏布局,但只能从你注册过的那份 catalog 里挑组件。你注册了 Card、Text、Button、Table,它就只能用这些。

一个交付成品页面,一个交付排版指令。前者省 token、好审查、跑得快;后者能按每个用户每次的请求重新组织版面。你让 agent 把日历、餐厅空位、打车价格拼在同一个视图里,只有后一条路做得到。

协议里那些别扭的设计,都是被模型逼出来的

翻 A2UI 的协议文档,会看到几个乍看很奇怪的决定,每一个背后都是模型的实际毛病。

组件是平铺的,父子靠字符串 ID 引用。 为什么不干脆用嵌套 JSON 树?因为让模型一次生成深层嵌套的 JSON,它很容易漏括号、错层级。改成平铺列表,模型想到一个组件就写一条,给个 ID,父组件里引用这个 ID 就行,树有多深它不用操心。客户端自己拿个 map 把 ID 还原成树。

一条消息一行 JSON,走 SSE。 每行都是完整的一小段,模型吐出第一行,客户端就能开始处理,不用等一大坨 JSON 拼完。

有一个专门的 beginRendering 信号。 客户端收到组件和数据先存着,等这条消息到了才画第一帧。不这么干,用户会先看见半个界面,然后它自己跳一下,补齐剩下的部分,看着像坏了。

结构和数据分开传。 surfaceUpdate 发组件结构,dataModelUpdate 只发改动的那点数据。界面上一个数字变了,不用把整棵树重发一遍。

没有 transformer。 数据绑定只能一对一绑路径,要格式化、要条件判断,服务端算好了再发。看着不灵活,但客户端也就没有执行模型给的逻辑的地方。

一段真实的 JSONL 长这样,画一张用户卡片:

{"surfaceUpdate": {"components": [{"id": "root", "component": {"Column": {"children": {"explicitList": ["card"]}}}}]}}
{"surfaceUpdate": {"components": [{"id": "card", "component": {"Card": {"child": "content"}}}]}}
{"surfaceUpdate": {"components": [{"id": "content", "component": {"Column": {"children": {"explicitList": ["name", "bio"]}}}}]}}
{"surfaceUpdate": {"components": [{"id": "name", "component": {"Text": {"usageHint": "h3", "text": {"literalString": "A2A Fan"}}}}]}}
{"dataModelUpdate": {"contents": []}}
{"beginRendering": {"root": "root"}}

啰嗦,但每一行都短,都能单独校验,都能增量推。

1.0 补的东西

四件事。双向函数调用,按钮可以直接问后端“这个舱位还有没有”,不用像 0.9 那样先发个事件、等模型再回一整轮。混合 catalog,同一块界面里能拼你自己的设计系统和其他人给的组件库。无障碍支持,屏幕阅读器能念出每个变更。还有一个扩展字段,给宿主自己加元数据用。

渲染器覆盖得比较全了:web 上有 React、Angular、Lit,移动端有 Flutter、SwiftUI,Jetpack Compose 还停在 0.9.1,1.0 在路上。web 和 Flutter 的渲染器能直接跑在 MCP Apps 的 iframe 里,所以 A2UI 的界面可以出现在 Claude 和 Gemini 这种支持 MCP Apps 的助手里面。

在用的名单不算短:Atlassian 把它放进 Jira,agent 需要人补信息或拍板时,就在工作项里弹一张卡片让人当场填;Alation 拿它做指标卡和图表,同一份 feed 在 Claude 里和在自家应用里排版几乎一样,图还能点开看背后的 SQL;字节约六月给 Lynx 写了渲染器;Bloomberg 也在名单里。Gemini 自己目前支持到 0.9.1。

真正值得看的一层:传输格式和生成方言是分开的

A2UI 里我觉得最有意思的设计,是它把两件事拆开了。渲染器收的东西叫 wire JSON,这部分是稳定的协议;模型写什么格式,是可以换的。

这个分法跟编译器的前后端一个道理。中间表示定死,前端随便换。

为什么不能直接让模型写 wire JSON?A2UI 自己的文档列了三条:重复的 key(id、component、@path)加上手写的组件 ID,让裸 JSON 的长度差不多是位置参数写法的三倍;把完整的 JSON Schema 塞进 system prompt,比塞紧凑签名多吃一倍 input token;括号引号嵌套一深,小模型就开始写错。

于是有了 Express 这种方言,模型写的是:

<a2ui>
$/flight/number = "NH 105"
$/flight/gate = "B12"
root = Card(Column([
  Text($/flight/number),
  Text($/flight/gate, "caption"),
  Button(Text("Board now"), action=Event("check_in", {flight: $/flight/number}))
]))
</a2ui>

服务端把它编译成 wire JSON,ID 自动生成(root_child_children_0 这种),嵌套结构自动拍平。

省得确实明显,但先别按最大值估。A2UI 自己的评测,54 个样本,gemini-3.8-flash,温度 0:

格式输入 token 中位数生成代码 token 中位数pass@1
裸 JSON10,98080798.1%
Express6,000(降 45.4%)297(降 63.2%)98.1%

准确率没掉,53/54 两边一样。

但同一份表里还有一行容易被忽略的。把 thinking 打开,裸 JSON 总输出是 2,320 token,Express 是 1,789,只降了 22.9%。原因很直白:思考 token 从 1,413 涨到 1,447,两边都在原地,大头在这里。

所以这笔账要这么算:省下的是“写界面的 token”,省不了“想”的 token。拿着 63% 去估成本,月底账单会打你的脸。这条我在之前算 agent token 账时也踩过,压缩的永远是输出体积,不一定是钱。

顺手说两个还在实验里的格式。Elemental 让模型直接写 HTML 风格的标记,好处是模型在预训练里见得多、tokenizer 也友好,还不许带自定义 JS 和 CSS。Atom 用 S 表达式,比 Express 和 Elemental 再省 25% 到 50%,而且靠括号定边界,流被中途掐断时能自动补右括号,硬给你拼出一块不完整的界面。这个设计思路是对的:模型输出被截断是常态,能降级渲染总比整个报错强。

什么时候值,什么时候纯属浪费

值的场景有共同点:用户在做一个结构化决策。比较三个方案、确认一份行程、填一张带依赖字段的表单、审批一件事、看一块会变的看板。一次点击能替掉三轮对话,界面就挣回了它的成本。

不值的场景也很清楚:解释一段原理、给一段代码、写一篇长文。这些内容用 markdown 就够了,生成一块界面只是把 token 花在排版上。

成本那边有几笔要提前算:

  • 每个 surface 都是一次完整生成,几百到上千 token,还要等首帧。聊天里嵌界面的产品,延迟多半花在这。
  • catalog 越大,模型选错组件的概率越高。CopilotKit 那个标准 catalog 有 70 个组件(18 个 A2UI 基础件加 52 个自研),已经不算小了。
  • sendDataModel 打开时,用户每做一次动作,客户端把整份 data model 塞回 metadata 发过去。界面越大、字段越多,这笔回传越贵。
  • 版本漂移是真实的运维问题。catalog 改了,模型手里的组件签名是旧 prompt 拼出来的,得把 catalog 当 API 一样做版本管理。

安全边界挪了个位置

A2UI 的宣传语是“像数据一样安全,像代码一样能表达”。说得不算夸张:模型只能从 catalog 里挑,挑不出来的组件会被拦下来,错误原样回给模型让它重写,重试耗尽就降级成一句话,不至于把半块坏界面扔给用户。CopilotKit 的 inspector 能看到每条被拒的输出、被拒的理由、当时的 prompt。

但风险没有消失,只是挪了地方。挪到了 catalog 里那些组件的实现,挪到了属性校验,也挪到了你给组件绑的那些动作上。一个 Text 组件的 props 没做约束,模型塞一段带链接的文本进去,照样能骗到点击。

MCP Apps 那边是另一套账:iframe 沙箱、预声明模板可以人工审、界面到宿主的每条消息走 JSON-RPC 可审计、UI 发起的工具调用可以要求用户确认。代价是那个 iframe 在你应用里长什么样、跑多快,由不得你。

两边共同的现实是:模板和 catalog 成了新的信任边界。跟依赖包一个道理,要锁版本、要看来源。

我会怎么选

入口在 Claude、ChatGPT、Gemini,界面形态也就那几种(图表、表单、看板):先上 MCP Apps。模板写一次,模型不生成界面,成本最低,审查路径也清楚。

界面需要按请求重排,比如一次把六个 app 的日程拼成一个视图,或者把三个方案摆在一起对比:A2UI 才划得来。配自己的 catalog,别一上来就用大而全的标准集,组件越多模型越容易选错。

两套可以叠着用。A2UI 的渲染器能跑在 MCP Apps 的 iframe 里,Alation 就这么干,同一份界面既能在自家产品里出现,也能落在 Claude 的对话里。

真要做,先做一个 surface,把闭环跑通,把被拒的输出翻日志看十遍,再谈铺开。生成式界面最容易翻车的地方从来不在第一版 demo,在你给它加了四十个组件之后。


参考来源:

Me

Cut out summary from your post content here.

The remaining content of your post.