ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

对话式数据分析系统:用LLM将自然语言自动转为ECharts配置

对话式数据分析系统:用LLM将自然语言自动转为ECharts配置

刚做完一个挺有意思的项目:对话式数据分析系统。用户输入一句大白话,比如“给我看看最近三个月各区域的销售额对比,顺便标出环比增长率”,系统自动把这句话转成ECharts配置,前端直接渲染出图表。整个过程不需要拖拽组件,不需要手写option,全部靠LLM理解意图、映射数据、生成配置。我在这篇文章里把这套东西的完整思路、架构设计、核心代码、踩坑记录全部整理出来,项目命名为“某对话式数据分析系统”(以下简称“对话分析系统”)。希望对正在做类似LLM应用落地的朋友有点帮助。

这套方案最直接的价值有三个。第一,数据分析门槛大幅降低,业务人员不需要懂前端,也不需要会写SQL,只要会提问就能看数据。第二,开发效率提升明显,原来写一个图表配置需要查文档、调样式,现在只要Prompt设计得好,LLM生成的配置完成度能做到80%以上。第三,对话上下文天然适合多维度的探索式分析,用户可以从“总销售额是多少”一路追问到“华东区哪个品类增长最快”,系统保持上下文连贯。

适合看这篇文章的人主要是这几类:做LLM应用开发的工程师,想知道怎么把大模型输出跟具体业务场景做可靠对接;前端开发者,想了解ECharts配置的自动化生成思路;数据分析师或数据产品经理,想低成本实现一个智能BI的Demo。

1. 先想清楚:自然语言转图表配置,难在哪

在写代码之前,我花了不少时间梳理这个问题的本质。表面上这是“把文字变成JSON配置”,但实际操作中你会发现,真正的难点根本不在配置生成上,而是在“语义理解”和“数据对齐”这两层。

1.1 传统方式的三个明显痛点

传统的图表开发流程,通常是前端工程师拿到需求,查ECharts文档,手写option,联调数据接口,最后调整样式。这个过程有几个老生常谈但一直没被很好解决的问题。

第一,需求沟通成本高。业务方说“做个好看的图”,但“好看”的标准是什么?柱状图还是折线图?颜色用蓝色系还是渐变色?要不要显示数值标签?这些细节点如果每个都靠沟通确认,效率非常低。

第二,图表配置的细节非常多。ECharts的option结构说简单也简单,说复杂也复杂。一个坐标轴就有十多个配置项,series里还有堆叠、平滑、动画、渐变色等用法。稍微没查文档就很容易写错属性名,或者漏掉必要的配置项导致图表渲染异常。

第三,数据格式跟图表类型强耦合。饼图需要{name, value}结构,折线图需要xAxis的category数据和series的data数组对齐。后端接口返回的数据往往不是现成的,还需要前端做大量map、filter、reduce的转换操作。

1.2 LLM介入之后,流程发生了什么变化

LLM介入后,原来的“需求 → 设计 → 开发 → 联调”压缩成了“提问 → 出配置”。用户直接用自然语言描述想要看什么,系统通过大模型解析意图、识别维度与指标,再从数据源取数并聚合,最后生成结构化ECharts配置。

这个流程里,LLM承担了最费脑子的部分:意图理解、字段映射、图表类型选择、配置内容生成。人只需要做两件事:一是把问题描述清楚,二是在结果不满意时追加一句“换成玫瑰图”或“不要显示数值标签”。这是典型的“AI Copilot”模式,不是替代人,而是替代了人跟图表配置搏斗的过程。

1.3 一个关键认知:LLM输出的可靠性取决于约束强度

这个项目做下来我最深的体会是:LLM生成的自由文本能力很强,但直接让它输出“JSON配置”时,如果不对输出格式做强制约束,结果会非常不稳定。可能出现多余的注释、Markdown代码块标记、错误的字段名、字符串和数值类型混乱等问题。

所以整个系统的核心设计思想就一句话:在自由语义和严格配置之间搭一座桥。LLM负责理解自然语言,但它输出的不是裸配置,而是经过Prompt约束、Schema校验、兜底修复三道防线之后才交给前端渲染。这座桥建得牢不牢,直接决定了系统的可用性。

2. 系统整体架构与模块划分

这个系统的架构设计从一开始就确定了原则:只做LLM能做好且适合做的事,其他全部用传统代码兜住。整体分为四层,各司其职。

2.1 四层结构:对话层、语义层、数据层、渲染层

  • 对话层:负责接收用户输入、维护多轮会话上下文、记录用户对配置结果的修正反馈。
  • 语义层:核心的Prompt编排与LLM调用模块,把用户问题结构化为“意图 + 维度 + 指标 + 图表偏好”四要素。
  • 数据层:负责从数据库中取数、按维度聚合、把结果转成ECharts需要的标准数据格式。
  • 渲染层:前端拿到配置和数据后直接执行,同时提供局部修改的入口。

每一层都只跟相邻层通信,不跨层调用。这样做的最大好处是,任何一个模块出问题都能单独替换或降级。

2.2 为什么选择ECharts而不是其他图表库

这个项目选择ECharts是一个经过比较后的决定,不是因为它最流行,而是它最适合这个场景。

对比之下,Chart.js的配置简单但图表类型较少,复杂统计图表支持一般。D3.js的定制性最强但学习曲线陡峭,让LLM生成D3代码的难度远高于生成声明式配置。而ECharts恰好是声明式配置的典型代表,本质上就是一个大的JSON对象,这在“大模型生成配置文件”的场景里优势非常明显。

另外ECharts的中文文档完善,社区方案丰富,遇到特殊图表需求(比如桑基图、主题河流图)时有现成案例。对于LLM应用来说,训练数据里ECharts的示例浓度也相对够用,模型对该配置结构的辨识度明显更高。

2.3 数据源与指标体系的预设

这个环节非常重要,也是很多人在做类似项目时容易忽略的。直接让LLM去数据库里查表是不现实的,你不可能让模型知道你的库里有什么表、字段叫什么、有没有权限限制。所以我在数据层做了一个“数据字典预设”的操作。

预先在系统里定义好业务指标和维度的映射关系,比如:

  • “销售额” → 字段total_amount,聚合方式SUM
  • “订单量” → 字段order_count,聚合方式COUNT
  • “区域” → 字段region,分组维度
  • “时间” → 字段order_date,按月份格式化

LLM不直接面对数据库,它只面对这份数据字典,输出“我要用哪些字段、怎么聚合、按什么维度分组”。然后由传统的代码模块把这个结构化描述翻译成SQL查询,执行取数。这样做有三重好处:安全性可控、生成的SQL不会乱来、字段歧义大幅降低。

3. Prompt工程:让LLM稳定输出ECharts配置

Prompt是这个项目的灵魂。如果Prompt设计得不到位,后面写得再漂亮也没用。这一节把我反复迭代出来的Prompt方案完整讲清楚。

3.1 系统级Prompt的结构化编排

我采用的System Prompt分为四个部分:角色设定、任务定义、数据字典、输出格式要求。下面是一个精简但完整的示例:

你是一个数据分析助手。你的任务是根据用户提出的问题,生成ECharts可视化配置。 可用的数据字段: - 日期维度:order_date(格式 YYYY-MM-DD) - 区域维度:region(值:华东、华南、华北、西南、西北) - 品类维度:category(值:数码、家电、服饰、食品) - 指标:total_amount(销售额,数值,单位元) - 指标:order_count(订单量,数值) 使用规则: 1. 从用户问题中提取需要展示的维度和指标。 2. 如果用户指定了图表类型,优先使用指定类型; 否则根据字段性质和关系推荐:时间序列用line,类别对比用bar,占比用pie。 3. 输出必须是JSON对象,包含两个字段:sql和option。 sql字段包含维度和指标信息,用query_columns表示。 option字段是ECharts的完整配置对象,必须包含title、tooltip、xAxis、yAxis、series。 4. 不要输出任何解释性文字,不要使用Markdown代码块标记,只输出JSON。 本次会话开始。

这个Prompt里面值得注意的细节是:我明确说了“不要使用Markdown代码块标记”,否则LLM很容易把JSON包在```json里,增加后端解析的额外动作和出错概率。同时把字段含义和可用值直接写进Prompt,让LLM能根据用户描述精确匹配,而不是自己瞎编。

3.2 图表类型的自动推荐规则

最开始的时候,我让LLM完全自由决定图表类型,结果发现它经常选得不对。比如用户问“各地区销售额情况”,它给个雷达图,视觉上好看但信息表达效率其实不高。后来我在Prompt里加了显式的推荐逻辑,效果一下子稳定了很多。

推荐规则参考了传统BI工具的选择逻辑:

  • 用户问题出现“趋势”“变化”“走势”等关键词,优先折线图。
  • 出现“对比”“排名”“Top”等关键词或明确提到“柱状图”,优先柱状图。
  • 出现“占比”“比例”“百分比”或单一维度多指标对比,优先饼图或环形图。
  • 出现“分布”“区间”“密度”等关键词,优先直方图或散点图。

这个规则本质上是在给LLM一个“决策路径”,比让它完全自由发挥要可靠得多。

3.3 Few-shot示例的加入时机

初期版本没有加入few-shot,效果不稳定但勉强能用。但遇到复杂一点的查询(比如“按区域和品类两个维度交叉分析销售额”),模型输出的SQL字段排列和ECharts配置就可能出现混乱。

后来我在Prompt里加入了两个典型示例:一个是简单的时间趋势图,一个是带钻取的环形图。示例会提多个不同场景的few-shot,让模型对不同复杂度的问题都能找到参照。注意few-shot不是越多越好,通常2到3个覆盖不同难度级别就够了,多了会拉长输入Token并增加成本。

加完few-shot之后,配置生成的成功率明显上了一个台阶。特别是涉及多指标并列展示的场景,模型能正确输出两个yAxis和两个series的配置结构。

3.4 用户反馈修正的上下文利用

多轮对话是这个系统的亮点,但开始我也没想清楚怎么管理上下文。如果每轮都把全部历史记录丢给LLM,Token消耗和响应延迟都受不了,而且很容易导致模型被无关信息干扰。

后来采用的方案是:维护一个结构化会话状态,每轮只保留三部分信息:用户最近的输入、上一轮的配置修正指令、系统上一轮输出的图表配置摘要。在Prompt中以“历史修改记录”的形式拼接,让模型知道用户上一次做了什么调整。

比如用户第一轮问“看看各区域销售额柱状图”,系统生成柱状图;第二轮说“改成玫瑰图,并且加上百分比”,历史记录里就保留“上一轮为bar图,用户希望改为pie玫瑰图并显示百分比”。这样模型就知道是在原有基础上修改,而不是重新生成一个全新的图表,上下文连贯性就有了保障。

4. 核心实现逻辑与关键代码细节

这一节是全文最硬核的部分。我会把后端接口、LLM输出解析、数据聚合映射、前端渲染这几个核心环节的代码逻辑完整走一遍,附上关键代码段和踩坑说明。

4.1 后端接口:接收自然语言并返回结构化配置

后端我用Python + FastAPI实现,整体是一个轻量级的代理服务。核心接口就一个:接收用户输入和会话ID,返回ECharts配置和状态信息。

示例代码如下:

from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import Optional, List, Dict, Any import json import uuid app = FastAPI() class QueryRequest(BaseModel): message: str session_id: Optional[str] = None class QueryResponse(BaseModel): session_id: str echarts_option: Dict[str, Any] sql_info: Dict[str, Any] raw_response: str # 模拟会话存储 session_store: Dict[str, List[Dict[str, str]]] = {} @app.post("/api/query", response_model=QueryResponse) async def handle_query(req: QueryRequest): if not req.session_id: req.session_id = str(uuid.uuid4()) history = session_store.get(req.session_id, []) # 调用核心处理函数 result = process_natural_language_query(req.message, history) # 保存会话历史 history.append({"role": "user", "content": req.message}) history.append({"role": "assistant", "content": result["raw_response"]}) session_store[req.session_id] = history[-10:] # 只保留最近10条 return QueryResponse( session_id=req.session_id, echarts_option=result["option"], sql_info=result["sql_info"], raw_response=result["raw_response"] )

这个接口设计比较朴素,但足够用。需要注意两点:一是会话存储简单用内存字典实现了,真实项目建议换Redis,否则重启会话就丢了;二是历史记录做了长度限制,避免无限增长导致Token成本失控。

4.2 LLM输出解析:从文本到JSON的可靠路径

这是整个项目里最坑的地方。LLM返回的内容偶尔会比较不可控,有时会非常规整地直接输出一个JSON,但也有时候会在JSON前后加一些自然语言描述,甚至在键名里加了没必要的引号,或者把布尔值写成字符串。

我封了一个解析函数来处理这类问题:

def parse_llm_json_response(text: str) -> dict: # 1. 直接尝试解析 try: return json.loads(text) except json.JSONDecodeError: pass # 2. 尝试提取JSON块,兼容 ```json ``` 或 ``` 包裹 import re json_pattern = r'```(?:json)?\s*(.*?)\s*```' match = re.search(json_pattern, text, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # 3. 尝试从文本中截取第一个 { 到最后一个 } 之间的内容 start_idx = text.find('{') end_idx = text.rfind('}') + 1 if start_idx != -1 and end_idx > start_idx: try: return json.loads(text[start_idx:end_idx]) except json.JSONDecodeError: pass # 4. 最后兜底:抛出明确异常 raise ValueError(f"无法从LLM输出中解析JSON: {text[:200]}")

实际测试下来,这个四级解析方案能覆盖绝大多数情况。我强烈建议不要只做第一级直接解析就完事,因为LLM输出的不确定性决定了你必须在解析层面做足够防御。

4.3 数据映射:SQL信息转成真实查询结果

前面Prompt里要求LLM输出sql字段,这个字段不是真正的SQL语句,而是我自定义的一个结构化查询描述。例如:

{ "query_columns": { "dimensions": ["region"], "metrics": [{"field": "total_amount", "agg": "SUM"}] }, "filter": { "date_range": "最近3个月" } }

后端拿到这个结构之后,再拼接成真的SQL去查数据库。这样做的好处是LLM不需要知道具体的数据库表结构,它只需要理解字段语义,真正的SQL生成由代码完成,可靠性和安全性都有保障。

import sqlite3 def generate_sql(sql_info: dict) -> str: dims = sql_info.get("query_columns", {}).get("dimensions", []) metrics = sql_info.get("query_columns", {}).get("metrics", []) dim_str = ", ".join(dims) if dims else "1" metric_str = ", ".join([f"{m['agg']}({m['field']}) AS {m['field']}_{m['agg'].lower()}" for m in metrics]) sql = f"SELECT {dim_str}, {metric_str} FROM sales_data GROUP BY {dim_str}" if dims else f"SELECT {metric_str} FROM sales_data" return sql

这里有一个实际项目中很容易踩的坑:如果用户只问“总销售额是多少”,没有指定维度,那GROUP BY子句就不能加,否则会按一行返回没有意义的结果。所以我在generate_sql函数里做了判断,没有任何维度时直接查询总指标,这个逻辑务必不要忽略。

4.4 ECharts配置的二次校正与降噪

LLM直接生成的option虽然整体结构正确,但偶尔会有一些影响渲染的细节问题。我写了一个配置文件的后处理函数,专门处理常见的“低级错误”。

def normalize_echarts_option(option: dict) -> dict: # 确保基本配置项存在 option.setdefault("tooltip", {"trigger": "axis"}) # 检测series是否为空列表 if not option.get("series"): raise ValueError("图表配置中series为空,无法渲染") # 修正xAxis/yAxis格式:ECharts允许对象或数组,但必须保证data存在 if "xAxis" in option and isinstance(option["xAxis"], list): if len(option["xAxis"]) == 1: option["xAxis"] = option["xAxis"][0] # 确保series.data与xAxis.data长度一致(这是一个高频问题) x_data = option.get("xAxis", {}).get("data", []) if isinstance(option.get("xAxis"), dict) else [] if x_data: for series_item in option.get("series", []): data_len = len(series_item.get("data", [])) if data_len > len(x_data): series_item["data"] = series_item["data"][:len(x_data)] return option

这里最值得说的是xAxis.data和series.data长度不一致的问题。由于LLM生成的series.data有时候会来自SQL查询结果,而SQL结果和维度列表之间存在顺序或数量上的错位,这个不一致会导致图表显示不全或坐标轴错位。写一个硬性的截断对齐逻辑,能避开不少渲染异常。

4.5 前端渲染与配置修正接口

前端这一端,我选择了Vue 3 + ECharts 5的组件化方案。核心逻辑就是接收后端返回的option,塞进ECharts实例,监听窗口变化做resize。

// ChartRenderer.vue (关键代码片段) <template> <div ref="chartEl" style="width: 100%; height: 480px;"></div> </template> <script> import * as echarts from 'echarts'; export default { name: 'ChartRenderer', props: { option: { type: Object, required: true } }, data() { return { chart: null }; }, watch: { option: { deep: true, handler(newVal) { if (!this.chart) return; this.chart.setOption(newVal, true); } } }, mounted() { this.chart = echarts.init(this.$refs.chartEl); this.chart.setOption(this.props.option, true); window.addEventListener('resize', this.handleResize); }, methods: { handleResize() { this.chart && this.chart.resize(); } }, beforeUnmount() { window.removeEventListener('resize', this.handleResize); this.chart && this.chart.dispose(); } }; </script>

注意一个细节:setOption的第二个参数我传了true,表示notMerge。这个在多次更新配置时很重要。如果不传,ECharts会做merge操作,可能出现上一次图表残留的问题,比如柱状图切换为折线图时旧的柱子还在。传true以后,每次全量覆盖,配置更新就是干净的。

5. 模型选型与成本控制策略

LLM是这个系统的核心动力源,选什么模型、怎么控制成本,直接影响项目能否从Demo走向落地。这一节聊聊我实测下来的选型感受和优化手段。

5.1 不同能力等级模型的表现差异

实测了几款当前主流的商用和开源模型,简单对比一个直观感受:

  • 轻量级模型(或同等级小参数模型):能处理简单配置生成,但复杂一点就经常出错。特别是多指标、双y轴、数据聚合逻辑,容易把配置结构写乱。
  • 中端模型:整体可用,在加了few-shot之后,解决常见图表生成问题表现不错。是这个系统的主力模型。
  • 高端旗舰模型:流畅度和理解力确实更好,但成本高出不少。适合用于复杂查询、多轮对话语义纠偏的场景,不适合作为默认全量调用。

具体到项目里,我是按问题复杂度做模型路由的。简单问题(单指标、单维度)走轻量模型,复杂问题(多指标、多维分析、图表修改)走中高端模型。这样既保住了体验,又控制了成本。

5.2 成本估算实例

假设一个中等复杂度的查询,输入Prompt约1200 Token,输出配置约600 Token。一次调用的总Token约为1800 Token。

按目前的Token计价折算下来单次约为千分之几元。如果日均调用1000次,一个月成本在几十元到上百元之间,完全在可接受范围内。但如果未来做到企业级、日调万次甚至更多,就需要引入更激进的缓存策略和语义分割方案。

成本控制我建议一定要做Token统计,每个会话、每次请求都记录Token消耗。有了数据才知道哪里该优化,不能拍脑袋省成本。

5.3 缓存策略:相同问题不重复计算

用户经常会问重复或高度相似的问题,比如“帮我看一下华东区销售额Top10的品类”。这个查询第一次执行后,结果短时间内一般不会变化。所以我在后端加了一个简单但有效的缓存层。

缓存Key由两部分组成:session_id + message的哈希值。有效期设个5分钟到10分钟。缓存命中直接返回上次的结果,不再调用LLM和数据源。实测命中率大约在20%到30%之间,整体响应速度和成本都优化了不少。

5.4 流式输出的权衡

很多LLM应用现在都搞流式输出,但在图表生成场景里,我一开始也做了,后来发现收益不大。图表配置的生成速度和流式展示的体验提升,对用户来说感知并不强。

因为用户最终看到的是图表渲染结果,而不是一行一行地看JSON。所以后端直接用同步接口返回完整配置,前端加一个加载动画,简洁且稳定。只有在制作长报告或复杂洞察分析时,流式输出的价值才会体现出来。

6. 常见问题与排查技巧实录

这个项目做了这么久,踩过的坑能写满一页A4纸。这一节把最高频的问题和排查思路整理成一个速查表,按“现象 → 原因 → 做法”的结构罗列,方便遇到问题直接对号入座。

6.1 图表渲染空白但控制台无报错

遇到这个问题我一开始一度怀疑是ECharts版本问题,排查了半天发现不是。后来定位到原因通常有两个:一是series.data为空数组,ECharts不渲染空数据,但也完全不报错;二是xAxis.data和series.data长度不匹配,导致某些数据点被丢弃。

排查步骤建议固定为:先把LLM返回的JSON打印出来,检查series和xAxis的data结构。其次,在后端对数据长度做一致性校验,发现不一致时提前修正。

6.2 生成的图表类型不符合用户预期

用户说“看下销售情况”,模型给出了饼图,但用户其实想看的是柱状图。这类问题本质上不是bug,而是意图理解不够精准。

解决思路有三个层次:

  • 最简单粗暴的:在Prompt中设定“当用户未明确指定图表类型时,默认使用柱状图”。
  • 较推荐的做法:把图表类型选择权交给一个单独的“意图分类”步骤,先判断用户想要的图表类型,再让LLM生成具体配置。
  • 体验最好的做法:前端提供图表类型切换按钮,用户不满意时一键切换,切换时只调整series类型和对应配置,不需要重新生成。

实际项目中我采用了第二和第三层的结合,既在对话层做了预分类,也在展示层做了快速切换兜底。

6.3 某些字段被LLM凭空捏造

这个问题在所有LLM应用中都会出现,图表配置场景也不例外。模型在生成查询字段时,偶尔会“发明”一些数据字典里根本不存在的字段名。比如数据字典里只有total_amount,单输出结果却可能出现profit_rate这种虚构指标。

这种问题的根源是Prompt数据字典的语义约束不够严格。我采取了三层防御:

  1. Prompt里加了硬性限制:“只能使用可用字段,不得新增字段”。
  2. 后端解析时加字段白名单校验,一旦发现未知字段立即抛异常要求模型重新生成。
  3. 加了重试机制,最多重试两次,用模型自己的上一个错误输出来纠正它。
def validate_sql_info(sql_info: dict, allowed_fields: set) -> bool: dims = sql_info.get("query_columns", {}).get("dimensions", []) metrics = sql_info.get("query_columns", {}).get("metrics", []) for dim in dims: if dim not in allowed_fields: return False for m in metrics: if m["field"] not in allowed_fields: return False return True

白名单校验看起来多做了一步,实际上避免了非常多后续的数据查询报错和渲染异常。

6.4 多轮对话的上下文污染

多轮对话是整个系统里维护成本最高的部分。我遇到过一种情况:用户第一轮问的是“华东区的销售额”,第二轮问“那华南呢”,模型把两轮问题合并成了一个查询,或者按第一轮的维度约束了第二轮的查询,最终结果不符合预期。

排查后发现,问题出在上下文拼接的方式上。我把完整的历史记录拼接后一次性发给模型,模型理解重点发生了偏移。后来改成“历史修改指令”模式,只保留上一轮的图表配置和用户最新的修改要求,情况明显好转。再进一步,可以在每轮会话记录时带上“当前数据范围”摘要,模型在生成新查询时就有明确的基准。

6.5 常见问题速查表

现象可能原因处理方案
前端图表空白series.data为空或长度不匹配打印JSON检查data,后端做长度校准
图表类型不符预期意图解析失败或Prompt缺乏倾向性增加类型预分类,前端提供手动切换
字段不存在模型幻觉白名单校验,异常时重试
多轮问题答非所问上下文过载或始终以历史为主结构化会话状态,只保留修正指令
生成的配置偶尔带Markdown标记模型输出格式不稳定解析时兼容```包裹,删除非JSON内容
响应延迟明显调用链路太长/模型太重走缓存、模型路由、去掉流式输出

6.6 一个被很多人忽略的细节:代码块包裹的冗余处理

即便我在Prompt里已经明确说了“不要使用Markdown代码块标记”,模型有时候还是会在输出中把JSON包在代码块里。前端直接传给后端解析时会失败。这个问题不复杂,但很影响体验。

我的建议是后端解析的时候做了一层“防呆处理”,不管模型输出什么格式,都尝试提取其中的JSON部分。也就是前面提到的解析函数第2层和第3层,这两层代码看着多余,实则在真实项目中能省下很多沟通和debug的时间。

7. 这个项目的边界与扩展可能性(个人实操回顾)

做到这个程度,这个系统基本达到了“业务人员能自己提问、自己看图”的效果。但我也很清楚它的边界在哪。数据字典预设意味着增加一个新的业务指标,需要手工维护字段映射。图表类型虽然覆盖面够用,但遇到非常规图表(比如关系图、树图、地图)时,生成效果依然不稳定。这些限制不是LLM本身的问题,而是工程上选择了“稳定优先”的路子。

从我实操的角度看,如果后续要继续演进,我会优先考虑两个方向。第一是接上真实的数据库Schema自动发现机制,让数据字典能动态更新,减少人工维护成本。第二是引入“用户行为反馈信号”,比如用户强行切换了图表类型、修改了取数范围,这些行为可以作为隐含标注数据,用于后续微调Prompt或模型。

这两个方向本质都是为了让系统从“固定配方”走向“自适应理解”。做出来之后,这个对话式数据分析系统的落地空间还会更大。

8. 最后的几个实操建议

文章写完之前,我再分享三个实用的经验总结,都是踩过坑换来的教训。

第一个建议:不要把LLM的JSON输出当成理所当然。无论Prompt写得多么严格,解析层都要做兜底。模型毕竟是概率模型,它的输出天然存在随机性,工程上必须设计好容错机制。用模板加校验加重试的方式,把不确定性限制在可控范围内。

第二个建议:在画界面之前,先把Prompt和数据结构跑通。我一开始最先做的是前端,以为把ECharts配好就完事了,结果后端LLM输出的格式频繁调整,导致前端也跟着改了两三回。如果先花时间把Prompt、输出Schema、解析逻辑确定下来,再去做前端,开发效率会高很多。

第三个建议:尽量保留每个请求的完整日志。LLM应用的调试难度远高于传统应用,因为很多问题不是代码逻辑错误,而是模型输出的语义偏差。完整记录Prompt输入、模型原始输出、解析后结果、最终渲染状态,排查问题时能省一半以上时间。日志字段用JSON格式存储,后面用来分析模型错误模式也能派上大用场。

这个项目目前已经作为内部工具投入使用,业务同学用得还挺顺手。如果你也在做类似的事情,希望里面的经验和代码片段能帮你少走几段弯路。有新的想法也欢迎交流。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进