同一张汇率卡片,A2UI 1.0 把 token 从 223 压到 21

AI Summary (BLUF)
本文通过一个基于 Google ADK 和 Gemini 的货币兑换 Agent,对比了 A2UI 0.9 和 1.0 两个版本在生成式 UI 上的差异。1.0 版本让模型用 Express 语言替代 JSON,并引入服务端宏,大幅减少 token 消耗(例如汇率卡片从 223 token 降至 21 token),同时简化了前端渲染逻辑。
核心洞察
这篇文章最有意思的地方在于,它用 token 数把“模型少写点东西”这件事量化了。同一个汇率卡片,0.9 版本要 223 个 token,1.0 的 ExpressA2UI 1.0 中模型输出的简短 Python 风格语言,由服务端编译成 JSON。 写法降到 139,服务端宏调用只要 21。这个差距在长对话里会不断累积,直接决定你的 agent 能撑多久不爆上下文。不过要注意,作者只测了 GeminiA family of multimodal large language models developed by Google DeepMind that can process text, code, images, audio, and video. 的 tokenizer,换别的模型数字会有出入。
这篇文章要讲的是 A2UI一种让 Agent 用 UI 组件描述来回答的协议,客户端用原生组件渲染,Agent 不发送代码。 0.9 到 1.0 之间改了什么,1.0 上生成式 UI 怎么工作,以及为什么更好。作者用同一个汇率 agent 在两个版本上各跑了一遍。这个 agent 基于 Google ADKGoogle的代理开发工具包。 和 Gemini,负责查汇率,然后用 A2UI 卡片和图表作答,前端是 Next.js 加 CopilotKit用于构建 AI 助手前端界面的 React 框架,示例中与 Next.js 配合使用。,走 AG-UI浏览器与 Agent 之间的通信协议,示例中通过 8008 端口的桥接服务连接。 协议渲染。
代码仓库在这里:
https://github.com/xbill9/currency-claude-agui-a2ui1
0.9 版本里,模型把 UI 写成 JSON,连数字都自己填。1.0 版本里,模型只写一行,剩下的服务端搞定。 一张“把 100 美元换成欧元”的汇率卡片,0.9 的 JSON 要 223 个 token,1.0 的 Express 写法 139 个 token,而 1.0 的服务端宏调用只要 21 个 token。一张 90 天历史走势图,0.9 的 JSON 是 1060 个 token,宏调用还是 21 个。以上都用 Gemini 的 tokenizer 数出来,组件完全一样。
核心结论
A2UI 1.0 将模型输出从 JSON 改为 Express 简写语言,同一张汇率卡片(100 美元换欧元)的 token 消耗从 0.9 版本的 223 个降至 139 个,使用服务端宏调用时仅需 21 个 token。
90 天历史走势图的 token 差距更为显著:0.9 版本 JSON 需 1060 个 token,1.0 服务端宏调用仍为 21 个 token,以上数据均使用 Gemini 的 tokenizer 测量,组件完全一致。
A2UI 1.0 的
createSurface支持内联components和dataModel,将 0.9 版本需要三条消息(createSurface、updateComponents、updateDataModel)才能完成的 surface 创建压缩为一条消息,渲染器收到首条消息即可完整渲染。1.0 版本中模型生成的 Express 代码在服务端编译并对照目录 schema 校验,格式错误在到达浏览器前即被拦截;前端
page.tsx因此从 309 行缩减至 150 行,移除了 JSON 解析器和规范化函数。服务端宏(
@macro)使汇率等数字无需经过模型逐一手写,避免了模型在图表中逐个填写 64 个日期和 64 个汇率值的 token 开销与出错风险。
什么是 A2UI?
A2UI 是一套让 agent 用 UI 作答的协议。agent 发送组件描述(卡片、文本、按钮、图表),这些组件来自客户端实现的一份目录,客户端用自己的原生控件渲染出来。agent 从不发送代码,所以什么能跑由客户端说了算。
用来做对比的 Agent

| 服务 | 端口 | 代码 |
|---|---|---|
React UI:Next.js、CopilotKit、@a2ui/react |
3000 | frontend-react/ |
| AG-UI 桥接:ADK agent 跑在进程内 | 8008 | frontend-react/agent/ |
| A2A 服务器:同一个 ADK agent,走 A2A 协议 | 10000 | currency_agent/ |
MCPModel Context Protocol - a protocol that enables AI models to access external tools, data sources, and services to enhance their capabilities and context awareness. 服务器:get_exchange_rate(FastMCP) |
8080,路径 /mcp |
mcp-server/ |
0.9 版本对应同一个仓库的 d181ddb 提交。之后的都是 1.0。版本号是 2026 年 11 月 11 日从机器上读出来的:
| 组件 | 0.9 版本 | 1.0 版本 |
|---|---|---|
| Agent 库 | a2ui-agent-sdk 0.2.1 |
a2ui-agent-sdk 0.11.0、a2ui-adk 0.3.0、a2ui-core 0.6.0 |
| 渲染器 | @copilotkit/a2ui-renderer |
@a2ui/react 0.15.0、@a2ui/web_core 0.15.0 |
| Agent 框架 | google-adk |
google-adk 2.13.0、ag_ui_adk 0.6.5 |
| 模型 | Gemini | gemini-3.8-flash(默认)、gemini-2.5-flash |
变化一览
以下内容读自安装好的 a2ui-core 0.6.0,这个版本同时带了 0.9 和 1.0 两套 schema:
| A2UI 0.9 | A2UI 1.0 | |
|---|---|---|
| 版本字符串 | "v0.9" |
"1.0" |
createSurface |
catalog id、theme、sendDataModel |
内联 components 和 dataModel、sendDataModel、metadata;没有 theme |
| 每个 surface 的目录 | 一个 | 每个组件一个,通过 catalogId 指定 |
| 绑定与调用 | {"path": ...}、{"call": ...} |
{"@path": ...}、{"@call": ...} |
| Agent → 渲染器 | createSurface、updateComponents、updateDataModel、deleteSurface | 同样四个,加上 callRendererFunction 和 agentFunctionResponse |
| 渲染器 → agent | action、error | action、error,加上 callAgentFunction 和 rendererFunctionResponse |
| 文本变体 | h1、h2、h3、h4、h5、caption、body | caption、body;标题改由 Markdown 提供 |
| 基础目录 | 18 个组件,14 个函数 | 同样 18 个组件,15 个函数(新增 @index) |
| 两端的叫法 | client 和 server | renderer 和 agent |
| 模型写什么(SDK) | JSON | Express,服务端编译成 JSON |
| 服务端宏(SDK) | 无 | @macro,发送前展开 |
最后两行属于 agent SDK,也就是 a2ui-agent-sdk 0.11.0,它随 1.0 协议一起发布。下面逐条拆解这些变化:0.9 怎么做、1.0 怎么做、换来了什么。
变化一:一条消息装下整个 surface
0.9 版本里,新建一个 surface 要三条消息:createSurface 指定目录,updateComponents 发组件,updateDataModel 填数据。在最后一条到达之前,渲染器手里是个空的 surface。
1.0 版本里,createSurface 直接内联 components 和 dataModel。下面这段 Express 代码是一个带绑定文本框、一个勾选框和一个按钮的表单,用 SDK 自带的编译器能编到两个版本:
surface("main")
$/user/name = "Ada"
title = Text(formatString("Hello ${/user/name}"))
btn = Button(Text("Save"), action=Event("save", {name: $/user/name}, userMessage="Saving"))
root = Column([title, TextField("Name", $/user/name, checks=[?required("Name is required")]), btn])
跑一下:
python3 docs/a2ui-1-0/tools/express_0_9_vs_1_0.py
输出:
## A2UI 1.0: 1 message(s): ['createSurface']
## A2UI 0.9 (userMessage removed): 3 message(s): ['createSurface', 'updateComponents', 'updateDataModel']
0.9 时代,模型得把 UI 写成 JSON,连数字都自己填。到了 1.0,模型只写一行,剩下的交给服务端。同一张汇率卡片,"把 100 美元换成欧元",0.9 的 JSON 要 223 个 token,1.0 的 Express 写法降到 139,服务端宏调用只要 21。90 天历史图表更夸张,0.9 的 JSON 是 1060 个 token,宏调用还是 21。以上数字都用 Gemini 的 tokenizer 数出来,组件完全一样。
什么是 A2UI
A2UI 是一套协议,让 agent 用 UI 来回答。agent 从客户端实现好的目录里挑组件(卡片、文本、按钮、图表),发一份描述过去,客户端用自己的原生控件渲染出来。agent 全程不碰代码,能跑什么由客户端说了算。
用来做对比的 agent

| 服务 | 端口 | 代码 |
|---|---|---|
React UI:Next.js、CopilotKit、@a2ui/react |
3000 | frontend-react/ |
| AG-UI 桥接层:ADK agent 跑在进程内 | 8008 | frontend-react/agent/ |
| A2A 服务端:同一个 ADK agent,走 A2A 协议 | 10000 | currency_agent/ |
MCP 服务:get_exchange_rate(FastMCP) |
8080,路径 /mcp |
mcp-server/ |
0.9 的构建是同一个仓库的 d181ddb 提交,之后的全是 1.0。版本号是 2026 年 11 月 11 日从机器上读出来的:
| 组件 | 0.9 构建 | 1.0 构建 |
|---|---|---|
| Agent 库 | a2ui-agent-sdk 0.2.1 |
a2ui-agent-sdk 0.11.0、a2ui-adk 0.3.0、a2ui-core 0.6.0 |
| 渲染器 | @copilotkit/a2ui-renderer |
@a2ui/react 0.15.0、@a2ui/web_core 0.15.0 |
| Agent 框架 | google-adk |
google-adk 2.13.0、ag_ui_adk 0.6.5 |
| 模型 | Gemini | gemini-3.8-flash(默认)、gemini-2.5-flash |
改动一览
以下内容读自装好的 a2ui-core 0.6.0,这个版本把 0.9 和 1.0 的 schema 并排放在一起:
| A2UI 0.9 | A2UI 1.0 | |
|---|---|---|
| 版本字符串 | "v0.9" |
"1.0" |
createSurface |
目录 id、theme、sendDataModel |
内联 components 和 dataModel、sendDataModel、metadata;没有 theme |
| 每个 surface 的目录 | 一个 | 每个组件一个,通过 catalogId 指定 |
| 绑定与调用 | {"path": ...}、{"call": ...} |
{"@path": ...}、{"@call": ...} |
| Agent → 渲染器 | createSurface、updateComponents、updateDataModel、deleteSurface | 同样四个,外加 callRendererFunction 和 agentFunctionResponse |
| 渲染器 → agent | action、error | action、error,外加 callAgentFunction 和 rendererFunctionResponse |
| 文本变体 | h1、h2、h3、h4、h5、caption、body | caption、body;标题改由 Markdown 提供 |
| 基础目录 | 18 个组件,14 个函数 | 同样 18 个组件,15 个函数(新增 @index) |
| 两端的叫法 | client 和 server | renderer 和 agent |
| 模型写什么(SDK) | JSON | Express,服务端编译成 JSON |
| 服务端宏(SDK) | 无 | @macro,发送前展开 |
最后两行属于 agent SDK,也就是随 1.0 协议一起发布的 a2ui-agent-sdk 0.11.0。下面逐条拆解这些改动:0.9 怎么做、1.0 怎么做、换来了什么。
改动一:一条消息装下整个 surface
0.9 的做法:新建一个 surface 要三条消息。createSurface 指定目录,updateComponents 发组件,updateDataModel 填数据。最后一条到达之前,渲染器手里一直是个空 surface。
1.0 的做法:createSurface 直接把 components 和 dataModel 内联进来。下面这段 Express,一个带绑定文本框、一个勾选框和一个按钮的表单,用 SDK 自带的编译器能同时编译出两个版本:
surface("main")
$/user/name = "Ada"
title = Text(formatString("Hello ${/user/name}"))
btn = Button(Text("Save"), action=Event("save", {name: $/user/name}, userMessage="Saving"))
root = Column([title, TextField("Name", $/user/name, checks=[?required("Name is required")]), btn])
python3 docs/a2ui-1-0/tools/express_0_9_vs_1_0.py
## A2UI 1.0: 1 message(s): ['createSurface']
## A2UI 0.9 (userMessage removed): 3 message(s): ['createSurface', 'updateComponents', 'updateDataModel']
## userMessage on 0.9
ExpressValidationError: Event userMessage requires protocol v1.0 or later; the target version v0.9 has no userMessage field.
好在哪:渲染器收到第一条消息就能把整个 surface 画出来,三条消息之间也没有先后顺序可以搞错。surface 建好之后,更新还是走 updateComponents 和 updateDataModel。
改动二:模型写 Express,不写 JSON
0.9 的做法:模型把 A2UI JSON 写进回复里,包在 <a2ui-json> 标签中。0.9 构建的 page.tsx 用正则把这段从文本里抠出来,再靠一个 normalizeA2UIJson 函数修补模型产出的结构,然后才交给渲染器。
1.0 的做法:SDK 默认输出 Express,一种类似 Python 的简短语言,包在 <a2ui> 标签里。$/path 是绑定,?required 是校验,root = 指定顶层组件,参数按目录里的签名来。SDK 根据目录生成提示词,在服务端编译这段代码,对照目录做校验,然后发出 JSON。汇率卡片用 Express 写出来是这样:
surface("main")
root__text_1 = Text("### 100.00 USD = 89.87 EUR")
root__text_2 = Text("1 USD = 0.89871 EUR")
root__divider_1 = Divider()
root__text_3 = Text("ECB reference rate for 2026-11-11 (Frankfurter API)", "caption")
root__column_1 = Column([root__text_1, root__text_2, root__divider_1, root__text_3])
root = Card(root__column_1)
同样的六个组件,0.9 的 JSON 长这样:
0.9 时代,模型得把 UI 写成 JSON,连数字都自己填。到了 1.0,模型只写一行,剩下的交给服务端。同一张汇率卡片,"把 100 美元换成欧元",0.9 的 JSON 要 223 个 token,1.0 的 Express 写法降到 139,服务端宏调用只要 21。90 天历史图表更夸张。
为什么这样更好: 模型写的 token 更少(这张卡片 139 对 223,下面有表),而且格式错了在服务端就挂掉,根本到不了浏览器——工具会把编译器的报错塞回结果里给模型看。浏览器收到的是已经通过 catalog schema 校验的 JSON,所以 1.0 的 page.tsx 里没有 parser 也没有 normalizer,从 309 行缩到了 150 行。
变化三——服务端宏:数字不经过模型的手
0.9 的做法是模型调用汇率工具,读回结果,然后把每一个数字敲进组件 JSON 里。画一张历史图表,意味着模型要把 64 个日期和 64 个汇率一个个抄出来。
1.0 的宏是一个 Python 函数,模型像调用组件一样调用它。@macro 把类型标注和 docstring 变成一个宏 catalog 的条目,这个 catalog 只有模型看得见。发送时 MacroExpander 执行函数,把调用替换成 Basic catalog 的组件,浏览器完全不需要支持宏。下面是 currency_agent/macros.py 里的代码:
@macro
def RateCard(currency_from: str, currency_to: str, amount: float = 1.0) -> Card:
"""Card with the live exchange rate and a converted amount. The server looks
up the rate, so do not pass rate figures.
"""
src, dst = _code(currency_from), _code(currency_to)
data = _get("latest", **{"from": src, "to": dst})
rate = data["rates"][dst]
return Card(
child=Column(
children=[
Text(text=f"### {amount:,.2f} {src} = {amount * rate:,.2f} {dst}"),
Text(text=f"1 {src} = {rate:.5f} {dst}"),
Divider(),
Text(
text=f"ECB reference rate for {data['date']} (Frankfurter API)",
variant="caption",
),
]
)
)
用户说"帮我把 100 美元换成欧元",模型整个工具调用就长这样:
<a2ui>
surface("conversion")
root = RateCard("USD", "EUR", 100)
</a2ui>
到达浏览器的内容:
version 1.0, ['createSurface'], 6 components
https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json: ['Text', 'Text', 'Divider', 'Text', 'Column', 'Card']
Text: ### 100.00 USD = 89.87 EUR
Text: 1 USD = 0.89871 EUR
Text: ECB reference rate for 2026-11-11 (Frankfurter API)
agent 会校验两次:一次针对模型能看到的 catalog(包含宏),展开之后再针对浏览器实现的 catalog 校验一次:
def _parse_payload(self, processor, payload):
messages = super()._parse_payload(processor, payload)
typed = [_message_adapter.validate_python(m) for m in messages]
expanded = macro_expander.transform_to_transport(typed)
wire = [m.model_dump(by_alias=True, exclude_none=True) for m in expanded]
validate_payload(BASE_CATALOGS, wire)
return wire
为什么这样更好: 数字在服务端从数据源直接来,模型没有机会打错汇率,模型也不需要看到那些跟它无关的数据。模型输出的长度不再随数据增长:90 天图表写成宏调用是 21 个 token,写成 0.9 的 JSON 是 1,060。宏的参数来自模型,所以 _code() 只接受三个字母的货币代码,天数被限制在 7 到 365 之间。

模型到底写多少 token?
docs/a2ui-1-0/tools/token_cost.py 用实时汇率生成三个答案,每个答案写成四种形式,再用 Gemini 的 countTokens API 逐一计数:
python3 docs/a2ui-1-0/tools/token_cost.py gemini-2.5-flash
# Tokens the model writes for the same UI, counted by gemini-2.5-flash countTokens
| Answer | Components | 0.9 JSON | 1.0 JSON | 1.0 Express | 1.0 macro |
| :--- | ---: | ---: | ---: | ---: | ---: |
| Rate card | 6 | 223 | 212 | 139 | 21 |
| 90-day chart | 5 | 1060 | 1152 | 1101 | 21 |
| 3-currency comparison | 8 | 347 | 522 | 253 | 22 |
gemini-3.8-flash 数出来是一样的数字。这张表说明两件事:
Express 把结构压扁了。 在汇率卡片和对比页上,token 大头是组件结构,Express 是模型手写能做到的最小形态。图表页不一样,那里 token 大头是 64 个日期加 64 个汇率,手写哪种写法成本都差不多。
宏调用把数据整个拿掉了。 一次宏调用固定 21 或 22 个 token,展开成什么都不变。
1.0 的 JSON 在对比页上是最大的形态,因为它混了两个 catalog,每个组件都得自己写 catalogId(对应变更 4)。Express 不写 catalog id,编译器来填。
变更 4:一个界面上挂多个 Catalog
0.9 的做法是 createSurface 指定一个 catalog id,界面上所有组件都来自它。0.9 版本通过 A2uiSchemaManager 加载 Basic 0.9 和它的图表 catalog https://a2ui.org/catalog/currency-charts,一个界面选一个 catalog。
1.0 里每个组件可以自己指定 catalogId。这个浏览器实现了 Basic 1.0,外加一个带 LineChart 和 BarChart 的汇率图表 catalog。对于"给我看过去 6 个月欧元兑美元的走势和当前汇率",模型写了两个宏调用:
<a2ui>
surface("main")
root = Column([rateCard, historyChart])
rateCard = RateCard("EUR", "USD")
historyChart = RateHistoryChart("EUR", "USD", 180)
</a2ui>
浏览器收到一条 createSurface,每个组件各自指定 catalog:
version 1.0, ['createSurface'], 12 components
https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json: ['Column', 'Text', 'Text', 'Divider', 'Text', 'Column', 'Card', 'Text', 'Text', 'Column', 'Card']
https://a2ui.org/catalog/currency-charts/v1/catalog.json: ['LineChart']
好在哪: 自定义 catalog 只装自定义组件,这里就是 LineChart 和 BarChart,和标准 Basic catalog 并排挂在同一个界面上。图表 catalog 现在是 https://a2ui.org/catalog/currency-charts/v1/catalog.json,protocolVersion 为 1.0,它的 id 和 props 在 currency_agent/custom_catalog.json 和浏览器里的 A2UICustomCatalog.tsx 之间是对得上的。
变更 5:UI 作为工具结果送达
0.9 的时候 UI 藏在回复文本里。CopilotKit 的 A2UI 中间件和 @copilotkit/a2ui-renderer 负责读它,0.9 版本的 AG-UI 检查会在最终文本里搜 <a2ui-json> 标签。
1.0 里模型把 Express 块交给 send_a2ui_json_to_client 工具,工具结果在 validated_a2ui_json 里带着校验过的消息。页面给这个工具注册一个渲染器,把消息交给 @a2ui/react:
useRenderTool(
{
name: "send_a2ui_json_to_client",
render: ({ result }: any) => {
const messages = extractA2uiMessages(result);
return messages ? <A2uiMessages messages={messages} /> : null;
},
} as any,
[],
);
好在哪: UI 和聊天文本分开走,一条回复里可以混着文字和卡片,浏览器不用猜哪里是分界。CopilotKit 内置的 A2UI 中间件说的是 0.9,所以在 route.ts 里关掉了。
变更 6:要提前处理的破坏性变更
把 0.9 的 agent 或渲染器迁到 1.0,下面这些地方都得改:
- 标题走 Markdown。 Text 的
h1–h5变体没了,只留caption和body,Text 直接渲染 Markdown。宏里写### 250.00 USD = 224.68 EUR作为标题行,@a2ui/react用@a2ui/markdown-it渲染。 createSurface里的theme没了。- 绑定和调用加了前缀:
{"path": ...}变成{"@path": ...},{"call": ...}变成{"@call": ...}。 - 版本字符串是
"1.0",不带v。 - 组件 props 挪了位置: TextField 新增
placeholder,validationRegexp换成regex检查;Slider 新增steps;Video 新增posterUrl。
变更 7:1.0 加了什么
来自 a2ui-core 0.6.0 的 1.0 schema:
- 双向函数调用。 agent 可以调渲染器函数(
callRendererFunction),渲染器也可以调 agent 函数(callAgentFunction),各有自己的响应消息。 - 函数权限。 catalog 给每个函数标
rendererOnly、agentOnly或rendererOrAgent,还能用requiresUserActivation要求用户手势。 - 组合规则。 catalog 可以给每个组件列
allowedParents和allowedChildren,渲染器用UNALLOWED_PARENT和UNALLOWED_CHILD两个错误码报告违规。 - 分级校验。 检查结果带
severity,分 error、warning、info。 - 会说话的动作。 按钮的 action 可以带
userMessage,跟着 action 一起送到 agent。编译器在 0.9 上会拒绝它,变更 1 里提过。 - 无障碍。 组件接受
live(off、polite、assertive)和hidden。 - 列表位置。
@index函数返回条目在列表模板里的位置。 - 扩展。 组件、界面、动作和 catalog 都带
metadata.extensions,用来放自定义数据。 - Catalog 说明。 catalog 可以带 Markdown 格式的
instructions给模型看。
撑得住吗
docs/a2ui-1-0/tools/render_suite.py 把十条 prompt 各发十次,走 Next.js。UI 类 prompt 的判定标准是回复渲染出了 A2UI,且聊天里没漏出原始的 <a2ui 文本;天气那个问题按纯文本通过,不出 UI。
python3 docs/a2ui-1-0/tools/render_suite.py 10 gemini-3.8-flash render-suite-gemini-3.8-flash.jsonl
两个模型在对比类回复上全部走了 RateComparison 宏,没有一次去调汇率工具:
## gemini-2.5-flash
10 of 10: no rate-tool call, BarChart=True | Compare 1 USD in EUR, GBP, JPY and CAD
10 of 10: no rate-tool call, BarChart=True | Compare the euro, pound and Swiss franc against the dol
10 of 10: no rate-tool call, BarChart=True | Show a bar chart of 1 USD in EUR, GBP, JPY, CHF and CAD
gemini-3.8-flash 那边同样干净,100 条回复全部正常渲染,10 个提示词各跑 10 次,没有一条出问题:
# gemini-3.8-flash: 100 of 100 replies rendered clean (10 runs per prompt)
10 of 10: pass | Compare 1 USD in EUR, GBP, JPY and CAD
10 of 10: pass | Compare the euro, pound and Swiss franc against the dollar in a bar chart
10 of 10: pass | Convert 100 USD to XYZ
10 of 10: pass | How much is 250 USD in EUR?
10 of 10: pass | Show a bar chart of 1 USD in EUR, GBP, JPY, CHF and CAD
10 of 10: pass | Show me EUR to USD for the past 6 months and the current rate
10 of 10: pass | Show me the USD to JPY trend over the last 90 days
10 of 10: pass | What's a thousand euros in dollars?
10 of 10: pass | What's the weather in Paris?
10 of 10: pass | Write the A2UI block for a USD to EUR rate card directly in your reply text. Do not call any tool.
gemini-2.5-flash 也是 100 条全过。
这个结果说明宏这条路是通的。模型不需要理解汇率数据长什么样,也不需要自己拼 64 个日期和 64 个汇率,它只需要决定调哪个宏、传什么参数。剩下的交给宏去填。
但这里有个细节值得看一眼:第 10 个提示词明确要求模型直接在回复文本里写 A2UI 块,不许调工具。两个模型都过了。这说明模型对“什么时候该调宏、什么时候该手写”这件事有判断力,不是无脑走宏。
那手写路径呢?下一节看。
两个模型在所有对比类回复里都走了 RateComparison 宏,一次都没调汇率工具:
## gemini-2.5-flash
10 of 10: no rate-tool call, BarChart=True | Compare 1 USD in EUR, GBP, JPY and CAD
10 of 10: no rate-tool call, BarChart=True | Compare the euro, pound and Swiss franc against the dol
10 of 10: no rate-tool call, BarChart=True | Show a bar chart of 1 USD in EUR, GBP, JPY, CHF and CAD
## gemini-3.8-flash
10 of 10: no rate-tool call, BarChart=True | Compare 1 USD in EUR, GBP, JPY and CAD
10 of 10: no rate-tool call, BarChart=True | Compare the euro, pound and Swiss franc against the dol
10 of 10: no rate-tool call, BarChart=True | Show a bar chart of 1 USD in EUR, GBP, JPY, CHF and CAD
提示:Express 是一种格式,不是 Python 的特性
A2UI 仓库里带了 Express 编译器和响应解析器的 YAML 一致性测试套件,跟着 a2ui-agent-sdk 0.11.0 一起发布。同一个 agent 的 Rust 移植版(https://github.com/xbill9/currency-agent-rust-agui-a2ui1)用 Rust 实现了 Express 和服务端宏,跑起来是这样的:
cargo test --test express_conformance -- --nocapture
agent/express/response_parser.yaml: 38/38 passed, 0 known gaps, 0 failed
agent/express/compiler.yaml: 132/135 passed, 3 known gaps, 0 failed
跳过的 3 个编译器用例用的是 0.9 catalog,这个移植版只针对 1.0。
提示:宏 catalog 放在模型那边
宏放在一个只给模型看的 catalog 里:macro_expander.transform_catalogs(BASE_CATALOGS) 把它加进 prompt 和编译器。浏览器端的 catalog 还是 Basic 1.0 加图表,第二遍校验会确认浏览器收到的每个组件都属于其中之一。
到这一步,你应该已经准备好
- Python 3.13 或更新版本,以及带 npm 的 Node.js
- 从 Google AI Studio 拿的 Gemini API key,或者一个开了 Vertex AI 的 Google Cloud 项目
- 仓库已经克隆好:
git clone https://github.com/xbill9/currency-claude-agui-a2ui1.git
cd currency-claude-agui-a2ui1
make install
echo "GOOGLE_API_KEY=<your_api_key_here>" >> .env
echo "GOOGLE_GENAI_USE_VERTEXAI=FALSE" >> .env
make install 会对两个前端分别跑 uv sync 和 npm install。agent 默认用 gemini-3.8-flash,除非你在 .env 里设了 GENAI_MODEL。
make start
curl -s http://127.0.0.1:10000/health
curl -s http://127.0.0.1:8008/health
{"status":"ok"}
{"status":"ok"}
打开 http://localhost:3000,问一句 250 美元换欧元是多少?
对比一下
同样的回答,token 数由 gemini-2.5-flash 统计:
| A2UI 0.9 | A2UI 1.0, Express | A2UI 1.0, 宏 | |
|---|---|---|---|
| 汇率卡片 | 223 tokens | 139 tokens | 21 tokens |
| 90 天图表 | 1,060 tokens | 1,101 tokens | 21 tokens |
| 三币种对比 | 347 tokens | 253 tokens | 22 tokens |
| 每个新 surface 的消息数 | 3 | 1 | 1 |
| 数字由谁填入 | 模型 | 模型 | 服务端 |
| 是否对照 catalog 校验 | 否;浏览器里修补形状 | 是,发送前在服务端 | 是,展开前后都在服务端 |
| 每个 surface 的 catalog 数 | 1 | 每个组件 1 个 | 每个组件 1 个 |
那到底选哪个?
上 1.0。一个 surface 一条消息就到了,模型写 Express 而不是 JSON,服务端在发出去之前先校验,而且一个 surface 可以混用标准 Basic catalog 和你自己的 catalog。
在 1.0 上,凡是 agent 经常展示的东西都写成宏。模型输出缩到一行,只带代码和金额,数字由服务端从数据源取,成本不再随数据量增长。汇率卡片、历史图表、对比,这里全都走这条路。宏覆盖不到的布局,用纯 Express 兜底。
记得给 Change 6 里的破坏性变更留出时间,还有换一个支持 1.0 的渲染器:这里用的是 @a2ui/react 0.15.0,替代 CopilotKit 的 0.9 渲染器。
总结
这篇文章想搞清楚三件事:A2UI 0.9 到 1.0 到底改了什么,1.0 上生成式 UI 怎么跑,以及为什么它更好。做法是用同一个货币 agent 在两边各建一遍。方案的关键在 1.0 agent SDK 在协议之上加的两样东西:模型写 Express,服务端负责编译和校验;服务端宏用实时数据拼装组件,数字从头到尾不经过模型。结果如下:
- 🟢 一张汇率卡片,0.9 的 JSON 是 223 个 token,Express 是 139 个,宏调用是 21 个,用 Gemini 的 tokenizer 数的
- 🟢 一张 90 天走势图,0.9 的 JSON 是 1,060 个 token,宏调用是 21 个;token 大头是数据本身,光靠 Express 省不了多少
- 🟢 同一段 Express 代码,在 1.0 上编译成一条消息,在 0.9 上是三条
- 🟢 一个 surface 混用了两套 catalog,每个组件自己声明
catalogId - 🟢 十个 prompt,每个跑十次:
gemini-3.8-flash上 100 次全部渲染正常,gemini-2.5-flash上也是 100 次全过 - 🟢 浏览器端代码从 309 行缩到 150 行,省掉的是 parser 和 normalizer
- 🟢 Rust 移植版通过了 132 个 Express 编译器用例(共 135 个)和 38 个响应解析用例(共 38 个);跳过的 3 个用的是 0.9 catalog
- ⚠️ 1.0 的 JSON 如果混用 catalog,每个组件都要重复写
catalogId,这是整个对比里体积最大的一种形式,522 个 token - ❌ 1.0 去掉了
theme和 h1–h5 的 Text 变体,还把path和call改名成了@path和@call
测试范围:一台笔记本跑全部四个服务,gemini-3.8-flash 和 gemini-2.5-flash 都用默认设置,汇率取 Frankfurter API 的 ECB 参考价,测量日期 2026-11-11,每个 prompt 每个模型跑十次。token 数比的是服务端构建出来的同一批组件的不同形式:0.9 的 JSON 是这些组件按 0.9 的形状写出来,不是从 0.9 版本模型那里抓的文本;所有形式里,保持默认值的属性都删掉了。协议细节来自 a2ui-core 0.6.0 里的 0.9 和 1.0 schema。
把一个生成式 UI agent 从 A2UI 0.9 迁到 1.0,这套策略是靠一步一步增量验证出来的。
参考链接
- 代码仓库:https://github.com/xbill9/currency-claude-agui-a2ui1
- Rust 移植版:https://github.com/xbill9/currency-agent-rust-agui-a2ui1
- 文中每个数字的原始证据:https://github.com/xbill9/currency-claude-agui-a2ui1/tree/main/docs/a2ui-1-0/evidence
- Demo、token 和测试脚本:https://github.com/xbill9/currency-claude-agui-a2ui1/tree/main/docs/a2ui-1-0/tools
- A2UI v1.0 规范:https://a2ui.org/specification/v1.0-a2ui/
- A2UI:https://a2ui.org
- AG-UI:https://docs.ag-ui.com
- CopilotKit:https://github.com/CopilotKit/CopilotKit
- Google Agent Development Kit:https://github.com/google/adk-python
- Frankfurter API:https://www.frankfurter.dev
- 上游 Currency Agent(A2A + ADK + MCP):https://github.com/jackwotherspoon/currency-agent
常见问题(FAQ)
A2UI 1.0 相比 0.9 在 token 消耗上具体降低了多少?
同一张汇率卡片,0.9 版本 JSON 需 223 个 token,1.0 的 Express 写法降至 139,服务端宏调用仅 21。90 天历史图表从 1060 降至 21。以上基于 Gemini tokenizer 实测,组件完全相同。
A2UI 1.0 的 Express 语言和服务端宏是怎么工作的?
1.0 中模型不再写 JSON,而是用 Express 语言描述 UI,服务端将其编译成 JSON。服务端宏(@macro)在发送前展开,模型只需写一行宏调用,大幅减少 token 并简化前端渲染。
A2UI 1.0 在消息结构上做了哪些简化?
0.9 新建 surface 需 createSurface、updateComponents、updateDataModel 三条消息;1.0 的 createSurface 直接内联 components 和 dataModel,一条消息即可完成,渲染器无需等待空 surface。
版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。
文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。
若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。




