这段时间团队里讨论最多的一件事,就是怎么把“数据聊天”这件事真正落地。我们手头有一个内部BI项目,用户在群里问一句“上周各区域销售额结构怎么样”,需要先后经历:找数据同学确认口径、写SQL、挑图表类型、调配色、把截图发回群里。一来一回少说半小时,遇到口径理解偏差,一上午就没了。后来我们用大模型把自然语言直接转成ECharts图表配置,把这条链路压缩成几秒钟,用户看到的不再是截图,而是一张能交互、能下钻、能改配置的实时图表。这篇文章就围绕这个实战项目,聊聊怎么用LLM把一句口语转成一份可落地的ECharts配置,包括整体管线设计、Prompt工程细节、后端校验兜底、前端渲染闭环,以及上线后才真正感受到的成本和延迟问题。
很多人第一反应是“让LLM直接生成HTML+JS渲染图表”,这听起来很直接,但实际跑起来会发现是个巨大的坑。真正稳的方案是让LLM只生成结构化配置,渲染交给前端ECharts,数据则从受控接口获取。这套思路适合正在做对话式数据分析、AI报表助手、自然语言可视化相关项目的开发者,也适合想了解LLM在实际业务中怎么避免“能跑但没法用”这类问题的读者。
1. 为什么先想清楚“LLM只负责配置,不负责渲染”这件事
1.1 直接生成完整代码的三种失控场景
最早我们试过让模型直接输出一个完整的HTML文件,包含内联JS和ECharts CDN引用。Demo阶段非常惊艳,输入一句“展示近30天订单量趋势”,浏览器里立刻渲染出一张漂亮的折线图,连动画效果都有。但进入真实业务场景以后,问题接踵而至。首先是安全性,让模型在你不知道的情况下拼接一段可执行脚本,等于把XSS漏洞主动请进门,尤其当这段脚本需要携带用户Token去请求受保护的数据接口时,风险完全不可控。其次是可维护性,模型生成的HTML结构每次都不一样,哪怕只有一个像素级别的布局差异,前端同学也要重新review一遍,根本谈不上版本管理和自动化测试。第三就是联动能力,对话式分析天然需要图表随对话持续修改,用户说“换成柱状图”,模型就得重新生成整个文件,会话上下文里的修改意图很难被有效复用。
所以真正落地时,我们收敛了目标:LLM只负责输出一份合法的ECharts option配置,不碰任何渲染逻辑。ECharts的option本身是声明式JSON结构,天生适合作为LLM的结构化输出目标,这比让模型去组织DOM层级要安全可靠得多。
1.2 把任务切成“理解+选型+结构化输出”三段
把任务拆开以后,模型的职责变得非常清晰。第一步是理解用户的自然语言,识别出用户到底想看什么维度的数据,是趋势、占比、对比还是分布。第二步是图表选型,在一份预设的枚举范围内选择最合适的图表类型,比如趋势对应折线图、占比对应饼图、排名对比对应柱状图、分布对应散点图或热力图。第三步是结构化输出,把选型结果和字段映射关系整理成一封固定的JSON响应。
这个切分最大的价值是把不确定性留在模型内部,而不是传导到系统边界。用户可以接受模型猜错图表类型,因为我们在交互层预留了“换个图表”的按钮;但用户不能接受模型生成了一段执行后报错的前端代码。前者是业务体验问题,后者是系统稳定性问题。我们在设计阶段就把这两类风险分开处理了。
1.3 ECharts声明式配置恰好是LLM友好的输出形态
ECharts的option配置大概是这样的:
{ "tooltip": { "trigger": "axis" }, "legend": { "data": ["华东"] }, "xAxis": { "type": "category", "data": ["周一", "周二", "周三"] }, "yAxis": { "type": "value" }, "series": [ { "name": "华东", "type": "line", "smooth": true, "data": [120, 132, 101] } ] }这份JSON结构高度规整,字段名是有限的、数据类型是固定的,组合关系也基本是固定的套路。对比让模型自由写代码,让模型填一份结构已知的“表格”,输出准确率和可校验性都会高很多。而且ECharts本身支持直接setOption合并更新,天然适配对话里的增量修改,比如用户说“把折线改成柱状”,我们只需要把series里的type字段从line改成bar,再重新注入数据即可,不需要整份配置重新生成。
这个设计的本质是把大模型中不可控的部分限制在“选什么图、选哪些字段、怎么聚合”这些业务判断上,而把渲染、交互、数据获取这些工程能力完全收回到代码里。
2. 主链路拆解:从一句自然语言到一份可渲染option
2.1 四段式流水线:归一化→选型→数据请求→配置注入
整个对话式数据分析的主链路,我们最终收敛成四段流水线。第一段是问题归一化,把用户带口音的、带错别字的、夹杂口头禅的自然语言整理成模型内部使用的标准意图描述,同时结合会话历史判断这是一个全新问题还是对上一张图的修改。第二段是图表选型与字段映射,模型基于预设的图表枚举和字段白名单,输出一份“图表配置草案”。第三段是数据请求,后端根据草案里的字段和聚合方式,调用受控的数据查询接口,拿到真正的数值。第四段是配置注入,把数值放进option配置里,交给前端渲染。
这里有一个非常关键的设计决定:让模型输出字段映射和聚合规则,但不让模型直接生成查询语句。后面章节会详细展开这个决定背后的安全考量,简单说就是模型对数据库结构的理解没有100%把握,口径一旦错了,图表看着正常但数据含义全错,这种错误比渲染失败更难排查。
2.2 关键的“配置与数据分离”设计
我们踩过一个很实在的坑:最开始让模型一次性输出“带数据的完整配置”,也就是把数值结果也写在series.data里。这么做的坏处非常明显。第一,模型输出的是静态快照,用户没法点选图例、没法缩放坐标轴,因为底层没有真实数据对象。第二,模型输出JSON的体量会随着数据量增长,三十个城市的销售数据塞进一张饼图的配置里,已经逼近上下文窗口边缘,稍有不慎就把前面的对话历史挤出窗口。第三,数值由模型“编造”还是由查询接口真实返回,很难区分,这对数据准确性要求高的场景是致命的。
所以最终形态是模型输出一份不含数值的配置骨架,标注好“这里放什么字段、做什么聚合、按什么排序”,后端拿到这个骨架后从数据服务层请求真实数据,再由一个轻量的模板填充器把数值注入骨架。渲染端拿到的永远是一份数据真实、结构完整的option。
2.3 一次完整请求的伪代码与请求/响应示例
下面这端伪代码是整套主链路的浓缩,分享出来供参考:
// 前端发起对话请求 const response = await fetch('/api/chat-chart', { method: 'POST', body: JSON.stringify({ message: '帮我看看华东大区近7天每天的销售额', sessionId: 'session_12345', chartId: null // 如果是对旧图修改,这里传上一轮的chartId }) }); // 后端主流程伪代码 function handleChatChart(message, sessionHistory, chartId) { // 1. 问题归一化:带上历史上下文判断意图 const normalized = llmNormalize(message, sessionHistory); // 2. 图表选型与字段映射:输出配置骨架,不含数值 const skeleton = llmChooseChart(normalized, CHART_ENUM, FIELD_SCHEMA); // skeleton示例: // { // "chartType": "line", // "dimensions": ["day"], // "metrics": [{ "field": "sales_amount", "aggregation": "sum" }], // "groupBy": ["region"], // "filters": [{ "field": "region", "operator": "equals", "value": "华东大区" }], // "title": "华东大区近7天每日销售额" // } // 3. 从受控数据接口获取真实数值 const data = await queryDataService(skeleton); // 4. 配置注入:把数据填充进ECharts option const option = buildEChartsOption(skeleton, data); return { option, skeleton }; }这个流程跑通以后,我们内部管它叫“配置骨架-数据填充”模式。之后所有的新需求几乎都在这个模式上扩展,比如增加地图类型、增加同比环比、增加钻取维度,改动都是局部增量,架构本身无需变化。
3. Prompt工程:图表选型、字段映射与JSON约束的落地细节
3.1 图表类型枚举:把开放选择关进笼子
大模型最擅长的就是给你“惊喜”,所以Prompt里第一件事就是对图表类型做硬约束。我们没有让模型在无限多图表类型里自由发挥,而是先梳理业务里的真实场景,最终只保留了六种图表:折线图、柱状图、饼图、散点图、热力图、漏斗图。每一种都配了明确的适用条件和禁用条件。
Prompt里写了这样一段约束:
请从以下图表类型中选择最合适的一种,只能输出枚举值,不要自创类型: - line:适合展示连续时间维度上的趋势变化 - bar:适合展示分类维度上的数值对比,类目在8个以内用柱状图,超过8个建议考虑条形图 - pie:适合展示占比结构,类目在2到10个之间才建议使用 - scatter:适合展示两个数值字段的分布关系 - heatmap:适合展示两个分类维度的交叉密度 - funnel:适合展示转化路径的阶段流失这个枚举看似简单,但极大地提升了后续处理的稳定性。图表类型一旦被限定,后端的数据处理逻辑、前端的渲染分支都是确定性的,不需要为每一种未知类型做通用处理。另外,我们看到一个有意思的现象:在约束枚举之后,模型对“什么时候该用什么图”的理解反而更准确,因为它不需要在几十个候选里纠结了。
3.2 数据Schema注入:给模型一份“只能选这些字段”的白名单
第二个关键点是字段白名单。我们做的是内部业务系统,数据表结构还算规整,核心指标无非销售额、订单量、客单价、退款率、库存周转天数这些。与其让模型在几十张表几万行字段schema里猜,不如直接把当前用户有权访问的指标清单注入Prompt。
Prompt里我们放了一份精简版字段列表:
当前数据环境可用的字段如下(仅能使用这些字段): - region_cn: 区域名称,文本,可用作维度 - province_cn: 省份名称,文本,可用作维度 - day: 日期,格式YYYY-MM-DD,可用作维度 - sku_category: 商品分类,文本,可用作维度 - sales_amount: 销售额,数值,可聚合(sum/avg) - order_cnt: 订单量,数值,可聚合(sum/avg) - refund_rate: 退款率,数值,可聚合(avg)字段白名单带来的收益立竿见影:模型不再凭空编造字段名,数据查询层不会一直报“字段不存在”。但有一个细节需要提醒,白名单不是越多越好,把几百个指标全塞进去会让模型选错字段的概率明显上升。我们最终的做法是,根据对话中识别出的业务主题动态注入相关字段子集,比如用户聊“库存”,就只注入库存相关的十余个字段。这个动态注入的调度逻辑,比单纯堆Prompt的技巧重要得多。
3.3 Few-shot示例与输出schema
Prompt里给3个完整的输入输出示例非常有用,但这里有个容易过犹不及的坑。示例不是为了教模型什么是折线图,而是为了教模型“输出的边界在哪”。我们的示例刻意包含了三种典型场景:一个正常的趋势查询、一个带模糊表述的查询(模型需要猜意图并给出理由)、一个不适合可视化的查询(模型应提示用户补充条件,而不是强行生成图表)。
输出格式约束我们用了一个严格的schema,不是让模型自由发挥JSON,而是指定了字段名、类型、可选值,并且在Prompt末尾强调“严格按此JSON结构输出,不要增加任何额外字段”。实际测试中,加了这句硬约束之后,JSON解析成功率从七成左右提升到九成以上,剩下的那些解析失败再由后端兜底处理。
3.4 温度参数与可解释性字段:让模型少一些自由发挥
很多人忽视了生成参数的设置对结构化输出的影响。我们的实践是把这类对话流程模型的temperature调到0.2甚至更低,目的就是让输出尽量确定、可复现。图表配置不是创意文案,用户不会因为配置措辞有惊喜而高兴,只会因为柱状图变成折线图而困惑。低温参数是“让模型当好翻译员”的关键设置。
另外我们要求模型在输出的JSON里附带一个reason字段,用一句话说明为什么选这个图表类型、为什么选这些维度。这个字段不参与渲染,但会展示在图表下方,用户看到“因为近30天的订单量变化是连续时间趋势,所以推荐折线图”这类解释时,对模型选型结果的信任度会明显提升。这也是降低返工率的一个低成本技巧。
4. 后端校验与兜底:LLM返回无效JSON时的工作流设计
4.1 一次真实的“烂输出”及其救回过程
无论Prompt怎么写,LLM总会有抽风的时候。我们遇到过模型输出了一段带Markdown代码块的JSON,遇到过字段名和Prompt里的白名单不一致,遇到过聚合方式写了个我们系统里根本不存在的“median”,也遇到过图表类型和字段含义完全对不上。
举一个真实发生过的例子,用户问“各区域销售额对比”,模型输出的skeleton是:
{ "chartType": "pie", "dimensions": ["province_cn"], "metrics": [{ "field": "sales_amount", "aggregation": "sum" }], "filters": [], "title": "各省份销售额占比" }单看skeleton本身没什么问题,但前端渲染阶段发现province_cn返回了34个值。34个省份塞进一张饼图,视觉上就是一团密密麻麻的色块,根本没法看。这说明模型按字面理解了“各区域”,但没有结合我们Prompt里“pie图类目2到10个之间才建议使用”这条约束。
这种问题靠提示词优化只能缓解,不能根除。所以我们把这类规则也搬到了后端校验层,单独写了一套图表合理性检查规则,不是依赖模型自觉,而是由代码强制保证。
4.2 两轮修复循环与降级策略
后端拿到模型输出后,我们设计了一个两轮校验修复循环。第一轮做JSON合法性校验,格式解析失败就用修正Prompt让模型重新输出。第二轮做业务校验,包括字段是否在白名单内、聚合方式是否合法、图表类型与维度数量是否匹配。业务校验失败时,我们会尝试在代码里做自动修复,比如维度过多时自动切换成柱状图并截取前10个类目。
如果两轮修复都没有成功,进入降级策略:不再显示图表,而是给用户返回一个结构化列表。列表按同样的skeleton里的字段和聚合逻辑展示数据,同时附上一句提示“当前问题自动生成图表失败,已为你展示数据明细,你可以换一种问法试试”。这个降级策略非常重要,它保证系统永远不会给用户一个空白页面或者一张错到离谱的图表,最坏的情况也有一份可读的数据兜底。
4.3 数据执行层的权限白名单与聚合限制
数据执行层是整个系统安全性的最后一道防线。我们的核心策略是让数据查询走专用的服务接口,这个接口不接受任意SQL,只接受白名单字段和限定聚合方式。换句话说,模型团队再怎么乱输出,到了接口层都会被拦截。
具体做法是三张表:第一张是用户权限表,决定当前登录用户能看到哪些区域的数据;第二张是字段白名单表,和Prompt里注入的子集保持一致;第三张是聚合操作表,只允许sum、avg、count、max、min这五种,其余聚合方式一律拒绝。任何请求如果触碰了这三张表之外的资源,直接返回错误。
这套约束把模型从“直接写SQL”的风险里彻底隔离出来。用户通过对话最多只能查询到他有权限访问的数据,最多只能按有限聚合方式计算,不存在拼接SQL注入的路径,也不存在越权读取的风险。
5. 前端渲染与多轮修改:用户不是在看答案,而是在调仪表盘
5.1 从option到ECharts渲染的兼容性处理
后端的option构造完成后,前端拿到JSON直接调用实例的setOption方法渲染。但这里有一个兼容性处理需要特别注意:ECharts版本升级后,某些配置字段的写法会变化,比如旧版的label.normalize在新版里改成了label.valueFormatter的写法。我们的做法是在前端维护一个轻量级的配置适配层,根据当前项目里锁定的ECharts版本对option做一层清洗,把废弃字段映射成新写法,避免后端构造的配置在不同版本间出现差异。
另外,我们的option并不完全由后端构造,前端会把一些交互体验相关的、不应该由模型干预的配置在渲染前统一覆盖。比如tooltip的触发方式、legend的位置、图表区域的边距,这些布局细节我们从一开始就固定写死,模型生成的配置里即使带了这些字段,也会被前端强制覆盖。这样既保留了模型对业务内容的掌控力,又保证UI风格始终一致。
5.2 多轮对话里的配置合并策略
对话式分析的核心体验在于多轮修改。用户第一句问“各区销售额”,第二句说“不要饼图,换成柱状图”,第三句又说“帮我按华东和华北对比”。这三个请求不是三个独立的图表生成任务,而是对同一张图的三次增量修改。
我们的做法是把会话上下文压缩成两个部分:一部分是“当前生效的配置骨架”,一部分是“最近三轮的自然语言交流记录”。新一轮请求进来,后端同时把这两个部分传给模型,让它基于当前图表做修改,而不是从零生成。这样做的token开销比把所有历史完整堆给模型要小得多,而且模型对“改哪里”的理解准确性更高。
这里有一个细节:多轮修改时,模型返回的skeleton可能是残缺的,比如只包含了被修改的字段。后端的配置合并器会把新一轮skeleton与当前生效配置做深合并,只替换被修改的部分。这套机制最大的受益者是前端,因为前端永远只需要监听整个配置对象的变化,而不用关心哪一轮改了哪个字段。
5.3 展示“为什么选这张图”的理由
前面提到skeleton里有个reason字段,前端会把这段文字渲染在图表下方的一个小气泡里,同时还加了一个“换个图表”按钮。点击这个按钮,前端会把当前配置里支持的六种图表类型展示出来,用户点选后直接触发配置更新,不经过模型。这么做是给用户一个明确的下车路径:模型选型可以错,但用户永远有权力手动纠正。
这个小设计对整体体验的提升超出预期。用户遇到“被猜错意图”的情况时,不再是一句“这系统不行”就放弃,而是会先看看模型的理由,发现“哦,它可能是因为我提到了占比才选了饼图”,然后轻松切换到柱状图。这种透明度带来的信任感,是对话式分析系统能在内部推广开的一个关键因素。
6. 上生产之后才意识到的三件事:成本、延迟与缓存
6.1 token成本核算示例
规模小时,谁也不会在乎token成本,但用户量上来以后,成本问题立刻变得扎眼。我们做个简单的核算:假设一次图表生成的Prompt包含系统指令、动态字段白名单、Few-shot示例和用户问题,大约需要1500到2500个输入token,模型输出量也要500到1000个token。按当前通用商用模型的定价粗算,一次请求的模型成本大约是几分钱。听起来不贵,但如果一天有几千次请求,加上多轮修改的重复调用,一个月的成本就出来了。
控制成本的方法有几个方向。一是动态注入字段子集,前面说过这个方案,它对成本的影响同样明显,字段列表从几十个缩小到十几个,输入token直接省了三分之一。二是把Few-shot示例精简精简再精简,我们试过把三条示例压到两条,准确率几乎没有变化,输入token又省了一块。三是非高峰期复用离线缓存,比如每月固定的月度经营分析报告,第一次花成本生成配置和查询数据,之后一个月内都命中缓存。
6.2 延迟优化:流式输出与切分
延迟是另一个容易被忽略的问题。LLM接口的响应时间普遍在1到3秒,再加上数据查询的时间,用户会明显感觉到“转圈圈”。我们做了两步优化。
第一步是流式输出配置骨架。因为配置骨架里第一个字节通常是chartType,这个字段一出来,前端就可以先渲染一个半透明的占位图表骨架并显示加载动画,后边的字段陆续到达再逐步填充。用户感知到的首屏时间从“等待整个JSON生成完”缩短到“等待第一个字节出来”,体感差距非常明显。
第二步是数据查询并行化。skeleton里的指标如果有多个,数据服务层会并发请求多个指标的计算结果,而不是串行等待。在数据量较大的场景下,这一步能把总耗时从三到四秒压到两秒以内。两秒依然不算快,但对于对话式交互来说,已经处于用户心理承受阈值之内。
6.3 查询归一化缓存
最后一个上线后才重视起来的事是缓存。用户的问题虽然千奇百怪,但从数据结构上看,真正不同的查询其实没有那么多。比如“华东大区近7天销售额”和“华东这次7天卖得怎么样”大概率指向同一份配置和数据。
我们实现的方案是基于归一化消息做哈希,把同一会话内类似的问题映射到同一份配置缓存。具体做法是在问题归一化阶段额外输出一个语义ID,该ID由用户权限、识别出的字段集合、聚合方式、过滤条件、时间范围共同决定。后端用这个ID缓存option配置和查询结果,命中缓存时可以直接跳过模型调用和数据查询。从实测数据看,日常运营类问题的缓存命中率能达到三到四成,考虑到每个缓存命中的请求都省掉了整次模型调用,这个优化对成本和延迟的双重收益都非常可观。
缓存实现上有个笨但有效的前提:缓存的key必须包含用户权限标识,否则A用户能看到的区域数据会被B用户的缓存串出去,这是绝不能含糊的。
最后分享点个人体会
这套系统做完以后我最大的感受是:对话式数据分析的难点不在于让LLM“看懂”自然语言,而在于让LLM的输出边界和整个工程系统对齐。把模型从“生成一切”的角色降级成“只做配置决策”的角色,把数据获取、渲染、权限控制、缓存这些确定性工作全部收回代码里,系统才能真正从Demo走向生产。如果后续要继续扩展,可以往两个方向走:一是加入更多的图表组合逻辑,让一句话同时生成包含多个图表的整张看板;二是接入多模态输出,让模型不只生成图表,还能生成配套的文字解读。但不管怎么扩展,配置骨架与数据分离这套核心架构,应该是不需要推翻的。