承上:上一篇我们给AI装上了“海马体”,它终于能记住上下文了。但老板扔过来一句话:“查一下这个月销售额最高的3个销售,以及他们各自的Top 5客户”。如果让AI自由发挥,它只能编。但如果让AI直接查数据库呢?今天,我们要让AI学会写SQL。
1. 先预热一下,感受一下AI的魅力
我近期已经做了两版「Text-to-SQL」,先不说实现的思路,让大家感受一下AI的魅力。
1.1. Text-to-SQL-V1
1.2. Text-to-SQL-V2
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语句。但这里有两个致命问题要解决:
- 安全性:AI生成的SQL可能是
DROP TABLE orders - 准确性: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协作完成