让 LLM 学会"嵌入图表":Lakemind 内联图表引用机制的实现解析

做 AI Agent 产品时有一个常见痛点:LLM 生成了图表,但在最终文字总结中引用图表时,要么贴一段裸 Markdown 图片语法 ![alt](url) 让用户看到一堆乱码,要么只能用文字干巴巴地描述"如上图所示"。

最近在 Lakemind 中实现了一套内联图表引用机制,让 LLM 能像写 Markdown 链接一样,在文字总结的任意位置嵌入一张可交互的图表。这篇文章拆解实现思路。

问题背景

Lakemind 的 Agent 在分析数据时会调用 render_chart 工具生成 ECharts 图表。工具执行完毕后,Rig 框架会把工具返回值注入到对话上下文中,LLM 据此知道图表已生成。但在最终文字总结中引用图表时,出现了两个问题:

  1. LLM 倾向用 ![销售额趋势](url) 这种 Markdown 图片语法,但 Lakemind 的图表不是静态图片,而是 ECharts 实例——这个语法无法渲染,只会显示成裸文本
  2. 即使告诉 LLM “不要用图片语法”,它也无法在文字流中精确地表达"在这里插入刚才那张图表"

解决方案:{{chart:<id>}} 标记协议

核心思路是设计一套轻量标记协议,让 LLM 在文本中嵌入标记,前端解析标记并原地渲染图表。

Rust 端(render_chart 工具)

工具执行成功后,不再只返回一句简单的摘要,而是返回一个包含标记的字符串:

let marker = "{{chart:".to_string() + &call_id + "}}";
Ok(format!(
    "{}。在结论中引用此图,请将以下标记原样粘贴到结论对应位置:{}",
    user_summary, marker
))

这里用 String 拼接而不是 format! 宏,原因是 format! 会把 {{ 解析为字面量 {,导致标记被转义。

call_id 是本次工具调用的唯一标识,同时也是前端 ChartSegment 的 id,保证了标记和图表的一一对应。

PREAMBLE 提示词引导

在系统提示词中新增两条规则:

  • 正面引导:在结论中引用图表时,将 {{chart:...}} 标记原样粘贴到对应位置,该处会原地渲染为交互式图表
  • 负面禁止:禁止使用 ![alt](url) 等 Markdown 图片语法引用图表,图表不以图片形态存在

前端解析:文本分段与图表匹配

新增 chartRef.ts 负责文本拆分和图表查找,MessageText.tsx 负责渲染。

拆分逻辑

const CHART_REF_RE = /\{\{\s*chart:\s*([^}]+?)\s*\}\}/g;

export function splitTextByChartRefs(text: string): TextChunk[] {
  // 用正则把文本切成交替的 text / chartRef 片段
  // 流式输出时未闭合的 {{chart: 会留在尾部 text 片段中
  // 等 }} 到达后自动愈合为标记
}

图表查找三级降级

export function findChartSegment(segments: Segment[], ref: string) {
  // 1. 精确 id 匹配——主路径
  // 2. 标题模糊匹配——LLM 用记忆中的标题引用时
  // 3. 数字序号 {{chart:1}} → 第一张图——最简引用
}

渲染组件

<Show when={hasRefs()} fallback={<MarkdownRenderer content={props.text} />}>
  <For each={chunks()}>
    {(c) => (
      <Switch>
        <Match when={c.kind === "text"}>
          <MarkdownRenderer content={c.content} />
        </Match>
        <Match when={c.kind === "chartRef" && c.chart}>
          <ChartSegment seg={chart()} />
        </Match>
        <Match when={c.kind === "chartRef"}>
          <span class="chart-ref-missing">📊 图表引用未找到</span>
        </Match>
      </Switch>
    )}
  </For>
</Show>

当没有任何标记时,直接走 MarkdownRenderer,零回归。当标记无法匹配到图表时,显示一个 muted badge 而不是泄露原始 {{...}} 花括号。

流式输出的边界情况

Agent 的回复是流式的——文字逐字到达,图表 segment 可能比文字标记晚到。MessageTextcreateMemo 依赖 props.segments,当新的 chart segment 到达时自动重新解析,标记会从"未找到"状态自愈为正常图表。

设计要点回顾

决策 选择 理由
标记格式 {{chart:id}} 双花括号避免与 Markdown 语法冲突
id 来源 工具调用的 call_id 天然唯一,且就是 segment id
拼接方式 String::to_string() 避免 format! 的花括号转义陷阱
查找降级 id → 标题模糊 → 序号 兼容 LLM 的不精确引用
无标记时 直接走 MarkdownRenderer 零回归保证

这套机制的核心洞察是:不要试图让 LLM 生成完美的富文本,而是给它一套简单的标记协议,让前端做重活。 LLM 只需要复制粘贴一个标记,剩下的事情——文本拆分、图表匹配、原地渲染、异常降级——全部由前端代码保证。

相关链接

  • Commit: https://github.com/tsingliuwin/lakemind/commit/43055e254ab5b81bf4d3d7003027576d83e21b5d
  • GitHub 仓库: https://github.com/tsingliuwin/lakemind
  • 许可证: AGPL-3.0
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐