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

2026/11/13
同一张汇率卡片,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 的 Express 写法降到 139,服务端宏调用只要 21。这个差距在长对话里会不断累积,直接决定你的 agent 能撑多久不爆上下文。不过要注意,作者只测了 Gemini 的 tokenizer,换别的模型数字会有出入。


这篇文章要讲的是 A2UI 0.9 到 1.0 之间改了什么,1.0 上生成式 UI 怎么工作,以及为什么更好。作者用同一个汇率 agent 在两个版本上各跑了一遍。这个 agent 基于 Google ADK 和 Gemini,负责查汇率,然后用 A2UI 卡片和图表作答,前端是 Next.js 加 CopilotKit,走 AG-UI 协议渲染。

代码仓库在这里:

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 数出来,组件完全一样。


核心结论

  1. A2UI 1.0 将模型输出从 JSON 改为 Express 简写语言,同一张汇率卡片(100 美元换欧元)的 token 消耗从 0.9 版本的 223 个降至 139 个,使用服务端宏调用时仅需 21 个 token。

  2. 90 天历史走势图的 token 差距更为显著:0.9 版本 JSON 需 1060 个 token,1.0 服务端宏调用仍为 21 个 token,以上数据均使用 Gemini 的 tokenizer 测量,组件完全一致。

  3. A2UI 1.0 的 createSurface 支持内联 components 和 dataModel,将 0.9 版本需要三条消息(createSurface、updateComponents、updateDataModel)才能完成的 surface 创建压缩为一条消息,渲染器收到首条消息即可完整渲染。

  4. 1.0 版本中模型生成的 Express 代码在服务端编译并对照目录 schema 校验,格式错误在到达浏览器前即被拦截;前端 page.tsx 因此从 309 行缩减至 150 行,移除了 JSON 解析器和规范化函数。

  5. 服务端宏(@macro)使汇率等数字无需经过模型逐一手写,避免了模型在图表中逐个填写 64 个日期和 64 个汇率值的 token 开销与出错风险。

什么是 A2UI?

A2UI 是一套让 agent 用 UI 作答的协议。agent 发送组件描述(卡片、文本、按钮、图表),这些组件来自客户端实现的一份目录,客户端用自己的原生控件渲染出来。agent 从不发送代码,所以什么能跑由客户端说了算。


用来做对比的 Agent

架构图:浏览器通过 AG-UI 连接 8008 端口的桥接服务,A2A 客户端连接 10000 端口的服务器,两者跑同一个 ADK agent,该 agent 调用 8080 端口的 MCP 服务器和 Frankfurter API

服务 端口 代码
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 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

架构:浏览器通过 AG-UI 连到 8008 端口的桥接层,A2A 客户端连到 10000 端口的服务端,两边跑同一个 ADK agent,agent 调用 8080 端口的 MCP 服务和 Frankfurter API

服务 端口 代码
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 之间。

USD 对 JPY 折线图,2026-08-13 至 2026-11-11,最低 153.27,最高 160.16,最新 159.23


模型到底写多少 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,这套策略是靠一步一步增量验证出来的。


参考链接

常见问题(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。

晓婷深圳
本文由 晓婷 审核,最后更新于 2026年11月13日
联系编辑 →
← 返回文章列表
分享到:微博

版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。

文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。

若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。