
如果你的工作里经常要跟图表打交道那你一定经历过这种时刻为了调一个坐标轴刻度的显示格式翻了一个小时的文档为了把接口返回的数据重新整理成图表需要的数据结构写了一大段 map 和 reduce老板临时要求把柱状图改成折线图你默默删掉重写。我一直觉得图表配置这件事烦的不是画图本身而是“把人的想法翻译成图表配置”这个过程。AI Chart UI 就是冲着这个来的。简单说它把自然语言描述直接转成图表界面配置你说一句“按产品线展示华东、华西两个区域的季度销售额对比用分组柱状图”它能在几秒内生成对应的图表配置并渲染出可交互的图表。这个项目最早是我在某内部报表系统里做的轮子后来单独拆出来维护核心思路是让大模型负责听懂自然语言传统图表库负责渲染中间用一层结构化描述协议来衔接。这篇分享适合两类人一是做数据可视化或低代码平台的前端工程师可以借鉴它的 Schema 设计和适配层思路二是想把大模型接进现有业务系统、但不知道怎么保证输出稳定的开发者这套架构里对模型输出的约束、校验和兜底策略都来自真实踩坑可以直接抄作业。1. 为什么要做 AI Chart UI先看清楚传统方式的痛点1.1 传统图表开发的真实痛点手写图表配置这件事大部分时间不是花在画图本身而是花在“语义转译”上。后端返回的数据通常是扁平结构比如[{ region: 华东, quarter: Q1, sales: 1240 }]但柱状图要求的是“维度数组 度量数组”折线图又要求“时间轴 序列”。你每画一次图都要把这些数据揉成图表库能吃的形状这是第一层成本。第二层成本是需求的不确定性。业务方不会说“请把 dimension 设置为 regionmeasure 设置为 sum(sales)”他们只会说“我想看一下各区域的销售额差异”。你需要把这句话翻译成图表类型、坐标轴、聚合方式、颜色、单位、图例等一系列技术决策。翻译得不对就要返工。我见过很多团队一张图表改了七八版每次改动的本质都是同一个问题业务语义和技术配置之间的翻译成本没人愿意承担。第三层成本是交互和迭代。图表不是画完就结束的业务方后续会追加“只看华东区”“按季度聚合”“把销售额和目标的对比也加上”这些追加都要回到配置层重新改代码。传统做法的维护成本会随着需求数量线性增长而且每一处细节修改都可能引入新 bug。1.2 AI Chart UI 解决了什么AI Chart UI 把“中文描述 → 图表配置 → 渲染”这条链路固化了下来。业务方说“我想看按月份的趋势”系统会解析出图表类型为折线图、维度为月份、度量为销售额、聚合粒度为月然后自动生成配置并渲染。这不仅省掉了手写代码更关键的是把“图表需求”本身变成了可对话、可修改的对象。有了这套机制需求变更的成本大幅降低。之前改图表要改代码、发版本现在只需要追加一句“把趋势图改成按季度聚合”大模型结合上一轮的图表配置做增量修改前端拿到新配置重新渲染就行。这就把“开发循环”缩短成了“对话循环”业务方自己都能完成一部分调整动作。真正让我决定做这个项目的是我发现大多数报表场景里的图表种类其实很少柱状图、折线图、饼图、表格顶多加个漏斗和散点。需求变化的核心不是“新图表类型”而是“维度换了、度量换了、筛选条件换了、聚合粒度换了”。这意味着生成图表的逻辑其实可以高度标准化完全有机会用自然语言驱动。1.3 方案选型为什么不是“让大模型直接写图表代码”我最早试过让大模型直接生成图表库的配置对象比如让它输出一个完整的折线图 option。试了一段时间之后发现这条路不太好走主要有三个原因。第一个原因是配置对象太庞大、版本耦合度高。主流图表库的配置项有几百个字段不同版本之间还有差异大模型很容易编造出不存在的字段或填进错误的层级结构。有些错误在渲染时是静默的——不报错但图表显示效果不对排查起来非常痛苦。第二个原因是模型根本不了解你的数据。它不知道你的数据库里字段叫sales_amount还是gmv不知道你的时间列是year_month还是stat_date。直接生成图表代码模型只能瞎猜字段名生成出来的配置几乎不可用。第三个原因是无法做统一的约束和校验。直接输出代码片段你很难在代码层面设置“只允许这些字段出现”“聚合方式必须是 sum/avg/count 之一”这类约束出了问题只能靠人工看代码。所以后来我调整了方案让大模型只负责“听懂人话”和“输出语义描述”也就是生成一份与具体图表库无关的 ChartSchema再由本地渲染器把 Schema 翻译成图表配置。相当于把“聪明的语义判断”和“确定性的技术实现”拆开大模型做它擅长的事代码做代码擅长的事。这个决策是整个项目的基石。2. 整体架构与核心设计2.1 三层架构NL 解析层、Schema 中间层、渲染层AI Chart UI 整体分成三层每层职责单一、边界清晰。NL 解析层负责把用户的自然语言转成结构化的 ChartSchema。这一层核心是大模型加上一些预处理逻辑比如数据注册表信息注入、Prompt 模板管理、输出 JSON 修复等。它不关心图表怎么渲染也不关心底层图表库是谁。Schema 中间层是整套系统的“标准合同”。它描述一份图表配置需要哪些要素图表类型、维度、度量、聚合方式、筛选条件、样式偏好、交互行为。这层不依赖任何具体图表库也不依赖大模型只是一份 JSON 格式的约定。渲染层负责把 ChartSchema 翻译成具体图表库能识别的配置并绑定到真实的 DOM 上渲染。这一层是纯确定性的代码完全可控、可测试。以后想换图表库只需要改这层对应的翻译函数不影响上层逻辑。你可以把这个架构想象成一个翻译公司NL 解析层是接电话的客服听懂客户说什么Schema 中间层是合同把需求白纸黑字写清楚渲染层是施工队照着合同干活。合同写得好不好直接决定施工队能不能干得漂亮。2.2 ChartSchema 数据结构详解ChartSchema 是整个项目的核心约束我把它设计成与具体图表库解耦的“语义描述”。下面是一份完整的示例你可以直观感受一下这个数据长什么样{ chartType: bar, title: 各区域季度销售额对比, dataSource: sales_summary, dimension: { field: region, axis: x }, measures: [ { field: sales_amount, aggregation: sum, axis: y } ], series: { splitBy: quarter, mode: group }, filters: [], style: { showGrid: true, showLegend: true, highlight: { field: region, value: 华东, color: #d94e5d } } }逐个字段解释一下。chartType只有枚举值可选bar、line、pie、scatter、table等这相当于告诉渲染层用哪套翻译逻辑。title是图表标题模型可以从用户语料里提取比如用户说“看一下各区域销售额”标题就是“各区域销售额”。dataSource指向数据注册表里的一个数据源标识规定模型只能引用注册表中已存在的数据源不能凭空虚造。dimension是维度字段决定 X 轴或类别方向它有一个 axis 属性标记是否为主坐标轴。measures是度量数组每个度量包含字段名、聚合方式和所在坐标轴。这样设计是为了支持多度量对比比如销售额和利润并列展示。series在 SQL 语义里对应 GROUP BY 的补充维度例如透出季度、月份、产品线等。mode表示分组柱状图、堆叠图或单序列。filters是筛选条件数组结构类似{ field: region, operator: eq, value: 华东 }。style则是样式和交互相关的兜底字段高亮、网格线、图例开关都存在这里面。为什么要用这份 Schema 而不是直接用图表库的 option因为“柱状图”在市面上所有图表库中都有实现但每个库的配置字段差异很大。如果让模型直接产出某个库的 option你就把系统绑定在了一个具体库上还要忍受模型在几百个字段里乱撞。Schema 这层把“图表的语义”和“图表的渲染”分开语义部分负责稳定渲染部分负责灵活。2.3 Prompt 设计让大模型稳定输出结构化配置大模型是最强的一环也是最不稳定的一环。想让它的输出稳定可用Prompt 设计很关键我的经验可以浓缩成几条核心准则。第一System Prompt 里必须内嵌 JSON Schema 定义。不要只写“请输出 JSON”要明确告诉模型每个字段的可选值、类型和含义。模型对结构越了解输出的字段越不会跑偏。下面是一段简化的 Prompt 模板你是一个图表配置助手。用户会描述一个数据可视化需求你需要将需求转化为 ChartSchema JSON。 ChartSchema 必须遵循以下规则 - chartType 只允许取值bar, line, pie, scatter, table - dimension.field 和 measures[].field 必须来自给定的数据注册表字段列表严禁编造字段 - aggregation 只允许取值sum, avg, count, max, min - 只输出 JSON不要包含 Markdown 代码块标记、不要包含解释性文字 - 数字不要加单位不要使用千分位分隔符 数据注册表 - sales_summary 表字段region(文本), quarter(文本), sales_amount(数值), target_amount(数值)第二要有 few-shot 示例。给模型一两个“用户问题 → ChartSchema”的完整示例能显著降低输出格式漂移的概率。没有示例时模型偶尔会把chartType写成bar-chart或者Bar给过示例之后这类问题几乎消失。第三温度参数要压低。我一般把 temperature 设置在 0.1 到 0.3 之间。图表配置是确定性很强的任务不需要模型发散温度越低输出越稳定。第四如果大模型服务支持 function calling 或结构化输出能力优先用这个能力让模型结构化输出到预设的 JSON Schema 中比靠 Prompt 约定更可靠。2.4 数据注册表给大模型“可选字段”而不是让它瞎编这可能是整套系统里最容易被忽略、但实际效果最明显的设计。如果你直接把用户问题丢给大模型它根本不知道你数据库里有哪些字段、哪些字段是数值型、哪些是文本型它就只能凭空猜测。数据注册表的核心作用是把当前可用的数据源及其字段元信息收集起来作为上下文的一部分注入提示词。你可以简单理解成每次请求时把“有哪些表、每张表有哪些字段、字段类型是什么”告诉模型并限定它只能从这份清单里选字段。注册表里的元信息可以做成 JSON 结构比如{ datasets: [ { name: sales_summary, fields: [ { name: region, type: string, label: 区域 }, { name: quarter, type: string, label: 季度 }, { name: sales_amount, type: number, label: 销售额 }, { name: target_amount, type: number, label: 目标额 } ] } ] }有了这份清单模型就知道region是一个文本类型的字段可以用来做维度sales_amount是数值字段适合做度量。它不会再输出一个amount或sales这种表里不存在的字段。数据注册表还有一个隐藏价值它可以不止包含物理字段名还可以包含“语义别名”。比如用户习惯说“金额”注册表里就可以把sales_amount的别名配成“金额、销售额、营收”。这样 Prompt 里就可以明确告诉模型用户提到的“金额”对应的是sales_amount这个字段。3. 实操从零实现一个最小可用的 AI Chart UI3.1 初始化项目与依赖我用 Vue 3 和 TypeScript 作为演示技术栈核心逻辑放在 React 上也完全一样。先创建一个 Vite 项目npm create vitelatest ai-chart-ui -- --template vue-ts cd ai-chart-ui npm install项目结构上我建议把三层逻辑拆到独立的目录里方便后续维护src/ nl/ prompt.ts # Prompt 模板与数据注册表注入 parseIntent.ts # 调用大模型并解析 JSON repairJson.ts # 清理和修复模型返回的 JSON schema/ types.ts # ChartSchema 类型定义 validateSchema.ts # 结构校验 render/ ChartRenderer.vue # 渲染组件 translators/ bar.ts # bar 类型的翻译器 line.ts # line 类型的翻译器 pie.ts datasets/ registry.ts # 数据注册表3.2 实现 NL→Schema 解析模块解析模块的核心是一个parseIntent函数。它接收用户输入和数据注册表元信息调用大模型接口返回一个校验过的 ChartSchema。// src/nl/parseIntent.ts import { buildSystemPrompt } from ./prompt import { repairJson } from ./repairJson import { validateSchema } from ../schema/validateSchema import type { ChartSchema } from ../schema/types interface ParseResult { schema: ChartSchema | null raw: string error?: string } export async function parseIntent( userInput: string, context?: ChartSchema ): PromiseParseResult { const systemPrompt buildSystemPrompt(context) // 这里以某个大模型服务的标准接口为例 const response await fetch(/api/llm/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: [ { role: system, content: systemPrompt }, { role: user, content: userInput } ], temperature: 0.2, response_format: { type: json_object } }) }) const data await response.json() const raw data.content ?? // 修复常见 JSON 格式问题 const cleaned repairJson(raw) // 结构校验不通过就返回错误信息 const validateResult validateSchema(cleaned) if (!validateResult.ok) { return { schema: null, raw, error: validateResult.message } } return { schema: cleaned as ChartSchema, raw } }细心的人会发现parseIntent接收了一个可选参数context。这是为了支持对话式修改如果用户上一轮已经生成过一张图这一轮说“改成折线图”模型需要知道上一轮的 Schema 才能做增量修改。buildSystemPrompt里会判断如果存在context就把上一轮的 Schema 也拼进 Prompt并提示模型在上一轮基础上修改而不是从头生成。3.3 实现 Schema→图表渲染适配器渲染层是整个系统的“确定性兜底”。我实现了buildChartOption函数它接收 ChartSchema返回底层图表库的标准 option 配置。这里以业界常用的开源图表库配置格式为例核心代码如下// src/render/buildChartOption.ts import type { ChartSchema } from ../schema/types export function buildChartOption(schema: ChartSchema): object { switch (schema.chartType) { case bar: return buildBarOption(schema) case line: return buildLineOption(schema) case pie: return buildPieOption(schema) default: throw new Error(不支持的图表类型: ${schema.chartType}) } } function buildBarOption(schema: ChartSchema): object { const dimensionField schema.dimension.field const measureField schema.measures[0].field const splitBy schema.series?.splitBy const mode schema.series?.mode ?? single const categories getCategories(schema.dataSource, dimensionField) const rawData getDataset(schema.dataSource) let seriesData if (splitBy mode group) { // 按 splitBy 字段拆分为多个序列 const splitValues getDistinctValues(rawData, splitBy) seriesData splitValues.map((value) ({ name: String(value), type: mode group ? bar : line, data: categories.map((cat) { const row rawData.find( (item) item[dimensionField] cat item[splitBy] value ) return row ? aggregate(row[measureField], schema.measures[0].aggregation) : null }) })) } else { // 单序列直接取值 seriesData [{ name: measureField, type: bar, data: categories.map((cat) { const row rawData.find((item) item[dimensionField] cat) return row ? aggregate(row[measureField], schema.measures[0].aggregation) : 0 }) }] } return { title: { text: schema.title, left: center }, tooltip: { trigger: axis }, legend: { show: schema.style?.showLegend ?? true, top: 24 }, grid: { show: schema.style?.showGrid ?? true, left: 48, right: 24, bottom: 32 }, xAxis: { type: category, data: categories, name: dimensionField }, yAxis: { type: value, name: schema.measures[0].field }, series: seriesData } }这个翻译器的核心职责是把 Schema 中的“语义”翻译成图表库的“样式”。比如 Schema 里说维度字段是region翻译器就去数据源里取出所有区域名作为 X 轴分类Schema 里说度量是sales_amount且聚合方式是sum翻译器就去数据源里按区域分组求和作为柱子的高度。这些逻辑是纯函数式的、完全可单测的不会出现过了一夜图表突然变成另一个样子的灵异事件。为了让不同图表类型可以热插拔我把每个图表类型的翻译逻辑拆成独立文件统一注册到一个翻译器映射表里。以后新增一个漏斗图只需要新增一个funnel.ts在映射表里加一行即可。3.4 图表随对话实时更新与交互闭环对话式图表的体验关键在于“每一轮对话都产生一个完整可渲染的 Schema”。前端维护一个currentSchema状态解析新意图时把currentSchema作为上下文传给模型模型输出新的 Schema渲染器重新渲染。这串流程用 Vue 组件实现如下template div classconsole div classchat-input input v-modelinputText placeholder描述你想看的图表例如各区域季度销售额对比 keyup.entersend / /div ChartRenderer :schemacurrentSchema :data-sourcecurrentDataSource / /div /template script setup langts import { ref } from vue import { parseIntent } from ../nl/parseIntent import ChartRenderer from ./ChartRenderer.vue import type { ChartSchema } from ../schema/types const inputText ref() const currentSchema refChartSchema | null(null) async function send() { if (!inputText.value.trim()) return const result await parseIntent(inputText.value, currentSchema.value ?? undefined) if (result.schema) { currentSchema.value result.schema } else { console.error(解析失败:, result.error, result.raw) } inputText.value } /script这里有个容易被忽视的细节数据源的接入。Schema 里只有dataSource标识没有具体数据内容。渲染器拿到新的 Schema 后需要根据dataSource去加载对应的数据集再交给翻译器使用。实际项目中我做了两层缓存数据源一旦加载过就缓存到前端避免每轮对话都请求一遍大数据集翻译器只从缓存里读数据保证渲染性能。交互闭环是 AI Chart UI 比普通自然语言生成图表高级的地方。图表渲染出来之后用户点击图例、框选范围、拖拽筛选器这些操作事件可以转成新的 Schema 片段回传给模型或者直接本地更新 Schema 后重新渲染。比如用户在图例里点掉“Q2”渲染层直接把filters数组里加上quarter ! Q2的条件图表即时更新不需要再走一次大模型。3.5 完整案例一句话生成季度销售分析图表把前面的模块串起来跑一遍。假设数据注册表里有sales_summary表字段为region、quarter、sales_amount、target_amount。用户在对话框里输入对比 2024 年各个区域的 Q1 和 Q2 销售额用分组柱状图展示parseIntent把这句话连同数据注册表信息发给大模型。模型返回的 ChartSchema 大约长这样{ chartType: bar, title: 2024年各区域Q1与Q2销售额对比, dataSource: sales_summary, dimension: { field: region, axis: x }, measures: [ { field: sales_amount, aggregation: sum, axis: y } ], series: { splitBy: quarter, mode: group }, filters: [ { field: quarter, operator: in, values: [Q1, Q2] } ] }前端拿到这个 SchemaChartRenderer解析dataSource加载数据buildBarOption按区域分类、按季度分系列、按 sum 聚合渲染出分组柱状图。整个过程从用户按下回车到图表出现大概两三秒。用户接着输入“把华东区高亮标红”。第二次请求会带上上一轮的完整 Schema 作为上下文模型只修改style.highlight字段其他结构保持不变。返回的 Schema 里style多了一段高亮配置翻译器看到高亮配置后给对应柱子加上红色样式。这种“增量修改”体验才是 AI 图表真正区别于传统配置埋点方案的地方。4. 实测踩坑与排查技巧实录4.1 大模型输出的 JSON 不稳定清理、修复、兜底这是整个项目里踩得最深的坑。大模型返回的内容看起来像 JSON但经常不合法有时被 Markdown 代码块包裹有时出现尾逗号有时字符串用了单引号有时混入了“这是一个有效的图表配置”这种解释性文字。我写了一个repairJson函数专门处理这些脏数据。处理步骤按优先级排列// src/nl/repairJson.ts export function repairJson(raw: string): any { let text raw.trim() // 去掉 Markdown 代码块标记 text text.replace(/^(?:json)?\s*/i, ).replace(/$/i, ) // 提取第一个 JSON 对象忽略前后的解释性文字 const start text.indexOf({) const end text.lastIndexOf(}) if (start -1 || end -1) { throw new Error(未找到 JSON 对象) } text text.slice(start, end 1) // 修正单引号问题 text text.replace(/:\s*([^]*)/g, : $1) // 修正尾逗号 text text.replace(/,\s*([}\]])/g, $1) // 去除 NaN、undefined 等非法值 text text.replace(/\b(NaN|undefined)\b/g, null) try { return JSON.parse(text) } catch (e) { // 如果还不行尝试用 JSON5 解析 return JSON5.parse(text) } }这段代码看起来粗糙但在实际使用中修复率能达到九成以上。不要小看正则清理这些脏数据问题基本都是规律性的用正则对症下药比重新请求大模型效率高得多。如果清理和修复都失败最后的兜底策略是把“修复失败的原始内容”和“校验错误信息”一起发回给大模型告诉它“你上次返回的 JSON 无法解析请重新输出注意不要包含 Markdown 标记”。实测这个重试策略能把最终成功率拉到接近百分之百。4.2 字段映射错位用数据注册表元信息纠正字段错位是第二坑。模型有时能理解用户的语义但选出来的字段名跟数据注册表不一致。比如用户说“看一下各区域的利润”表里的字段其实叫profit_amount模型可能输出profit看起来差不多但渲染时取不到数据。解决思路分三层。第一层是 Prompt 约束明确告诉模型“所有字段必须严格来自数据注册表不许重命名、不许省略前缀”。第二层是校验层validateSchema里会检查 Schema 中出现的每个字段名是否存在于注册表中不存在就报错。第三层是容错映射把校验错误信息返回给模型让它修正字段名后重新生成。我还在注册表里给字段配置了“别名”字段比如profit_amount的别名可以写成[利润, 净利润, profit]Prompt 里附带了一句话“如果用户描述的含义与某个字段的别名匹配使用该字段的真实字段名。”这能很大程度预防错位问题而不是等问题出现再补救。4.3 高频对话导致重复渲染取消机制与防抖对话式 AI 图表的输入方式是逐字键入的如果每轮敲击都触发大模型请求不仅浪费资源还会出现严重的竞态问题上一次的请求晚于下一次返回导致界面被一个过期结果覆盖。我在这边做了双保险。第一层是防抖输入框停止输入 500 毫秒后才发起大模型请求。第二层是取消机制每次发起新请求前用AbortController取消尚未完成的旧请求。以下是一个关键片段let currentAbort: AbortController | null null async function sendWithDebounce() { if (currentAbort) { currentAbort.abort() } currentAbort new AbortController() const result await parseIntent(inputText.value, currentSchema.value ?? undefined, { signal: currentAbort.signal }) // ... }这里要提醒一句取消请求只是前端不再等待响应并不代表大模型服务端已经停止生成。所以实际项目中还要配合后端做“请求 ID 覆盖”策略即同一个对话 session 的新请求会覆盖旧请求的生成结果避免旧结果通过回调渠道又回到前端。渲染层的重复渲染问题也一样。如果新 Schema 和旧 Schema 的chartType、维度、度量都没变只是筛选条件变了渲染层可以直接复用上一次的分类计算结果只更新数据部分。我在渲染层加了一个浅比较chartType、dimension.field、measures[0].aggregation这些关键字段不变的情况下不重建坐标轴和 series 结构只替换数据数组实测能省掉不少不必要的布局计算。4.4 图表类型自动推荐的判定规则用户描述里有时不会明确说“用柱状图”或“用折线图”只说“看一下销售额趋势”。这时候需要自动推荐图表类型。我一开始把推荐逻辑完全交给大模型让它根据用户描述自己选。后来发现效果不稳定同样的描述有时推荐折线图、有时推荐面积图业务方会觉得系统“发疯”。后来我把推荐规则改成本地确定性逻辑大模型只负责输出维度和度量信息图表类型由本地函数根据数据特征决定。规则表大致是这样输入特征推荐图表类型决策逻辑维度是时间字段line时间序列趋势优先用折线图1 个维度 1 个度量维度无固定顺序bar分类对比用柱状图1 个维度 1 个度量维度取值少于 5 个pie占比场景用饼图2 个维度 1 个度量bar分组多维度对比用分组柱状图只有度量没有维度数字卡片图单值指标直接展示这条规则表同样会被写入 Prompt相当于让模型知道系统的推荐偏好同时在本地做二次校验如果模型推荐的chartType和本地规则的结论不一致以本地规则为准并打日志。AI 落地的一个关键原则是模型可以提建议但关键决策最终要由确定性代码把关。把这个原则用到图表推荐上整个系统的行为才可预期。数据量放大之后自动推荐还要考虑字段的数据类型。时间字段的判断不能只看字段名我会在注册表里给字段增加一个semanticType显式标记为date、category、metric等。这样推荐逻辑才能稳定判断“哪一列是时间、哪一列适合做分组”不会因为字段名叫month就默认它是时间。5. 再多说几句实战体会做到中后期我最深的感受是大模型接入业务系统与其追求天马行空的生成能力不如想清楚怎么给模型划定边界。ChartSchema 就是那个边界模型负责语义代码负责确定性。没有边界约束的初版实现几乎不可控加了边界之后整个系统才真正变得可用、可维护。如果你也想做类似的东西我建议从最小的闭环跑起来一个固定的数据源、两三种图表类型、一条写死的 Prompt先跑通“一句话出图”这个核心链路再逐步加交互和更多图表类型。我自己一开始先把柱状图和折线图跑稳后面才补的饼图和散点图每一步都不会失控。现在内部报表系统已经把这个方案用在了日常数据答疑里业务同事说“帮我看一下这个月哪些品类的退货率涨了”一张图就直接出来了沟通成本确实降了很多。这个项目持续迭代的空间也很大后续可以扩展的方向包括自然语言驱动的图表下钻、多数据源自动关联、以及把 AI 生成的图表嵌入到自动周报里。但别急着加功能先把 Schema 定义和校验体系打磨踏实一切都会水到渠成。