跳至正文
全部文章

用 AVA 构建 Mini Data Agent

从一份销售数据开始,让 Agent 分析销售额变化、核对结果,并交付结论和图表。

做一个 Data Agent,可以从一个很小的需求开始:给它一份销售数据,让它分析销售额下降主要来自哪些产品,并整理成一份带图表的简短报告。

完成这份报告,需要比较整体变化、按产品定位下降来源,并核对合计。遇到疑点时,再根据结果补充查询。

AVA 是面向 JavaScript 应用和 AI Agent 的开源数据分析与可视化框架。AVA 负责执行数据操作,Data Agent 负责根据结果组织下一步。

本文用 AVA 和 AI SDK,通过 53 行核心代码 搭建一个 Mini Data Agent。

准备数据

先准备一份销售订单数据,共 240 笔订单、12 个字段。下面列出前 3 笔订单的日期、产品和销售额:

order_date(订单日期)product(产品)sales(折后销售额)
2025-01-01Monitor224.10
2025-01-02Office Chair680.20
2025-01-04Monitor2,191.20

YOUR_DATA 表示完整 CSV 解析得到的对象数组,其中 sales 为数值。

定义语义

接着,告诉 Agent 这些数据该怎么算:销售额按折后金额统计,月份以下单时间为准,再比较各产品的销售额增加或减少了多少。

语义配置会把这些约定作为 AVA 生成查询的依据,减少模型对指标含义的猜测,也让后续追问沿用同一套口径。

yaml
models:
  - name: data
    tableReference:
      table: data
    properties:
      description: Each row represents one order.
cubes:
  - name: sales
    baseObject: data
    measures:
      - name: total_sales
        expression: CAST(SUM(sales) AS DOUBLE)
        type: DOUBLE
        description: Sales after discounts, calculated by summing sales.
    dimensions:
      - name: product
        expression: product
        type: VARCHAR
    timeDimensions:
      - name: order_date
        expression: CAST(order_date AS DATE)
        type: DATE
    properties:
      description: |
        Compare both periods using the same set of products. Change is current-period sales minus prior-period sales; negative values indicate a decline.
        Change rate is change divided by prior-period sales. Return null when prior-period sales are zero, indicating that the rate is not applicable.

YOUR_SEMANTIC 表示这段 YAML 解析后的对象。字段用法见业务语义。

构建 Data Agent

数据和口径准备好后,为 Agent 提供两个工具:analyze 查询和计算数据,visualize 接收绘图要求和数据,生成图表。ToolLoopAgent 把每次执行结果交回模型,让它决定继续分析、选择哪些结果传给 visualize,还是交付结论。

示例使用 Node.js 22.13.0 或更高版本,安装以下依赖:

YOUR_MODEL 是已配置好、支持工具调用的模型实例。将它与数据、语义一起传入,Agent 就能调用 AVA 开展分析:

javascript
import { AVA } from '@antv/ava';
import { ToolLoopAgent, isStepCount, tool } from 'ai';
import { z } from 'zod';
 
async function createDataAgent({ model, data, semantic }) {
  const ava = new AVA({ llm: { model } });
  await ava.source({ type: 'json', options: { data } });
  await ava.semantic(undefined, () => semantic);
 
  const messages = [];
  let analysis;
  let chart;
 
  const agent = new ToolLoopAgent({
    model,
    instructions: `Analyze the user's task step by step, using conversation history to make each query self-contained.
    Use analyze to compute and verify data.
    For visualize, select relevant rows and fields from analyze results; never invent or change values.
    Base conclusions on tool results and state what data is missing when evidence is insufficient.`,
    stopWhen: isStepCount(8),
    providerOptions: { openai: { parallelToolCalls: false } },
    tools: {
      analyze: tool({
        description: 'Query and analyze data using a self-contained question.',
        inputSchema: z.object({ query: z.string() }),
        execute: async ({ query }) => {
          analysis = chart = undefined;
          analysis = await ava.analyze(query);
          return analysis;
        },
      }),
      visualize: tool({
        description: 'Generate a chart from the visualization request and data returned by analyze.',
        inputSchema: z.object({ query: z.string(), data: z.array(z.record(z.string(), z.unknown())) }),
        execute: async ({ query, data }) => {
          chart = await ava.visualize({ query, data });
          return { chartType: chart?.chartType };
        },
      }),
    },
  });
 
  return {
    async ask(question) {
      chart = undefined;
      messages.push({ role: 'user', content: question });
      const result = await agent.generate({ messages });
      messages.push(...result.responseMessages);
      return { text: result.text, analysis, chart };
    },
    dispose: () => ava.dispose(),
  };
}

Agent 会保留对话和分析记录,方便继续追问。绘图时,它从分析结果中选择需要展示的行和列,通过 data 参数传给 visualize,不局限于最近一次分析结果。现在把开头的需求交给它:

javascript
import { writeFile } from 'node:fs/promises';
 
const assistant = await createDataAgent({
  model: YOUR_MODEL,
  data: YOUR_DATA,
  semantic: YOUR_SEMANTIC,
});
 
try {
  const task = `分析 2025 年 2 月较 1 月的销售额下降来源,列出各产品两个月的销售额和变化额,
按降幅排序、核对合计,并用条形图展示变化额。`;
  const answer = await assistant.ask(task);
  console.log(answer.text);
  console.table(answer.analysis?.data);
  console.log(answer.analysis?.sql);
  if (answer.chart) await writeFile('chart.html', answer.chart.html);
 
  console.log((await assistant.ask('Laptop 销售额下降是因为促销结束了吗?')).text);
} finally {
  await assistant.dispose();
}

查看结果

以这个任务为例,Agent 可以先用 analyze 比较两个月的总额,看到下降后再按产品拆解;核对各产品的变化额合计与整体一致,再用 visualize 画图并整理结论。如果合计对不上,就需要继续查询核对。

按这份数据汇总,销售额从 1 月的 38,897.18 降至 2 月的 21,804.11,下降 43.94%。各产品的变化如下:

产品1 月销售额2 月销售额变化额(2 月 − 1 月)
Laptop19,688.103,596.00−16,092.10
Standing Desk9,655.805,546.10−4,109.70
Monitor6,536.254,731.00−1,805.25
Pen Set232.20127.71−104.49
Bookshelf1,225.501,251.30+25.80
Copy Paper59.19137.06+77.87
Notebook31.50137.03+105.53
Keyboard215.641,015.31+799.67
Office Chair1,253.005,262.60+4,009.60
合计38,897.1821,804.11−17,093.07

各产品的变化额合计为 −17,093.07,与整体变化一致。画成图后,下降的来源更加清楚:Laptop 是主要拖累,Office Chair 的增长则抵消了部分下降。

2025 年 1 月至 2 月各产品销售额变化,Laptop 下降最多,Office Chair 增长最多

接下来可以追问:“Laptop 销售额下降是因为促销结束了吗?”现有数据缺少活动时间和参与范围,还不能确认。定位下降来源之后,解释业务原因还需要证据。

接入自己的业务时,换成相应的数据和业务口径,再交给它新的分析任务即可。