第10篇:《老板说“查一下这个月谁业绩最好”,我让AI自己写了SQL》

111 阅读9分钟

承上:上一篇我们给AI装上了“海马体”,它终于能记住上下文了。但老板扔过来一句话:“查一下这个月销售额最高的3个销售,以及他们各自的Top 5客户”。如果让AI自由发挥,它只能编。但如果让AI直接查数据库呢?今天,我们要让AI学会写SQL。

1. 先预热一下,感受一下AI的魅力

我近期已经做了两版「Text-to-SQL」,先不说实现的思路,让大家感受一下AI的魅力。

1.1. Text-to-SQL-V1

jvideo

1.2. Text-to-SQL-V2

jvideo

2. 问题场景:AI的“数据墙”

先看一个经典场景:

老板:这个月哪个销售业绩最好?

AI:根据我的经验,优秀的销售通常具备以下特质:
    1. 良好的沟通能力
    2. 持续跟进客户的耐心
    ...

血压又上来了。老板要的是数据,不是鸡汤。

根本问题:AI再聪明,它看不到你数据库里的订单表、客户表、销售表。它是一个“数据库盲”。

解决方案有两种:

方案做法问题
方案A:写死接口给每个查询场景写一个API老板问法千变万化,接口写不完
方案B:Text-to-SQL让AI把自然语言转成SQL,直接查库这才是根本解法

3. 核心概念:Text-to-SQL是什么?

Text-to-SQL = 自然语言 → SQL

用户:上个月销售额最高的3个产品
 ↓
AI生成:SELECT product_name, SUM(amount) as total
         FROM orders
         WHERE order_date BETWEEN '2026-06-01' AND '2026-06-30'
         GROUP BY product_name
         ORDER BY total DESC
         LIMIT 3
 ↓
执行SQL → 返回结果 → AI组织成自然语言回复

本质上,这就是一个特殊的 Function Calling——工具是“执行SQL”,参数是AI生成的SQL语句。但这里有两个致命问题要解决:

  1. 安全性:AI生成的SQL可能是 DROP TABLE orders
  2. 准确性:AI不知道你的表结构,会编造字段名

4. 安全第一:构筑三层防线

在写代码之前,先想清楚怎么防止AI删库跑路。

4.1. 第一层:数据库只读账号

-- 创建一个只读用户
CREATE USER 'ai_readonly'@'%' IDENTIFIED BY 'strong_password';
GRANT SELECT ON company_db.* TO 'ai_readonly'@'%';
-- 坚决不给 INSERT、UPDATE、DELETE、DROP、ALTER 权限

这是最后一道防线。即使前面所有校验都失效,这个账号也只能查。

4.2. 第二层:SQL关键字黑名单

@Component
public class SqlSecurityGuard {

    // 危险关键字黑名单
    private static final Set<String> DANGEROUS_KEYWORDS = Set.of(
            "DROP", "DELETE", "UPDATE", "INSERT", "ALTER",
            "TRUNCATE", "CREATE", "REPLACE", "GRANT", "REVOKE",
            "EXEC", "EXECUTE", "INTO OUTFILE", "INTO DUMPFILE",
            "LOAD_FILE", "SLEEP(", "BENCHMARK("
    );

    public void validate(String sql) {
        String upperSql = sql.toUpperCase();

        // 检查危险关键字
        for (String keyword : DANGEROUS_KEYWORDS) {
            if (upperSql.contains(keyword.toUpperCase())) {
                throw new SecurityException(
                        "SQL包含危险关键字:" + keyword + ",已拦截");
            }
        }

        // 只允许SELECT开头
        if (!upperSql.trim().startsWith("SELECT")) {
            throw new SecurityException("只允许SELECT查询,已拦截:" + sql);
        }
    }
}

4.3. 第三层:结果集大小限制

// 在SQL上追加LIMIT,防止SELECT * 返回百万行
if (!upperSql.contains("LIMIT")) {
    sql = sql.trim() + " LIMIT 100";
}

三层防线,层层递进:权限 → SQL校验 → 结果限制。AI再疯也掀不起浪。

5. 第一步:用@Tool暴露SQL的执行能力

package com.yunxi.ai.tools;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.jdbc.core.JdbcTemplate;

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

@Component
@Slf4j
public class SqlQueryTools {

    // 危险关键字黑名单
    private static final Set<String> DANGEROUS_KEYWORDS = Set.of(
            "DROP", "DELETE", "UPDATE", "INSERT", "ALTER",
            "TRUNCATE", "CREATE", "REPLACE", "GRANT", "REVOKE",
            "EXEC", "EXECUTE", "INTO OUTFILE", "INTO DUMPFILE",
            "LOAD_FILE", "SLEEP(", "BENCHMARK("
    );

    private final JdbcTemplate jdbcTemplate;

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

    @Tool(description = """
            执行SELECT查询获取数据。
            注意:
            1. 只允许SELECT查询
            2. 必须在SQL末尾加 LIMIT,最多返回100条
            3. 使用MySQL语法
            4. 只输出SQL,不输出任何解释或说明
            """)
    public List<Map<String, Object>> executeQuery(
            @ToolParam(description = "完整的SELECT SQL语句,必须包含LIMIT") String sql) {
        log.info("执行SELECT查询:{}", sql);

        // 第一道防线:安全检查
        validate(sql);

        // 第二道防线:结果集限制
        String safeSql = addLimitIfMissing(sql);

        // 第三道防线:只读账号(在配置里,不在这里体现)
        return jdbcTemplate.queryForList(safeSql);
    }

    @Tool(description = "查询数据库中有哪些表,以及它们的结构信息")
    public String getDatabaseSchema() {
        log.info("查询数据库表结构");
        return """
                数据库表结构:
                -- ============================================
                -- 1. 销售表 (sales)
                -- ============================================
                CREATE TABLE sales (
                    id INT PRIMARY KEY AUTO_INCREMENT COMMENT '销售记录ID',
                    salesperson_id INT COMMENT '销售人员ID',
                    salesperson_name VARCHAR(50) COMMENT '销售人员姓名',
                    customer_name VARCHAR(100) COMMENT '客户名称',
                    product_name VARCHAR(100) COMMENT '产品名称',
                    amount DECIMAL(10,2) COMMENT '销售金额',
                    sale_date DATE COMMENT '销售日期',
                    region VARCHAR(50) COMMENT '区域'
                ) COMMENT='销售表';
                
                -- ============================================
                -- 2. 产品表 (products)
                -- ============================================
                CREATE TABLE products (
                    id INT PRIMARY KEY AUTO_INCREMENT COMMENT '产品ID',
                    product_name VARCHAR(100) COMMENT '产品名称',
                    category VARCHAR(50) COMMENT '产品分类',
                    unit_price DECIMAL(10,2) COMMENT '单价',
                    stock INT COMMENT '库存数量'
                ) COMMENT='产品表';
                
                -- ============================================
                -- 3. 员工表 (employees)
                -- ============================================
                CREATE TABLE employees (
                    id INT PRIMARY KEY AUTO_INCREMENT COMMENT '员工ID',
                    name VARCHAR(50) COMMENT '姓名',
                    department VARCHAR(50) COMMENT '部门',
                    position VARCHAR(50) COMMENT '职位',
                    hire_date DATE COMMENT '入职日期'
                ) COMMENT='员工表';
                """;
    }

    private String addLimitIfMissing(String sql) {
        String upperSql = sql.toUpperCase().trim();
        if (!upperSql.contains("LIMIT")) {
            return sql.trim() + " LIMIT 100";
        }
        return sql;
    }


    public void validate(String sql) {
        String upperSql = sql.toUpperCase();

        // 检查危险关键字
        for (String keyword : DANGEROUS_KEYWORDS) {
            if (upperSql.contains(keyword.toUpperCase())) {
                throw new SecurityException(
                        "SQL包含危险关键字:" + keyword + ",已拦截");
            }
        }

        // 只允许SELECT开头
        if (!upperSql.trim().startsWith("SELECT")) {
            throw new SecurityException("只允许SELECT查询,已拦截:" + sql);
        }
    }
}

6. 第二步:Service组装

package com.yunxi.ai.service;

import com.yunxi.ai.tools.SqlQueryTools;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class ChatService {

    private final ChatClient chatClient;

    public ChatService(ChatClient.Builder builder, ChatMemory chatMemory, SqlQueryTools sqlQueryTools) {
        this.chatClient = builder
                .defaultAdvisors(new SimpleLoggerAdvisor(),
                        MessageChatMemoryAdvisor.builder(chatMemory).build())
                .defaultTools(sqlQueryTools)
                .build();
    }

    public String chat(String message) {
        return chatClient.prompt()
                .system("""
                        你是一个数据分析助手,可以根据数据库中的数据回答业务问题。
                              工作流程:
                              1. 如果不知道表结构,先调用 getDatabaseSchema 获取
                              2. 根据用户问题,用 executeQuery 执行SQL查询
                              3. 将查询结果翻译成自然语言回复
                              4. 如果查询结果为空,告知用户未找到相关数据
                              安全红线(绝对不可违反):
                              - 只生成SELECT语句
                              - 每条SQL必须包含LIMIT
                              - 不允许多表联合查询时使用子查询绕过LIMIT
                              回复格式:
                              - 先给出简洁的结论
                              - 再列出关键数据
                              - 最后可以给出简单建议(如果合适)
                        """)
                .user(message)
                .call()
                .content();
    }
}

7. 测试结果

7.1. 获取有哪些表的权限

大模型交互日志分析:

7.2. 分析今天的销售数据

大数据交互日志分析:

8. 进阶:DataAgent

基础版的Text-to-SQL已经能跑了,但它离真正的企业级应用还差三步。这三步不需要你现在就写代码,但值得你在脑子里先建立认知地图。

8.1. 进化一:自定义表结构——让AI认识你的任意数据库

目前的问题:表结构写死在Java代码里。新增一张表?改代码、编译、部署一条龙。多环境?开发、测试、生产三套代码。

进化方向:表结构外置到配置文件,甚至存到数据库里,支持动态加载。管理员在后台新增一张表的描述,AI立刻就能查。

核心收益:

  • 新增业务表零代码改动
  • 不同环境不同配置,互不影响
  • 可隐藏敏感字段(如用户密码、手机号),AI看不到就查不到

8.2. 进化二:Graph编排——让AI分步骤执行复杂分析

目前的问题:一次只能查一条SQL。老板问“对比华东和华南的业绩差异,分析原因”,这不是一个SQL能回答的。

进化方向:用工作流引擎把复杂问题拆成多个步骤——先查华东数据,再查华南数据,对比差异后深挖具体产品线,最后生成分析报告。AI自己决定下一步该查什么,而不是预设好所有路径。

核心收益:

  • 复杂问题自动拆解为多步骤查询
  • 每一步基于上一步结果动态调整
  • 从“查完就完”变成“分析到底”

8.3. 进化三:RAG向量库——让AI理解数据背后的知识

目前的问题:AI查到了销售额下降20%,但不知道为什么下降。季节性波动?竞品冲击?政策影响?纯数据回答不了这些问题。

进化方向:把业务知识文档(市场分析、历史报告、专家经验)存入向量数据库。查询数据的同时,检索相关知识作为分析依据。AI不再是冷冰冰地报数字,而是能结合业务背景给出有深度、有价值的分析。

核心收益:

  • 数据分析带上业务背景,不再是“就数论数”
  • 区分“数据事实”和“推测原因”,回答更可靠
  • 知识可积累、可传承,新人问AI也能得到老员工级别的洞察

8.4. 三层进化的关系

┌─────────────────┐
                    │   RAG向量库      │  ← 给AI注入业务知识
                    │  (知识层)       │
                    └────────┬────────┘
                             │
                    ┌────────▼────────┐
                    │   Graph编排      │  ← 让AI分步骤执行
                    │  (编排层)       │
                    └────────┬────────┘
                             │
                    ┌────────▼────────┐
                    │   动态表结构      │  ← 让AI认识你的数据
                    │  (数据层)       │
                    └─────────────────┘

底层:动态表结构,让AI知道有哪些数据可以查。
中层:Graph编排,让AI分步骤、有逻辑地分析问题。
顶层:RAG向量库,让AI理解数据背后的业务含义。

三层叠加,Text-to-SQL从一个“查数工具”进化成一个“数据分析师”。

其实这些我已经在开篇的视频展示了,大家可以依托Vibe Codeing 自由发挥实践。享受AI带来的快感。

后面我也会单独写一个系列来写DataAgent的实战过程。

9. 本篇小结

这一篇我们让AI学会了查数据库:

能力实现
表结构描述喂给AI的表结构说明
SQL生成AI根据自然语言 + 表结构自动生成
三层安全防线只读账号 + SQL黑名单 + 结果集限制
结果翻译AI将查询结果翻译成自然语言

核心心法:Text-to-SQL本质上就是Function Calling的一个特例——工具是执行SQL,参数是AI生成的SQL语句。安全是重中之重,三道防线缺一不可。

现在我们的AI已经能:

  • ✅ 流式输出,打字机体验
  • ✅ 结构化响应,直接映射成DTO
  • ✅ 调用各种工具,查天气、查订单
  • ✅ 通过MCP接入外部生态
  • ✅ 记住上下文,多轮对话无缝衔接
  • ✅ 自己写SQL查业务数据库

功能都跑通了,但离生产环境还差最后一步:如果有人恶意注入Prompt怎么办?Token成本怎么控制?模型挂了怎么降级?


本文与DeepSeek协作完成