Intelligent UI 实现详解
让 AI 直接回答出一个界面#
问 AI「帮我算一下房贷」,过去它会回一段文字:「贷款 100 万、30 年、利率 3.5%,月供约 4,490 元。」想换成 300 万?再问一遍。
现在 ChatGPT 可以直接回给你一个能操作的小工具:几个滑块调金额、年限、利率,月供跟着实时变化,下面还有一张图对比两种还款方式。你拖滑块时它不需要再问 AI,计算就在你的浏览器里完成。
这类能力叫生成式界面(Generative UI) 。OpenAI 在 ChatGPT 里的实现叫 Intelligent UI(内部代号 DIL);Google 牵头做了一个开放格式 A2UI;还有一个开源协议 AG-UI。三个名字经常被放在一起讨论,但它们解决的问题并不一样。
这篇文章想讲清楚两件事:Intelligent UI 到底是怎么做的,以及它和 A2UI、AG-UI 有什么异同。不需要你用过 A2UI 或 AG-UI。 文章由浅入深分四部分,读到第二部分结尾,就已经能讲清三者的区别。
关于来源:Intelligent UI 不对外开放。文中关于它的结论来自对一次 ChatGPT 生成过程的抓包分析,以及我写的一个能在本地跑起来的复刻;抓包已脱敏,例子里的产品名是化名。A2UI 与 AG-UI 的内容以撰写时(2026 年 10 月)的官方文档和规范为准,文中的 A2UI 消息都用 v0.9 官方 JSON Schema 校验过。凡是推断,文中会明说。
第一部分:思路#
要做到这件事,必须回答三个问题#
不管用哪种方案,让 AI 回答出一个界面,都绕不开三个问题:
- AI 用什么「语言」描述界面? AI 只会输出文字,得有一种约定好的格式,告诉浏览器「这里放一个滑块、那里显示一个数字」。
- 界面怎么从 AI 那里传到屏幕上? AI 是一个字一个字往外吐的,复杂界面可能要写几十秒。总不能让用户干等,所以要边写边传、边传边显示。
- AI 写的东西,怎么安全地显示出来? AI 写的内容不能完全信任:它可能写错,也可能被恶意提示词诱导,写出想偷数据的东西。显示之前必须有一道防线。
后面所有方案的区别,都可以归结为它们对这三个问题给出了不同答案。
两条路:让 AI 写数据,还是让 AI 写程序#
对第一个问题,业界有两种根本不同的答案。
第一条路,像点菜。 AI 给你一张清单:「一个滑块,叫『贷款金额』,范围 10 到 500;一行文字,显示『月供』。」浏览器(或手机 App)照着清单,用自己已有的组件把界面拼出来。清单里只有「要什么」,没有「怎么算」。
第二条路,像写一个小程序。 AI 直接写一段程序:「有一个变量叫金额,一个叫年限;月供等于这个公式;滑块和变量绑在一起。」浏览器运行这段程序,界面和计算逻辑都由它产生。
两条路各有得失:
| 写数据(清单) | 写程序 | |
|---|---|---|
| 能做什么 | 只能用已有组件,需要计算时要回去问 AI | 几乎什么都能算,交互在本地即时完成 |
| 安不安全 | 天然安全:清单本身不会「做」任何事 | 必须把程序关进严格的隔离环境里运行 |
| 写错了会怎样 | 通常只是某个组件显示不出来 | 程序可能整体跑不起来 |
| 能不能用在手机 App | 可以,用原生组件照单拼就行 | 很难,依赖浏览器的隔离能力 |
A2UI 走第一条路,ChatGPT 的 Intelligent UI 走第二条路。
三个名字,各管什么#
还剩一个 AG-UI。它和前两者不在同一层,用寄快递来比喻最清楚:
- A2UI 规定的是包裹里东西的格式,也就是「界面清单」长什么样。
- AG-UI 是快递公司和运单规则:规定 AI 后端和前端之间怎么传消息,包括文字、工具调用、状态变化,也包括界面。
- Intelligent UI 是一家自产自销的公司:格式、运输、拆包显示全包了,而且装的是程序而不是清单。
所以 AG-UI 和 A2UI 不是竞争关系,可以一起用:AG-UI 负责传输,传的内容是 A2UI 格式的界面。AG-UI 的文档自己也说得很明白:它不是生成式界面规范,而是 AI 与应用之间的「交互协议」,可以承载 A2UI 等多种界面格式。
第二部分:用同一个例子看三种做法#
为了方便比较,下面都用开头那个计算器的简化版:两个滑块(金额、年限,利率固定 3.5%),一行显示月供。
A2UI:AI 写一份「界面清单」#
A2UI 由 Google 发起,CopilotKit 等社区参与,Apache 2.0 开源,撰写本文时的生产版本是 v0.9.1。
AI 输出的是一行行 JSON 消息。第一条,开一块「画布」,并指明用哪一套组件:
{"version":"v0.9","createSurface":{"surfaceId":"loan",
"catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}
第二条,列出组件:
{"version":"v0.9","updateComponents":{"surfaceId":"loan","components":[
{"id":"root", "component":"Column", "children":["amount","years","result"]},
{"id":"amount", "component":"Slider", "label":"贷款金额(万元)", "min":10, "max":500, "value":{"path":"/amount"}},
{"id":"years", "component":"Slider", "label":"贷款年限", "min":5, "max":30, "value":{"path":"/years"}},
{"id":"result", "component":"Text", "text":{"path":"/monthly"}}
]}}
第三条,填数据:
{"version":"v0.9","updateDataModel":{"surfaceId":"loan","path":"/",
"value":{"amount":100,"years":30,"monthly":"¥4,490"}}}
有两个设计值得注意。
组件是平铺的,不是嵌套的。 每个组件有一个 id,父组件用 children 列出子组件的 id(必须有一个叫 root 的根)。这样 AI 每写完一个组件就能先发出去,不必等整棵树写完,非常适合流式生成。
组件不写死数值,而是指向一个「数据位置」。 {"path":"/amount"} 的意思是「去数据里的 /amount 找值」。界面结构和数据分开,改数据时不用重发组件。
一共只有四种消息:createSurface(开画布)、updateComponents(放组件)、updateDataModel(改数据)、deleteSurface(关画布)。
用户拖动滑块之后呢? 滑块和数据是双向绑定的,拖动后本地的 /amount 立刻变了。但 monthly 是 AI 算好填进来的,客户端不知道怎么重新计算它。A2UI 基础组件目录里的客户端函数只有格式化和校验两类(formatCurrency、formatNumber、required、regex……),没有「按公式计算」。
要更新月供,得把操作发回给 AI。基础目录里能挂「动作」的组件只有按钮,所以一般加一个「重新计算」按钮:
{"id":"recalc", "component":"Button", "child":"recalc_label",
"action":{"event":{"name":"recalculate",
"context":{"amount":{"path":"/amount"}, "years":{"path":"/years"}}}}},
{"id":"recalc_label", "component":"Text", "text":"重新计算"}
用户点按钮后,客户端把动作连同当前的金额、年限发回 AI,AI 算出新月供,再发一条 updateDataModel 回来。每次重新计算,都要和 AI 往返一次。 (宿主当然也可以扩展组件目录,自己实现一个「房贷计算器」组件来本地计算,但那样公式是宿主预先写好的,不是 AI 写的。)
安全靠什么? 每个客户端声明一份组件目录(catalog) ,AI 只能从里面选。清单里没有任何可执行代码,最坏情况也只是显示了一个奇怪的界面。官方的说法是 Secure by Design:「是声明式数据格式,而不是可执行代码」。同一份清单可以在网页上用 Web 组件渲染,也能在 Flutter App 里用原生组件渲染,这是写数据这条路天然的优势。
Intelligent UI:AI 写一段「小程序」#
同样的计算器,在 Intelligent UI 里 AI 写出来的是这样一段东西(这段在我的复刻里可以直接运行):
这是一个房贷计算器,拖动滑块看看月供怎么变。
{@body const [amount,setAmount] = DIL.useState(100)}
{@body const [years,setYears] = DIL.useState(30)}
{@body const r = 0.035 / 12}
{@body const n = years * 12}
{@body const monthly = amount * 10000 * r * (1+r)**n / ((1+r)**n - 1)}
<box gap={3}>
<slider label="贷款金额(万元)" value={amount} onChange={setAmount} min={10} max={500}/>
<slider label="贷款年限" value={years} onChange={setYears} min={5} max={30}/>
<title size="xl">每月还款 ¥{Math.round(monthly).toLocaleString()}</title>
</box>
逐段看:
| 部分 | 意思 |
|---|---|
| 第一行文字 | 普通说明文字,就是 Markdown |
{@body const [amount,setAmount] = DIL.useState(100)} | 声明一个变量 amount,初始值 100,setAmount 用来修改它。熟悉 React 的人会认出这是 useState |
{@body const monthly = …} | 月供公式,写在 AI 的回答里 |
<slider value={amount} onChange={setAmount}/> | 滑块显示 amount,拖动时调用 setAmount |
{Math.round(monthly)…} | 把计算结果插进文字 |
关键区别:公式是 AI 写的,而且在你的浏览器里运行。 拖滑块时浏览器自己重算、立刻显示,完全不用回去问 AI。
这种格式内部叫 DIL,看上去像 Markdown 和 React 的 JSX 混在一起,这是有意为之:AI 最擅长写 Markdown,正文直接写;界面部分像 JSX,但只能用宿主提供的几十个标签,不能写任意 HTML 和 CSS,所以生成的界面风格始终和 ChatGPT 一致。
代价也很明显:浏览器要运行 AI 写的程序,就得先解决「这段程序不可信」的问题,ChatGPT 为此搭了一套相当重的隔离系统,第三部分会详细讲。
AG-UI:负责「送快递」的那一层#
AG-UI 是一个开源协议,定义 AI 应用的后端和前端之间怎么传消息。它不关心界面长什么样,只规定一组标准「事件」。一次对话可以想成后端不断往前端发一条条带类型的消息:
事件(type 字段的取值) | 意思 |
|---|---|
RUN_STARTED / RUN_FINISHED | 一次回答开始 / 结束 |
TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END | 一段文字开始、陆续到达、结束 |
TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END | AI 要调用一个工具,参数陆续到达 |
STATE_SNAPSHOT | 当前完整的状态,直接替换掉手上的 |
STATE_DELTA | 状态只改了这几处 |
最后两个值得多说一句,后面还会遇到。假设状态是 {"amount":100,"years":30},要把金额改成 300,有两种发法:
{"type":"STATE_SNAPSHOT", "snapshot": {"amount":300, "years":30}}
{"type":"STATE_DELTA", "delta": [{"op":"replace", "path":"/amount", "value":300}]}
第一种整份重发,简单但浪费;第二种只发变化,这种「改哪儿说哪儿」的格式就是 JSON Patch(互联网标准 RFC 6902)。「先给一份完整快照,之后只发补丁」是流式传输里很常见的做法,ChatGPT 内部用的也是同样的思路。
回到快递的比喻:前端要显示 A2UI 界面时,AG-UI 负责把那几条 A2UI 的 JSON 送过去;它也能送别的格式。AG-UI 文档里列出的就有 A2UI、OpenAI 的 Open-JSON-UI,以及基于 iframe 的 MCP-UI。
放在一起比一比#
| A2UI | Intelligent UI | AG-UI | |
|---|---|---|---|
| 是什么 | 界面描述格式 | 一整套闭环系统 | 前后端通信协议 |
| AI 输出的是 | 组件清单(JSON) | 小程序(DSL,编译成 JS) | 不规定 |
| 拖滑块后重算月供 | 点按钮发回 AI,AI 算好再发回来 | 浏览器本地即时完成 | 不涉及 |
| 安全靠 | 格式里没有可执行代码,组件来自白名单 | 一套严格的隔离沙箱 | 交给具体实现 |
| AI 写错的影响 | 通常只影响一个组件 | 可能整个程序跑不起来,需要大量容错 | 不涉及 |
| 手机原生 App | 支持 | 只有网页 | 不涉及 |
| 谁能用 | 开源 | 只在 ChatGPT 内部 | 开源 |
再对照开头的三个问题:
| ① 用什么描述 | ② 怎么传 | ③ 怎么保证安全 | |
|---|---|---|---|
| A2UI | 平铺的组件 JSON | 规范本身很薄,常借助 AG-UI 等 | 不执行代码 + 组件白名单 |
| AG-UI | 不管 | 标准事件流 + 快照/补丁 | 不管 |
| Intelligent UI | Markdown + 受控 JSX 的小语言 | 自有的补丁流 | 双层沙箱 |
三者也有共同点:
- 都要边写边显示。 没人愿意等 30 秒才看到界面。
- 组件都由宿主提供,不由 AI 定义。 AI 只能描述用哪些组件,不能凭空造一个出来。
- 都要把用户的操作送回 AI。 用户在界面上做了什么,AI 下一轮需要知道。
如果只想了解大概,读到这里就够了。下面进入 Intelligent UI 的实现细节。
第三部分:深入 Intelligent UI 的实现#
这一部分会用到一些前端概念(iframe、Web Worker、CSP),第一次出现时会简单解释。
全景:一次回答经过的五个环节#
我们抓到的那次真实生成,是一个有三个页签的计费控制台:
| 数据 | 数值 |
|---|---|
| AI 写的源码 | 23,422 字符 |
| 编译后的程序 | 30,932 字符 |
| 生成过程中服务器重新编译的次数 | 134 次 |
| 界面里的状态(选中的页签、各滑块的值……) | 15 个 |
还有一个细节:抓包里一条纯文字的回答,也带着一份编译后的程序,只有 232 字符,里面就渲染了一个文本元素。所以这不是一个可以开关的「模式」,而是 ChatGPT 所有回答默认走的渲染路径,普通回答只是恰好没用到交互组件。
编译器:AI 写错是常态#
为什么要编译。 浏览器不能直接运行 DIL 源码,服务器上的编译器把它翻译成标准 JavaScript。前面那个计算器,编译结果的核心部分大致是:
function __dilSafe(evaluate, fallback) { try { return evaluate() } catch { return fallback } }
DIL.render(__dil.jsx(() => {
const [amount, setAmount] = DIL.useState(100, {key: "amount"});
const [years, setYears] = DIL.useState(30, {key: "years"});
const monthly = __dilSafe(() => (amount * 10000 * r * …), undefined);
return __dil.jsx("box", {gap: 3},
__dilSafe(() => __dil.jsx("slider", {value: amount, onChange: setAmount, …}), null),
…);
}));
和源码相比,编译器偷偷加了三样东西,每一样都有讲究。
第一样:给每个状态起名字。 源码是 DIL.useState(100),编译后变成 DIL.useState(100, {key: "amount"}),key 就是变量名。它有两个用处:AI 还在写的时候界面会被反复刷新,有了名字才能找回每个状态之前的值,你已经拖到 300 的滑块不会跳回 100;另外,用户的选择会按这个名字上报,下一轮 AI 看到 amount: 300 就知道指的是哪个滑块。抓包里那 15 个状态,编译后全部带上了以变量名命名的 key。
第二样:给可能出错的地方套上保护罩。 __dilSafe(() => 表达式, 备用值) 的意思是:试着算,出错就用备用值,别让错误扩散。AI 写的公式可能引用了不存在的变量,可能对 undefined 取属性。没有保护罩时,一个小错误就让整个界面白屏;有了它,只是这一个值显示不出来。
我逐个对比了抓包里的编译结果,发现规则很精确:属性或内容里有表达式的元素单独套一层保护罩,全是固定值的元素不套。 一个下拉框的选项写错了,消失的只是这个下拉框,旁边的内容照常显示。
第三样:把文字抽到一张表里。 源码里的说明文字被统一抽进一个常量表,程序里只留编号,比如 __dilConstants["3"]。抓包里这张表有 108 条。
从错误中恢复。 AI 是一个字一个字写的,编译器每隔一小段时间就把「目前为止的全部内容」重新编译一次。这意味着它面对的输入几乎总是不完整的:标签写了一半,括号没闭合,{#if} 还没写到 {/if}。
编译器的策略是从不报错退出:遇到不完整的结构,记一条「诊断」,用已经能确定的部分继续生成程序。
{"code":"unclosed_block","action":"recovered_parse","line":66,"column":9,"directive":"if"}
意思是第 66 行第 9 列有个 {#if} 还没闭合,已经恢复处理。抓包里这样的诊断有 338 条,比程序本身的更新次数(134 次)还多。这个数字说明:对边写边显示的系统来说,处理「写到一半」不是边缘情况,而是主要工作。
至于为什么每次都从头编译而不是只编译新增部分:源码的中间状态几乎都不合法,增量编译要维护复杂的中间结构,而这份源码只有几十 KB,从头编译一次只要几毫秒,用一点算力换来大幅简化,很划算。
边写边显示:流式补丁#
服务器和浏览器之间用的是 SSE(Server-Sent Events,一种让服务器持续向浏览器推消息的标准技术)。抓包里整条 2.5 MB 的连接上只有一种消息,叫 delta,内容是一个补丁:
{"p":"/message/content/parts/0", "o":"append", "v":"…新写出来的几个字…"}
{"p":"/message/metadata/model_dil_v2/code", "o":"replace", "v":"function __dilSafe(…){…}"}
p 是要改的位置,o 是怎么改(append 追加、replace 替换、add、remove),v 是新值。这和前面 AG-UI 的 STATE_DELTA 是同一个思路,只是 AG-UI 把不同用途拆成了不同类型的事件,而 ChatGPT 只服务自己的前端,一种消息加路径约定就够了。
同一条连接里其实跑着两条线:
浏览器每收到一份新程序,就交给隔离环境重新运行;因为状态有名字,重新运行不会丢掉用户已经做过的操作。另外还有一个 resume 接口,带上「我收到第几条了」就能从中间接着收,对一个持续几十秒、几 MB 的连接来说很重要。
沙箱:怎么安全地运行 AI 写的代码#
这是「写程序」这条路必须付出的代价,也是 Intelligent UI 工程量最大的部分。先认识两个工具:
- iframe:嵌在网页里的另一个独立网页。如果它来自另一个网址(「跨域」),浏览器会禁止它读写外面那个页面的内容,比如你的登录信息(cookie)和页面元素。
- Web Worker:在后台单独运行 JavaScript 的线程。它没有页面,不能画任何东西,只能计算并通过消息传递结果。
ChatGPT 把 AI 写的程序关进了两层「笼子」:
为什么要两层?因为每层各挡一部分风险:
| 风险 | 外层 iframe 挡住 | 内层 Worker 挡住 |
|---|---|---|
| 把你的数据偷偷发到外面的服务器 | ✓ | |
| 读取 ChatGPT 页面上的登录信息 | ✓ | |
| 在框里画一个假的「请重新登录」窗口骗你输密码 | ✓(它根本没有页面) | |
| 写个死循环把页面卡死 | ✓(独立线程,可以直接终止) |
只有 iframe,程序可以在框里画假的登录框;只有 Worker,它和 ChatGPT 同源,网络和存储都是通的。两层叠起来,程序只剩下「计算并交出一棵树」这一件事能做。
断网靠 CSP。 CSP(Content Security Policy,内容安全策略)是网页可以声明的一组规则,告诉浏览器「这个页面只允许加载哪些东西、连接哪些地址」。抓包里那个 iframe 页面声明的是:
default-src 'none'; 默认什么都不允许
script-src 'sha256-…' 'unsafe-eval' …; 只允许一段指定的启动脚本,并允许运行字符串形式的代码
worker-src blob: data: 只允许创建 Worker
default-src 'none' 且没有放行任何网络连接,意味着页面里的 fetch、WebSocket、加载图片、加载字体全部会被浏览器拦下。这比在代码里删掉 fetch 可靠得多:代码层面的限制可能被绕过,浏览器执行的规则不会。'unsafe-eval' 看起来吓人,但这里是必须的:这个页面存在的意义,就是运行一段字符串形式的程序。
需要「复制」「打开链接」怎么办? 程序关在笼子里,但有些事确实需要做,比如把一段文字复制到剪贴板。做法是在笼子里放「替身」:ChatGPT 页面告诉笼子「你可以用这几个能力」,给的却不是真函数,只是编号;笼子里把编号包成替身函数;程序调用替身时,替身只是往外发一条消息「请执行 3 号能力,参数是这段文字」;ChatGPT 页面检查参数后,在自己这边真正执行。
能用的能力一共只有 5 个(复制、打开链接、发起新一轮提问、打开实体详情、调用插件),参数检查非常严格。比如调用插件时,参数必须恰好包含两个字段,还要检查对象的原型,防止一种叫「原型污染」的攻击。
为什么启动这么复杂。 ChatGPT 页面把这套沙箱的启动拆成了 72 个带名字的阶段(创建 iframe、加载脚本、创建 Worker、健康检查……),每一步单独记录成败。这么细,是因为「跨域 iframe + 后台 Worker」在不同浏览器、插件、公司网络策略下有非常多种失败方式,而用户看到的永远只有一种症状:「界面没出来」。不把每一步记下来,线上出问题几乎无从查起。
组件和状态:界面怎么和产品、和下一轮对话连起来#
从数据树到真实界面。 隔离环境里的程序跑完,交出来的不是界面本身,而是一棵描述界面的数据。前面那个计算器实际输出的树是这样的(略去开头的说明文字):
{"t":"box","p":{"gap":3},"c":[
{"t":"slider","p":{"label":"贷款金额(万元)","value":100,"onChange":{"__dilFn":"fn1"},"min":10,"max":500}},
{"t":"slider","p":{"label":"贷款年限","value":30,"onChange":{"__dilFn":"fn2"},"min":5,"max":30}},
{"t":"title","p":{"size":"xl"},"c":[{"t":"#text","v":"每月还款 ¥"},{"t":"#text","v":"4,490"}]}
]}
t 是组件类型,p 是属性,c 是子元素。注意 onChange 不是函数,只是一个编号 fn1:函数本身留在隔离环境里,不能也不需要传出来。
ChatGPT 页面用自己的组件把这棵树画出来。用户拖动滑块时,页面只回传一句话:「fn1 被触发了,新值是 300」。隔离环境里的程序执行 setAmount(300),重新计算,交出一棵新树,页面再更新。整个过程都在浏览器里,不经过服务器。
更新时页面会比对新旧两棵树,只改变化的部分。这对输入框尤其重要:AI 还在写时界面会反复刷新,如果每次都重建输入框,用户正在输入的内容和光标就会丢失。
宿主组件:把产品里现成的卡片拿来用。 除了滑块、文字、表格这些基础组件,AI 还能写一些大写开头的标签,比如 <MemoryCite />,它们是 ChatGPT 产品里已经做好的完整组件。抓包里的组件名单有两份:30 个常驻的(引用、代码块、商品卡片、航班卡片……),以及 283 个按需加载的,几乎涵盖了 ChatGPT 做过的所有卡片:股票、体育比分、天气、学习卡片,还有约 50 个医疗计算器。
AI 写 <MemoryCite /> 时并不知道这个组件怎么实现,中间有一层「查表」:服务器告诉页面「这个位置的 MemoryCite 已确认可用」,页面才把真实组件放上去;对不上的只显示占位框。AI 没法靠编造一个名字调出不该用的组件。这和 A2UI 的组件目录是同一个思想:AI 只能从宿主提供的组件里选。
状态回到 AI:下一轮对话「记得」你做了什么。 这是 Intelligent UI 和「生成一个网页」最不一样的地方。界面显示出来后,页面会把所有带名字的状态发给服务器(抓包,已脱敏):
POST /backend-api/conversation/{对话id}/message/{消息id}/dil/view_state
{"updates": [{
"scope": "root",
"state": {
"tab": "overview", "period": "7", "channel": "all", "incident": false,
"model": "balanced", "turns": 8000, "retry": 5, "rewards": 15, "wallet": 250,
"ledger": [], "message": "", "search": "", "statusFilter": "all",
"riskOnly": false, "selectedAudit": "EVT-1042"
},
"client_update_id": "…"
}]}
state 里的 15 个名字,和编译时加上的 15 个 key 完全一致。下一轮对话的请求里有一个字段 genui_state_snapshots,用来把这些状态交给 AI。
我在复刻里试过这个场景:生成房贷计算器后把滑块拖到「300 万、20 年」,然后问「按我现在的参数,等额本金每月要还多少」,AI 直接按 300 万、20 年作答,用户不用复述自己调了什么。
(需要说明:抓包只覆盖了第一轮,当时 genui_state_snapshots 是空的,所以状态具体以什么格式交给 AI,是推断。复刻的做法是只把用户改动过的状态整理成一段文字,放在用户问题前面。)
和另外两者对比:A2UI 通过「动作」把操作发回 AI,可以选择附带整份数据;AG-UI 有专门的状态事件。三者解决的是同一个问题,只是时机不同:A2UI 和 AG-UI 在交互发生时就告诉 AI,Intelligent UI 先记下来、下一轮再给,因为它的交互本来就不需要 AI 参与。
复刻时踩到的坑#
为了验证上面的理解,我把五个环节都实现了一遍。抓包里那份真实源码在复刻里能零错误编译,三个页签都能操作,状态名字和上报内容与抓包完全一致。
但换成 DeepSeek 现场生成后,问题才真正暴露出来。ChatGPT 自己的模型很少写错,通用模型错得多。用「帮我做一个 MacBook 选购指南」「帮我创建一个 Golang 高并发系统原理」反复测,常见错误是这些:
| AI 写了什么 | 后果 | 复刻怎么处理 |
|---|---|---|
在 <code> 里放了一段 Go 代码,里面全是 { } | 编译器把 Go 代码当成 DIL 表达式,整个程序无法运行 | <code>、<pre> 里的内容一律当纯文字 |
{@body const T = {…} 漏了最后一个 } | 后面整篇内容都被当成这一行的一部分 | 这种指令都是单行的,在这一行结尾截断 |
用了 value={usage},却从没声明 usage | 控件消失,有时整个界面空白 | 看到 usage 和 setUsage 成对出现,自动补上声明 |
整篇回答包在 ```html 里 | 页面上出现一串反引号 | 去掉这几行,记一条诊断 |
前两种最危险,因为它们让整个程序无法运行:浏览器运行程序时,任何一处语法错误都会让整段代码失败;而且 AI 每多写几个字、编译器每重新编译一次,都会再失败一次,用户看到的就是一直空白。
复刻最后加了两道保险:
用 12 份真实 AI 输出、共 30,184 个「写到一半」的版本做测试:每个版本编译出来的程序都能正常运行,没有一个需要退回纯文字。
要强调的是,这两道保险是复刻自己加的,没有抓包依据。 ChatGPT 用的模型很可能专门针对这种格式训练过,错误会少得多。这一节真正想说明的是:用通用模型走「写程序」这条路,编译器的容错要比原版做得更重。
第四部分:总结#
该怎么选#
| 你的情况 | 更合适的方向 | 原因 |
|---|---|---|
| 自己的模型、自己的网页,想让回答变成能操作的工具 | 写程序(类似 Intelligent UI) | 本地即时交互、能做复杂计算;隔离环境和编译器的成本由你自己承担 |
| 第三方 AI 要往别人的 App 里显示界面 | 写数据(A2UI) | 双方互不信任,「不运行对方的代码」是最清楚的安全边界 |
| 要同时支持网页、iOS、Android | 写数据(A2UI) | 一份清单可以映射成各平台的原生组件 |
| 主要是表单、卡片、确认操作 | 写数据(A2UI) | 现成组件够用,不需要复杂计算 |
| 需要本地筛选、排序、模拟计算(计算器、配置器、仪表盘) | 写程序 | 每次操作都问一次 AI,体验和成本都受不了 |
| 已有 AI 后端,要接多种前端 | AG-UI 做传输,内容按上面选 | AG-UI 解决的是「怎么传」,不是「传什么」 |
如果真要用通用模型走「写程序」这条路,复刻得出的几条经验:
- 编译器的容错要做重。 逐段检查语法、自动补全漏掉的声明、代码块按纯文字处理,在通用模型上都是必需的。
- 把出错的影响控制在一个元素以内。 每个可能出错的元素单独套保护罩。
- 给 AI 的说明里放一个完整、能运行的例子。 实测比写多少条规则都管用;这个例子本身也要放进测试,保证它一直能跑。
- 开启「思考」模式要显示进度。 开启后首屏要多等二三十秒,界面上必须让用户知道 AI 正在想,否则会以为卡住了。
哪些是确定的,哪些是推断#
有抓包直接证据:
- AI 写的是源码,服务器反复整体编译(134 次),以补丁流推给浏览器
- 编译结果的结构:保护罩、常量表、状态名字、元素级保护规则、图表组件的校验
- 隔离环境的结构:禁止联网的跨域 iframe 里运行 Worker,能力以「替身」形式提供
- 30 + 283 个宿主组件的名单,以及「查表」式的解析
- 状态上报的格式,15 个状态名字与编译结果一一对应
推断,或没有覆盖到:
- 给 AI 的系统提示词完全没抓到;复刻里用的是根据编译结果反推、再按 DeepSeek 的出错情况调整的版本。
- 服务器编译器的内部实现看不到,只能看到输出。复刻的保护规则和原版很接近但不完全相同:同一份源码,复刻套了 61 处保护罩,原版是 56 处。
- 状态交给 AI 的具体格式没有样本。
- 宿主组件进入隔离环境的方式:原版传的是替身函数,复刻传的是名字加查表,效果相同但机制不同。
- 样本只有一次完整生成,来自一个账号、一个模型版本。
完整的抓包笔记、脱敏后的数据和可运行的复刻都在 Disdjj/intelligent-ui-demo。复刻不需要任何密钥就能跑(会回放抓包里的原始界面),右上角的开发者面板可以看到每一轮的源码、编译结果、补丁记录和状态上报。
参考:
- A2UI:官网、v0.9 规范
- AG-UI:事件、状态、与生成式界面规范的关系