AI BI Helper 开发实录 02:Graph 工作流编排——SQL 生成、执行与邮件推送

0 阅读24分钟

AI BI Helper 开发实录 02:Graph 工作流编排——SQL 生成、执行与邮件推送

跟着课程《Java转AI高薪领域必备——从0到1打通生产级AI Agent开发》做的实战记录。技术栈:Spring AI Alibaba 1.1.2.0 + Milvus 3.0 + MySQL 8.x。项目代号 ai-bi-helper,这是第 02 期,聚焦 Graph 工作流编排。

前置阅读:AI BI Helper 开发实录 01:文本向量与 Milvus 向量数据库集成

前一期已经把基础打好了——Spring Boot 环境搭建、文本向量、Milvus 向量数据库集成,以及要访问的数据库的数据准备都完成了。这一期开始真正编写 BI 的功能。

说白了,这一期的目标就是把下面这条链路打通:自然语言提问 → RAG 召回表结构 → LLM 生成 SQL → 执行 SQL → 生成 Excel → 邮件推送。整个过程用 Spring AI Alibaba 的 Graph 来编排,一个节点一个节点地写、测、记日志。

一. Graph 工作流总览

1.1 完成的 Graph

先把最终要做的 Graph 摆出来,心里有个全貌:

image-20260728213045208.png

按照惯例是一个节点一个节点的编写、测试,记录好日志。这样出问题才好定位,不至于一上来全串起来查不到毛病在哪。

二. 第一个节点:GenSQLNode(生成 SQL)

2.1 节点编写

2.1.1 GenSQLNode

第一个节点负责生成 SQL,核心就三件事:实现 RAG 的召回、定义提示词、和 LLM 交互。

package vip.wayhua.ivy.ai.bi.nodes;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.VectorStore;

import reactor.core.publisher.Flux;
import vip.wayhua.ivy.ai.bi.constants.Constant;

import java.util.Map;

public class GenSQLNode implements NodeAction {
private static final Logger log= LoggerFactory.getLogger(GenSQLNode.class);
    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public GenSQLNode(ChatClient chatClient, VectorStore vectorStore) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
    }

    /***
     *   1. 实现RAG的召回过程
     *   2. 定义提示词
     *   3.与LLM交互(对话)
     * @param state
     * @return
     * @throws Exception
     */
    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        //        1. 实现RAG的召回过程
        String userInput = state.value(Constant.KeyName.USER_INPUT, "");
        RetrievalAugmentationAdvisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
                //指定你使用哪一种文档检索器
                .documentRetriever(VectorStoreDocumentRetriever
                        .builder()
                        .vectorStore(vectorStore)
                        .build())
                .build();

        Flux<String> content = chatClient.prompt()
                .advisors(retrievalAugmentationAdvisor)
                .system("""
                        # 角色
                        你是一名熟练的 SQL 专家,负责根据企业数据表结构生成 SQL 查询。用户将以自然语言提出数据需求。
                        你有能力访问企业数据库表结构和表之间的关系(这些信息通过矢量数据库检索得到)
                        
                        # 要求
                        1. 仅生成可执行的 SQL,不输出任何与 SQL 无关的文字或解释。
                        2. 在生成 SQL 前,首先理解用户需求和检索得到的表结构信息。
                        3. 根据表结构和关系选择合适的表和字段,生成可执行的 SQL。
                        4. 输出 SQL 时,禁止使用markdown格式```sql来输出,直接以文本格式输出。
                        5. 如果存在多种实现方式,优先选择最简洁、性能较优的写法。
                        6. 禁止输出与 SQL 无关的文本或解释。
                        7. 不可凭空虚构数据,若数据不足,请返回空字符串。
                        
                        """)
                .user(userInput)
                .stream().content();

        StringBuilder sb = new StringBuilder();
        content.doOnNext(c-> sb.append(c)).blockLast();
        log.info("genSQL=[{}]",sb.toString());
        return Map.of(Constant.KeyName.GEN_SQL,sb.toString());
    }
}

补充说明:这里用 RetrievalAugmentationAdvisor 把 RAG 接进来——它会先用 VectorStoreDocumentRetriever 去 Milvus 里召回跟用户问题相关的表结构文档,再把召回的内容拼到提示词里一起喂给 LLM。简单说就是:先查向量库找相关表,再让大模型照着表结构写 SQL。提示词里反复强调「只输出 SQL、不要解释、不要用 markdown 包裹」,是因为 LLM 总喜欢多说话,得用规则把它框住。

2.1.2 Graph 配置

这个比较简单就不搞什么持久化了,测试完成就可以了。

package vip.wayhua.ivy.ai.bi.config;

import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.AsyncNodeAction;
import com.alibaba.cloud.ai.graph.exception.GraphStateException;
import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import vip.wayhua.ivy.ai.bi.constants.Constant;
import vip.wayhua.ivy.ai.bi.nodes.GenSQLNode;

import java.util.HashMap;
import java.util.Map;

@Configuration
public class GraphConfig {

    @Autowired
    VectorStore vectorStore;

    @Bean
    public CompiledGraph graph(ChatClient.Builder chatClientBuilder) throws GraphStateException {
        ChatClient chatClient = chatClientBuilder.build();


        KeyStrategyFactory keyStrategyFactory = () -> {


            Map<String, KeyStrategy> map = new HashMap<String, KeyStrategy>();
            //添加key以及Key所对应Value的更新策略

            //fei的更新策略为覆盖
            //总体费用  先写这么多后面补充
            map.put(Constant.KeyName.USER_INPUT, new ReplaceStrategy());
            map.put(Constant.KeyName.GEN_SQL, new ReplaceStrategy());

            return map;
        };

        StateGraph stateGraph = new StateGraph(Constant.GraphName, keyStrategyFactory);


        // 添加Nodes
        stateGraph.addNode(Constant.NodeName.GEN_SQL_NODE,
                AsyncNodeAction.node_async(new GenSQLNode(chatClient, vectorStore)));

        //添加 Edges
        stateGraph.addEdge(StateGraph.START, Constant.NodeName.GEN_SQL_NODE);
        stateGraph.addEdge(Constant.NodeName.GEN_SQL_NODE, StateGraph.END);


        //compile
        return stateGraph.compile();
    }

}

补充说明:KeyStrategyFactory 定义的是 Graph 状态里每个 key 的更新策略。这里用 ReplaceStrategy,意思是后写覆盖先写。目前先注册 USER_INPUTGEN_SQL 两个 key,后面加节点时再补。

2.1.3 测试 Controller
package vip.wayhua.ivy.ai.bi.controller;

import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.OverAllState;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import vip.wayhua.ivy.ai.bi.constants.Constant;
import vip.wayhua.ivy.ai.core.dto.R;

import java.util.Map;

@Tag(name = "生成SQL Controller")
@RestController
@RequestMapping("/genSql")
public class GenSqlController {

    @Resource
    private CompiledGraph graph;

    @GetMapping("/talk")
    public R<Map<String, Object>> talk(@RequestParam("userInput") String userInput) {

        OverAllState state = graph.invoke(
                        Map.of(Constant.KeyName.USER_INPUT, userInput))
                .get();
        Map<String, Object> data = state.data();
        return R.success(data);
    }
}

2.1.4 第一次测试

先拿一个问题试一下:

计算2025年每个月的销售总额,并按月分升序排列

生成 SQL,执行才发现前一篇导入的 SQL 是错误的,我已改过来。但为了完整性,我还是补上。

QQ_1785310638011.png

2.2 补充 SQL(修复前一期的数据)

前面创建表和数据是 ai-agent 的数据(已修复,但不全),这次要换成 BI 场景的维度表/事实表结构。删除原来的数据表和数据,重新创建。

2.2.1 创建表
-- ============================================
-- 维度表:商品维度 (dim_product)
-- ============================================
DROP TABLE IF EXISTS dim_product;
CREATE TABLE dim_product
(
    product_id    BIGINT PRIMARY KEY COMMENT '商品唯一ID',
    product_name  VARCHAR(255) NOT NULL COMMENT '商品名称',
    category_id   BIGINT NULL COMMENT '商品分类ID',
    category_name VARCHAR(255) NULL COMMENT '商品分类名称',
    brand         VARCHAR(255) NULL COMMENT '品牌',
    cost_price    DECIMAL(10, 2) NULL COMMENT '成本价',
    retail_price  DECIMAL(10, 2) NULL COMMENT '建议零售价'
) COMMENT='商品维度表';


-- ============================================
-- 维度表:门店维度 (dim_store)
-- ============================================
DROP TABLE IF EXISTS dim_store;
CREATE TABLE dim_store
(
    store_id   BIGINT PRIMARY KEY COMMENT '门店唯一ID',
    store_name VARCHAR(255) NOT NULL COMMENT '门店名称',
    province   VARCHAR(100) NULL COMMENT '所在省份',
    city       VARCHAR(100) NULL COMMENT '所在城市',
    address    VARCHAR(255) NULL COMMENT '门店详细地址',
    open_date  DATE NULL COMMENT '开业日期'
) COMMENT='门店维度表';


-- ============================================
-- 维度表:时间维度 (dim_date)
-- ============================================
DROP TABLE IF EXISTS dim_date;
CREATE TABLE dim_date
(
    date_id DATE PRIMARY KEY COMMENT '日期ID',
    year    INT NOT NULL,
    quarter INT NOT NULL,
    month   INT NOT NULL,
    day     INT NOT NULL,
    weekday INT NOT NULL
) COMMENT='时间维度表';


-- ============================================
-- 维度表:客户维度 (dim_customer)
-- ============================================
DROP TABLE IF EXISTS dim_customer;
CREATE TABLE dim_customer
(
    customer_id   BIGINT PRIMARY KEY COMMENT '客户ID',
    customer_name VARCHAR(255) NOT NULL,
    gender        VARCHAR(10) NULL,
    age           INT NULL,
    city          VARCHAR(100) NULL,
    province      VARCHAR(100) NULL
) COMMENT='客户维度表';


-- ============================================
-- 事实表:销售事实表 (fact_sales)
-- ============================================
DROP TABLE IF EXISTS fact_sales;
CREATE TABLE fact_sales
(
    sales_id     BIGINT PRIMARY KEY COMMENT '销售记录ID',
    product_id   BIGINT         NOT NULL COMMENT '商品ID',
    store_id     BIGINT         NOT NULL COMMENT '门店ID',
    customer_id  BIGINT NULL COMMENT '客户ID',
    date_id      DATE           NOT NULL COMMENT '销售日期',
    quantity     INT            NOT NULL COMMENT '销售数量',
    sales_amount DECIMAL(10, 2) NOT NULL COMMENT '销售金额',
    discount     DECIMAL(10, 2) NULL COMMENT '折扣金额',
    FOREIGN KEY (product_id) REFERENCES dim_product (product_id),
    FOREIGN KEY (store_id) REFERENCES dim_store (store_id),
    FOREIGN KEY (customer_id) REFERENCES dim_customer (customer_id),
    FOREIGN KEY (date_id) REFERENCES dim_date (date_id)
) COMMENT='销售事实表';


-- ============================================
-- 事实表:库存事实表 (fact_inventory)
-- ============================================
DROP TABLE IF EXISTS fact_inventory;
CREATE TABLE fact_inventory
(
    inventory_id BIGINT PRIMARY KEY COMMENT '库存记录ID',
    product_id   BIGINT NOT NULL COMMENT '商品ID',
    store_id     BIGINT NOT NULL COMMENT '门店ID',
    date_id      DATE   NOT NULL COMMENT '库存日期',
    quantity     INT    NOT NULL COMMENT '库存数量',
    FOREIGN KEY (product_id) REFERENCES dim_product (product_id),
    FOREIGN KEY (store_id) REFERENCES dim_store (store_id),
    FOREIGN KEY (date_id) REFERENCES dim_date (date_id)
) COMMENT='库存事实表';

补充说明:这套是典型的星型模型——中间是事实表(fact_sales、fact_inventory),周围是维度表(dim_product、dim_store、dim_date、dim_customer)。BI 分析就是围绕事实表做聚合,再 JOIN 维度表拿描述性字段。

2.2.2 添加数据
-- 扩展商品维度数据
INSERT INTO dim_product (product_id, product_name, category_id, category_name, brand, cost_price, retail_price)
VALUES (6, 'iPad Air', 102, '平板电脑', 'Apple', 3500.00, 4599.00),
       (7, '小米平板6', 102, '平板电脑', 'Xiaomi', 1800.00, 2499.00),
       (8, '索尼电视 75寸', 103, '电视', 'Sony', 5200.00, 6999.00),
       (9, '海尔冰箱 450L', 104, '家电', 'Haier', 2600.00, 3299.00),
       (10, '美的洗衣机 10kg', 104, '家电', 'Midea', 2200.00, 2899.00),
       (11, '荣耀 Magic6', 101, '手机', 'Honor', 3800.00, 4999.00),
       (12, 'OPPO Find X7', 101, '手机', 'OPPO', 3600.00, 4799.00),
       (13, 'vivo X200', 101, '手机', 'vivo', 3400.00, 4599.00),
       (14, 'MacBook Air M3', 102, '笔记本', 'Apple', 7800.00, 9999.00),
       (15, '惠普战X', 102, '笔记本', 'HP', 5200.00, 6999.00),
       (16, '雷蛇游戏本 16', 102, '笔记本', 'Razer', 9000.00, 12999.00),
       (17, '戴尔 XPS 13', 102, '笔记本', 'Dell', 6800.00, 8999.00),
       (18, '海信电视 55寸', 103, '电视', 'Hisense', 2100.00, 2799.00),
       (19, 'TCL 50寸电视', 103, '电视', 'TCL', 1800.00, 2499.00),
       (20, '科沃斯扫地机器人 T10', 104, '家电', 'Ecovacs', 2400.00, 3299.00);

-- 扩展门店维度
INSERT INTO dim_store (store_id, store_name, province, city, address, open_date)
VALUES (1006, '南京新街口店', '江苏', '南京市', '新街口中央路18号', DATE ('2021-02-12')),
       (1007, '成都春熙路店', '四川', '成都市', '锦江区春熙路66号', DATE ('2020-08-07')),
       (1008, '武汉光谷店', '湖北', '武汉市', '东湖高新区光谷大道88号', DATE ('2021-10-03')),
       (1009, '西安小寨店', '陕西', '西安市', '雁塔区小寨路88号', DATE ('2019-12-30')),
       (1010, '重庆解放碑店', '重庆', '重庆市', '渝中区解放碑CBD', DATE ('2020-03-11')),
       (1011, '苏州园区店', '江苏', '苏州市', '工业园区金鸡湖大道88号', DATE ('2022-01-15')),
       (1012, '长沙五一广场店', '湖南', '长沙市', '芙蓉区五一大道66号', DATE ('2022-05-10')),
       (1013, '天津滨江道店', '天津', '天津市', '和平区滨江道99号', DATE ('2020-06-18')),
       (1014, '青岛万象城店', '山东', '青岛市', '市南区香港中路8号', DATE ('2021-09-28')),
       (1015, '沈阳太原街店', '辽宁', '沈阳市', '和平区太原街66号', DATE ('2019-04-03'));


-- 扩展日期维度(2025-01-04 ~ 2025-01-31)
INSERT INTO dim_date (date_id, year, quarter, month, day, weekday)
VALUES ('2025-01-04', 2025, 1, 1, 4, 6),
       ('2025-01-05', 2025, 1, 1, 5, 7),
       ('2025-01-06', 2025, 1, 1, 6, 1),
       ('2025-01-07', 2025, 1, 1, 7, 2),
       ('2025-01-08', 2025, 1, 1, 8, 3),
       ('2025-01-09', 2025, 1, 1, 9, 4),
       ('2025-01-10', 2025, 1, 1, 10, 5),
       ('2025-01-11', 2025, 1, 1, 11, 6),
       ('2025-01-12', 2025, 1, 1, 12, 7),
       ('2025-01-13', 2025, 1, 1, 13, 1),
       ('2025-01-14', 2025, 1, 1, 14, 2),
       ('2025-01-15', 2025, 1, 1, 15, 3),
       ('2025-01-16', 2025, 1, 1, 16, 4),
       ('2025-01-17', 2025, 1, 1, 17, 5),
       ('2025-01-18', 2025, 1, 1, 18, 6),
       ('2025-01-19', 2025, 1, 1, 19, 7),
       ('2025-01-20', 2025, 1, 1, 20, 1),
       ('2025-01-21', 2025, 1, 1, 21, 2),
       ('2025-01-22', 2025, 1, 1, 22, 3),
       ('2025-01-23', 2025, 1, 1, 23, 4),
       ('2025-01-24', 2025, 1, 1, 24, 5),
       ('2025-01-25', 2025, 1, 1, 25, 6),
       ('2025-01-26', 2025, 1, 1, 26, 7),
       ('2025-01-27', 2025, 1, 1, 27, 1),
       ('2025-01-28', 2025, 1, 1, 28, 2),
       ('2025-01-29', 2025, 1, 1, 29, 3),
       ('2025-01-30', 2025, 1, 1, 30, 4),
       ('2025-01-31', 2025, 1, 1, 31, 5);

-- 扩展客户维度
INSERT INTO dim_customer (customer_id, customer_name, gender, age, city, province)
VALUES (4, '赵六', '男', 29, '深圳', '广东'),
       (5, '孙七', '女', 24, '南京', '江苏'),
       (6, '钱八', '男', 31, '成都', '四川'),
       (7, '周九', '女', 27, '杭州', '浙江'),
       (8, '吴十', '男', 35, '武汉', '湖北'),
       (9, '郑一', '女', 22, '天津', '天津'),
       (10, '王二', '男', 33, '重庆', '重庆'),
       (11, '李三', '男', 30, '西安', '陕西'),
       (12, '张四', '女', 28, '长沙', '湖南'),
       (13, '陈五', '男', 40, '青岛', '山东'),
       (14, '王小明', '男', 20, '广州', '广东'),
       (15, '李丽', '女', 23, '北京', '北京'),
       (16, '赵雪', '女', 26, '上海', '上海'),
       (17, '周浩', '男', 34, '成都', '四川'),
       (18, '钱娜', '女', 25, '深圳', '广东'),
       (19, '吴峰', '男', 36, '南京', '江苏'),
       (20, '孙美', '女', 29, '杭州', '浙江'),
       (21, '丁强', '男', 31, '武汉', '湖北'),
       (22, '贾玲', '女', 38, '天津', '天津'),
       (23, '刘伟', '男', 41, '重庆', '重庆'),
       (24, '王冬', '男', 27, '西安', '陕西'),
       (25, '李雪', '女', 33, '长沙', '湖南'),
       (26, '张亮', '男', 39, '青岛', '山东'),
       (27, '赵婷', '女', 22, '广州', '广东'),
       (28, '孙诚', '男', 24, '上海', '上海'),
       (29, '周影', '女', 32, '北京', '北京'),
       (30, '钱军', '男', 37, '深圳', '广东'),
       (31, '吴倩', '女', 30, '成都', '四川'),
       (32, '郑凯', '男', 35, '南京', '江苏');

2.2.3 存储过程创建 fact_inventory

事实表数据量大,手写不现实,用存储过程批量生成。

DROP PROCEDURE IF EXISTS gen_fact_inventory;
DELIMITER $$

CREATE PROCEDURE gen_fact_inventory()
BEGIN
    DECLARE i INT DEFAULT 1;
    DECLARE v_product_id BIGINT;
    DECLARE v_store_id BIGINT;
    DECLARE v_date_id DATE;
    DECLARE v_quantity INT;

    WHILE i <= 300 DO
        SET v_product_id = 6 + MOD(i - 1, 15);
        SET v_store_id = 1006 + MOD(i - 1, 9);
        SET v_date_id = DATE_SUB('2025-01-31', INTERVAL MOD(i - 1, 7) DAY);

        SET v_quantity =
            CASE v_product_id
                WHEN 6  THEN 35 + MOD(i, 12)
                WHEN 7  THEN 18 + MOD(i, 15)
                WHEN 8  THEN 12 + MOD(i, 17)
                WHEN 9  THEN 41 + MOD(i, 11)
                WHEN 10 THEN 22 + MOD(i, 14)
                WHEN 11 THEN 15 + MOD(i, 15)
                WHEN 12 THEN 32 + MOD(i, 12)
                WHEN 13 THEN 18 + MOD(i, 10)
                WHEN 14 THEN 12 + MOD(i, 9)
                WHEN 15 THEN 38 + MOD(i, 8)
                WHEN 16 THEN 16 + MOD(i, 11)
                WHEN 17 THEN 25 + MOD(i, 13)
                WHEN 18 THEN 33 + MOD(i, 10)
                WHEN 19 THEN 23 + MOD(i, 8)
                WHEN 20 THEN 5 + MOD(i, 6)
                ELSE 20
END;

INSERT INTO fact_inventory (
    inventory_id, product_id, store_id, date_id, quantity
) VALUES (
             i, v_product_id, v_store_id, v_date_id, v_quantity
         );

SET i = i + 1;
END WHILE;
END$$

DELIMITER ;

CALL gen_fact_inventory();
DROP PROCEDURE IF EXISTS gen_fact_inventory;

QQ_1785310459101.png 生成数据

QQ_1785310479238.png

2.2.4 存储过程创建 fact_sales
DROP PROCEDURE IF EXISTS gen_fact_sales;
DELIMITER $$

CREATE PROCEDURE gen_fact_sales()
BEGIN
    DECLARE i INT DEFAULT 1;
    DECLARE v_product_id BIGINT;
    DECLARE v_store_id BIGINT;
    DECLARE v_customer_id BIGINT;
    DECLARE v_date_id DATE;
    DECLARE v_quantity INT;
    DECLARE v_price DECIMAL(10,2);
    DECLARE v_discount DECIMAL(10,2);
    DECLARE v_sales_amount DECIMAL(10,2);

    WHILE i <= 300 DO
        SET v_product_id = 6 + MOD(i - 1, 15);
        SET v_store_id = 1006 + MOD(i - 1, 9);
        SET v_customer_id = 4 + MOD(i - 1, 29);
        SET v_date_id = DATE_ADD('2025-01-04', INTERVAL MOD(i - 1, 28) DAY);
        SET v_quantity = 1 + MOD(i - 1, 4);

SELECT retail_price INTO v_price
FROM dim_product
WHERE product_id = v_product_id;

SET v_sales_amount = v_price * v_quantity;

        SET v_discount =
            CASE MOD(i - 1, 7)
                WHEN 0 THEN 0.00
                WHEN 1 THEN ROUND(v_sales_amount * 0.04, 2)
                WHEN 2 THEN ROUND(v_sales_amount * 0.03, 2)
                WHEN 3 THEN ROUND(v_sales_amount * 0.05, 2)
                WHEN 4 THEN ROUND(v_sales_amount * 0.025, 2)
                WHEN 5 THEN ROUND(v_sales_amount * 0.00, 2)
                ELSE ROUND(v_sales_amount * 0.02, 2)
END;

INSERT INTO fact_sales (
    sales_id, product_id, store_id, customer_id, date_id, quantity, sales_amount, discount
) VALUES (
             i, v_product_id, v_store_id, v_customer_id, v_date_id, v_quantity, v_sales_amount, v_discount
         );

SET i = i + 1;
END WHILE;
END$$

DELIMITER ;

CALL gen_fact_sales();
DROP PROCEDURE IF EXISTS gen_fact_sales;



QQ_1785310535838.png 生成数据

QQ_1785310563228.png

2.3 测试生成的 SQL

数据补齐后,再回到那个问题,看看生成的 SQL 执行对不对:

SELECT d.month, SUM(f.sales_amount) AS total_sales
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2025
GROUP BY d.month
ORDER BY d.month ASC

QQ_1785310602509.png

2.4 用户提问不标准:查询重写

当用户提问不标准时,怎么办?创建重写查询转换器,借助 LLM 帮我们进行用户查询的重写,变成可以让 LLM 更好理解的 Query。

要增加查询转换器,配置查询转换器,允许空参数。

package vip.wayhua.ivy.ai.bi.nodes;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.VectorStore;

import reactor.core.publisher.Flux;
import vip.wayhua.ivy.ai.bi.constants.Constant;

import java.util.Map;

public class GenSQLNode implements NodeAction {
    private static final Logger log = LoggerFactory.getLogger(GenSQLNode.class);
    private final VectorStore vectorStore;
    private final ChatClient.Builder builder;
    private final ChatClient chatClient;

    public GenSQLNode(ChatClient.Builder builder, VectorStore vectorStore) {
        this.vectorStore = vectorStore;
        this.builder = builder;
        this.chatClient = builder.build();
    }

    /***
     *   1. 实现RAG的召回过程
     *   2. 定义提示词
     *   3.与LLM交互(对话)
     * @param state
     * @return
     * @throws Exception
     */
    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        //        1. 实现RAG的召回过程
        String userInput = state.value(Constant.KeyName.USER_INPUT, "");
//        创建重写查询转换器,借助LLM帮我们进行用户查询的重写,变成可以让LLM更好理解的Query
        RewriteQueryTransformer queryTransformer = RewriteQueryTransformer.builder()
                .chatClientBuilder(builder)
                .build();


        RetrievalAugmentationAdvisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
                .queryTransformers(queryTransformer)
                //指定你使用哪一种文档检索器
                .documentRetriever(VectorStoreDocumentRetriever
                        .builder()
                        .vectorStore(vectorStore)
                        .build())
                .queryAugmenter(ContextualQueryAugmenter.builder()
                        .allowEmptyContext(true)
                        .build()
                )
                .build();

        Flux<String> content = chatClient.prompt()
                .advisors(retrievalAugmentationAdvisor)
                .system("""
                        # 角色
                        你是一名熟练的 SQL 专家,负责根据企业数据表结构生成 SQL 查询。用户将以自然语言提出数据需求。
                        你有能力访问企业数据库表结构和表之间的关系(这些信息通过矢量数据库检索得到)
                        
                        # 要求
                        1. 仅生成可执行的 SQL,不输出任何与 SQL 无关的文字或解释。
                        2. 在生成 SQL 前,首先理解用户需求和检索得到的表结构信息。
                        3. 根据表结构和关系选择合适的表和字段,生成可执行的 SQL。
                        4. 输出 SQL 时,禁止使用markdown格式```sql来输出,直接以文本格式输出。
                        5. 如果存在多种实现方式,优先选择最简洁、性能较优的写法。
                        6. 禁止输出与 SQL 无关的文本或解释。
                        7. 不可凭空虚构数据,若数据不足,请返回空字符串。
                        
                        """)
                .user(userInput)
                .stream().content();

        StringBuilder sb = new StringBuilder();
        content.doOnNext(c -> sb.append(c)).blockLast();
        log.info("genSQL=[{}]", sb.toString());
        return Map.of(Constant.KeyName.GEN_SQL, sb.toString());
}

补充说明:相比第一版,这里多了两个东西:

  • RewriteQueryTransformer:检索前先把用户的口语化问题重写成更规范的 Query,提高召回准确率。简单说就是先让 LLM 把用户的话「翻译」得更精确,再去向量库里找
  • ContextualQueryAugmenter + allowEmptyContext(true):把召回到的表结构拼进提示词;允许空上下文是为了防止召回为空时直接报错,宁可让它兜底也别崩。

2.5 再测试

生成的 SQL 果然有错误的。第一次生成的:

SELECT
    d.month_id AS sale_month,
    SUM(f.sales_amount) AS total_sales_amount
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year_id = 2025
GROUP BY d.month_id
ORDER BY d.month_id ASC

踩坑记录:LLM 把 month 幻觉成了 month_id、把 year 幻觉成了 year_id——表里根本没这些字段。这就是大模型生成 SQL 的通病:会编不存在的字段名

第二次生成的是正确的:

SELECT d.month, SUM(f.sales_amount) AS total_sales
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
WHERE d.year = 2025
GROUP BY d.month
ORDER BY d.month ASC

QQ_1785311958710.png 同一个问题两次结果不一样,说明 SQL 生成不稳定,这个问题后面会专门处理。

2.6 安全隐患防护

2.6.1 有没有安全隐患?

有。万一用户让 LLM 生成 DELETE、DROP、UPDATE、INSERT,那数据库就危险了。所以在提示词里增加一条约束,执行时也要禁止(这个在后面讲)。

8. 禁止生成 DELETEDROPUPDATEINSERT 语句,只允许生成SELECT语句。
   当用户的输入涉及DELETEDROPUPDATEINSERT这四种不安全SQL时,你必须输出空内容。

补充说明:这是「提示词层面的防护」,靠 LLM 自觉。真正稳妥的是执行层面也加一道校验,双保险。简单说就是:嘴上让它别干坏事,手上还得防着它真干

2.6.2 测试

挨个试一遍危险操作:

删除2025年一月份的销售数据

QQ_1785313820115.png 删除这种操作,返回空 SQL,是正确的。

清空2025年销售数据 QQ_1785313952350.png 插入一条2025年一月份的销售数据

QQ_1785314002445.png 更新2025年一月份数据,每条记录增加300销售金额

QQ_1785314063640.png DELETE、清空、INSERT、UPDATE 这几种都被拦下了,返回空 SQL,防护生效。

三. 查询节点:ExecSqlAndCreateExcelNode

3.1 添加数据库 pom 以及配置(忽略)

        <dependency>
            <groupId>vip.wayhua.ivy.ai</groupId>
            <artifactId>ivy-starter-core-db</artifactId>
            <version>1.0.1</version>
        </dependency>

3.2 ExecSqlAndCreateExcelNode

package vip.wayhua.ivy.ai.bi.nodes;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import vip.wayhua.ivy.ai.bi.constants.Constant;
import vip.wayhua.ivy.ai.core.utils.JsonUtils;

import java.util.List;
import java.util.Map;

public class ExecSqlAndCreateExcelNode implements NodeAction {
    private static final Logger log = LoggerFactory.getLogger(ExecSqlAndCreateExcelNode.class);
    private final JdbcTemplate jdbcTemplate;

    public ExecSqlAndCreateExcelNode(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    /****
     * 业务逻辑
     * 1.获取LLM生成的SQL文本
     * 2.通过jdbcTemplate进行执行SQL
     * 3.动态生成Excel导出模板文件
     * @param state
     * @return
     * @throws Exception
     */
    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        // 1.获取LLM生成的SQL文本
        String genSQL = state.value(Constant.KeyName.GEN_SQL, "");
        //  2.通过jdbcTemplate进行执行SQL
        List<Map<String, Object>> maps = jdbcTemplate.queryForList(genSQL);
        log.info("查询sql结果:[{}]", JsonUtils.toJson(maps));
        return Map.of();
    }
}

query 不使用 execute 就不会执行 update 等操作,一些 Execute 的操作测试,这里就忽略了。

补充说明:这里特意用 jdbcTemplate.queryForList() 而不是 execute(),是因为 queryForList 只跑查询,天然挡住写操作。这是执行层面的第二道保险——就算提示词没拦住,这里也执行不了 DELETE 之类的。

3.3 Graph 配置

把查询节点接进 Graph,串在 GenSQLNode 后面:

 stateGraph.addNode(Constant.NodeName.EXEC_SQL_AND_CREATE_EXCEL_NODE,
                AsyncNodeAction.node_async(new ExecSqlAndCreateExcelNode(jdbcTemplate)));

        //添加 Edges
        stateGraph.addEdge(StateGraph.START, Constant.NodeName.GEN_SQL_NODE);
        stateGraph.addEdge(Constant.NodeName.GEN_SQL_NODE,Constant.NodeName.EXEC_SQL_AND_CREATE_EXCEL_NODE );
        stateGraph.addEdge(Constant.NodeName.EXEC_SQL_AND_CREATE_EXCEL_NODE, StateGraph.END);

3.4 测试

计算2025年每个月的销售总额,并按月份升序排列

QQ_1785315148897.png 查看日志

QQ_1785315172826.png

3.5 生成 Excel

光查出数据不够,得导出成 Excel 才方便看。加一个生成 Excel 文件的方法:

private File generateExcelFile(List<Map<String, Object>> rows) throws IOException {

        if (rows.size() == 0 || rows.isEmpty()) {
            throw new XException(500, "SQL查询结果为空,无法生成Excel");
        }

        XSSFWorkbook workbook = new XSSFWorkbook();
        XSSFSheet sheet = workbook.createSheet("AI生成内容");
        // 1. 表头
        Map<String, Object> firstRow = rows.get(0);
        List<String> colums = new ArrayList<>(firstRow.keySet());

        // 表头行,第一行
        Row header = sheet.createRow(0);
        for (int i = 0; i < colums.size(); i++) {
            header.createCell(i).setCellValue(colums.get(i));
        }
        // 2.数据填充
        for (int r = 0; r < rows.size(); r++) {
            Row row = sheet.createRow(r);
            Map<String, Object> data = rows.get(r);
            for (int c = 0; c < colums.size(); c++) {
                Object value = data.get(colums.get(c));
                row.createCell(c).setCellValue(value == null ? "" : value.toString());
            }
        }
        // 3.生成临时文件
        File file=File.createTempFile("report_",".xlsx");
        try(FileOutputStream out=new FileOutputStream(file)){
            workbook.write(out);
        }
        return file;
    }

补充说明:逻辑很直白——第一行写表头(取第一条数据的 key 当列名),后面逐行填数据,最后写到临时文件。生成出来的 File 放进 Graph 状态,给后面的邮件节点用。

3.6 返回数据

再测几个问题,看看结果:

计算2025年每个月的销售总额,并按月份升序排列 QQ_1785317527391.png

QQ_1785317538986.png 计算2025年各个商品的销售总额,并按月份升序 QQ_1785317657089.png

QQ_1785317644262.png 查看文件不太方便,所以将发送邮件节点编写完成再说。

四. 发送邮件节点

4.1 发送邮件 service

邮件配置就自行查看前面的文章。

 package vip.wayhua.ivy.ai.bi.service.impl;
 
 
 import jakarta.mail.internet.MimeMessage;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.core.io.FileSystemResource;
 import org.springframework.mail.javamail.JavaMailSender;
 import org.springframework.mail.javamail.MimeMessageHelper;
 import org.springframework.stereotype.Service;
 import vip.wayhua.ivy.ai.bi.service.EmailSendService;
 
 import java.io.File;
 
 @Service
 public class EmailSendServiceImpl implements EmailSendService {
 private static final Logger log= LoggerFactory.getLogger(EmailSendServiceImpl.class);
 
     /**
      * 发件人邮箱地址,从spring.mail.username配置读取
      */
     @Value("${spring.mail.username}")
     private String from;
 
     /**
      * Spring Mail邮件发送器
      */
     @Autowired
     private JavaMailSender javaMailSender;
 
     @Override
     public void sendEmail(String to, String content) {
         try {
             MimeMessage mimeMessage = javaMailSender.createMimeMessage();
             MimeMessageHelper messageHelper = new MimeMessageHelper
                     (mimeMessage, true, "UTF-8");
             messageHelper.setFrom(from);
             messageHelper.setTo(to);
             messageHelper.setSubject("AI生成报表");
             messageHelper.setText(content);
             javaMailSender.send(mimeMessage);
             log.info("send   email success to=>{}", to);
         } catch (Exception e) {
             log.error("send   email error=>{}", e.getMessage(), e);
             throw new RuntimeException(e);
         }
     }
 
     @Override
     public void sendEmailWithAttachment(String to, File file) {
         try {
             MimeMessage mimeMessage = javaMailSender.createMimeMessage();
             MimeMessageHelper messageHelper = new MimeMessageHelper
                     (mimeMessage, true, "UTF-8");
             messageHelper.setFrom(from);
             messageHelper.setTo(to);
             messageHelper.setSubject("AI生成报表");
             messageHelper.setText("附件中的内容由AI报表助手生成,请查收!!!");
             //填充文件
             FileSystemResource fileSystemResource=new FileSystemResource(file);
             //添加附件
             messageHelper.addAttachment(file.getName(), fileSystemResource);
             javaMailSender.send(mimeMessage);
             log.info("send   email success to=>{}", to);
         } catch (Exception e) {
             log.error("send   email error=>{}", e.getMessage(), e);
             throw new RuntimeException(e);
         }
     }
 }
 

补充说明:两个方法——sendEmail 发纯文本(查询为空时用),sendEmailWithAttachment 带附件发(正常结果用)。用 MimeMessageHelper 的第二个参数 true 开启 multipart 模式才能加附件。

4.2 发送邮件节点

package vip.wayhua.ivy.ai.bi.nodes;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.wayhua.ivy.ai.bi.constants.Constant;
import vip.wayhua.ivy.ai.bi.service.EmailSendService;

import java.io.File;
import java.util.Map;
import java.util.Optional;

public class SendEmailNode implements NodeAction {
    private final EmailSendService emailSendService;

    public SendEmailNode(EmailSendService emailSendService) {
        this.emailSendService = emailSendService;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        // 1.获取ExcelFile
        Optional<Object> excelFile = state.value(Constant.KeyName.EXCEL_FILE);
        String recEmail = "48366939@qq.com";
        if (excelFile.isEmpty()) {
            emailSendService.sendEmail(recEmail, "查询数据不存在");
        } else {
            emailSendService.sendEmailWithAttachment(recEmail, (File) excelFile.get());
        }

        return Map.of();
    }
}

补充说明:这里收件人先写死成测试邮箱,从状态里取 Excel 文件——有就带附件发,没有就发一句「查询数据不存在」。简单说就是有结果发附件,没结果也通知一声

4.3 Graph 配置发送邮件节点

 stateGraph.addNode(Constant.NodeName.SEND_EMAIL_NODE,
                AsyncNodeAction.node_async(new SendEmailNode(emailSendService )));
        //添加 Edges
        stateGraph.addEdge(StateGraph.START, Constant.NodeName.GEN_SQL_NODE);
        stateGraph.addEdge(Constant.NodeName.GEN_SQL_NODE, Constant.NodeName.EXEC_SQL_AND_CREATE_EXCEL_NODE);
        stateGraph.addEdge(Constant.NodeName.EXEC_SQL_AND_CREATE_EXCEL_NODE, Constant.NodeName.SEND_EMAIL_NODE);
        stateGraph.addEdge(Constant.NodeName.SEND_EMAIL_NODE, StateGraph.END);

到这里整条链路就串起来了:START → GenSQLNode → ExecSqlAndCreateExcelNode → SendEmailNode → END

4.4 测试

计算2025年各个商品的销售总额,并按月份升序

QQ_1785329900384.png

QQ_1785329932738.png

QQ_1785329880637.png

4.5 问题

现在的问题是,product_id、month、total_sales 如果能显示中文更好。

生成的 SQL 有错误,有两三次才生成一次正确的,这个问题要解决。会在下一期专门解决这个问题。

五. 中文名显示问题

5.1 修改 GenSQLNode

针对上面「列名是英文」的问题,在 system 配置里再加一条要求:

   9. 每个查询字段必须使用 AS 指定一个中文别名,中文别名应尽量简短、清晰、描述字段含义。
                           例如:`user_name AS '用户名'`

QQ_1785330206756.png

5.2 原测试

调用函数和前面一样:

QQ_1785330358779.png

QQ_1785330391645.png 查看一下 SQL:

SELECT
    d.month AS '月份',
    p.product_name AS '商品名称',
    SUM(f.sales_amount) AS '销售总额'
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
JOIN dim_product p ON f.product_id = p.product_id
WHERE d.year = 2025
GROUP BY d.month, p.product_name
ORDER BY d.month ASC

列名变成中文了,效果不错。

5.3 换个测试

查询订单金额超过10000的大额订单,并联表显示客户信息、产品名称

QQ_1785330578628.png

QQ_1785330589375.png

QQ_1785330631022.png 好像没有显示客户名,仔细查询才发现是因为在文本向量数据库中没有客户的信息。现在修改一下向量文本。

踩坑记录:这个问题不是提示词的锅,而是向量库里压根没存 dim_customer 表的结构。RAG 召回不到,LLM 自然不知道有客户表,也就 JOIN 不上。这让我意识到——向量库里的文档就是 LLM 的「眼睛」,文档缺什么,它就看不见什么

六. 重新上传《企业智能 BI 数据库表结构说明文档》

6.1 企业智能 BI 数据库表结构说明文档

把文档补全,特别是加上 dim_customer 的描述:

企业智能 BI 数据库表结构说明文档
1 文档目的
本文件用于描述企业智能 BI 系统使用的数据库表结构,包括事实表、维度表及其字段说明,供后续 RAG 系统及 AI-SQL-Agent 使用。
2 维度表结构
2.1 商品维度(dim_product)
| 字段名 | 类型 | 主键 | 描述 |
| --- | --- | --- | --- |
| product_id | bigint | YES | 商品唯一ID |
| product_name | varchar(255) |  | 商品名称 |
| category_id | bigint |  | 商品分类ID |
| category_name | varchar(255) |  | 商品分类名称 |
| brand | varchar(255) |  | 品牌 |
| cost_price | decimal(10,2) |  | 成本价 |
| retail_price | decimal(10,2) |  | 建议零售价 |
2.2 门店维度(dim_store)
| 字段名 | 类型 | 主键 | 描述 |
| --- | --- | --- | --- |
| store_id | bigint | YES | 门店唯一ID |
| store_name | varchar(255) |  | 门店名称 |
| province | varchar(100) |  | 所在省份 |
| city | varchar(100) |  | 所在城市 |
| address | varchar(255) |  | 门店详细地址 |
| open_date | date |  | 开业日期 |
2.3 时间维度(dim_date)
| 字段名 | 类型 | 主键 | 描述 |
| --- | --- | --- | --- |
| date_id | date | YES | 日期ID |
| year | int |  | 年份 |
| quarter | int |  | 季度 |
| month | int |  | 月份 |
| day | int |  | 日 |
| weekday | int |  | 星期 |
2.4 客户维度(dim_customer)
| 字段名 | 类型 | 主键 | 描述 |
| --- | --- | --- | --- |
| customer_id | bigint | YES | 客户ID |
| customer_name | varchar(255) |  | 客户名称 |
| gender | varchar(10) |  | 性别 |
| age | int |  | 年龄 |
| city | varchar(100) |  | 城市 |
| province | varchar(100) |  | 省份 |
3 事实表结构
3.1 销售事实表(fact_sales)
| 字段名 | 类型 | 主键 | 描述 |
| --- | --- | --- | --- |
| sales_id | bigint | YES | 销售记录ID |
| product_id | bigint | FK→dim_product(product_id) | 商品ID |
| store_id | bigint | FK→dim_store(store_id) | 门店ID |
| customer_id | bigint | FK→dim_customer(customer_id) | 客户ID |
| date_id | date | FK→dim_date(date_id) | 销售日期 |
| quantity | int |  | 销售数量 |
| sales_amount | decimal(10,2) |  | 销售金额 |
| discount | decimal(10,2) |  | 折扣金额 |
3.2 库存事实表(fact_inventory)
| 字段名 | 类型 | 主键 | 描述 |
| --- | --- | --- | --- |
| inventory_id | bigint | YES | 库存记录ID |
| product_id | bigint | FK→dim_product(product_id) | 商品ID |
| store_id | bigint | FK→dim_store(store_id) | 门店ID |
| date_id | date | FK→dim_date(date_id) | 库存日期 |
| quantity | int |  | 库存数量 |
4 表关系说明(ER)
fact_sales 关联 dim_product、dim_store、dim_customer、dim_date
fact_inventory 关联 dim_product、dim_store、dim_date
5 用途说明
这些表用于构建 BI 场景,如:
•按门店、商品、日期进行销售分析
•GMV 统计
•库存周转率分析
•区域销售对比
•热销商品分析
•客户画像分析

6.2 先删除 Milvus 中的数据

重新上传前,先把旧数据清掉,避免新旧文档混在一起干扰召回。

QQ_1785331936178.png

6.3 上传

QQ_1785332026219.png

6.4 查看 Milvus 数据

现在有 9 条数据,多了 dim_customer。

QQ_1785332066444.png

QQ_1785332090332.png

6.5 再次生成查询

查询订单金额超过10000的大额订单,并联表显示客户信息、产品名称

QQ_1785332146306.png

QQ_1785332168096.png 前面是因为文本向量少了 dim_customer 表相关的内容,所以无法显示。这次成功了。

生成的 SQL:

SELECT 
    fs.sales_id AS '订单ID',
    fs.sales_amount AS '订单金额',
    dc.customer_name AS '客户名称',
    dc.gender AS '性别',
    dc.age AS '年龄',
    dc.city AS '城市',
    dp.product_name AS '产品名称'
FROM fact_sales fs
JOIN dim_customer dc ON fs.customer_id = dc.customer_id
JOIN dim_product dp ON fs.product_id = dp.product_id
WHERE fs.sales_amount > 10000

这次客户信息、产品名称都出来了,列名也全是中文。印证了前面的判断:向量库的文档必须完整,LLM 才能看得全。

七. 小结

本期把 AI BI Helper 的 Graph 工作流从 0 到 1 串了起来,核心就是那条链路:自然语言提问 → RAG 召回表结构 → LLM 生成 SQL → 执行 SQL → 生成 Excel → 邮件推送。

已完成内容

  1. GenSQLNode(生成 SQL 节点):RAG 召回 + 提示词 + LLM 交互,把自然语言转成 SQL
  2. 查询重写:用 RewriteQueryTransformer 把用户口语化问题规范化,提高召回准确率
  3. 安全防护:提示词层面禁止 DELETE/DROP/UPDATE/INSERT,执行层面用 queryForList 兜底
  4. ExecSqlAndCreateExcelNode(查询节点):执行 SQL,把结果导出成 Excel
  5. SendEmailNode(邮件节点):有结果带附件发,没结果发通知
  6. 中文名显示:提示词加 AS 中文别名要求,列名变中文
  7. 文档补全:发现向量库缺 dim_customer 导致召回不全,重新上传完整文档后解决

踩坑记录

问题解决方案
LLM 幻觉字段名(month_id、year_id)同一问题多试几次能出对的;根本解决要靠下一期的重试机制
SQL 生成不稳定,两三次才对一次下期专门做「不断尝试直到成功或超过次数退出」
列名是英文(product_id、month)提示词要求每个字段用 AS 指定中文别名
大额订单查不到客户名向量库缺 dim_customer 文档;补全后重新上传解决

一点感悟

编写程序是一个渐进的过程,不断的修改。这一期反复印证了几件事:

  • SQL 一直会出错,大模型会编字段、会不稳定,这是常态,得有容错机制;
  • 向量库的文档就是 LLM 的眼睛,文档缺什么它就看不见什么,文档维护比提示词调优更重要;
  • 安全要双保险,提示词拦一道,执行层再拦一道,别全信 LLM 的自觉。

下期预告

SQL 一直会出错,下期将进行不断尝试,直到成功生成 SQL 或超过尝试次数退出——也就是给 GenSQLNode 加一个自校验 + 重试的循环。

代码

gitee.com/wavaya88/Do… bi-2-all 分支


本文是 AI BI Helper 开发实录的第二期,主要完成了 Graph 工作流编排,把自然语言到 SQL 生成、执行、Excel 导出、邮件推送整条链路跑通了。感谢阅读!