LiveReview团队让LLM只生成图表定义DSL,由专门的渲染引擎处理像素,实现同一图表定义同时输出Web交互图和Slack静态图。
先把丑话说在前头。
有数据是好事。但数据库里堆满了代码评审、提交记录和组织活动数据,却无人问津、没有读者,从来没有哪个喝着咖啡、有想法的人瞄过一眼?那不叫"有数据",那叫昂贵的数据墓地。
LiveReview 做的是我们称之为"业务关键系统的爆炸半径感知 AI 代码评审"。
说白了就是:帮你评审代码、评估变更出问题时有多严重、然后追着你不放直到你修好为止。
这一路下来我们积累了大量评审数据:谁评审了、多少、多快、多频繁、哪些仓库在冒烟。
有段时间,这些数据就这么放着落灰。
工程负责人问"采用率在涨吗?"得到的回答是一种感觉,而不是答案。
所以我们做了 Livi,一个能回答关于这些数据的真实问题、给出真实图表而不是大段含糊文字的聊天机器人。
这篇文章技术层面讲的是 Livi 如何绘制这些图表。
具体来说:为什么我们从不让 LLM 碰一个像素、为什么同一个图表定义既能在浏览器里变成活的可交互图形、也能在 Slack 频道里变成静态 PNG、以及为什么教语言模型选对图表形状是一个意外深的坑。
核心决策:不要让 LLM 画图,让它来描述
那个诱人但错误的想法是:"让 LLM 生成一张图片。"千万别这么做。
图像生成模型是完全不同的物种,哪怕你真能让一个模型画个柱状图,你也没有办法验证上面的数字是真实的。
你让一个会把评审数量编得听起来很靠谱的模型,同时忠实地把它们渲染成像素。
这不是图表,这是图表形状的同人文。
真正好的想法——也是每个正经做 LLM 图表集成最终都会走向的方向——是:让 LLM 写 Vega-Lite,一种声明式描述图表的 JSON 语法。
你不说"画一根往上走的蓝色柱子",你说:
{
"mark": "bar",
"encoding": {
"x": { "field": "month", "type": "temporal" },
"y": { "field": "review_count", "type": "quantitative" }
}
}
这就是整个图表。
没有像素、没有绘画,只有对数据含义和如何映射到图像的描述。
Vega-Lite 完成真正的绘制工作。

LLM 的工作缩小到它真正擅长的事情上:填一个定义良好的 schema。
模型在"选 mark: bar 还是 mark: line"上比"凭空想象 600 个像素的正确 y 轴"强得多。
关键的是:LLM 在数字被渲染出来之前永远看不到真实的数字。
它写 SQL,我们执行,然后真实的结果集通过我们自己的 Go 代码被拼接到 data.values 里。
模型可以在呈现方式上随心所欲。
但对数字,它没有任何发挥的余地。
从"上个月有多少评审"到屏幕上的图表之间实际发生了什么
大致流程是这样的:
有几个地方值得展开说,因为每一个步骤的存在都是因为之前出过问题。
为什么要分两步写 SQL 而不是一步?因为模型需要知道答案会有多少行,才能决定是画个图表还是有意义、或者直接给你一个 CSV。
没人想要一个带 4000 根柱子的柱状图。
所以第一步基本上是"这会有多大",第二步才是"好,现在真正给我数据并告诉我怎么画"。
为什么要有 SQL 守卫?因为让 LLM 裸写 SQL 对接多租户数据库,只需要一个措辞自信的提示词就能变成 org_id = 1 OR 1 = 1。
我们对每个生成的查询都跑一个守卫,拒绝任何非只读操作、检查每个表是否在黑名单里、特别关注租户隔离绕过的形态(常量与常量比较、裸露的 OR TRUE,各种经典手法)。
这工作不光彩,但它是"酷 AI 功能"和"为什么组织 A 能看到组织 B 的评审数据"出现在事故频道之间的分水岭。
(表情包占位:"嗯是的,但实际不是" / 骑车摔倒的人。顶部文字:"查询里有 org_id 过滤。"底部文字(正在摔):"WHERE org_id = 1 OR 1=1")
为什么 LLM 只能看到 schema 缩窄后的切片,而不是整个数据库?
因为我们实际的 schema 有 50 多张表,把所有表都塞进每个提示词成本高得离谱,而且很容易让模型搞混哪个 created_at 属于哪张表。这个值得展开说说,它值得有自己的章节。
教模型表的存在:dbctx
这是一个直到你的 schema 不再是玩具 demo 时才会暴露的问题。
我们有 58 张表而且还在增加:评审、Pull Request、AI 评论、评审反馈、账单、授权、任务队列,等等。
如果每次提示词都粘贴完整 schema,光是描述 license_seat_assignments 就每问题烧掉数千 token,而模型其实被问的是"上个月有多少评审",它完全不关心、永远也不会关心那张表。
所以我们不用"这是整个数据库,祝你好运",而是采用 dbctx,一个专门为这个问题构建的 Go 库:给定一个自然语言问题和一个活的 Postgres 连接,只返回实际相关的表,格式化为紧凑的、对 LLM 友好的文本,而不是原始的 information_schema 转储。
将 PostgreSQL 数据库编译成紧凑的、可查询的上下文。
将 PostgreSQL 数据库编译成紧凑的、可查询的上下文。
dbctx 是一个 Go 库和 CLI 工具,将 PostgreSQL 数据库编译成可移植的、可查询的上下文索引(.dtx 文件)。它从确定性的内省、统计和启发式方法中提取 schema、关系、字段语义、代表性值、JSONB 结构,并构建全文搜索索引——核心索引无需生成式 LLM 和外部服务。它还支持可选的本地语义嵌入信号和可选的、用户控制的术语字典——两者都是增量式的、默认关闭成本的,下面会描述。
用它来为文本转 SQL 系统、AI Agent 和数据库感知应用在查询时提供数据库 schema 的紧凑、相关切片,而不是每次提示词都倾倒整个 information_schema。
自然语言查询——从文本问题中找到相关的表、列和关系
语义检索(可选,默认开启)——本地嵌入模型(BGE-small-en-v1.5,约 33M 参数)……
它用的是真正分层的检索管道,不是简单的关键词匹配。
对表名和列名进行词法匹配和模糊匹配、全文搜索、对数据中实际采样值的匹配、可选的语义嵌入层,以及我们自己喂给它的精心策划的术语层(所以"LOC"解析为 billable_loc,"MR"和"PR"都解析为同一个底层 Pull Request 概念,因为我们的用户取决于来自哪个 Git 托管平台两者都说)。
任何得分大于零的都会被拉进来,再加上通过外键可以从得分项到达的任何东西,因为与问题零词法重叠的连接目标往往恰恰是查询需要的。
效果不是微妙的。
在我们真实的 schema 上,一个典型问题把 58 张表缩小到大约 25 到 30 张左右,这意味着在模型写出一个 SQL 字符之前,上下文就减少了一半。
这不是四舍五入的优化,这是模型能真正清晰推理的提示词和重要表被淹没在账单和授权座位噪音中的提示词之间的区别。
像素最终出现的部分:vl-convert
现在我们有了 Vega-Lite spec。太棒了。
如果用户在 Web 仪表板上,基本上就完成了,待会再说。
但 Slack 呢?Discord 呢?这些平台不会在聊天消息里运行 JavaScript 图表库。
Slack 消息不是浏览器标签页。
你不能客气地请 Slack 解释 Vega-Lite spec 并为你渲染 SVG。
所以对于任何非我们自有前端的地方,我们需要一个实际的图像文件。
这就是 vl-convert 的用武之地:一个 Rust 二进制文件(有 Python 和 Node 绑定,但我们调用它的 CLI),接收 Vega-Lite spec 并直接光栅化为 PNG,不需要无头浏览器。
最后这部分比听起来重要得多。
服务端渲染图表的老派方法是启动一个无头 Chrome 实例,加载带图表库的页面,截图,然后祈祷你的 Docker 镜像不会膨胀到两吉字节。
vl-convert 跳过了所有这些。
一个单独的二进制文件,接收 JSON,输出 PNG 字节,而且速度快到没人注意到它在发生。
(表情包占位:康普茶女孩,厌恶然后好奇的两格图。厌恶格:"启动无头 Chrome 来截图一个图表。"好奇格:"一个 Rust 二进制文件,JSON 进,PNG 出。")
同一个 spec,两种截然不同的命运
这是我实际上觉得很有意思的部分。
我们每个图表生成一个 Vega-Lite spec。
它接下来发生什么完全取决于它要去哪里。
(表情包占位:交易offer / 我的世界村民交易。给出:"一个 Vega-Lite spec"。换取:"浏览器里的活的可交互图表,或者 Slack 频道里的一张静态 PNG,取决于它最终去哪里。")

在 Web 上,前端直接把原始 spec 交给 react-vega,让浏览器完成工作。
你可以获得悬停提示、可以获得调整大小的能力、获得一个真正可交互的图表,而我们的后端对这个路径零图像渲染。
对于 Slack 和 Discord,相同的 spec 改为通过 vl-convert 路由,变成静态 PNG 作为文件附件发送到消息里。
机器人不知道也不关心这算是"同一个"图表走了不同的路。
从管道的角度来看,Vega-Lite spec 就是 Vega-Lite spec。
它去哪里决定了它变成活的、呼吸的 SVG 还是一张安静的、永远坐在聊天线程里的 JPEG 相邻物。
这种分流也是我们能低成本添加新图表目的地的原因。
想要带嵌入式图表的邮件每周摘要?相同的 spec、相同的 vl-convert 路径、新的投递机制。
困难的问题(从 LLM 那里获得正确、合理图表 spec)只解决了一次。
Livi 的意义从来不是"加个聊天机器人",而是"把我们已经在收集的数据和真正需要根据它采取行动的人之间的闭环闭合"。
CTO 问"工程师真的在用这东西吗"应该得到一个看起来像使用节律日历热力图的答案,而不是一个电子表格加一个耸肩。
如果你在构建类似的东西,配方真的不复杂:
永远不要让模型碰像素。让它写声明式 spec。
永远不要让模型在你自己运行它的查询之前看到真实数字。
守卫查询要认真,不要凭感觉写正则。
选择一个渲染管道(我们用的是 vl-convert),让目的地决定静态还是交互式,而不是模型。
为图表品味预留真正的提示词空间。这是真正需要迭代的部分。
LiveReview 评审你的代码、告诉你变更可能有多糟糕、追着你不放直到修好——而 Livi 把所有那些积累的评审数据变成人类真正能采取行动的图表。
如果你喜欢这篇文章,给仓库点个 ⭐,现在就试试 LiveReview。