Tag

Agent

从 XJSX 到 Mermaid:我为什么不再让 LLM 在对话里生成复杂 UI

AI 工程

最开始做对话里的可视化,是因为看到了 CodePilot 这个项目。

它的方案是在对话中直接生成 HTML / SVG,看起来非常唬人。模型一边输出,页面一边出现各种组件、图形、卡片和布局,第一眼看上去很像未来的交互方式。

但是实际用了一段时间后,问题很快暴露出来:LLM 会把大量 token 浪费在内嵌样式和绝对定位上。

它会不停写 style="left: 32px; top: 18px" 之类的东西,还会手搓一堆颜色、阴影、圆角、布局宽高。结果是上下文被样式污染,生成成本变高,最后画出来的东西还很粗糙。尤其是复杂一点的页面,实际看的时候总是一眼能看出有地方做的没对上。

这件事让我产生了一个想法:既然 LLM 不擅长设计细节,那就不要让它设计细节。让它只写结构。

第一版:让模型写 JSX,而不是 HTML

我的第一版方案是走 JSX 组件。

系统预先提供一部分组件,比如文本、卡片、表格、图表、Mermaid、分栏、区块之类的东西。LLM 不需要写完整 HTML,也不需要写一堆内嵌样式,只需要写类似这样的结构:

jsx
return (
  <Section title="风险概览">
    <Text md={refs.summary} />
    <Chart config={refs.trend} />
  </Section>
);

这样做的好处很明显:

  • 样式由系统统一控制,不让模型随便发挥。
  • 组件能力可以被限制,减少安全风险。
  • token 主要花在内容和结构上,而不是花在 CSS 上。
  • UI 的整体风格比较稳定,不会每次回答都像换了一个设计师。

这个方向一开始效果还不错。至少比直接生成 HTML / SVG 稳定得多,也更符合对话产品的需求。

但很快又遇到了新的问题:流式输出。

如果模型要先写完完整 JSX,前端才能渲染,那用户在等待过程中只能看源码。这个体验不好。尤其是图表、流程图、长文本这些内容,本来可以先展示出来,却因为布局代码还没生成完,只能等到最后。

于是我把内容和布局拆开了。

第二版:把图表、Mermaid 和文本块拆成最小引用单元

当时比较常见的内容有三类:

  • ECharts 图表配置。
  • Mermaid 流程图、时序图、关系图。
  • Markdown 文本块。

这些东西不一定非要等 JSX 布局完成之后才能看。只要某个内容块生成完整,前端就可以先预览它。

所以我设计了一套最小单元引用语法。模型先输出内容块:

纯文本
```xjsx
---ref:markdown:summary---
这里是一段分析摘要

---ref:mermaid:flow---
flowchart TD
  A[开始] --> B[处理]
```

然后最后再输出 JSX,把这些内容组装起来:

纯文本
```jsx
return (
  <Section title="处理流程">
    <Text md={refs.summary} />
    <Flowchart def={refs.flow} />
  </Section>
);
```

这样流式体验就好很多。文本块、图表、Mermaid 都可以逐步出现,最后 JSX 只负责布局。

但继续跑下去,又发现另一个问题:并不是所有内容都应该进入这套协议。

有些回答就是普通 Markdown。比如解释一个问题、列几个步骤、写一个结论,根本不需要组件系统。如果强行让模型都走 JSX,就会把简单问题复杂化。

所以我把这套语法放进了 xjsx 代码块。只有模型明确输出:

纯文本
```xjsx
...
```

前端才会按 XJSX 解析。其他内容仍然按普通 Markdown 渲染。

这就是 XJSX 的第一阶段:它不是为了替代 Markdown,而是给“确实需要结构化展示”的回答提供一个更强的表达方式。

问题开始变多

XJSX 跑起来后,确实能做出一些不错的效果。但随着使用次数变多,错误率也开始变得明显。

最容易理解的问题是语法错误。模型会少写一个括号,少闭合一个标签,或者生成一个不存在的组件。比如系统里只有 Flowchart,它可能写成 MermaidChart;系统里要求 ---ref:charts:userdata---,它可能写成 ---ref:data:userdata---

这类问题看起来可以靠提示词解决,但实际只能缓解,不能根治。

更麻烦的是代码块边界问题。

XJSX 本身在 Markdown 代码块里,但 XJSX 内部又可能包含 Markdown 内容。只要内部 Markdown 里也出现同样数量的反引号,外层代码块就会被提前结束。这个问题非常烦,因为它不是业务逻辑错,而是 Markdown 本身没写对。

我调了很久提示词,要求模型内部不要输出同级别反引号,也在前端做了兜底修复,才勉强把“代码块提前结束”的问题压下去。但这类修复很脆弱,因为模型只要换一种写法,问题又会回来。

还有 ECharts 配置错误。模型会生成不存在的图表配置字段,或者在 dataset、encode、series 之间写出互相对不上的结构。前端可以做兼容,但兼容太多之后,协议就会越来越混乱。

这类错误还有一个麻烦点:它不是后端拿到文本时就一定能判断出来。XJSX 的设计里,图表配置最终要交给前端组件和 ECharts 实例执行,很多问题只有真正渲染时才会暴露。也就是说,系统必须等前端完成一次渲染,再由前端把错误上报回来,后面才有机会进入修复流程。

于是后面加了 review + fix。

review + fix 也救不了所有问题

当时的思路是:既然模型第一次生成容易错,那就让 reviewer 针对特定方向并行检查,最后由 fixer 统一用行级补丁修复。

检查方向大概有:

  • XJSX 语法是否合法。
  • ref 有没有被 JSX 引用。
  • 图表配置是否能渲染。
  • 组件是否存在。
  • 布局和证据链是否合理。

其中“图表配置是否能渲染”不是一个纯静态检查。它依赖前端真实渲染后的错误上报:前端渲染 XJSX,ECharts 抛错或组件异常,再把错误信息带回到 review + fix 链路里。

这个方案能修一部分问题,但并没有想象中稳定。

比如一个内容块没有被 JSX 引用,正确修复方式应该是修改 JSX,把这个内容块接入 UI。可是 reviewer 可能会得出结论:这个内容块没有引用,所以应该删除。这样看起来“问题消失了”,实际上证据链被删掉了。

再比如 fixer 修完以后,新的代码依然可能有语法错误。它可能修好了一个标签,又引入了另一个括号问题。或者它为了修复图表,把配置改成一个前端仍然不能识别的结构。

还有一些幻觉问题更难处理。模型会生成不存在的代码块类型、组件名、组件属性。前端能做白名单校验,但校验失败后怎么修,仍然要靠模型。模型不一定知道系统里真实存在什么。

这时候我意识到,XJSX 最大的问题不是“错误太多”,而是它很难强校验。

它本质上仍然是一段自由生成的代码。可以做解析,可以做运行时限制,可以做前端兜底,可以做 reviewer 和 fixer,但很难像一个严格协议一样在生成时就把错误挡住。

最后就会变成:协议看起来很强,实际很多问题只能手动修。

这也引出了后来的 a2ui。

a2ui 看起来更正确,但实际更不稳定

后来同事 review 这块代码时提到 Google 有 a2ui 协议,并问为什么不用这个方案。

从人的角度看,a2ui 的方向确实更完善。它不是让模型直接生成一段 JSX,而是用工具调用逐步组装 UI。数据和 UI 分开,协议可校验,模型还可以在失败后自己检查和修复。

这听起来非常合理。

因为 XJSX 暴露出来的问题,很大一部分就是无法强校验。那如果换成结构化 JSON,加上严格 schema,是不是就能解决?

我调研并尝试之后,发现事情没有这么简单。

a2ui 把数据和 UI 分成两块,理论上更干净,实际会出现很多新的错误:

  • UI 没有引用已经生成的数据。
  • UI 引用了不存在的数据。
  • 数据结构改了,UI 没有同步改。
  • 某一步失败后模型反复重试,把前面已经生成好的部分又改坏。
  • 刚看到页面生成了一部分,下一段又被模型删掉或重组。
  • 最后提交出来的结果像是多次中间状态叠在一起,变成四不像。

这类问题和 XJSX 的语法错误不一样。它不是“某一行写错了”,而是模型没有稳定维护 UI 状态。

我当时用 Gemini 的时候发现 Gemini 自己的应用中UI生成也有类似的问题。页面经常闪烁、变化、损坏,刚生成出来的 UI 一会儿又消失。这个现象基本说明了一件事:协议本身再完善,如果模型没有针对这种表达形式训练过,它就很难稳定使用。

也就是说,a2ui 在工程师眼里是更正确的协议,但在当前模型眼里不一定是更容易的协议。

模型更擅长一次性写一段 Markdown,或者写一段相对完整的代码。它不擅长在多轮工具调用中维护一个复杂 UI 树的中间状态。尤其是数据和 UI 分开之后,引用关系变多,错误传播也更隐蔽。

这时我对这件事的判断变了。

之前我以为问题是“需要一个更强的 UI 协议”。后来发现,真正的问题可能是:对话里根本不应该承载这么复杂的 UI 生成。

对话里真的需要复杂 UI 吗

后来我不再参与原来的项目,自己写了一个 Agent 项目 RunForge,把这些反思带了进去。

在 RunForge 里,我最终选择了更克制的方案:

  • 默认使用 Markdown。
  • 需要流程、结构、关系、简单图表时使用 Mermaid。
  • 需要公式时使用 LaTeX。
  • 需要复杂报表、交互页面、可分享结果时,生成 HTML artifact 文件。

这套规则看起来没有 XJSX 那么酷,但更稳定。

因为大部分对话中的图表,并不是为了产出一份精美报告。很多时候只是为了“看一下数据大概是什么样”。用户需要的是快速理解趋势、结构、流程和关系,不需要一张经过精细设计的 ECharts 图。

这里更关键的判断是:LLM 不应该在对话里过多关注样式和展示。

它应该专注于回答用户问题,负责把数据、结构、关系和结论表达清楚。至于图表怎么渲染、颜色怎么选、间距怎么排、不同图表之间的视觉风格怎么统一,应该交给系统来做。这样图表的稳定性、准确性和样式一致性都更可控,也不会让模型把上下文浪费在展示细节上。

如果真的要给别人看,或者需要一个美观的 dashboard,那它就不应该塞在聊天气泡里。它应该是一个 artifact,一个独立的 HTML 或 Markdown 文档。这样用户可以打开、分享、保存,也可以让模型用更完整的上下文去生成页面,而不是在流式对话里一点点拼 UI。

Mermaid 的好处是,它不太给模型发挥样式的空间。

模型只需要表达节点、边、关系、时序、状态。它输出的是更接近“数据和结构”的东西,而不是一份带着大量样式决策的图表实现。样式交给前端统一处理,渲染交给系统完成。这样 token 不会浪费在颜色和绝对位置上,整个对话里的图表风格也更统一。

这和最开始做 XJSX 的动机其实是一致的:不要让 LLM 做它不擅长的事情。

只不过第一阶段的答案是“让它写 JSX 组件,不要写 HTML 样式”;后来的答案变成了“能用 Markdown/Mermaid 表达,就不要让它写 UI 组件”。

现在的边界

我现在对这类能力的边界大概是这样看的。

对话里的展示应该优先满足三个目标:

  1. 快速生成
  2. 稳定渲染
  3. 帮助理解

不应该优先追求:

  1. 每次都定制布局
  2. 每次都生成复杂组件树
  3. 在聊天气泡里做完整 dashboard

如果只是解释流程,用 Mermaid。

如果只是展示结构,用 Mermaid。

如果只是说明结论和证据,用 Markdown 表格和列表。

如果只是写公式,用 LaTeX。

如果真的需要复杂交互、筛选、联动、精美样式和可分享页面,再生成 HTML artifact。

这不是能力倒退,而是边界变清楚了。

LLM 不是不能生成 UI,但让它在对话中持续维护复杂 UI 协议,成本很高,稳定性也差。尤其当协议需要跨数据、布局、引用、状态、多次修复时,模型很容易在中间状态里迷路。

总结

XJSX 对我来说不是一个失败的方案。它验证了一个方向:直接让 LLM 生成 HTML/SVG 确实不靠谱,组件化和内容引用能明显降低样式浪费,也能改善流式预览。

但它也暴露了另一个问题:只要协议仍然依赖自由代码生成,就很难做到强校验。review + fix 可以缓解,但不能从根上解决语法、引用、组件幻觉和错误修复的问题。

a2ui 看起来解决了强校验问题,但又把问题转移到了状态维护和数据/UI 引用一致性上。当前模型对这种多次工具调用组装 UI 的形式并不擅长,甚至不如直接生成 Markdown/Mermaid 稳定。

所以最后的结论反而很简单:

对话里的可视化应该克制。能用 Markdown/Mermaid/LaTeX 表达的,就不要上复杂 UI 协议。需要交付和分享的,再生成 artifact。

这件事看起来是在选渲染方案,实际是在给 LLM 划工作边界。

不要把所有看起来炫的能力都塞进对话框里。很多时候,让模型少做一点,系统反而更可靠。