跳到主要内容

AI 与数据可视化

问题

AI 如何与前端数据可视化结合?包括自然语言生成图表(NL2Chart)、智能 SQL 查询(NL2SQL)、图表推荐引擎、交互式数据探索、Dashboard 生成、流式图表更新、数据叙事和异常检测可视化。

答案

AI + 数据可视化的核心目标是让用户用自然语言与数据交互,取代手动配置图表参数、编写 SQL 查询和搭建仪表盘的复杂流程。这个领域正在快速演进,从单一的 NL2Chart 发展到完整的 AI 数据分析平台,覆盖数据查询、可视化、洞察发现和叙事呈现的全链路。

一、自然语言生成图表(NL2Chart)

NL2Chart 是整个 AI 可视化体系的基石。核心思路是通过 Structured Output 让 LLM 返回强类型的图表配置对象,然后前端直接渲染。

增强版图表配置 Schema

lib/chart-schema.ts
import { z } from 'zod';

// 动画配置
const AnimationConfigSchema = z.object({
enabled: z.boolean().default(true).describe('是否启用动画'),
duration: z.number().default(750).describe('动画时长 ms'),
easing: z.enum(['linear', 'easeInOut', 'easeOut', 'bounce']).default('easeInOut'),
delay: z.number().default(0).describe('动画延迟 ms'),
});

// 主题配置
const ThemeConfigSchema = z.object({
colorPalette: z.enum([
'default', 'warm', 'cool', 'pastel', 'vivid', 'monochrome',
]).default('default').describe('配色方案'),
backgroundColor: z.string().default('transparent'),
fontFamily: z.string().default('system-ui'),
darkMode: z.boolean().default(false),
});

// 轴配置
const AxisConfigSchema = z.object({
field: z.string().describe('对应数据字段名'),
label: z.string().describe('轴标签'),
type: z.enum(['category', 'value', 'time', 'log']).default('category'),
format: z.string().optional().describe('格式化字符串,如 {value}% 或日期格式'),
min: z.number().optional(),
max: z.number().optional(),
rotate: z.number().optional().describe('标签旋转角度'),
});

// 系列配置
const SeriesConfigSchema = z.object({
name: z.string().describe('系列名称'),
field: z.string().describe('数据字段'),
color: z.string().optional(),
smooth: z.boolean().optional().describe('折线是否平滑'),
areaStyle: z.boolean().optional().describe('是否填充面积'),
stack: z.string().optional().describe('堆叠分组 ID'),
label: z.object({
show: z.boolean().default(false),
position: z.enum(['top', 'inside', 'bottom', 'left', 'right']).optional(),
format: z.string().optional(),
}).optional(),
});

// 筛选条件
const FilterSchema = z.object({
field: z.string(),
operator: z.enum(['=', '!=', '>', '<', '>=', '<=', 'between', 'in', 'like']),
value: z.union([z.string(), z.number(), z.array(z.union([z.string(), z.number()]))]),
});

// 完整图表配置 Schema —— 支持 12 种图表类型
const ChartConfigSchema = z.object({
chartType: z.enum([
'line', 'bar', 'pie', 'scatter', 'area',
'heatmap', 'radar', 'treemap', 'funnel', 'sankey',
'gauge', 'boxplot',
]).describe('图表类型'),
title: z.string().describe('图表标题'),
subtitle: z.string().optional().describe('副标题'),
xAxis: AxisConfigSchema.optional(),
yAxis: AxisConfigSchema.optional(),
series: z.array(SeriesConfigSchema),
filters: z.array(FilterSchema).optional(),
animation: AnimationConfigSchema.optional(),
theme: ThemeConfigSchema.optional(),
legend: z.object({
show: z.boolean().default(true),
position: z.enum(['top', 'bottom', 'left', 'right']).default('top'),
}).optional(),
tooltip: z.object({
trigger: z.enum(['item', 'axis']).default('axis'),
format: z.string().optional(),
}).optional(),
// 特殊图表配置
extra: z.record(z.unknown()).optional().describe('特定图表类型的额外配置'),
});

type ChartConfig = z.infer<typeof ChartConfigSchema>;

export { ChartConfigSchema, type ChartConfig };

NL2Chart 核心函数

lib/nl2chart.ts
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { ChartConfigSchema, type ChartConfig } from './chart-schema';

interface DataSchema {
fields: Array<{
name: string;
type: 'string' | 'number' | 'date' | 'boolean';
description?: string;
sampleValues?: unknown[];
}>;
rowCount: number;
}

async function nl2chart(
query: string,
dataSchema: DataSchema,
context?: string
): Promise<ChartConfig> {
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: ChartConfigSchema,
prompt: `你是一个数据可视化专家。根据用户需求生成图表配置。

可用数据字段:
${dataSchema.fields.map(f =>
`- ${f.name} (${f.type})${f.description ? `: ${f.description}` : ''}${
f.sampleValues ? `,示例:${JSON.stringify(f.sampleValues.slice(0, 3))}` : ''
}`
).join('\n')}

数据量:${dataSchema.rowCount}

${context ? `业务背景:${context}` : ''}

图表类型选择指南:
- 趋势变化 → line/area
- 数值对比 → bar
- 占比分布 → pie/treemap
- 相关性 → scatter
- 多维指标 → radar
- 热力分布 → heatmap
- 转化漏斗 → funnel
- 流向关系 → sankey
- 进度/KPI → gauge
- 数据分布 → boxplot

用户需求:${query}`,
});

return object;
}

// 使用示例
const config = await nl2chart(
'展示各部门的人数分布,用饼图,暖色系配色',
{
fields: [
{ name: 'department', type: 'string', description: '部门名称' },
{ name: 'employee_count', type: 'number', description: '员工数量' },
{ name: 'avg_salary', type: 'number', description: '平均薪资' },
],
rowCount: 8,
}
);

增强版 AIChart 组件(ECharts + Recharts 双引擎)

components/AIChart.tsx
import { useMemo, useRef, useCallback } from 'react';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import * as echarts from 'echarts/core';
import {
LineChart, BarChart as EBarChart, PieChart as EPieChart, ScatterChart as EScatterChart,
HeatmapChart, RadarChart as ERadarChart, TreemapChart, FunnelChart, SankeyChart,
GaugeChart, BoxplotChart,
} from 'echarts/charts';
import {
GridComponent, TooltipComponent, LegendComponent,
TitleComponent, ToolboxComponent, DataZoomComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import type { ChartConfig } from '@/lib/chart-schema';

// 注册 ECharts 模块
echarts.use([
LineChart, EBarChart, EPieChart, EScatterChart,
HeatmapChart, ERadarChart, TreemapChart, FunnelChart,
SankeyChart, GaugeChart, BoxplotChart,
GridComponent, TooltipComponent, LegendComponent,
TitleComponent, ToolboxComponent, DataZoomComponent,
CanvasRenderer,
]);

// 配色方案
const COLOR_PALETTES: Record<string, string[]> = {
default: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4'],
warm: ['#ff7c43', '#ffa600', '#f95d6a', '#d45087', '#a05195', '#ff6361', '#bc5090', '#58508d'],
cool: ['#003f5c', '#2f4b7c', '#665191', '#a05195', '#d45087', '#f95d6a', '#ff7c43', '#ffa600'],
pastel: ['#b5d8eb', '#f5c4c0', '#c3e6c3', '#fde4a5', '#d4c4e6', '#f0d1a8', '#a8d8e6', '#e6a8c3'],
vivid: ['#e6194b', '#3cb44b', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45'],
monochrome: ['#1a1a2e', '#3d3d5c', '#5f5f8a', '#8181b8', '#a3a3d0', '#c5c5e0', '#d8d8ea', '#ebebf4'],
};

interface AIChartProps {
config: ChartConfig;
data: Record<string, unknown>[];
height?: number;
engine?: 'echarts' | 'recharts';
onChartReady?: (instance: echarts.ECharts) => void;
}

// 将 AI 生成的 ChartConfig 转换为 ECharts option
function toEChartsOption(
config: ChartConfig,
data: Record<string, unknown>[]
): echarts.EChartsOption {
const colors = COLOR_PALETTES[config.theme?.colorPalette || 'default'];

const baseOption: echarts.EChartsOption = {
title: {
text: config.title,
subtext: config.subtitle,
left: 'center',
},
tooltip: {
trigger: config.tooltip?.trigger || 'axis',
},
legend: config.legend?.show !== false ? {
top: config.legend?.position === 'bottom' ? 'bottom' : 'top',
} : undefined,
color: colors,
backgroundColor: config.theme?.backgroundColor || 'transparent',
animation: config.animation?.enabled !== false,
animationDuration: config.animation?.duration || 750,
toolbox: {
feature: {
saveAsImage: { title: '保存图片' },
dataZoom: { title: { zoom: '缩放', back: '还原' } },
restore: { title: '还原' },
},
},
};

switch (config.chartType) {
case 'line':
case 'area':
case 'bar':
return {
...baseOption,
xAxis: {
type: config.xAxis?.type || 'category',
data: data.map(d => d[config.xAxis?.field || ''] as string),
name: config.xAxis?.label,
axisLabel: config.xAxis?.rotate ? { rotate: config.xAxis.rotate } : {},
},
yAxis: {
type: 'value',
name: config.yAxis?.label,
},
dataZoom: [{ type: 'inside' }, { type: 'slider' }],
series: config.series.map((s, i) => ({
name: s.name,
type: config.chartType === 'area' ? 'line' : config.chartType,
data: data.map(d => d[s.field]),
smooth: s.smooth,
areaStyle: (config.chartType === 'area' || s.areaStyle) ? {} : undefined,
stack: s.stack,
itemStyle: s.color ? { color: s.color } : undefined,
label: s.label?.show ? {
show: true,
position: s.label.position || 'top',
} : undefined,
})),
};

case 'pie':
return {
...baseOption,
series: [{
type: 'pie',
radius: ['40%', '70%'], // 环形图效果更好
data: data.map((d, i) => ({
name: d[config.xAxis?.field || ''] as string,
value: d[config.series[0]?.field || ''] as number,
})),
label: { formatter: '{b}: {d}%' },
emphasis: {
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' },
},
}],
};

case 'radar':
return {
...baseOption,
radar: {
indicator: config.series.map(s => ({
name: s.name,
max: config.extra?.radarMax as number || undefined,
})),
},
series: [{
type: 'radar',
data: data.map(d => ({
name: d[config.xAxis?.field || ''] as string,
value: config.series.map(s => d[s.field] as number),
})),
}],
};

case 'heatmap':
return {
...baseOption,
xAxis: {
type: 'category',
data: [...new Set(data.map(d => d[config.xAxis?.field || ''] as string))],
},
yAxis: {
type: 'category',
data: [...new Set(data.map(d => d[config.yAxis?.field || ''] as string))],
},
visualMap: {
min: 0,
max: Math.max(...data.map(d => d[config.series[0]?.field || ''] as number)),
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: '0%',
},
series: [{
type: 'heatmap',
data: data.map(d => [
d[config.xAxis?.field || ''],
d[config.yAxis?.field || ''],
d[config.series[0]?.field || ''],
]),
label: { show: true },
}],
};

case 'treemap':
return {
...baseOption,
series: [{
type: 'treemap',
data: data.map(d => ({
name: d[config.xAxis?.field || ''] as string,
value: d[config.series[0]?.field || ''] as number,
})),
label: { show: true, formatter: '{b}\n{c}' },
levels: [{
itemStyle: { borderColor: '#fff', borderWidth: 2, gapWidth: 2 },
}],
}],
};

case 'funnel':
return {
...baseOption,
series: [{
type: 'funnel',
left: '10%',
width: '80%',
sort: 'descending',
data: data.map(d => ({
name: d[config.xAxis?.field || ''] as string,
value: d[config.series[0]?.field || ''] as number,
})),
label: { show: true, position: 'inside', formatter: '{b}: {c}' },
}],
};

case 'sankey':
return {
...baseOption,
series: [{
type: 'sankey',
data: (config.extra?.sankeyNodes as Array<{ name: string }>) || [],
links: data.map(d => ({
source: d[(config.extra?.sourceField as string) || 'source'] as string,
target: d[(config.extra?.targetField as string) || 'target'] as string,
value: d[config.series[0]?.field || 'value'] as number,
})),
emphasis: { focus: 'adjacency' },
lineStyle: { color: 'gradient', curveness: 0.5 },
}],
};

case 'gauge':
return {
...baseOption,
series: [{
type: 'gauge',
data: [{ value: data[0]?.[config.series[0]?.field || ''] as number, name: config.series[0]?.name }],
detail: { formatter: '{value}%' },
max: (config.extra?.gaugeMax as number) || 100,
}],
};

default:
return baseOption;
}
}

export function AIChart({ config, data, height = 400, engine = 'echarts', onChartReady }: AIChartProps) {
const chartRef = useRef<ReactEChartsCore>(null);

const option = useMemo(() => toEChartsOption(config, data), [config, data]);

// 导出图表为图片
const exportImage = useCallback((format: 'png' | 'svg' = 'png') => {
const instance = chartRef.current?.getEchartsInstance();
if (!instance) return;

const url = instance.getDataURL({
type: format,
pixelRatio: 2, // 高清
backgroundColor: '#fff',
});

const link = document.createElement('a');
link.download = `${config.title}.${format}`;
link.href = url;
link.click();
}, [config.title]);

return (
<div className="ai-chart-container">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-bold">{config.title}</h3>
<div className="flex gap-2">
<button
onClick={() => exportImage('png')}
className="text-xs px-2 py-1 border rounded hover:bg-gray-50"
>
导出 PNG
</button>
</div>
</div>
<ReactEChartsCore
ref={chartRef}
echarts={echarts}
option={option}
style={{ height }}
onChartReady={(instance) => onChartReady?.(instance)}
notMerge
lazyUpdate
/>
</div>
);
}

二、NL2SQL + 安全查询

NL2SQL 让用户用自然语言直接查询数据库,但安全性是第一优先级——AI 生成的 SQL 必须经过严格验证才能执行。

lib/nl2sql.ts
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

const QueryAndChartSchema = z.object({
sql: z.string().describe('安全的 SQL SELECT 查询语句'),
chart: z.object({
type: z.enum(['line', 'bar', 'pie', 'scatter', 'area', 'heatmap', 'table']),
title: z.string(),
xField: z.string(),
yField: z.string(),
seriesField: z.string().optional(),
}),
explanation: z.string().describe('用通俗语言解释查询逻辑'),
suggestedFollowUps: z.array(z.string()).describe('推荐的后续问题'),
});

// SQL 安全验证器
class SQLValidator {
// 危险关键字黑名单
private static DANGEROUS_KEYWORDS = [
'DELETE', 'UPDATE', 'INSERT', 'DROP', 'ALTER', 'TRUNCATE',
'CREATE', 'REPLACE', 'GRANT', 'REVOKE', 'EXEC', 'EXECUTE',
'INTO OUTFILE', 'INTO DUMPFILE', 'LOAD_FILE',
];

// 危险函数黑名单
private static DANGEROUS_FUNCTIONS = [
'SLEEP', 'BENCHMARK', 'LOAD_FILE', 'OUTFILE',
'USER()', 'DATABASE()', 'VERSION()',
];

static validate(sql: string): { safe: boolean; reason?: string } {
const upperSQL = sql.toUpperCase().trim();

// 1. 必须以 SELECT 开头
if (!upperSQL.startsWith('SELECT')) {
return { safe: false, reason: '只允许 SELECT 查询' };
}

// 2. 检查危险关键字
for (const keyword of this.DANGEROUS_KEYWORDS) {
// 使用单词边界匹配,避免误判(如字段名包含 update)
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
if (regex.test(sql)) {
return { safe: false, reason: `包含危险关键字: ${keyword}` };
}
}

// 3. 检查危险函数
for (const func of this.DANGEROUS_FUNCTIONS) {
if (upperSQL.includes(func.toUpperCase())) {
return { safe: false, reason: `包含危险函数: ${func}` };
}
}

// 4. 检查多语句执行(SQL 注入常见手法)
const statementCount = sql.split(';').filter(s => s.trim()).length;
if (statementCount > 1) {
return { safe: false, reason: '不允许多条语句执行' };
}

// 5. 检查注释注入
if (sql.includes('--') || sql.includes('/*')) {
return { safe: false, reason: '不允许 SQL 注释' };
}

// 6. 检查子查询深度(防止复杂查询拖垮数据库)
const subqueryDepth = (sql.match(/SELECT/gi) || []).length;
if (subqueryDepth > 3) {
return { safe: false, reason: '子查询层级过深(最多3层)' };
}

return { safe: true };
}

// 强制添加安全限制
static sanitize(sql: string, maxRows: number = 1000): string {
let sanitized = sql.trim();

// 自动添加 LIMIT(如果没有的话)
if (!/\bLIMIT\b/i.test(sanitized)) {
sanitized = `${sanitized} LIMIT ${maxRows}`;
}

return sanitized;
}
}

// 列类型推断(用于自动选择图表类型)
interface ColumnMeta {
name: string;
type: 'number' | 'string' | 'date' | 'boolean';
nullable: boolean;
distinctCount?: number;
}

function inferColumnTypes(rows: Record<string, unknown>[]): ColumnMeta[] {
if (!rows.length) return [];

return Object.keys(rows[0]).map(name => {
const values = rows.map(r => r[name]).filter(v => v != null);
const sample = values[0];

let type: ColumnMeta['type'] = 'string';
if (typeof sample === 'number') type = 'number';
else if (typeof sample === 'boolean') type = 'boolean';
else if (typeof sample === 'string' && !isNaN(Date.parse(sample))) type = 'date';

return {
name,
type,
nullable: values.length < rows.length,
distinctCount: new Set(values.map(String)).size,
};
});
}

// 完整的 NL2SQL + 可视化流程
async function queryAndVisualize(
question: string,
dbSchema: string,
options: { maxRows?: number; explainQuery?: boolean } = {}
) {
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: QueryAndChartSchema,
prompt: `你是一个数据分析师。根据用户问题生成 SQL 查询和图表配置。

数据库结构:
${dbSchema}

规则:
1. SQL 必须是 SELECT 语句
2. 合理使用 GROUP BY、ORDER BY
3. 大数据量自动 LIMIT
4. 为图表选择最合适的类型

用户问题:${question}`,
});

// 安全验证
const validation = SQLValidator.validate(object.sql);
if (!validation.safe) {
throw new Error(`SQL 安全检查失败: ${validation.reason}`);
}

// 添加安全限制
const safeSql = SQLValidator.sanitize(object.sql, options.maxRows);

// 执行查询(使用只读数据库连接)
const data = await executeReadOnlyQuery(safeSql);

// 获取 EXPLAIN 分析(可选,用于性能调优)
let explain: string | undefined;
if (options.explainQuery) {
const explainResult = await executeReadOnlyQuery(`EXPLAIN ${safeSql}`);
explain = JSON.stringify(explainResult, null, 2);
}

// 推断列类型
const columns = inferColumnTypes(data);

return {
sql: safeSql,
chart: object.chart,
data,
columns,
explanation: object.explanation,
suggestedFollowUps: object.suggestedFollowUps,
explain,
totalRows: data.length,
};
}
NL2SQL 安全要点

无论 Prompt 中如何强调「只生成 SELECT」,LLM 仍可能被 Prompt 注入攻击误导生成危险 SQL。因此绝不能只依赖 Prompt 约束,必须在代码层面做严格的 SQL 验证:

  1. 数据库层面:使用只读账号,物理隔离写权限
  2. 代码层面:关键字黑名单 + 白名单正则
  3. 运行时层面:查询超时(如 30 秒)+ 结果行数限制
  4. 审计层面:记录所有生成和执行的 SQL

三、图表推荐引擎

图表推荐引擎分析数据特征(分布、趋势、对比、组成),自动建议最合适的图表类型。这是智能可视化的关键环节——即使用户不指定图表类型,系统也能给出最佳选择。

lib/chart-recommendation.ts
// 数据特征分析
interface DataCharacteristics {
rowCount: number;
columns: ColumnMeta[];
// 数据意图
intent: 'trend' | 'comparison' | 'composition' | 'distribution' | 'correlation' | 'flow' | 'kpi';
// 维度与度量
dimensions: string[]; // 分类/时间字段
measures: string[]; // 数值字段
// 统计特征
hasTimeSeries: boolean;
hasHierarchy: boolean;
cardinality: Record<string, number>; // 各字段的去重值数量
}

// 分析数据特征
function analyzeData(
data: Record<string, unknown>[],
columns: ColumnMeta[]
): DataCharacteristics {
const dimensions = columns
.filter(c => c.type === 'string' || c.type === 'date')
.map(c => c.name);

const measures = columns
.filter(c => c.type === 'number')
.map(c => c.name);

// 检测时间序列
const hasTimeSeries = columns.some(c =>
c.type === 'date' || /date|time|year|month|day|week/i.test(c.name)
);

// 计算各字段基数
const cardinality: Record<string, number> = {};
for (const col of columns) {
cardinality[col.name] = new Set(data.map(d => String(d[col.name]))).size;
}

// 推断意图
let intent: DataCharacteristics['intent'] = 'comparison';
if (hasTimeSeries && measures.length >= 1) intent = 'trend';
else if (dimensions.length === 1 && measures.length === 1) {
const dimCardinality = cardinality[dimensions[0]];
if (dimCardinality <= 8) intent = 'composition';
else intent = 'comparison';
} else if (measures.length >= 2 && dimensions.length === 0) {
intent = 'correlation';
}

return {
rowCount: data.length,
columns,
intent,
dimensions,
measures,
hasTimeSeries,
hasHierarchy: false,
cardinality,
};
}

interface ChartRecommendation {
chartType: string;
score: number; // 0-100 推荐分数
reason: string;
config: Partial<ChartConfig>;
}

// 规则引擎 + AI 混合推荐
function recommendCharts(
chars: DataCharacteristics
): ChartRecommendation[] {
const recommendations: ChartRecommendation[] = [];

// 规则 1: 趋势 → 折线图 / 面积图
if (chars.intent === 'trend') {
recommendations.push({
chartType: 'line',
score: 95,
reason: '数据包含时间维度,折线图最适合展示趋势变化',
config: {
chartType: 'line',
xAxis: { field: chars.dimensions[0], label: '时间', type: 'time' },
},
});
recommendations.push({
chartType: 'area',
score: 85,
reason: '面积图可以更直观地展示趋势和量级',
config: { chartType: 'area' },
});
}

// 规则 2: 对比(少量类别)→ 柱状图
if (chars.intent === 'comparison') {
const dimCardinality = chars.cardinality[chars.dimensions[0]] || 0;

if (dimCardinality <= 20) {
recommendations.push({
chartType: 'bar',
score: 90,
reason: `${dimCardinality} 个类别,柱状图清晰直观`,
config: { chartType: 'bar' },
});
}

// 大量类别 → 横向柱状图或 treemap
if (dimCardinality > 10) {
recommendations.push({
chartType: 'treemap',
score: 75,
reason: '类别较多,矩形树图可高效利用空间',
config: { chartType: 'treemap' },
});
}
}

// 规则 3: 组成/占比 → 饼图 / treemap
if (chars.intent === 'composition') {
const dimCardinality = chars.cardinality[chars.dimensions[0]] || 0;

if (dimCardinality <= 6) {
recommendations.push({
chartType: 'pie',
score: 92,
reason: `${dimCardinality} 个类别,饼图直观展示占比`,
config: { chartType: 'pie' },
});
} else {
recommendations.push({
chartType: 'treemap',
score: 88,
reason: '类别超过 6 个,treemap 比饼图更易读',
config: { chartType: 'treemap' },
});
}
}

// 规则 4: 相关性 → 散点图 / 热力图
if (chars.intent === 'correlation') {
recommendations.push({
chartType: 'scatter',
score: 90,
reason: '两个数值维度的相关性分析,散点图最合适',
config: { chartType: 'scatter' },
});

if (chars.measures.length > 2) {
recommendations.push({
chartType: 'heatmap',
score: 80,
reason: '多维数值相关性,热力图可同时展示多对关系',
config: { chartType: 'heatmap' },
});
}
}

// 规则 5: 多维对比 → 雷达图
if (chars.measures.length >= 3 && chars.measures.length <= 8) {
recommendations.push({
chartType: 'radar',
score: 75,
reason: `${chars.measures.length} 个指标的多维对比,雷达图一目了然`,
config: { chartType: 'radar' },
});
}

// 按分数排序
return recommendations.sort((a, b) => b.score - a.score);
}

// AI 增强推荐(当规则引擎不确定时用 LLM 辅助判断)
async function aiEnhancedRecommendation(
data: Record<string, unknown>[],
chars: DataCharacteristics,
ruleResults: ChartRecommendation[]
): Promise<ChartRecommendation[]> {
// 如果规则引擎有高置信度结果(score > 85),直接返回
if (ruleResults[0]?.score > 85) return ruleResults;

const { object } = await generateObject({
model: openai('gpt-4o'),
schema: z.object({
recommendations: z.array(z.object({
chartType: z.string(),
score: z.number(),
reason: z.string(),
})),
}),
prompt: `分析以下数据特征,推荐最佳图表类型:

数据特征:
- 行数: ${chars.rowCount}
- 维度字段: ${chars.dimensions.join(', ')}
- 度量字段: ${chars.measures.join(', ')}
- 是否时间序列: ${chars.hasTimeSeries}
- 各字段基数: ${JSON.stringify(chars.cardinality)}

样本数据(前5行):
${JSON.stringify(data.slice(0, 5), null, 2)}

规则引擎建议:${ruleResults.slice(0, 3).map(r => `${r.chartType}(${r.score}分)`).join(', ')}

请给出 top 3 推荐,包含理由。`,
});

return object.recommendations.map(r => ({
...r,
config: { chartType: r.chartType as ChartConfig['chartType'] },
}));
}
规则引擎 + AI 混合策略

纯规则引擎速度快但覆盖面有限,纯 AI 推荐准确但有延迟和成本。最佳实践是规则引擎优先,AI 兜底

  • 规则引擎置信度 > 85 分:直接使用,0 延迟
  • 规则引擎置信度 < 85 分:调用 LLM 辅助判断
  • 用户明确指定图表类型:跳过推荐,直接生成

四、交互式数据探索

交互式探索让用户通过下钻、筛选、缩放、联动等操作深入分析数据,AI 在探索过程中提供引导和发现。

components/DataExplorer.tsx
import { useState, useCallback } from 'react';
import { useChat } from '@ai-sdk/react';

interface ExplorerState {
// 当前钻取路径
drillPath: Array<{ field: string; value: string }>;
// 活跃筛选条件
filters: Array<{ field: string; operator: string; value: unknown }>;
// 当前选中的数据子集
selection: Record<string, unknown>[] | null;
// 图表间联动状态
linkedField: string | null;
linkedValue: unknown | null;
}

interface DrillDownConfig {
// 钻取层级定义:年 → 季度 → 月 → 周 → 日
hierarchy: string[];
currentLevel: number;
}

// 下钻管理器
function useDrillDown(
allData: Record<string, unknown>[],
hierarchy: DrillDownConfig
) {
const [state, setState] = useState<ExplorerState>({
drillPath: [],
filters: [],
selection: null,
linkedField: null,
linkedValue: null,
});

// 下钻:点击某个数据点,进入下一层级
const drillDown = useCallback((field: string, value: string) => {
setState(prev => {
const newPath = [...prev.drillPath, { field, value }];
const newFilters = [...prev.filters, { field, operator: '=', value }];

// 根据筛选条件过滤数据
const filteredData = allData.filter(row =>
newFilters.every(f => {
const rowValue = row[f.field];
switch (f.operator) {
case '=': return rowValue === f.value;
case '>': return (rowValue as number) > (f.value as number);
case '<': return (rowValue as number) < (f.value as number);
default: return true;
}
})
);

return {
...prev,
drillPath: newPath,
filters: newFilters,
selection: filteredData,
};
});
}, [allData]);

// 回退到上一层
const drillUp = useCallback(() => {
setState(prev => {
const newPath = prev.drillPath.slice(0, -1);
const newFilters = prev.filters.slice(0, -1);

const filteredData = newPath.length === 0
? null
: allData.filter(row =>
newFilters.every(f => row[f.field] === f.value)
);

return {
...prev,
drillPath: newPath,
filters: newFilters,
selection: filteredData,
};
});
}, [allData]);

// 重置到顶层
const resetDrill = useCallback(() => {
setState({
drillPath: [],
filters: [],
selection: null,
linkedField: null,
linkedValue: null,
});
}, []);

return {
state,
drillDown,
drillUp,
resetDrill,
currentData: state.selection || allData,
currentLevel: state.drillPath.length,
};
}

// 图表联动控制器:多个图表之间的交互联动
function useChartLinking() {
const [linkedState, setLinkedState] = useState<{
sourceChart: string;
field: string;
value: unknown;
} | null>(null);

const link = useCallback((sourceChart: string, field: string, value: unknown) => {
setLinkedState({ sourceChart, field, value });
}, []);

const unlink = useCallback(() => {
setLinkedState(null);
}, []);

// 根据联动状态过滤数据
const getLinkedData = useCallback(
(chartId: string, data: Record<string, unknown>[]) => {
if (!linkedState || linkedState.sourceChart === chartId) return data;
return data.filter(d => d[linkedState.field] === linkedState.value);
},
[linkedState]
);

return { linkedState, link, unlink, getLinkedData };
}

// AI 引导探索:分析当前数据视图,推荐有趣的探索方向
async function getAIExplorationSuggestions(
currentData: Record<string, unknown>[],
drillPath: Array<{ field: string; value: string }>,
columns: ColumnMeta[]
): Promise<Array<{ action: string; description: string; reason: string }>> {
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: z.object({
suggestions: z.array(z.object({
action: z.string().describe('建议操作:drill_down / filter / compare / correlate'),
description: z.string().describe('操作描述'),
reason: z.string().describe('为什么推荐这个探索方向'),
})),
}),
prompt: `你是一个数据分析助手。分析当前的数据视图,推荐 3-5 个有价值的探索方向。

当前钻取路径:${drillPath.map(p => `${p.field}=${p.value}`).join(' → ') || '顶层'}
数据字段:${columns.map(c => `${c.name}(${c.type})`).join(', ')}
数据行数:${currentData.length}
样本数据:${JSON.stringify(currentData.slice(0, 5), null, 2)}

推荐有趣的模式、异常值或值得深入的维度。`,
});

return object.suggestions;
}

五、Dashboard 自动生成

从自然语言描述直接生成多图表 Dashboard,包括响应式网格布局和图表间的依赖管理。

lib/dashboard-generator.ts
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import { ChartConfigSchema } from './chart-schema';

// Dashboard 布局单元
const DashboardPanelSchema = z.object({
id: z.string(),
title: z.string(),
// 网格位置(12列网格系统)
layout: z.object({
x: z.number().min(0).max(11).describe('列起始位置 0-11'),
y: z.number().min(0).describe('行起始位置'),
w: z.number().min(1).max(12).describe('占据列数 1-12'),
h: z.number().min(1).max(4).describe('占据行数 1-4'),
}),
chart: ChartConfigSchema,
// 数据源:SQL 查询或数据引用
dataSource: z.object({
type: z.enum(['sql', 'reference', 'static']),
query: z.string().optional().describe('SQL 查询'),
referenceId: z.string().optional().describe('引用其他面板的数据'),
}),
// 刷新配置
refreshInterval: z.number().optional().describe('自动刷新间隔(秒)'),
});

const DashboardSchema = z.object({
title: z.string(),
description: z.string(),
panels: z.array(DashboardPanelSchema),
// 全局筛选器
globalFilters: z.array(z.object({
field: z.string(),
label: z.string(),
type: z.enum(['select', 'dateRange', 'numberRange']),
defaultValue: z.unknown().optional(),
})).optional(),
// 自动刷新
autoRefresh: z.boolean().default(false),
refreshInterval: z.number().default(60).describe('全局刷新间隔(秒)'),
});

type Dashboard = z.infer<typeof DashboardSchema>;

async function generateDashboard(
description: string,
dbSchema: string,
screenSize: 'desktop' | 'tablet' | 'mobile' = 'desktop'
): Promise<Dashboard> {
const columnPresets = {
desktop: 12,
tablet: 6,
mobile: 1,
};

const { object } = await generateObject({
model: openai('gpt-4o'),
schema: DashboardSchema,
prompt: `你是一个数据仪表盘设计专家。根据用户描述生成完整的 Dashboard 配置。

数据库结构:
${dbSchema}

布局规则(${screenSize} 模式,${columnPresets[screenSize]} 列网格):
- 关键 KPI 指标放在顶部,占 3 列
- 主要趋势图占 6-12 列
- 对比和分布图占 4-6 列
- 详细数据表格放在底部,占满宽度
- 相关联的图表放在相邻位置
- 总面板数控制在 4-8 个

用户需求:${description}`,
});

return object;
}

// 使用示例
const dashboard = await generateDashboard(
'生成一个电商运营日报,包含今日销售额、订单趋势、商品品类分布、客单价变化和退款率',
`
orders(id, created_at, amount, status, user_id, product_id)
products(id, name, category, price)
refunds(id, order_id, amount, reason, created_at)
`
);

Dashboard 响应式渲染组件

components/AIDashboard.tsx
import { useState, useEffect, useCallback } from 'react';
import { AIChart } from './AIChart';

interface DashboardPanel {
id: string;
title: string;
layout: { x: number; y: number; w: number; h: number };
chart: ChartConfig;
data?: Record<string, unknown>[];
loading?: boolean;
error?: string;
}

interface AIDashboardProps {
dashboard: Dashboard;
}

export function AIDashboard({ dashboard }: AIDashboardProps) {
const [panels, setPanels] = useState<DashboardPanel[]>(
dashboard.panels.map(p => ({ ...p, loading: true }))
);
const [globalFilters, setGlobalFilters] = useState<Record<string, unknown>>({});

// 加载各面板数据
useEffect(() => {
async function loadPanelData(panel: DashboardPanel) {
try {
const resp = await fetch('/api/dashboard/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
panelId: panel.id,
filters: globalFilters,
}),
});
const data = await resp.json();

setPanels(prev => prev.map(p =>
p.id === panel.id ? { ...p, data: data.rows, loading: false } : p
));
} catch (error) {
setPanels(prev => prev.map(p =>
p.id === panel.id ? { ...p, error: (error as Error).message, loading: false } : p
));
}
}

panels.forEach(loadPanelData);
}, [globalFilters]);

return (
<div>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">{dashboard.title}</h1>
<p className="text-gray-500">{dashboard.description}</p>
</div>
</div>

{/* 全局筛选器 */}
{dashboard.globalFilters && (
<div className="flex gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
{dashboard.globalFilters.map(filter => (
<div key={filter.field}>
<label className="text-sm font-medium">{filter.label}</label>
{/* 根据 filter.type 渲染不同控件 */}
</div>
))}
</div>
)}

{/* 响应式网格布局 */}
<div
className="grid gap-4"
style={{
gridTemplateColumns: 'repeat(12, 1fr)',
gridAutoRows: '200px',
}}
>
{panels.map(panel => (
<div
key={panel.id}
className="border rounded-lg p-4 bg-white shadow-sm"
style={{
gridColumn: `${panel.layout.x + 1} / span ${panel.layout.w}`,
gridRow: `${panel.layout.y + 1} / span ${panel.layout.h}`,
}}
>
{panel.loading ? (
<div className="animate-pulse h-full bg-gray-100 rounded" />
) : panel.error ? (
<div className="text-red-500 text-sm">{panel.error}</div>
) : panel.data ? (
<AIChart
config={panel.chart}
data={panel.data}
height={panel.layout.h * 200 - 80}
/>
) : null}
</div>
))}
</div>
</div>
);
}

六、流式图表更新

结合 流式渲染 SSE 技术,实现实时数据推送 + 图表动画更新 + AI 解说。适用于实时监控、交易大盘等场景。

hooks/useStreamingChart.ts
import { useState, useEffect, useCallback, useRef } from 'react';

interface StreamingChartData {
timestamp: number;
values: Record<string, number>;
}

interface AICommentary {
text: string;
severity: 'info' | 'warning' | 'critical';
timestamp: number;
}

export function useStreamingChart(wsUrl: string, maxDataPoints: number = 100) {
const [data, setData] = useState<StreamingChartData[]>([]);
const [commentary, setCommentary] = useState<AICommentary[]>([]);
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const bufferRef = useRef<StreamingChartData[]>([]);
const rafRef = useRef<number>(0);

// RAF 批量更新(避免高频 WebSocket 消息导致渲染卡顿)
const flushBuffer = useCallback(() => {
if (bufferRef.current.length === 0) return;

setData(prev => {
const newData = [...prev, ...bufferRef.current];
// 保留最近 maxDataPoints 个数据点
return newData.slice(-maxDataPoints);
});

bufferRef.current = [];
rafRef.current = 0;
}, [maxDataPoints]);

useEffect(() => {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;

ws.onopen = () => setIsConnected(true);
ws.onclose = () => setIsConnected(false);

ws.onmessage = (event) => {
const message = JSON.parse(event.data);

if (message.type === 'data') {
// 数据消息:缓冲后批量更新
bufferRef.current.push({
timestamp: message.timestamp,
values: message.values,
});

if (!rafRef.current) {
rafRef.current = requestAnimationFrame(flushBuffer);
}
} else if (message.type === 'ai_commentary') {
// AI 解说消息:立即更新
setCommentary(prev => [...prev.slice(-20), {
text: message.text,
severity: message.severity,
timestamp: Date.now(),
}]);
}
};

return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
ws.close();
};
}, [wsUrl, flushBuffer]);

return { data, commentary, isConnected };
}

实时图表渲染组件

components/RealtimeChart.tsx
import { useRef, useEffect } from 'react';
import * as echarts from 'echarts/core';
import { useStreamingChart } from '@/hooks/useStreamingChart';

interface RealtimeChartProps {
wsUrl: string;
fields: string[];
title: string;
}

export function RealtimeChart({ wsUrl, fields, title }: RealtimeChartProps) {
const { data, commentary, isConnected } = useStreamingChart(wsUrl);
const chartRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<echarts.ECharts | null>(null);

// 初始化 ECharts
useEffect(() => {
if (!chartRef.current) return;
instanceRef.current = echarts.init(chartRef.current);

return () => instanceRef.current?.dispose();
}, []);

// 增量更新图表(不重新渲染整个图表,只追加数据)
useEffect(() => {
const instance = instanceRef.current;
if (!instance || data.length === 0) return;

instance.setOption({
title: { text: title },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'time',
data: data.map(d => d.timestamp),
},
yAxis: { type: 'value' },
series: fields.map((field, i) => ({
name: field,
type: 'line',
data: data.map(d => [d.timestamp, d.values[field]]),
smooth: true,
showSymbol: false,
// 平滑动画过渡
animationDuration: 300,
animationEasing: 'linear',
})),
}, { notMerge: false }); // notMerge: false 实现增量更新
}, [data, fields, title]);

return (
<div className="relative">
{/* 连接状态指示器 */}
<div className={`absolute top-2 right-2 z-10 flex items-center gap-1 text-xs ${
isConnected ? 'text-green-500' : 'text-red-500'
}`}>
<span className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'
}`} />
{isConnected ? '实时' : '断开'}
</div>

{/* 图表 */}
<div ref={chartRef} style={{ height: 400 }} />

{/* AI 解说 */}
{commentary.length > 0 && (
<div className="mt-2 space-y-1">
{commentary.slice(-3).map((c, i) => (
<div key={i} className={`text-sm px-3 py-1 rounded ${
c.severity === 'critical' ? 'bg-red-50 text-red-700' :
c.severity === 'warning' ? 'bg-yellow-50 text-yellow-700' :
'bg-blue-50 text-blue-700'
}`}>
{c.text}
</div>
))}
</div>
)}
</div>
);
}

后端实时数据 + AI 解说

services/realtime-analytics.ts
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import type { WebSocket } from 'ws';

// 实时数据推送 + AI 解说
async function startRealtimeAnalytics(
ws: WebSocket,
dataSource: AsyncIterable<Record<string, number>>
) {
const recentData: Array<Record<string, number>> = [];
let lastCommentaryTime = 0;
const COMMENTARY_INTERVAL = 30_000; // 每 30 秒生成一次解说

for await (const values of dataSource) {
// 1. 推送数据
ws.send(JSON.stringify({
type: 'data',
timestamp: Date.now(),
values,
}));

recentData.push(values);
if (recentData.length > 60) recentData.shift();

// 2. 定期生成 AI 解说
const now = Date.now();
if (now - lastCommentaryTime > COMMENTARY_INTERVAL && recentData.length >= 10) {
lastCommentaryTime = now;

// 异步生成,不阻塞数据推送
generateAICommentary(recentData).then(commentary => {
ws.send(JSON.stringify({
type: 'ai_commentary',
...commentary,
}));
});
}
}
}

async function generateAICommentary(
recentData: Array<Record<string, number>>
): Promise<{ text: string; severity: 'info' | 'warning' | 'critical' }> {
const { text } = await generateText({
model: openai('gpt-4o-mini'), // 用小模型,速度快
prompt: `分析以下最近 ${recentData.length} 个数据点,用一句话总结当前趋势或异常:
${JSON.stringify(recentData.slice(-10))}

要求:简洁、直接、有见解。如果发现异常,说明严重程度。
格式:[severity:info|warning|critical] 解说文字`,
});

const severityMatch = text.match(/\[severity:(\w+)\]/);
const severity = (severityMatch?.[1] as 'info' | 'warning' | 'critical') || 'info';
const cleanText = text.replace(/\[severity:\w+\]\s*/, '');

return { text: cleanText, severity };
}

七、数据叙事(Data Storytelling)

数据叙事将 AI 洞察与嵌入式图表结合,生成可阅读的分析报告。区别于简单的图表+数字,叙事模式用自然语言讲故事,让非技术人员也能理解数据。

lib/data-storytelling.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

interface StorySection {
type: 'text' | 'chart' | 'kpi' | 'insight';
content?: string; // type=text 时的文字内容
chartConfig?: ChartConfig; // type=chart 时的图表配置
kpi?: { // type=kpi 时的 KPI 卡片
label: string;
value: string;
change: number;
changeLabel: string;
};
insight?: { // type=insight 时的洞察高亮
title: string;
description: string;
severity: 'positive' | 'negative' | 'neutral';
};
}

// 生成数据故事
async function generateDataStory(
data: Record<string, unknown>[],
context: string,
audience: 'executive' | 'analyst' | 'general' = 'general'
): Promise<StorySection[]> {
const sampleData = data.slice(0, 30);
const fields = Object.keys(data[0] || {});

const { object } = await generateObject({
model: openai('gpt-4o'),
schema: z.object({
story: z.array(z.discriminatedUnion('type', [
z.object({
type: z.literal('text'),
content: z.string().describe('叙事文本段落'),
}),
z.object({
type: z.literal('chart'),
chartConfig: ChartConfigSchema,
caption: z.string().describe('图表说明文字'),
}),
z.object({
type: z.literal('kpi'),
label: z.string(),
value: z.string(),
change: z.number().describe('环比变化百分比'),
changeLabel: z.string(),
}),
z.object({
type: z.literal('insight'),
title: z.string(),
description: z.string(),
severity: z.enum(['positive', 'negative', 'neutral']),
}),
])),
}),
prompt: `你是一个数据叙事专家。根据数据生成一份结构化的分析报告。

受众:${audience === 'executive' ? '高管(简洁、关注业务指标)' : audience === 'analyst' ? '分析师(详细、关注趋势和异常)' : '通用(易懂、有洞察)'}

数据背景:${context}
字段:${fields.join(', ')}
数据量:${data.length}
样本:${JSON.stringify(sampleData, null, 2)}

报告结构:
1. 开头:1-2 个 KPI 卡片展示核心指标
2. 主体:交替使用文字叙述和图表,讲一个连贯的数据故事
3. 洞察:3-5 个关键发现(正面/负面/中性标注)
4. 结论:一段总结和建议

文字要求:不用术语,用通俗语言;数据变化要给出具体数值;因果分析优于现象描述。`,
});

return object.story;
}

数据叙事渲染组件

components/DataStory.tsx
import { AIChart } from './AIChart';

interface DataStoryProps {
sections: StorySection[];
data: Record<string, unknown>[];
}

export function DataStory({ sections, data }: DataStoryProps) {
return (
<article className="max-w-4xl mx-auto py-8 space-y-8">
{sections.map((section, i) => {
switch (section.type) {
case 'text':
return (
<p key={i} className="text-gray-700 leading-relaxed text-lg">
{section.content}
</p>
);

case 'kpi':
return (
<div key={i} className="inline-block p-6 bg-white border rounded-xl shadow-sm mr-4">
<p className="text-sm text-gray-500">{section.kpi?.label}</p>
<p className="text-3xl font-bold mt-1">{section.kpi?.value}</p>
<p className={`text-sm mt-1 ${
(section.kpi?.change || 0) > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{(section.kpi?.change || 0) > 0 ? '+' : ''}{section.kpi?.change}%
{' '}{section.kpi?.changeLabel}
</p>
</div>
);

case 'chart':
return (
<figure key={i} className="my-8">
{section.chartConfig && (
<AIChart config={section.chartConfig} data={data} height={350} />
)}
</figure>
);

case 'insight':
return (
<div key={i} className={`p-4 rounded-lg border-l-4 ${
section.insight?.severity === 'positive'
? 'border-green-500 bg-green-50'
: section.insight?.severity === 'negative'
? 'border-red-500 bg-red-50'
: 'border-blue-500 bg-blue-50'
}`}>
<h4 className="font-bold">{section.insight?.title}</h4>
<p className="text-sm mt-1">{section.insight?.description}</p>
</div>
);

default:
return null;
}
})}
</article>
);
}

八、异常检测可视化

AI 自动识别数据中的异常值、趋势断裂和季节性模式,并在图表上高亮标注。

lib/anomaly-detection.ts
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

interface Anomaly {
type: 'outlier' | 'trend_break' | 'seasonal' | 'sudden_change' | 'missing_data';
severity: 'low' | 'medium' | 'high';
dataIndex: number; // 异常数据点的索引
field: string; // 异常字段
value: number; // 异常值
expectedRange: [number, number]; // 预期范围
description: string; // 异常描述
}

// 统计方法检测异常(快速、零成本)
function detectAnomaliesStatistical(
data: Record<string, unknown>[],
field: string,
method: 'zscore' | 'iqr' = 'iqr'
): Anomaly[] {
const values = data.map(d => d[field] as number).filter(v => typeof v === 'number');
const anomalies: Anomaly[] = [];

if (method === 'zscore') {
// Z-Score 方法:偏离均值超过 2.5 个标准差
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const std = Math.sqrt(
values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length
);
const threshold = 2.5;

values.forEach((value, index) => {
const zScore = Math.abs((value - mean) / std);
if (zScore > threshold) {
anomalies.push({
type: 'outlier',
severity: zScore > 3.5 ? 'high' : zScore > 3 ? 'medium' : 'low',
dataIndex: index,
field,
value,
expectedRange: [mean - threshold * std, mean + threshold * std],
description: `${field}${value} 偏离均值 ${mean.toFixed(1)}${zScore.toFixed(1)} 个标准差`,
});
}
});
} else {
// IQR 方法:超出 Q1 - 1.5*IQR 或 Q3 + 1.5*IQR
const sorted = [...values].sort((a, b) => a - b);
const q1 = sorted[Math.floor(sorted.length * 0.25)];
const q3 = sorted[Math.floor(sorted.length * 0.75)];
const iqr = q3 - q1;
const lowerBound = q1 - 1.5 * iqr;
const upperBound = q3 + 1.5 * iqr;

values.forEach((value, index) => {
if (value < lowerBound || value > upperBound) {
anomalies.push({
type: 'outlier',
severity: (value < q1 - 3 * iqr || value > q3 + 3 * iqr) ? 'high' : 'medium',
dataIndex: index,
field,
value,
expectedRange: [lowerBound, upperBound],
description: `${field}${value} 超出 IQR 范围 [${lowerBound.toFixed(1)}, ${upperBound.toFixed(1)}]`,
});
}
});
}

// 检测突变(相邻数据点变化超过 50%)
for (let i = 1; i < values.length; i++) {
const changeRate = Math.abs(values[i] - values[i - 1]) / Math.abs(values[i - 1] || 1);
if (changeRate > 0.5 && values[i - 1] !== 0) {
anomalies.push({
type: 'sudden_change',
severity: changeRate > 1 ? 'high' : 'medium',
dataIndex: i,
field,
value: values[i],
expectedRange: [values[i - 1] * 0.5, values[i - 1] * 1.5],
description: `${field} 在第 ${i} 个数据点发生 ${(changeRate * 100).toFixed(0)}% 的突变`,
});
}
}

return anomalies;
}

// AI 增强异常分析(对统计方法发现的异常做归因和解释)
async function analyzeAnomaliesWithAI(
data: Record<string, unknown>[],
anomalies: Anomaly[],
context: string
): Promise<Array<Anomaly & { aiExplanation: string; actionSuggestion: string }>> {
if (anomalies.length === 0) return [];

const { object } = await generateObject({
model: openai('gpt-4o'),
schema: z.object({
analyses: z.array(z.object({
dataIndex: z.number(),
aiExplanation: z.string().describe('对异常的可能原因分析'),
actionSuggestion: z.string().describe('建议的处理方式'),
})),
}),
prompt: `分析以下数据异常,给出可能的原因和处理建议。

业务背景:${context}
数据样本:${JSON.stringify(data.slice(0, 20), null, 2)}

发现的异常:
${anomalies.map(a => `- 第${a.dataIndex}个数据点: ${a.description}`).join('\n')}`,
});

return anomalies.map(anomaly => {
const analysis = object.analyses.find(a => a.dataIndex === anomaly.dataIndex);
return {
...anomaly,
aiExplanation: analysis?.aiExplanation || '暂无分析',
actionSuggestion: analysis?.actionSuggestion || '建议人工核查',
};
});
}

异常标注图表组件

components/AnomalyChart.tsx
import { useMemo } from 'react';
import * as echarts from 'echarts/core';
import ReactEChartsCore from 'echarts-for-react/lib/core';

interface AnomalyChartProps {
data: Record<string, unknown>[];
field: string;
xField: string;
anomalies: Anomaly[];
title: string;
}

export function AnomalyChart({ data, field, xField, anomalies, title }: AnomalyChartProps) {
const option = useMemo((): echarts.EChartsOption => {
const xData = data.map(d => d[xField] as string);
const yData = data.map(d => d[field] as number);

// 正常数据点和异常数据点分离
const normalData = yData.map((v, i) =>
anomalies.some(a => a.dataIndex === i) ? null : v
);
const anomalyData = yData.map((v, i) =>
anomalies.some(a => a.dataIndex === i) ? v : null
);

return {
title: { text: title },
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const p = (params as Array<{ dataIndex: number; value: number }>)[0];
const anomaly = anomalies.find(a => a.dataIndex === p.dataIndex);

if (anomaly) {
return `<strong style="color:red">异常</strong><br/>
值: ${p.value}<br/>
预期: ${anomaly.expectedRange[0].toFixed(1)} - ${anomaly.expectedRange[1].toFixed(1)}<br/>
${anomaly.description}`;
}
return `值: ${p.value}`;
},
},
xAxis: { type: 'category', data: xData },
yAxis: { type: 'value' },
series: [
{
name: '正常值',
type: 'line',
data: normalData,
smooth: true,
connectNulls: true,
lineStyle: { color: '#5470c6' },
},
{
name: '异常值',
type: 'scatter',
data: anomalyData,
symbolSize: 15,
itemStyle: { color: '#ee6666' },
label: { show: true, position: 'top', formatter: '{c}', color: '#ee6666' },
},
{
// 预期范围区域(置信区间带)
name: '预期范围',
type: 'line',
data: yData.map((_, i) => {
const anomaly = anomalies.find(a => a.dataIndex === i);
return anomaly ? anomaly.expectedRange[1] : null;
}),
lineStyle: { opacity: 0 },
areaStyle: { color: 'rgba(84, 112, 198, 0.1)' },
stack: 'confidence',
},
],
// 在异常点添加标记线
markLine: anomalies.length > 0 ? {
silent: true,
data: anomalies
.filter(a => a.severity === 'high')
.map(a => ({
xAxis: xData[a.dataIndex],
lineStyle: { color: '#ee6666', type: 'dashed' },
label: { formatter: '异常' },
})),
} : undefined,
};
}, [data, field, xField, anomalies, title]);

return (
<div>
<ReactEChartsCore echarts={echarts} option={option} style={{ height: 400 }} />
{/* 异常列表 */}
{anomalies.length > 0 && (
<div className="mt-4 space-y-2">
<h4 className="font-bold text-sm">发现 {anomalies.length} 个异常</h4>
{anomalies.map((a, i) => (
<div key={i} className={`text-sm p-2 rounded border-l-4 ${
a.severity === 'high' ? 'border-red-500 bg-red-50' :
a.severity === 'medium' ? 'border-yellow-500 bg-yellow-50' :
'border-blue-500 bg-blue-50'
}`}>
<span className="font-medium">[{a.type}]</span> {a.description}
</div>
))}
</div>
)}
</div>
);
}

九、图表库选型对比

选择图表库时需要从 AI 集成友好度、渲染性能、图表丰富度等维度综合考量。

维度EChartsRechartsD3.jsObservable Plot
AI 集成友好度⭐⭐⭐ 声明式 option 对象⭐⭐ JSX 组件式⭐ 命令式编程⭐⭐ 声明式 spec
LLM 生成难度低(JSON 配置)中(需要 JSX)高(需要完整代码)中(类 JSON spec)
图表类型丰富度⭐⭐⭐ 30+ 种⭐⭐ 10+ 种⭐⭐⭐ 无限可能⭐⭐ 15+ 种
渲染性能Canvas(大数据量优)SVG(小数据量优)SVG/CanvasSVG
大数据量支持⭐⭐⭐ 10 万+ 点⭐ 千级⭐⭐⭐ 取决于实现⭐⭐ 万级
动画效果⭐⭐⭐ 内置丰富⭐⭐ 基础⭐⭐⭐ 完全自定义⭐ 基础
导出能力⭐⭐⭐ PNG/SVG/PDF⭐ 需手动⭐⭐ 需手动⭐⭐ SVG
React 集成echarts-for-react原生 React需封装需封装
Bundle 大小~800KB(按需 ~300KB)~170KB~250KB~80KB
学习曲线低(配置驱动)低(JSX 直觉)高(底层 API)
社区生态⭐⭐⭐ 国内生态强⭐⭐ React 生态⭐⭐⭐ 最大社区⭐ 新兴
主题切换⭐⭐⭐ 内置暗色主题⭐ 需手动⭐⭐ 自行实现⭐⭐ CSS 变量
AI 可视化选型建议
  • 首选 ECharts:AI 生成 JSON option 最简单,图表类型最丰富,大数据量性能最好,内置导出
  • React 轻量场景用 Recharts:如果只需要折线、柱状、饼图等基础图表,Recharts 的 JSX 写法更 React
  • 高度定制用 D3:需要完全自定义的可视化效果(如自定义 tooltip、特殊交互),但 AI 生成难度高
  • 混合方案:用 ECharts 做主力渲染,用 D3 做特殊的自定义标注和交互

十、大数据量处理

当数据量达到万级、十万级时,直接发给 LLM 和直接渲染都不可行,需要采样和聚合策略。

lib/data-sampling.ts
// 数据采样策略
type SamplingStrategy = 'random' | 'systematic' | 'stratified' | 'reservoir' | 'lttb';

function sampleData(
data: Record<string, unknown>[],
targetSize: number,
strategy: SamplingStrategy = 'lttb',
options?: { timeField?: string; valueField?: string; groupField?: string }
): Record<string, unknown>[] {
if (data.length <= targetSize) return data;

switch (strategy) {
case 'random':
// 随机采样:简单但可能丢失极值
return shuffleAndTake(data, targetSize);

case 'systematic':
// 等间隔采样:适合均匀分布数据
const step = Math.ceil(data.length / targetSize);
return data.filter((_, i) => i % step === 0);

case 'stratified':
// 分层采样:保证每个组都有代表性
if (!options?.groupField) return data.slice(0, targetSize);
const groups = groupBy(data, options.groupField);
const perGroup = Math.ceil(targetSize / Object.keys(groups).length);
return Object.values(groups).flatMap(group =>
shuffleAndTake(group, Math.min(perGroup, group.length))
);

case 'lttb':
// Largest Triangle Three Buckets(最适合时序数据的降采样算法)
// 保留视觉特征的同时大幅减少数据点
return lttbDownsample(data, targetSize, options?.valueField || 'value');

case 'reservoir':
// 蓄水池采样:适合流式数据
return reservoirSample(data, targetSize);

default:
return data.slice(0, targetSize);
}
}

// LTTB 降采样算法实现
function lttbDownsample(
data: Record<string, unknown>[],
threshold: number,
valueField: string
): Record<string, unknown>[] {
const dataLength = data.length;
if (threshold >= dataLength || threshold < 3) return data;

const sampled: Record<string, unknown>[] = [];
const bucketSize = (dataLength - 2) / (threshold - 2);

// 保留第一个点
sampled.push(data[0]);

for (let i = 0; i < threshold - 2; i++) {
const avgStart = Math.floor((i + 1) * bucketSize) + 1;
const avgEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, dataLength);

// 计算下一个桶的平均值
let avgY = 0;
for (let j = avgStart; j < avgEnd; j++) {
avgY += data[j][valueField] as number;
}
avgY /= (avgEnd - avgStart);

// 在当前桶中找到与前一个选中点和下一桶均值组成最大三角形的点
const rangeStart = Math.floor(i * bucketSize) + 1;
const rangeEnd = Math.floor((i + 1) * bucketSize) + 1;

const prevPoint = sampled[sampled.length - 1][valueField] as number;
let maxArea = -1;
let maxAreaIndex = rangeStart;

for (let j = rangeStart; j < rangeEnd; j++) {
const area = Math.abs(
(rangeStart - avgEnd) * ((data[j][valueField] as number) - prevPoint) -
(rangeStart - j) * (avgY - prevPoint)
);
if (area > maxArea) {
maxArea = area;
maxAreaIndex = j;
}
}

sampled.push(data[maxAreaIndex]);
}

// 保留最后一个点
sampled.push(data[dataLength - 1]);

return sampled;
}

// 数据聚合(用于 Dashboard 中的不同时间粒度)
function aggregateData(
data: Record<string, unknown>[],
groupField: string,
valueField: string,
aggregation: 'sum' | 'avg' | 'max' | 'min' | 'count'
): Record<string, unknown>[] {
const groups = groupBy(data, groupField);

return Object.entries(groups).map(([key, rows]) => {
const values = rows.map(r => r[valueField] as number);
let result: number;

switch (aggregation) {
case 'sum': result = values.reduce((a, b) => a + b, 0); break;
case 'avg': result = values.reduce((a, b) => a + b, 0) / values.length; break;
case 'max': result = Math.max(...values); break;
case 'min': result = Math.min(...values); break;
case 'count': result = values.length; break;
}

return { [groupField]: key, [valueField]: result };
});
}

// 辅助函数
function shuffleAndTake<T>(arr: T[], n: number): T[] {
const shuffled = [...arr].sort(() => Math.random() - 0.5);
return shuffled.slice(0, n);
}

function groupBy<T extends Record<string, unknown>>(arr: T[], field: string): Record<string, T[]> {
return arr.reduce((acc, item) => {
const key = String(item[field]);
(acc[key] ??= []).push(item);
return acc;
}, {} as Record<string, T[]>);
}

function reservoirSample<T>(data: T[], k: number): T[] {
const reservoir = data.slice(0, k);
for (let i = k; i < data.length; i++) {
const j = Math.floor(Math.random() * (i + 1));
if (j < k) reservoir[j] = data[i];
}
return reservoir;
}

十一、对话式数据分析

将上述所有能力整合到一个对话界面中,用户通过自然语言完成完整的数据分析工作流。这依赖 Function Calling 来调度不同的分析工具,使用 AI 生成 UI 来动态渲染图表组件。

app/api/data-chat/route.ts
import { streamText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

export async function POST(req: Request) {
const { messages, dbSchema } = await req.json();

const result = streamText({
model: openai('gpt-4o'),
system: `你是一个数据分析助手。你可以查询数据库、生成图表、分析异常和生成报告。
用简洁的中文回答,并主动推荐有价值的分析方向。`,
messages,
tools: {
queryData: tool({
description: '用 SQL 查询数据库。当用户询问具体数据时调用。',
parameters: z.object({
question: z.string().describe('用户的数据问题'),
}),
execute: async ({ question }) => {
const result = await queryAndVisualize(question, dbSchema);
return result;
},
}),

generateChart: tool({
description: '根据自然语言描述生成图表。当用户需要可视化时调用。',
parameters: z.object({
query: z.string().describe('图表需求描述'),
data: z.array(z.record(z.unknown())).optional(),
}),
execute: async ({ query, data }) => {
const config = await nl2chart(query, inferSchemaFromData(data || []));
return { chartConfig: config };
},
}),

detectAnomalies: tool({
description: '检测数据中的异常值。当用户想了解数据异常时调用。',
parameters: z.object({
field: z.string().describe('要检测的字段'),
data: z.array(z.record(z.unknown())),
}),
execute: async ({ field, data }) => {
const anomalies = detectAnomaliesStatistical(data, field);
const analyzed = await analyzeAnomaliesWithAI(data, anomalies, '');
return { anomalies: analyzed };
},
}),

generateDashboard: tool({
description: '生成完整的数据仪表盘。当用户需要全景视图时调用。',
parameters: z.object({
description: z.string().describe('仪表盘需求'),
}),
execute: async ({ description }) => {
const dashboard = await generateDashboard(description, dbSchema);
return { dashboard };
},
}),

generateStory: tool({
description: '生成数据叙事报告。当用户想要一份分析报告时调用。',
parameters: z.object({
context: z.string().describe('分析背景'),
data: z.array(z.record(z.unknown())),
}),
execute: async ({ context, data }) => {
const story = await generateDataStory(data, context);
return { story };
},
}),
},
maxSteps: 5,
});

return result.toDataStreamResponse();
}

常见面试问题

Q1: 如何用 Structured Output 实现 NL2Chart?

答案

核心流程分为 4 步:

  1. 定义图表 Schema:用 Zod 定义图表配置的类型约束(chartType、xAxis、yAxis、series 等)
  2. 组装 Prompt:将数据的字段名、类型、示例值和用户需求拼接成 Prompt
  3. 调用 generateObject:AI SDK 的 generateObject 函数结合 Zod Schema 约束 LLM 输出,保证返回的 JSON 严格符合 Schema
  4. 渲染图表:前端根据配置对象渲染对应的图表库(ECharts 或 Recharts)
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: ChartConfigSchema, // Zod Schema 约束输出格式
prompt: `数据字段: ${fieldInfo}\n用户需求: ${query}`,
});
// object 严格符合 ChartConfig 类型,可直接驱动渲染

关键点是 Zod Schema 同时承担了两个角色:为 LLM 描述输出格式(通过 .describe()),为前端代码提供类型安全。这比让 LLM 自由输出 JSON 然后手动解析可靠得多。相关原理详见 AI SDK 与框架 中的 Structured Output 部分。

Q2: 如何确保 AI 生成的 SQL 是安全的?

答案

绝不能只依赖 Prompt 约束("请只生成 SELECT"),必须在多层做安全防护:

防护层措施作用
数据库层只读账号连接即使绕过所有检查,也无法写入
代码层关键字黑名单(DELETE/UPDATE/DROP)拦截危险操作
代码层正则白名单(必须 SELECT 开头)确保查询类型
代码层禁止多语句(检查分号)防止 SQL 注入拼接
代码层禁止注释(--/*防止注释注入
运行时强制 LIMIT(自动追加)防止全表扫描
运行时查询超时(30 秒)防止慢查询拖垮数据库
运行时子查询深度限制防止复杂查询
审计层记录所有 SQL + 执行者事后追溯

最重要的是数据库层的只读隔离——这是唯一物理层面的安全保证。

Q3: 如何自动推荐最佳图表类型?

答案

采用规则引擎 + AI 混合策略:

规则引擎(零延迟、零成本):

  • 分析数据特征:维度/度量字段数量、时间字段检测、字段基数(去重值数量)
  • 推断数据意图:趋势(有时间维度)、对比(分类 + 数值)、组成(少量分类 + 数值)、相关性(多个数值字段)
  • 根据意图匹配图表:趋势 -> 折线图、对比 -> 柱状图、组成 -> 饼图/treemap、相关性 -> 散点图

AI 兜底(当规则引擎置信度 < 85 分时调用 LLM):

  • 发送数据样本和特征给 LLM
  • LLM 综合考虑业务语义,给出更精准的推荐

关键细节:饼图最多支持 6 个类别(超过 6 个扇区视觉效果差,应切换为 treemap 或柱状图),这是规则引擎中容易被忽略的点。

Q4: 如何构建 AI 驱动的交互式 Dashboard?

答案

完整流程:

  1. 生成阶段:用户用自然语言描述需求("生成电商运营日报"),LLM 返回 Dashboard Schema(包含多个面板的图表配置、布局、数据源 SQL)
  2. 布局渲染:使用 12 列 CSS Grid 系统,每个面板根据 {x, y, w, h} 定位
  3. 数据加载:并行执行各面板的 SQL 查询,独立加载,独立显示 loading
  4. 交互联动:点击 A 图表的某个类别 -> 自动筛选 B 图表的数据(通过共享 linkedState)
  5. 全局筛选:顶部筛选器(时间范围、部门等)影响所有面板的数据查询
  6. 自动刷新:实时场景下定时重新查询数据

响应式处理:Desktop 用 12 列,Tablet 压缩为 6 列(面板自动折行),Mobile 强制单列。

Q5: 对比主流图表库在 AI 可视化场景的适用性

答案

ECharts 是 AI 可视化的最佳选择,核心原因:

  1. 声明式 JSON option:LLM 生成纯 JSON 对象即可,不需要生成代码。而 Recharts 需要生成 JSX,D3 需要生成完整的命令式代码
  2. 图表类型最丰富(30+):包括热力图、桑基图、矩形树图等高级类型,一套 Schema 覆盖所有场景
  3. Canvas 渲染:大数据量(万级以上)性能远好于 SVG 方案(Recharts/D3)
  4. 内置导出getDataURL() 一行代码导出 PNG/SVG
  5. 主题切换echarts.registerTheme() 全局切换深色/浅色

Recharts 的适用场景:只需基础图表(折线、柱状、饼图),追求最小 Bundle 大小(~170KB vs ECharts ~300KB),或团队更习惯 React JSX 的声明式写法。

D3 的适用场景:需要高度定制的可视化效果(如自定义 tooltip、特殊动画、非标准图表),但 AI 难以生成正确的 D3 代码。

Q6: 如何实现流式实时图表更新?

答案

关键技术点:

  1. WebSocket 传输:实时数据用 WebSocket 而非 SSE,因为需要双向通信(客户端可以发送订阅/取消订阅)
  2. RAF 缓冲:WebSocket 消息可能每秒数十条,直接更新图表会卡顿。将消息缓冲到 bufferRef 中,在 requestAnimationFrame 回调中批量 flush
  3. 增量更新:ECharts 的 setOption 配合 notMerge: false 实现增量更新,不重绘整个图表
  4. 滑动窗口:只保留最近 N 个数据点(如 100 个),避免内存增长
  5. AI 解说:后端每隔 30 秒将最近数据发给 LLM(用小模型如 gpt-4o-mini),生成一句话的趋势/异常解说

流式图表更新的详细 SSE 实现原理可参考 流式渲染与 SSE

Q7: 什么是数据叙事?如何用 AI 实现?

答案

数据叙事(Data Storytelling)是将数据分析结果以故事形式呈现的技术。相比于单独的图表 + 数字,叙事模式用自然语言串联洞察、图表和 KPI,形成完整的分析报告。

实现方式:

  1. 生成结构化报告:用 Structured Output 让 LLM 返回包含 text(叙事段落)、chart(图表配置)、kpi(指标卡片)、insight(关键发现)的混合数组
  2. 交替渲染:前端根据 section 类型交替渲染文字和图表,形成阅读流
  3. 受众适配:根据受众角色(高管、分析师、通用)调整叙事风格——高管看 KPI 和结论,分析师看趋势和异常细节

关键是叙事的连贯性——不是简单罗列图表,而是每段文字引出下一个图表,每个图表支撑下一个洞察。这需要在 Prompt 中明确要求 LLM「讲一个连贯的数据故事」。

Q8: 如何检测和可视化数据异常?

答案

异常检测分为两层:

统计方法(快速、零成本):

  • IQR 方法:值超出 Q1 - 1.5IQR 或 Q3 + 1.5IQR 标记为异常,适合非正态分布
  • Z-Score 方法:偏离均值超过 2.5 个标准差,适合正态分布
  • 突变检测:相邻数据点变化率超过 50% 标记为突变

AI 增强分析(归因和解释):

  • 将统计方法发现的异常 + 数据上下文发给 LLM
  • LLM 给出可能的原因分析和处理建议(如"第 15 天的 PV 暴跌可能与服务器故障有关")

可视化呈现

  • 正常数据用折线连接,异常点用红色散点高亮
  • 添加置信区间带(预期范围的浅色区域)
  • 高严重度异常添加标记线
  • 下方列表展示每个异常的类型、严重程度和描述

Q9: 大数据量下如何处理 AI 可视化?

答案

数据量大时有两个瓶颈:LLM Token 限制图表渲染性能

发给 LLM 的处理

  • 只发数据 Schema + 采样数据(如 20 行)+ 统计摘要(总行数、各字段的 min/max/avg)
  • LLM 基于采样数据生成配置,应用到全量数据渲染

图表渲染的处理(按数据量级):

数据量策略方法
< 1,000直接渲染无需处理
1,000 - 10,000降采样LTTB 算法(保留视觉特征)
10,000 - 100,000聚合 + Canvas按时间粒度聚合 + ECharts Canvas 渲染
> 100,000分页 + 虚拟滚动只渲染可视区域数据

**LTTB(Largest Triangle Three Buckets)**是时序数据降采样的最佳算法——它将数据分桶,每个桶保留视觉上最「重要」的点(与相邻桶形成最大三角形面积的点),在大幅减少数据点的同时保持曲线的视觉特征。

Q10: NL2Chart 和传统 BI 工具的核心区别是什么?

答案

维度传统 BI(Tableau/Power BI)AI 可视化(NL2Chart)
交互方式拖拽字段到行/列/筛选器自然语言描述
学习成本高(需要理解维度/度量概念)低(会说话就会用)
精确控制⭐⭐⭐(像素级调整)⭐⭐(依赖 LLM 理解)
探索效率中(需要人工尝试)高(AI 主动推荐探索方向)
洞察发现完全人工AI 自动发现异常和模式
报告生成手动截图+写文字AI 自动生成数据叙事
适用人群数据分析师所有人
成本软件许可费LLM API 调用费

两者是互补而非替代关系:AI 可视化降低了入门门槛和探索效率,但当需要精确控制图表样式、复杂计算字段时,传统 BI 仍有优势。实际趋势是传统 BI 工具正在集成 AI 能力(如 Tableau GPT、Power BI Copilot)。

Q11: AI 可视化系统的完整技术架构是什么?

答案

关键架构决策:

  1. BFF 层隔离:前端不直接连数据库,所有查询经 BFF 的安全验证
  2. 大小模型分工:复杂任务(NL2Chart、NL2SQL)用大模型,轻量任务(图表推荐、异常检测解说)用小模型控制成本
  3. 规则引擎优先:图表推荐和异常检测先用统计方法,不确定时再调 AI
  4. 流式返回:图表配置生成后立即返回,不等数据叙事完成

相关链接