目录
- [1. 为什么需要自然语言操作数据库](#1. 为什么需要自然语言操作数据库)
- [2. 核心原理:Spring AI Tool Calling](#2. 核心原理:Spring AI Tool Calling)
- [2.1 工作流程](#2.1 工作流程)
- [2.2 为什么不用 LLM 直出 SQL](#2.2 为什么不用 LLM 直出 SQL)
- [3. 环境准备](#3. 环境准备)
- [4. 项目搭建](#4. 项目搭建)
- [4.1 引入依赖](#4.1 引入依赖)
- [4.2 编写配置文件](#4.2 编写配置文件)
- [5. 定义实体与 Repository](#5. 定义实体与 Repository)
- [5.1 员工实体](#5.1 员工实体)
- [5.2 Repository 接口](#5.2 Repository 接口)
- [6. 定义数据库 Tool 方法](#6. 定义数据库 Tool 方法)
- [6.1 自动注册说明](#6.1 自动注册说明)
- [7. 配置 ChatClient 与对外接口](#7. 配置 ChatClient 与对外接口)
- [7.1 配置类(可选)](#7.1 配置类(可选))
- [7.2 Controller](#7.2 Controller)
- [8. 运行与验证](#8. 运行与验证)
- [8.1 初始化测试数据](#8.1 初始化测试数据)
- [8.2 测试查询](#8.2 测试查询)
- [8.3 测试写入](#8.3 测试写入)
- [9. 进阶优化与实践建议](#9. 进阶优化与实践建议)
- [9.1 控制工具返回数据量](#9.1 控制工具返回数据量)
- [9.2 提高工具选择的准确率](#9.2 提高工具选择的准确率)
- [9.3 安全与权限](#9.3 安全与权限)
- [9.4 可观测性](#9.4 可观测性)
- [10. 总结](#10. 总结)
1. 为什么需要自然语言操作数据库
在传统业务系统里,用户查询数据必须依赖固定的界面表单、固定的接口或手写 SQL,业务人员无法用"查一下 IT 部薪资超过一万的员工"这样自然的表述直接拿到结果。大模型(LLM)虽然能理解自然语言,但它本身并不直接访问数据库,更不能可靠地生成并执行业务 SQL------这存在严重的安全与正确性风险。
Spring AI 提供的 Tool Calling(工具调用) 机制正好解决了这个问题:我们把对 MySQL 的访问能力封装成一组"受控的工具方法",由大模型理解用户意图后,自动选择并调用合适的工具,再把查询结果组织成自然语言返回给用户。整个过程模型并不直接拼接 SQL,而是执行我们预先定义好的、参数化的数据库操作。
本文将带你从零搭建一个 Spring Boot + Spring AI + MySQL 的项目,实现:
- 用自然语言查询员工信息(按姓名、部门、薪资范围等);
- 用自然语言新增员工记录;
- 理解 Tool Calling 的工作原理与安全边界。
2. 核心原理:Spring AI Tool Calling
2.1 工作流程
Spring AI 的 Tool Calling 流程可以概括为下图:
#mermaid-svg-9LLhQjfNFdOWlxHm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9LLhQjfNFdOWlxHm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9LLhQjfNFdOWlxHm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9LLhQjfNFdOWlxHm .error-icon{fill:#552222;}#mermaid-svg-9LLhQjfNFdOWlxHm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9LLhQjfNFdOWlxHm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9LLhQjfNFdOWlxHm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9LLhQjfNFdOWlxHm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9LLhQjfNFdOWlxHm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9LLhQjfNFdOWlxHm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9LLhQjfNFdOWlxHm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9LLhQjfNFdOWlxHm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9LLhQjfNFdOWlxHm .marker.cross{stroke:#333333;}#mermaid-svg-9LLhQjfNFdOWlxHm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9LLhQjfNFdOWlxHm p{margin:0;}#mermaid-svg-9LLhQjfNFdOWlxHm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9LLhQjfNFdOWlxHm .cluster-label text{fill:#333;}#mermaid-svg-9LLhQjfNFdOWlxHm .cluster-label span{color:#333;}#mermaid-svg-9LLhQjfNFdOWlxHm .cluster-label span p{background-color:transparent;}#mermaid-svg-9LLhQjfNFdOWlxHm .label text,#mermaid-svg-9LLhQjfNFdOWlxHm span{fill:#333;color:#333;}#mermaid-svg-9LLhQjfNFdOWlxHm .node rect,#mermaid-svg-9LLhQjfNFdOWlxHm .node circle,#mermaid-svg-9LLhQjfNFdOWlxHm .node ellipse,#mermaid-svg-9LLhQjfNFdOWlxHm .node polygon,#mermaid-svg-9LLhQjfNFdOWlxHm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9LLhQjfNFdOWlxHm .rough-node .label text,#mermaid-svg-9LLhQjfNFdOWlxHm .node .label text,#mermaid-svg-9LLhQjfNFdOWlxHm .image-shape .label,#mermaid-svg-9LLhQjfNFdOWlxHm .icon-shape .label{text-anchor:middle;}#mermaid-svg-9LLhQjfNFdOWlxHm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9LLhQjfNFdOWlxHm .rough-node .label,#mermaid-svg-9LLhQjfNFdOWlxHm .node .label,#mermaid-svg-9LLhQjfNFdOWlxHm .image-shape .label,#mermaid-svg-9LLhQjfNFdOWlxHm .icon-shape .label{text-align:center;}#mermaid-svg-9LLhQjfNFdOWlxHm .node.clickable{cursor:pointer;}#mermaid-svg-9LLhQjfNFdOWlxHm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9LLhQjfNFdOWlxHm .arrowheadPath{fill:#333333;}#mermaid-svg-9LLhQjfNFdOWlxHm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9LLhQjfNFdOWlxHm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9LLhQjfNFdOWlxHm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9LLhQjfNFdOWlxHm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9LLhQjfNFdOWlxHm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9LLhQjfNFdOWlxHm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9LLhQjfNFdOWlxHm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9LLhQjfNFdOWlxHm .cluster text{fill:#333;}#mermaid-svg-9LLhQjfNFdOWlxHm .cluster span{color:#333;}#mermaid-svg-9LLhQjfNFdOWlxHm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9LLhQjfNFdOWlxHm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9LLhQjfNFdOWlxHm rect.text{fill:none;stroke-width:0;}#mermaid-svg-9LLhQjfNFdOWlxHm .icon-shape,#mermaid-svg-9LLhQjfNFdOWlxHm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9LLhQjfNFdOWlxHm .icon-shape p,#mermaid-svg-9LLhQjfNFdOWlxHm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9LLhQjfNFdOWlxHm .icon-shape .label rect,#mermaid-svg-9LLhQjfNFdOWlxHm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9LLhQjfNFdOWlxHm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9LLhQjfNFdOWlxHm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9LLhQjfNFdOWlxHm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 需要
不需要
用户输入自然语言
ChatClient 组装 Prompt 与工具元数据
大模型理解意图
是否需要调用工具
模型返回工具调用请求(方法名+参数)
Spring AI 定位并执行 @Tool 方法
通过 JPA/JDBC 访问 MySQL
工具执行结果回传给模型
模型组织自然语言回答
返回最终答复给用户
关键点在于:大模型并不自己写 SQL,也不直接连数据库 。它只会根据工具描述(description)判断该调哪个方法、传什么参数;真正的 SQL 由我们写的 @Tool 方法内部通过 Spring Data JPA 生成,参数经过预处理与绑定,天然规避 SQL 注入。
2.2 为什么不用 LLM 直出 SQL
让模型直接生成 SQL 再执行,存在三个明显问题:
- 安全风险 :用户可通过 Prompt 注入诱导模型执行
DROP TABLE、全表删除等危险语句; - 正确性难以保证:模型对表结构、字段名的把握不稳定,容易生成错误 SQL;
- 难以审计:SQL 由模型临时生成,排查问题困难。
而 Tool Calling 把数据库能力收敛为白名单方法,模型只能调用我们允许的操作,参数类型严格约束,既安全又可控。
3. 环境准备
开始编码前,请准备以下环境:
| 组件 | 版本/说明 |
|---|---|
| JDK | 17 及以上 |
| Maven | 3.8+ |
| Spring Boot | 3.4.x(本文以 3.4.5 为例) |
| Spring AI | 1.0.0(GA 版本,基于 Tool Calling 新 API) |
| MySQL | 8.x |
| 大模型 API | OpenAI 兼容接口(可替换为通义千问、DeepSeek 等) |
说明:本文示例使用
spring-ai-starter-model-openai接入 OpenAI 兼容接口;如果你使用国产模型,只需替换为对应的 starter(如spring-ai-alibaba-starter-dashscope)并修改配置,其余代码完全一致。
4. 项目搭建
4.1 引入依赖
在 pom.xml 中引入 Spring AI BOM 及所需依赖:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>spring-ai-mysql-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-ai-mysql-demo</name>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI:OpenAI 兼容模型 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- 数据库:JPA + MySQL -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
4.2 编写配置文件
application.yml 中配置模型与数据源:
yaml
spring:
application:
name: spring-ai-mysql-demo
# 大模型配置(OpenAI 兼容)
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: https://api.openai.com # 可替换为兼容网关
chat:
options:
model: gpt-4o-mini
temperature: 0.2
# MySQL 数据库
datasource:
url: jdbc:mysql://localhost:3306/spring_ai_demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
username: root
password: ${MYSQL_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update # 演示环境自动建表,生产请改为 validate/none
show-sql: true
properties:
hibernate:
format_sql: true
在 MySQL 中手动创建数据库:
sql
CREATE DATABASE IF NOT EXISTS spring_ai_demo
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
5. 定义实体与 Repository
5.1 员工实体
java
package com.example.demo.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "employee")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 64)
private String name;
@Column(length = 64)
private String department;
@Column(nullable = false)
private Double salary;
// 无参构造
public Employee() {
}
public Employee(String name, String department, Double salary) {
this.name = name;
this.department = department;
this.salary = salary;
}
// getter / setter 略
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
public Double getSalary() { return salary; }
public void setSalary(Double salary) { this.salary = salary; }
@Override
public String toString() {
return "Employee{id=" + id + ", name='" + name + "', department='"
+ department + "', salary=" + salary + "}";
}
}
5.2 Repository 接口
java
package com.example.demo.repository;
import com.example.demo.entity.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
List<Employee> findByNameContaining(String name);
List<Employee> findByDepartment(String department);
List<Employee> findBySalaryBetween(Double minSalary, Double maxSalary);
List<Employee> findByDepartmentAndSalaryGreaterThanEqual(String department, Double minSalary);
}
6. 定义数据库 Tool 方法
这是整篇文章的核心。我们通过 @Tool 注解把数据库访问方法暴露给大模型,@ToolParam 注解用来描述参数含义,帮助模型正确传参。
java
package com.example.demo.tool;
import com.example.demo.entity.Employee;
import com.example.demo.repository.EmployeeRepository;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class DatabaseTools {
private final EmployeeRepository employeeRepository;
public DatabaseTools(EmployeeRepository employeeRepository) {
this.employeeRepository = employeeRepository;
}
@Tool(description = "查询全部员工信息")
public List<Employee> listAllEmployees() {
return employeeRepository.findAll();
}
@Tool(description = "根据姓名关键词模糊查询员工,例如输入"张"可查到所有名字含张的员工")
public List<Employee> searchEmployeesByName(
@ToolParam(description = "员工姓名关键词") String name) {
return employeeRepository.findByNameContaining(name);
}
@Tool(description = "根据部门名称查询该部门的所有员工")
public List<Employee> getEmployeesByDepartment(
@ToolParam(description = "部门名称,如研发部、市场部") String department) {
return employeeRepository.findByDepartment(department);
}
@Tool(description = "查询薪资在指定区间内的员工,左闭右闭")
public List<Employee> getEmployeesBySalaryRange(
@ToolParam(description = "最低薪资(包含)") Double minSalary,
@ToolParam(description = "最高薪资(包含)") Double maxSalary) {
return employeeRepository.findBySalaryBetween(minSalary, maxSalary);
}
@Tool(description = "查询指定部门中薪资大于等于某个值的员工")
public List<Employee> getEmployeesByDepartmentAndSalary(
@ToolParam(description = "部门名称") String department,
@ToolParam(description = "最低薪资") Double minSalary) {
return employeeRepository.findByDepartmentAndSalaryGreaterThanEqual(department, minSalary);
}
@Tool(description = "新增一名员工,需要姓名、部门与薪资,返回保存后的员工信息")
public Employee addEmployee(
@ToolParam(description = "员工姓名") String name,
@ToolParam(description = "所属部门") String department,
@ToolParam(description = "薪资") Double salary) {
Employee employee = new Employee(name, department, salary);
return employeeRepository.save(employee);
}
}
6.1 自动注册说明
在 Spring AI 1.0 中,@Tool 注解的方法会被 ToolCallingAutoConfiguration 自动扫描并注册为 ToolCallback。默认情况下,ChatClient 会自动加载这些工具,无需手动绑定。
但为了代码更直观、可控,我们也可以在构建 ChatClient 时显式指定。两种方式任选其一即可。
7. 配置 ChatClient 与对外接口
7.1 配置类(可选)
如果需要自定义系统提示词,可以写一个配置类:
java
package com.example.demo.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("你是一个友好的员工信息助手,请根据用户问题调用合适的数据库工具查询或新增员工,并用简洁的中文回答。")
.build();
}
}
7.2 Controller
java
package com.example.demo.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping("/chat")
public String chat(@RequestParam String message) {
return chatClient.prompt(message).call().content();
}
}
到这里,一个完整的最小可用版本就写好了。启动项目后,大模型即可根据自然语言自动调用 DatabaseTools 中的方法操作 MySQL。
8. 运行与验证
8.1 初始化测试数据
启动项目后,JPA 会自动创建 employee 表。我们先插入几条基础数据:
sql
INSERT INTO employee (name, department, salary) VALUES
('张三', '研发部', 15000),
('李四', '研发部', 12000),
('王五', '市场部', 9000),
('赵六', '市场部', 11000),
('钱七', '财务部', 13000);
8.2 测试查询
模糊查询
请求:
bash
curl "http://localhost:8080/chat?message=帮我查一下名字里带张的员工"
大模型内部会调用 searchEmployeesByName("张"),返回类似:
名字里带"张"的员工有:张三,研发部,薪资 15000。
部门查询
请求:
bash
curl "http://localhost:8080/chat?message=研发部都有哪些人"
模型调用 getEmployeesByDepartment("研发部") 并组织回答。
组合条件查询
请求:
bash
curl "http://localhost:8080/chat?message=查询研发部薪资大于等于13000的员工"
模型会调用 getEmployeesByDepartmentAndSalary("研发部", 13000)。
8.3 测试写入
请求:
bash
curl "http://localhost:8080/chat?message=帮我新增一个员工,叫孙悟空,属于研发部,薪资18000"
模型会调用 addEmployee("孙悟空", "研发部", 18000),保存后返回成功信息:
已成功新增员工:孙悟空,研发部,薪资 18000。
此时再去 MySQL 里 SELECT * FROM employee,可以看到新记录已经落库。
9. 进阶优化与实践建议
9.1 控制工具返回数据量
当表数据量很大时,listAllEmployees 这类全量查询可能把大量数据塞进模型上下文,既浪费 Token 又可能超限。建议:
- 增加分页参数
page、size,或限制默认返回条数; - 对于大数据量统计分析,优先使用聚合查询(如
AVG、SUM),只把统计结果返回给模型。
9.2 提高工具选择的准确率
- 写好
@Tool的 description:说明方法用途、适用场景与边界; - 写好
@ToolParam的 description:说明参数格式、枚举范围、示例值; - 必要时在系统提示词中补充业务规则(如"部门名称必须使用规范全称")。
9.3 安全与权限
- Tool 方法即能力白名单,不要提供
executeNativeSql(String sql)这类直接执行 SQL 的工具,否则相当于把数据库钥匙交给模型; - 对写入类工具(新增、修改、删除)增加业务校验与权限控制,必要时要求二次确认;
- 生产环境关闭
ddl-auto=update,数据库账号只授予最小权限; - 对敏感字段(如薪资)做脱敏或在 Tool 层过滤。
9.4 可观测性
建议记录每次工具调用的入参与返回结果(脱敏后),便于排查模型"答非所问"或"调用错误工具"的问题。可以结合 Spring AI 的 Advisor 或 AOP 统一拦截 @Tool 方法。
10. 总结
本文完整演示了如何用 Spring AI Tool Calling 实现自然语言操作 MySQL:
- 用
@Tool把数据库查询/写入能力封装为白名单方法; - 大模型理解用户意图后自动选择并调用合适工具;
- 工具方法通过 Spring Data JPA 生成参数化 SQL,安全可控;
- 模型将结果组织成自然语言返回给用户。
相比让 LLM 直接生成 SQL,这种方式在安全性、可控性和可维护性上都有明显优势,非常适合企业内部的智能问答、智能报表等场景。你可以在此基础上继续扩展更多工具(分页、统计、关联查询、更新、删除等),构建一个完整的自然语言数据库助手。