让 LLM 学会嵌入图表:Lakemind 内联图表引用机制的实现解析
让 LLM 学会"嵌入图表":Lakemind 内联图表引用机制的实现解析
做 AI Agent 产品时有一个常见痛点:LLM 生成了图表,但在最终文字总结中引用图表时,要么贴一段裸 Markdown 图片语法  让用户看到一堆乱码,要么只能用文字干巴巴地描述"如上图所示"。
最近在 Lakemind 中实现了一套内联图表引用机制,让 LLM 能像写 Markdown 链接一样,在文字总结的任意位置嵌入一张可交互的图表。这篇文章拆解实现思路。
问题背景
Lakemind 的 Agent 在分析数据时会调用 render_chart 工具生成 ECharts 图表。工具执行完毕后,Rig 框架会把工具返回值注入到对话上下文中,LLM 据此知道图表已生成。但在最终文字总结中引用图表时,出现了两个问题:
- LLM 倾向用
这种 Markdown 图片语法,但 Lakemind 的图表不是静态图片,而是 ECharts 实例——这个语法无法渲染,只会显示成裸文本 - 即使告诉 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:...}}标记原样粘贴到对应位置,该处会原地渲染为交互式图表 - 负面禁止:禁止使用
等 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 可能比文字标记晚到。MessageText 用 createMemo 依赖 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
更多推荐



所有评论(0)