據(jù)分析系統(tǒng):用LLM將自然語言自動轉(zhuǎn)為ECharts配置)
剛做完一個挺有意思的項目對話式數(shù)據(jù)分析系統(tǒng)。用戶輸入一句大白話比如“給我看看最近三個月各區(qū)域的銷售額對比順便標出環(huán)比增長率”系統(tǒng)自動把這句話轉(zhuǎn)成ECharts配置前端直接渲染出圖表。整個過程不需要拖拽組件不需要手寫option全部靠LLM理解意圖、映射數(shù)據(jù)、生成配置。我在這篇文章里把這套東西的完整思路、架構(gòu)設(shè)計、核心代碼、踩坑記錄全部整理出來項目命名為“某對話式數(shù)據(jù)分析系統(tǒng)”以下簡稱“對話分析系統(tǒng)”。希望對正在做類似LLM應(yīng)用落地的朋友有點幫助。這套方案最直接的價值有三個。第一數(shù)據(jù)分析門檻大幅降低業(yè)務(wù)人員不需要懂前端也不需要會寫SQL只要會提問就能看數(shù)據(jù)。第二開發(fā)效率提升明顯原來寫一個圖表配置需要查文檔、調(diào)樣式現(xiàn)在只要Prompt設(shè)計得好LLM生成的配置完成度能做到80%以上。第三對話上下文天然適合多維度的探索式分析用戶可以從“總銷售額是多少”一路追問到“華東區(qū)哪個品類增長最快”系統(tǒng)保持上下文連貫。適合看這篇文章的人主要是這幾類做LLM應(yīng)用開發(fā)的工程師想知道怎么把大模型輸出跟具體業(yè)務(wù)場景做可靠對接前端開發(fā)者想了解ECharts配置的自動化生成思路數(shù)據(jù)分析師或數(shù)據(jù)產(chǎn)品經(jīng)理想低成本實現(xiàn)一個智能BI的Demo。1. 先想清楚自然語言轉(zhuǎn)圖表配置難在哪在寫代碼之前我花了不少時間梳理這個問題的本質(zhì)。表面上這是“把文字變成JSON配置”但實際操作中你會發(fā)現(xiàn)真正的難點根本不在配置生成上而是在“語義理解”和“數(shù)據(jù)對齊”這兩層。1.1 傳統(tǒng)方式的三個明顯痛點傳統(tǒng)的圖表開發(fā)流程通常是前端工程師拿到需求查ECharts文檔手寫option聯(lián)調(diào)數(shù)據(jù)接口最后調(diào)整樣式。這個過程有幾個老生常談但一直沒被很好解決的問題。第一需求溝通成本高。業(yè)務(wù)方說“做個好看的圖”但“好看”的標準是什么柱狀圖還是折線圖顏色用藍色系還是漸變色要不要顯示數(shù)值標簽這些細節(jié)點如果每個都靠溝通確認效率非常低。第二圖表配置的細節(jié)非常多。ECharts的option結(jié)構(gòu)說簡單也簡單說復(fù)雜也復(fù)雜。一個坐標軸就有十多個配置項series里還有堆疊、平滑、動畫、漸變色等用法。稍微沒查文檔就很容易寫錯屬性名或者漏掉必要的配置項導(dǎo)致圖表渲染異常。第三數(shù)據(jù)格式跟圖表類型強耦合。餅圖需要{name, value}結(jié)構(gòu)折線圖需要xAxis的category數(shù)據(jù)和series的data數(shù)組對齊。后端接口返回的數(shù)據(jù)往往不是現(xiàn)成的還需要前端做大量map、filter、reduce的轉(zhuǎn)換操作。1.2 LLM介入之后流程發(fā)生了什么變化LLM介入后原來的“需求 → 設(shè)計 → 開發(fā) → 聯(lián)調(diào)”壓縮成了“提問 → 出配置”。用戶直接用自然語言描述想要看什么系統(tǒng)通過大模型解析意圖、識別維度與指標再從數(shù)據(jù)源取數(shù)并聚合最后生成結(jié)構(gòu)化ECharts配置。這個流程里L(fēng)LM承擔(dān)了最費腦子的部分意圖理解、字段映射、圖表類型選擇、配置內(nèi)容生成。人只需要做兩件事一是把問題描述清楚二是在結(jié)果不滿意時追加一句“換成玫瑰圖”或“不要顯示數(shù)值標簽”。這是典型的“AI Copilot”模式不是替代人而是替代了人跟圖表配置搏斗的過程。1.3 一個關(guān)鍵認知LLM輸出的可靠性取決于約束強度這個項目做下來我最深的體會是LLM生成的自由文本能力很強但直接讓它輸出“JSON配置”時如果不對輸出格式做強制約束結(jié)果會非常不穩(wěn)定??赡艹霈F(xiàn)多余的注釋、Markdown代碼塊標記、錯誤的字段名、字符串和數(shù)值類型混亂等問題。所以整個系統(tǒng)的核心設(shè)計思想就一句話在自由語義和嚴格配置之間搭一座橋。LLM負責(zé)理解自然語言但它輸出的不是裸配置而是經(jīng)過Prompt約束、Schema校驗、兜底修復(fù)三道防線之后才交給前端渲染。這座橋建得牢不牢直接決定了系統(tǒng)的可用性。2. 系統(tǒng)整體架構(gòu)與模塊劃分這個系統(tǒng)的架構(gòu)設(shè)計從一開始就確定了原則只做LLM能做好且適合做的事其他全部用傳統(tǒng)代碼兜住。整體分為四層各司其職。2.1 四層結(jié)構(gòu)對話層、語義層、數(shù)據(jù)層、渲染層對話層負責(zé)接收用戶輸入、維護多輪會話上下文、記錄用戶對配置結(jié)果的修正反饋。語義層核心的Prompt編排與LLM調(diào)用模塊把用戶問題結(jié)構(gòu)化為“意圖 維度 指標 圖表偏好”四要素。數(shù)據(jù)層負責(zé)從數(shù)據(jù)庫中取數(shù)、按維度聚合、把結(jié)果轉(zhuǎn)成ECharts需要的標準數(shù)據(jù)格式。渲染層前端拿到配置和數(shù)據(jù)后直接執(zhí)行同時提供局部修改的入口。每一層都只跟相鄰層通信不跨層調(diào)用。這樣做的最大好處是任何一個模塊出問題都能單獨替換或降級。2.2 為什么選擇ECharts而不是其他圖表庫這個項目選擇ECharts是一個經(jīng)過比較后的決定不是因為它最流行而是它最適合這個場景。對比之下Chart.js的配置簡單但圖表類型較少復(fù)雜統(tǒng)計圖表支持一般。D3.js的定制性最強但學(xué)習(xí)曲線陡峭讓LLM生成D3代碼的難度遠高于生成聲明式配置。而ECharts恰好是聲明式配置的典型代表本質(zhì)上就是一個大的JSON對象這在“大模型生成配置文件”的場景里優(yōu)勢非常明顯。另外ECharts的中文文檔完善社區(qū)方案豐富遇到特殊圖表需求比如桑基圖、主題河流圖時有現(xiàn)成案例。對于LLM應(yīng)用來說訓(xùn)練數(shù)據(jù)里ECharts的示例濃度也相對夠用模型對該配置結(jié)構(gòu)的辨識度明顯更高。2.3 數(shù)據(jù)源與指標體系的預(yù)設(shè)這個環(huán)節(jié)非常重要也是很多人在做類似項目時容易忽略的。直接讓LLM去數(shù)據(jù)庫里查表是不現(xiàn)實的你不可能讓模型知道你的庫里有什么表、字段叫什么、有沒有權(quán)限限制。所以我在數(shù)據(jù)層做了一個“數(shù)據(jù)字典預(yù)設(shè)”的操作。預(yù)先在系統(tǒng)里定義好業(yè)務(wù)指標和維度的映射關(guān)系比如“銷售額” → 字段total_amount聚合方式SUM“訂單量” → 字段order_count聚合方式COUNT“區(qū)域” → 字段region分組維度“時間” → 字段order_date按月份格式化LLM不直接面對數(shù)據(jù)庫它只面對這份數(shù)據(jù)字典輸出“我要用哪些字段、怎么聚合、按什么維度分組”。然后由傳統(tǒng)的代碼模塊把這個結(jié)構(gòu)化描述翻譯成SQL查詢執(zhí)行取數(shù)。這樣做有三重好處安全性可控、生成的SQL不會亂來、字段歧義大幅降低。3. Prompt工程讓LLM穩(wěn)定輸出ECharts配置Prompt是這個項目的靈魂。如果Prompt設(shè)計得不到位后面寫得再漂亮也沒用。這一節(jié)把我反復(fù)迭代出來的Prompt方案完整講清楚。3.1 系統(tǒng)級Prompt的結(jié)構(gòu)化編排我采用的System Prompt分為四個部分角色設(shè)定、任務(wù)定義、數(shù)據(jù)字典、輸出格式要求。下面是一個精簡但完整的示例你是一個數(shù)據(jù)分析助手。你的任務(wù)是根據(jù)用戶提出的問題生成ECharts可視化配置。 可用的數(shù)據(jù)字段 - 日期維度order_date格式 YYYY-MM-DD - 區(qū)域維度region值華東、華南、華北、西南、西北 - 品類維度category值數(shù)碼、家電、服飾、食品 - 指標total_amount銷售額數(shù)值單位元 - 指標order_count訂單量數(shù)值 使用規(guī)則 1. 從用戶問題中提取需要展示的維度和指標。 2. 如果用戶指定了圖表類型優(yōu)先使用指定類型 否則根據(jù)字段性質(zhì)和關(guān)系推薦時間序列用line類別對比用bar占比用pie。 3. 輸出必須是JSON對象包含兩個字段sql和option。 sql字段包含維度和指標信息用query_columns表示。 option字段是ECharts的完整配置對象必須包含title、tooltip、xAxis、yAxis、series。 4. 不要輸出任何解釋性文字不要使用Markdown代碼塊標記只輸出JSON。 本次會話開始。這個Prompt里面值得注意的細節(jié)是我明確說了“不要使用Markdown代碼塊標記”否則LLM很容易把JSON包在json里增加后端解析的額外動作和出錯概率。同時把字段含義和可用值直接寫進Prompt讓LLM能根據(jù)用戶描述精確匹配而不是自己瞎編。3.2 圖表類型的自動推薦規(guī)則最開始的時候我讓LLM完全自由決定圖表類型結(jié)果發(fā)現(xiàn)它經(jīng)常選得不對。比如用戶問“各地區(qū)銷售額情況”它給個雷達圖視覺上好看但信息表達效率其實不高。后來我在Prompt里加了顯式的推薦邏輯效果一下子穩(wěn)定了很多。推薦規(guī)則參考了傳統(tǒng)BI工具的選擇邏輯用戶問題出現(xiàn)“趨勢”“變化”“走勢”等關(guān)鍵詞優(yōu)先折線圖。出現(xiàn)“對比”“排名”“Top”等關(guān)鍵詞或明確提到“柱狀圖”優(yōu)先柱狀圖。出現(xiàn)“占比”“比例”“百分比”或單一維度多指標對比優(yōu)先餅圖或環(huán)形圖。出現(xiàn)“分布”“區(qū)間”“密度”等關(guān)鍵詞優(yōu)先直方圖或散點圖。這個規(guī)則本質(zhì)上是在給LLM一個“決策路徑”比讓它完全自由發(fā)揮要可靠得多。3.3 Few-shot示例的加入時機初期版本沒有加入few-shot效果不穩(wěn)定但勉強能用。但遇到復(fù)雜一點的查詢比如“按區(qū)域和品類兩個維度交叉分析銷售額”模型輸出的SQL字段排列和ECharts配置就可能出現(xiàn)混亂。后來我在Prompt里加入了兩個典型示例一個是簡單的時間趨勢圖一個是帶鉆取的環(huán)形圖。示例會提多個不同場景的few-shot讓模型對不同復(fù)雜度的問題都能找到參照。注意few-shot不是越多越好通常2到3個覆蓋不同難度級別就夠了多了會拉長輸入Token并增加成本。加完few-shot之后配置生成的成功率明顯上了一個臺階。特別是涉及多指標并列展示的場景模型能正確輸出兩個yAxis和兩個series的配置結(jié)構(gòu)。3.4 用戶反饋修正的上下文利用多輪對話是這個系統(tǒng)的亮點但開始我也沒想清楚怎么管理上下文。如果每輪都把全部歷史記錄丟給LLMToken消耗和響應(yīng)延遲都受不了而且很容易導(dǎo)致模型被無關(guān)信息干擾。后來采用的方案是維護一個結(jié)構(gòu)化會話狀態(tài)每輪只保留三部分信息用戶最近的輸入、上一輪的配置修正指令、系統(tǒng)上一輪輸出的圖表配置摘要。在Prompt中以“歷史修改記錄”的形式拼接讓模型知道用戶上一次做了什么調(diào)整。比如用戶第一輪問“看看各區(qū)域銷售額柱狀圖”系統(tǒng)生成柱狀圖第二輪說“改成玫瑰圖并且加上百分比”歷史記錄里就保留“上一輪為bar圖用戶希望改為pie玫瑰圖并顯示百分比”。這樣模型就知道是在原有基礎(chǔ)上修改而不是重新生成一個全新的圖表上下文連貫性就有了保障。4. 核心實現(xiàn)邏輯與關(guān)鍵代碼細節(jié)這一節(jié)是全文最硬核的部分。我會把后端接口、LLM輸出解析、數(shù)據(jù)聚合映射、前端渲染這幾個核心環(huán)節(jié)的代碼邏輯完整走一遍附上關(guān)鍵代碼段和踩坑說明。4.1 后端接口接收自然語言并返回結(jié)構(gòu)化配置后端我用Python FastAPI實現(xiàn)整體是一個輕量級的代理服務(wù)。核心接口就一個接收用戶輸入和會話ID返回ECharts配置和狀態(tài)信息。示例代碼如下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_modelQueryResponse) async def handle_query(req: QueryRequest): if not req.session_id: req.session_id str(uuid.uuid4()) history session_store.get(req.session_id, []) # 調(diào)用核心處理函數(shù) 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_idreq.session_id, echarts_optionresult[option], sql_inforesult[sql_info], raw_responseresult[raw_response] )這個接口設(shè)計比較樸素但足夠用。需要注意兩點一是會話存儲簡單用內(nèi)存字典實現(xiàn)了真實項目建議換Redis否則重啟會話就丟了二是歷史記錄做了長度限制避免無限增長導(dǎo)致Token成本失控。4.2 LLM輸出解析從文本到JSON的可靠路徑這是整個項目里最坑的地方。LLM返回的內(nèi)容偶爾會比較不可控有時會非常規(guī)整地直接輸出一個JSON但也有時候會在JSON前后加一些自然語言描述甚至在鍵名里加了沒必要的引號或者把布爾值寫成字符串。我封了一個解析函數(shù)來處理這類問題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. 嘗試從文本中截取第一個 { 到最后一個 } 之間的內(nèi)容 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]})實際測試下來這個四級解析方案能覆蓋絕大多數(shù)情況。我強烈建議不要只做第一級直接解析就完事因為LLM輸出的不確定性決定了你必須在解析層面做足夠防御。4.3 數(shù)據(jù)映射SQL信息轉(zhuǎn)成真實查詢結(jié)果前面Prompt里要求LLM輸出sql字段這個字段不是真正的SQL語句而是我自定義的一個結(jié)構(gòu)化查詢描述。例如{ query_columns: { dimensions: [region], metrics: [{field: total_amount, agg: SUM}] }, filter: { date_range: 最近3個月 } }后端拿到這個結(jié)構(gòu)之后再拼接成真的SQL去查數(shù)據(jù)庫。這樣做的好處是LLM不需要知道具體的數(shù)據(jù)庫表結(jié)構(gòu)它只需要理解字段語義真正的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 fSELECT {dim_str}, {metric_str} FROM sales_data GROUP BY {dim_str} if dims else fSELECT {metric_str} FROM sales_data return sql這里有一個實際項目中很容易踩的坑如果用戶只問“總銷售額是多少”沒有指定維度那GROUP BY子句就不能加否則會按一行返回沒有意義的結(jié)果。所以我在generate_sql函數(shù)里做了判斷沒有任何維度時直接查詢總指標這個邏輯務(wù)必不要忽略。4.4 ECharts配置的二次校正與降噪LLM直接生成的option雖然整體結(jié)構(gòu)正確但偶爾會有一些影響渲染的細節(jié)問題。我寫了一個配置文件的后處理函數(shù)專門處理常見的“低級錯誤”。def normalize_echarts_option(option: dict) - dict: # 確?;九渲庙棿嬖?option.setdefault(tooltip, {trigger: axis}) # 檢測series是否為空列表 if not option.get(series): raise ValueError(圖表配置中series為空無法渲染) # 修正xAxis/yAxis格式ECharts允許對象或數(shù)組但必須保證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查詢結(jié)果而SQL結(jié)果和維度列表之間存在順序或數(shù)量上的錯位這個不一致會導(dǎo)致圖表顯示不全或坐標軸錯位。寫一個硬性的截斷對齊邏輯能避開不少渲染異常。4.5 前端渲染與配置修正接口前端這一端我選擇了Vue 3 ECharts 5的組件化方案。核心邏輯就是接收后端返回的option塞進ECharts實例監(jiān)聽窗口變化做resize。// ChartRenderer.vue (關(guān)鍵代碼片段) template div refchartEl stylewidth: 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注意一個細節(jié)setOption的第二個參數(shù)我傳了true表示notMerge。這個在多次更新配置時很重要。如果不傳ECharts會做merge操作可能出現(xiàn)上一次圖表殘留的問題比如柱狀圖切換為折線圖時舊的柱子還在。傳true以后每次全量覆蓋配置更新就是干凈的。5. 模型選型與成本控制策略LLM是這個系統(tǒng)的核心動力源選什么模型、怎么控制成本直接影響項目能否從Demo走向落地。這一節(jié)聊聊我實測下來的選型感受和優(yōu)化手段。5.1 不同能力等級模型的表現(xiàn)差異實測了幾款當(dāng)前主流的商用和開源模型簡單對比一個直觀感受輕量級模型或同等級小參數(shù)模型能處理簡單配置生成但復(fù)雜一點就經(jīng)常出錯。特別是多指標、雙y軸、數(shù)據(jù)聚合邏輯容易把配置結(jié)構(gòu)寫亂。中端模型整體可用在加了few-shot之后解決常見圖表生成問題表現(xiàn)不錯。是這個系統(tǒng)的主力模型。高端旗艦?zāi)P土鲿扯群屠斫饬Υ_實更好但成本高出不少。適合用于復(fù)雜查詢、多輪對話語義糾偏的場景不適合作為默認全量調(diào)用。具體到項目里我是按問題復(fù)雜度做模型路由的。簡單問題單指標、單維度走輕量模型復(fù)雜問題多指標、多維分析、圖表修改走中高端模型。這樣既保住了體驗又控制了成本。5.2 成本估算實例假設(shè)一個中等復(fù)雜度的查詢輸入Prompt約1200 Token輸出配置約600 Token。一次調(diào)用的總Token約為1800 Token。按目前的Token計價折算下來單次約為千分之幾元。如果日均調(diào)用1000次一個月成本在幾十元到上百元之間完全在可接受范圍內(nèi)。但如果未來做到企業(yè)級、日調(diào)萬次甚至更多就需要引入更激進的緩存策略和語義分割方案。成本控制我建議一定要做Token統(tǒng)計每個會話、每次請求都記錄Token消耗。有了數(shù)據(jù)才知道哪里該優(yōu)化不能拍腦袋省成本。5.3 緩存策略相同問題不重復(fù)計算用戶經(jīng)常會問重復(fù)或高度相似的問題比如“幫我看一下華東區(qū)銷售額Top10的品類”。這個查詢第一次執(zhí)行后結(jié)果短時間內(nèi)一般不會變化。所以我在后端加了一個簡單但有效的緩存層。緩存Key由兩部分組成session_id message的哈希值。有效期設(shè)個5分鐘到10分鐘。緩存命中直接返回上次的結(jié)果不再調(diào)用LLM和數(shù)據(jù)源。實測命中率大約在20%到30%之間整體響應(yīng)速度和成本都優(yōu)化了不少。5.4 流式輸出的權(quán)衡很多LLM應(yīng)用現(xiàn)在都搞流式輸出但在圖表生成場景里我一開始也做了后來發(fā)現(xiàn)收益不大。圖表配置的生成速度和流式展示的體驗提升對用戶來說感知并不強。因為用戶最終看到的是圖表渲染結(jié)果而不是一行一行地看JSON。所以后端直接用同步接口返回完整配置前端加一個加載動畫簡潔且穩(wěn)定。只有在制作長報告或復(fù)雜洞察分析時流式輸出的價值才會體現(xiàn)出來。6. 常見問題與排查技巧實錄這個項目做了這么久踩過的坑能寫滿一頁A4紙。這一節(jié)把最高頻的問題和排查思路整理成一個速查表按“現(xiàn)象 → 原因 → 做法”的結(jié)構(gòu)羅列方便遇到問題直接對號入座。6.1 圖表渲染空白但控制臺無報錯遇到這個問題我一開始一度懷疑是ECharts版本問題排查了半天發(fā)現(xiàn)不是。后來定位到原因通常有兩個一是series.data為空數(shù)組ECharts不渲染空數(shù)據(jù)但也完全不報錯二是xAxis.data和series.data長度不匹配導(dǎo)致某些數(shù)據(jù)點被丟棄。排查步驟建議固定為先把LLM返回的JSON打印出來檢查series和xAxis的data結(jié)構(gòu)。其次在后端對數(shù)據(jù)長度做一致性校驗發(fā)現(xiàn)不一致時提前修正。6.2 生成的圖表類型不符合用戶預(yù)期用戶說“看下銷售情況”模型給出了餅圖但用戶其實想看的是柱狀圖。這類問題本質(zhì)上不是bug而是意圖理解不夠精準。解決思路有三個層次最簡單粗暴的在Prompt中設(shè)定“當(dāng)用戶未明確指定圖表類型時默認使用柱狀圖”。較推薦的做法把圖表類型選擇權(quán)交給一個單獨的“意圖分類”步驟先判斷用戶想要的圖表類型再讓LLM生成具體配置。體驗最好的做法前端提供圖表類型切換按鈕用戶不滿意時一鍵切換切換時只調(diào)整series類型和對應(yīng)配置不需要重新生成。實際項目中我采用了第二和第三層的結(jié)合既在對話層做了預(yù)分類也在展示層做了快速切換兜底。6.3 某些字段被LLM憑空捏造這個問題在所有LLM應(yīng)用中都會出現(xiàn)圖表配置場景也不例外。模型在生成查詢字段時偶爾會“發(fā)明”一些數(shù)據(jù)字典里根本不存在的字段名。比如數(shù)據(jù)字典里只有total_amount單輸出結(jié)果卻可能出現(xiàn)profit_rate這種虛構(gòu)指標。這種問題的根源是Prompt數(shù)據(jù)字典的語義約束不夠嚴格。我采取了三層防御Prompt里加了硬性限制“只能使用可用字段不得新增字段”。后端解析時加字段白名單校驗一旦發(fā)現(xiàn)未知字段立即拋異常要求模型重新生成。加了重試機制最多重試兩次用模型自己的上一個錯誤輸出來糾正它。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白名單校驗看起來多做了一步實際上避免了非常多后續(xù)的數(shù)據(jù)查詢報錯和渲染異常。6.4 多輪對話的上下文污染多輪對話是整個系統(tǒng)里維護成本最高的部分。我遇到過一種情況用戶第一輪問的是“華東區(qū)的銷售額”第二輪問“那華南呢”模型把兩輪問題合并成了一個查詢或者按第一輪的維度約束了第二輪的查詢最終結(jié)果不符合預(yù)期。排查后發(fā)現(xiàn)問題出在上下文拼接的方式上。我把完整的歷史記錄拼接后一次性發(fā)給模型模型理解重點發(fā)生了偏移。后來改成“歷史修改指令”模式只保留上一輪的圖表配置和用戶最新的修改要求情況明顯好轉(zhuǎn)。再進一步可以在每輪會話記錄時帶上“當(dāng)前數(shù)據(jù)范圍”摘要模型在生成新查詢時就有明確的基準。6.5 常見問題速查表現(xiàn)象可能原因處理方案前端圖表空白series.data為空或長度不匹配打印JSON檢查data后端做長度校準圖表類型不符預(yù)期意圖解析失敗或Prompt缺乏傾向性增加類型預(yù)分類前端提供手動切換字段不存在模型幻覺白名單校驗異常時重試多輪問題答非所問上下文過載或始終以歷史為主結(jié)構(gòu)化會話狀態(tài)只保留修正指令生成的配置偶爾帶Markdown標記模型輸出格式不穩(wěn)定解析時兼容包裹刪除非JSON內(nèi)容響應(yīng)延遲明顯調(diào)用鏈路太長/模型太重走緩存、模型路由、去掉流式輸出6.6 一個被很多人忽略的細節(jié)代碼塊包裹的冗余處理即便我在Prompt里已經(jīng)明確說了“不要使用Markdown代碼塊標記”模型有時候還是會在輸出中把JSON包在代碼塊里。前端直接傳給后端解析時會失敗。這個問題不復(fù)雜但很影響體驗。我的建議是后端解析的時候做了一層“防呆處理”不管模型輸出什么格式都嘗試提取其中的JSON部分。也就是前面提到的解析函數(shù)第2層和第3層這兩層代碼看著多余實則在真實項目中能省下很多溝通和debug的時間。7. 這個項目的邊界與擴展可能性個人實操回顧做到這個程度這個系統(tǒng)基本達到了“業(yè)務(wù)人員能自己提問、自己看圖”的效果。但我也很清楚它的邊界在哪。數(shù)據(jù)字典預(yù)設(shè)意味著增加一個新的業(yè)務(wù)指標需要手工維護字段映射。圖表類型雖然覆蓋面夠用但遇到非常規(guī)圖表比如關(guān)系圖、樹圖、地圖時生成效果依然不穩(wěn)定。這些限制不是LLM本身的問題而是工程上選擇了“穩(wěn)定優(yōu)先”的路子。從我實操的角度看如果后續(xù)要繼續(xù)演進我會優(yōu)先考慮兩個方向。第一是接上真實的數(shù)據(jù)庫Schema自動發(fā)現(xiàn)機制讓數(shù)據(jù)字典能動態(tài)更新減少人工維護成本。第二是引入“用戶行為反饋信號”比如用戶強行切換了圖表類型、修改了取數(shù)范圍這些行為可以作為隱含標注數(shù)據(jù)用于后續(xù)微調(diào)Prompt或模型。這兩個方向本質(zhì)都是為了讓系統(tǒng)從“固定配方”走向“自適應(yīng)理解”。做出來之后這個對話式數(shù)據(jù)分析系統(tǒng)的落地空間還會更大。8. 最后的幾個實操建議文章寫完之前我再分享三個實用的經(jīng)驗總結(jié)都是踩過坑換來的教訓(xùn)。第一個建議不要把LLM的JSON輸出當(dāng)成理所當(dāng)然。無論Prompt寫得多么嚴格解析層都要做兜底。模型畢竟是概率模型它的輸出天然存在隨機性工程上必須設(shè)計好容錯機制。用模板加校驗加重試的方式把不確定性限制在可控范圍內(nèi)。第二個建議在畫界面之前先把Prompt和數(shù)據(jù)結(jié)構(gòu)跑通。我一開始最先做的是前端以為把ECharts配好就完事了結(jié)果后端LLM輸出的格式頻繁調(diào)整導(dǎo)致前端也跟著改了兩三回。如果先花時間把Prompt、輸出Schema、解析邏輯確定下來再去做前端開發(fā)效率會高很多。第三個建議盡量保留每個請求的完整日志。LLM應(yīng)用的調(diào)試難度遠高于傳統(tǒng)應(yīng)用因為很多問題不是代碼邏輯錯誤而是模型輸出的語義偏差。完整記錄Prompt輸入、模型原始輸出、解析后結(jié)果、最終渲染狀態(tài)排查問題時能省一半以上時間。日志字段用JSON格式存儲后面用來分析模型錯誤模式也能派上大用場。這個項目目前已經(jīng)作為內(nèi)部工具投入使用業(yè)務(wù)同學(xué)用得還挺順手。如果你也在做類似的事情希望里面的經(jīng)驗和代碼片段能幫你少走幾段彎路。有新的想法也歡迎交流。