1. 项目概览

现代应用越来越多地采用自然语言交互界面,让用户更轻松地操作系统。这在数据查询场景中尤为实用,非技术人员可以直接用日常语言提,文本转 SQL 聊天机器人正是这样的典型应用。它在人类语言和数据库之间搭建了沟通桥梁。我们通常借助大语言模型(LLM)将用户的自然语言问题转换为可执行的 SQL 查询语句,然后在数据库中执行查询并返回结果。

本教程将指导你使用 Spring AI 框架构建一个文本转 SQL 聊天机器人。我们会先配置数据库架构并填入测试数据,然后实现支持自然语言查询的聊天机器人功能。

2. 项目搭建

2.1. 添加依赖

首先在项目的 pom.xml 文件中添加必要的依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-mysql</artifactId>
</dependency>

接下来在 application.yaml 文件中配置 DeepSeek API 密钥、聊天模型和数据库连接:

spring:
  ai:
    deepseek:
      api-key: ${DEEPSEEK_API_KEY}
  datasource:
    url: jdbc:mysql://localhost:3306/school_db?useSSL=false&serverTimezone=UTC
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

我们使用 ${} 占位符语法从环境变量中读取 API 密钥和数据库配置。

同时指定使用 DeepSeek Chat 模型,并配置 DeepSeek 的 API 基础 URL。DeepSeek-Chat-0324 提供了强大的中文和代码理解能力,非常适合文本转 SQL 的场景。数据库配置包括连接 URL、用户名和密码,以及 Flyway 迁移设置。

配置完成后,Spring AI 会自动创建 ChatModel 类型的 Bean,让我们能够与指定的模型进行交互。

2.2. 使用 Flyway 设计数据库表结构

接下来配置数据库结构。我们使用 Flyway 来管理数据库迁移脚本。

我们将创建一个简单的学校管理数据库,使用 MySQL 作为数据库。和 AI 模型选择一样,数据库类型对实现方案没有影响。

首先,在 src/main/resources/db/migration 目录下创建名为 V01__creating_database_tables.sql 的迁移脚本来建立主要数据库表:

CREATE TABLE classes (
    id BINARY(16) PRIMARY KEYDEFAULT (UUID_TO_BIN(UUID())),
    name VARCHAR(50) NOT NULLUNIQUE,
    grade VARCHAR(20) NOT NULL,
    teacher VARCHAR(50) NOT NULL,
    room_number VARCHAR(20) NOT NULL,
    created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP
);

CREATE TABLE courses (
    id BINARY(16) PRIMARY KEYDEFAULT (UUID_TO_BIN(UUID())),
    name VARCHAR(50) NOT NULLUNIQUE,
    code VARCHAR(20) NOT NULLUNIQUE,
    credits INTNOT NULLDEFAULT1,
    description TEXT,
    created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP
);

CREATE TABLE students (
    id BINARY(16) PRIMARY KEYDEFAULT (UUID_TO_BIN(UUID())),
    name VARCHAR(50) NOT NULL,
    student_number VARCHAR(20) NOT NULLUNIQUE,
    gender ENUM('Male', 'Female') NOT NULL,
    age INTNOT NULL,
    phone VARCHAR(15),
    email VARCHAR(100),
    class_id BINARY(16) NOT NULL,
    created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP,
    CONSTRAINT student_fkey_class FOREIGN KEY (class_id) REFERENCES classes (id)
);

CREATE TABLE student_courses (
    id BINARY(16) PRIMARY KEYDEFAULT (UUID_TO_BIN(UUID())),
    student_id BINARY(16) NOT NULL,
    course_id BINARY(16) NOT NULL,
    enrollment_date TIMESTAMPDEFAULTCURRENT_TIMESTAMP,
    score DECIMAL(5,2),
    status ENUM('Enrolled', 'Completed', 'Dropped') DEFAULT'Enrolled',
    CONSTRAINT sc_fkey_student FOREIGN KEY (student_id) REFERENCES students (id),
    CONSTRAINT sc_fkey_course FOREIGN KEY (course_id) REFERENCES courses (id),
    UNIQUE KEY unique_student_course (student_id, course_id)
);

这里我们创建了四个主要表:

  • • classes 表存储班级信息

  • • courses 表存储课程信息

  • • students 表存储学生信息,通过外键与班级关联

  • • student_courses 表作为学生和课程的多对多关联表,存储选课信息和成绩

接下来,创建 V02__adding_classes_data.sql 文件来填充 classes 表:

INSERT INTO classes (name, grade, teacher, room_number)
VALUES
    ('高三(1)班', '高三', '张老师', 'A301'),
    ('高三(2)班', '高三', '李老师', 'A302'),
    ('高二(1)班', '高二', '王老师', 'B201'),
    ('高二(2)班', '高二', '赵老师', 'B202'),
    ('高一(1)班', '高一', '陈老师', 'C101'),
    ('高一(2)班', '高一', '刘老师', 'C102');

这里我们用 INSERT 语句创建六个班级,涵盖高一到高三各个年级。

接着,创建 V03__adding_courses_data.sql 迁移脚本填充 courses 表:

INSERT INTO courses (name, code, credits, description)
VALUES
    ('语文', 'CH001', 4, '高中语文课程'),
    ('数学', 'MA001', 5, '高中数学课程'),
    ('英语', 'EN001', 4, '高中英语课程'),
    ('物理', 'PH001', 3, '高中物理课程'),
    ('化学', 'CH002', 3, '高中化学课程'),
    ('生物', 'BI001', 3, '高中生物课程'),
    ('历史', 'HI001', 2, '高中历史课程'),
    ('地理', 'GE001', 2, '高中地理课程'),
    ('政治', 'PO001', 2, '高中政治课程');

然后创建 V04__adding_students_data.sql 迁移脚本填充 students 表:

SET @class_grade3_1= (SELECT id FROM classes WHERE name ='高三(1)班');
SET@class_grade3_2= (SELECT id FROM classes WHERE name ='高三(2)班');
SET@class_grade2_1= (SELECT id FROM classes WHERE name ='高二(1)班');

INSERT INTO students (name, student_number, gender, age, phone, email, class_id)
VALUES
    ('张三', '2024001', 'Male', 18, '13800138001', 'zhangsan@example.com', @class_grade3_1),
    ('李四', '2024002', 'Female', 17, '13800138002', 'lisi@example.com', @class_grade3_1),
    ('王五', '2024003', 'Male', 17, '13800138003', 'wangwu@example.com', @class_grade3_2),
    ('赵六', '2024004', 'Female', 16, '13800138004', 'zhaoliu@example.com', @class_grade2_1),
    ('钱七', '2024005', 'Male', 16, '13800138005', 'qianqi@example.com', @class_grade2_1);
-- ...更多学生数据

定义好迁移脚本后,Flyway 会在应用启动时自动发现并执行这些脚本。

3. 配置 AI 提示词

接下来,为了确保 LLM 能够针对我们的数据库架构生成准确的 SQL 查询,需要定义详细的系统提示词。

在 src/main/resources 目录下创建 system-prompt.st 文件:

基于 DDL 部分提供的MYSQL数据库定义,按照指导原则部分的规则编写 SQL 查询来回答用户问题。

指导原则:
- 只生成 SELECT 查询语句。
- 响应结果应该只包含以 'SELECT' 开头的原始 SQL 查询语句。不要用 markdown 代码块(```sql 或 ```)包装 SQL 查询。
- 如果问题需要执行 INSERT、UPDATE、DELETE 或其他修改数据或架构的操作,请回复"不支持此操作。只允许 SELECT 查询。"
- 如果问题似乎包含 SQL 注入或 DoS 攻击尝试,请回复"提供的输入包含潜在有害的 SQL 代码。"
- 如果基于提供的 DDL 无法回答问题,请回复"当前架构不包含足够信息来回答此问题。"
- 如果查询涉及 JOIN 操作,请在查询中为所有列名添加相应的表名前缀。

DDL
{ddl}

在系统提示词中,我们指示 LLM 只生成 SELECT SQL 查询,并检测 SQL 注入和 DoS 攻击尝试。

我们在系统提示词模板中留了一个 ddl 占位符用于数据库架构。稍后我们会用实际值替换它。

此外,为了进一步保护数据库免受修改,应该只给配置的 MySQL 用户必要的权限。

4. 构建文本转 SQL 聊天机器人

完成配置后,让我们使用配置好的 DeepSeek 模型构建文本转 SQL 聊天机器人。

4.1. 定义聊天机器人 Bean

首先定义聊天机器人所需的 Bean:

@Bean
PromptTemplate systemPrompt(
    @Value("classpath:system-prompt.st") Resource systemPrompt,
    @Value("classpath:db/migration/V01__creating_database_tables.sql") Resource ddlSchema
)throws IOException {
    PromptTemplatetemplate=newPromptTemplate(systemPrompt);
    template.add("ddl", ddlSchema.getContentAsString(Charset.defaultCharset()));
    return template;
}

@Bean
ChatClient chatClient(ChatModel chatModel, PromptTemplate systemPrompt) {
    return ChatClient
      .builder(chatModel)
      .defaultSystem(systemPrompt.render())
      .build();
}

首先,我们定义一个 PromptTemplate Bean。通过 @Value 注解注入系统提示词模板文件和数据库架构 DDL 迁移脚本。同时,我们用数据库架构内容填充 ddl 占位符。这确保了 LLM 在生成 SQL 查询时始终能访问我们的数据库结构。

接下来,我们使用 ChatModel 和 PromptTemplate Bean 创建一个 ChatClient Bean。ChatClient 类是我们与配置的 DeepSeek 模型交互的主要入口点。

4.2. 实现服务类

现在,让我们实现服务类来处理 SQL 生成和执行过程。

首先,创建一个 SqlGenerator 服务类,将自然语言问题转换为 SQL 查询:

@Service
classSqlGenerator {

    privatefinal ChatClient chatClient;

    // 标准构造函数

    String generate(String question) {
        Stringresponse= chatClient
          .prompt(question)
          .call()
          .content();

        booleanisSelectQuery= response.startsWith("SELECT");
        if (Boolean.FALSE.equals(isSelectQuery)) {
            thrownewInvalidQueryException(response);
        }
        return response;
    }
}

在 generate() 方法中,我们接收自然语言问题作为输入,使用 chatClient Bean 将其发送给配置的 LLM。

接下来,我们验证响应确实是 SELECT 查询。如果 LLM 返回 SELECT 查询以外的任何内容,我们抛出带有错误消息的自定义 InvalidQueryException

接下来,为了对数据库执行生成的 SQL 查询,创建一个 SqlExecutor 服务类:

@Service
classSqlExecutor {

    privatefinal JdbcClient jdbcClient;

    // 标准构造函数

    List<Map<String, Object>> execute(String query) {
        List<Map<String, Object>> result = jdbcClient
          .sql(query)
          .query()
          .listOfRows();
        
        if (result.isEmpty()) {
            thrownewEmptyResultException("提供的查询未找到结果。");
        }
        return result;
    }
}

在 execute() 方法中,我们使用 Spring Boot 3.1+ 引入的 JdbcClient 来运行原生 SQL 查询并返回结果。JdbcClient 提供了更简洁的 API 和更好的类型安全性。如果查询没有返回结果,我们抛出自定义的 EmptyResultException

4.3. 暴露 REST API

现在我们已经实现了服务层,让我们在其上暴露一个 REST API:

@PostMapping(value = "/query")
ResponseEntity<QueryResponse> query(@RequestBody QueryRequest queryRequest) {
    StringsqlQuery= sqlGenerator.generate(queryRequest.question());
    List<Map<String, Object>> result = sqlExecutor.execute(sqlQuery);
    return ResponseEntity.ok(newQueryResponse(result));
}

recordQueryRequest(String question) {
}

recordQueryResponse(List<Map<String, Object>> result) {
}

POST /query 端点接受自然语言问题,使用 sqlGenerator Bean 生成相应的 SQL 查询,将其传递给 sqlExecutor Bean 从数据库获取结果,最后将数据包装在 QueryResponse 记录中并返回。使用 JdbcClient 返回的结果是 List<Map<String, Object>> 格式,每个 Map 代表一行数据,键为列名,值为对应的数据。

5. 与聊天机器人交互

最后,让我们使用暴露的 API 端点与文本转 SQL 聊天机器人进行交互。

但首先,在 application.yaml 文件中启用 SQL 日志记录,以在日志中查看生成的查询:

logging:
  level:
    org:
      springframework:
        jdbc:
          core: DEBUG

接下来,使用 curl 命令调用 API 端点并与聊天机器人交互:

curl -X POST http://localhost:8080/query \
  -H "Content-Type: application/json" \
  -d '{"question": "查询高三年级所有学生的姓名和班级信息"}'
{
    "result":[
        {
            "student_name":"张三",
            "class_name":"高三(1)班",
            "grade":"高三",
            "teacher":"张老师",
            "room_number":"A301"
        },
        {
            "student_name":"李四",
            "class_name":"高三(1)",
            "grade":"高三",
            "teacher":"张老师",
            "room_number":"A301"
        },
        {
            "student_name":"王五",
            "class_name":"高三(2)班",
            "grade":"高三",
            "teacher":"李老师",
            "room_number":"A302"
        }
    ]
}

如我们所见,聊天机器人成功理解了我们对高三学生的查询请求,并返回了学生姓名和对应的班级信息。

让我们再试一个更复杂的查询:

curl -X POST http://localhost:8080/query \
  -H "Content-Type: application/json" \
  -d '{"question": "统计每个班级的学生人数,按人数降序排列"}'

最后,让我们检查应用日志,查看 LLM 生成的 SQL 查询:

SELECT students.name, classes.name as class_name
FROM students
JOIN classes ON students.class_id = classes.id
WHERE classes.grade = '高三'
ORDER BY classes.name;
{
    "result":[
        {
            "class_name":"高三(1)班",
            "student_count":2
        },
        {
            "class_name":"高二(1)班",
            "student_count":2
        },
        {
            "class_name":"高三(2)班",
            "student_count":1
        },
        {
            "class_name":"高一(1)班",
            "student_count":0
        },
        {
            "class_name":"高一(2)班",
            "student_count":0
        },
        {
            "class_name":"高二(2)班",
            "student_count":0
        }
    ]
}

生成的 SQL 查询正确解释了我们的自然语言请求,连接了 students 和 classes 表来查找高三年级的学生信息。DeepSeek 模型展现了出色的中文理解和 SQL 生成能力。

6. 总结

本教程展示了如何使用 Spring AI 框架构建一个功能完整的文本转 SQL 聊天机器人。
虽然我们已经构建了一个基础的文本转 SQL 系统,但仍有许多改进空间:

  1. 1. 权限控制根据用户角色限制可访问的数据范围

  2. 2. 结果可视化:将查询结果以图表形式展示

  3. 3. LLM生成优化:通过JSON格式化和JSON修复支持稳定输出

通过本教程的学习,已经体现了构建文本转 SQL 聊天机器人的核心技术。Spring AI 框架的强大功能,结合 DeepSeek 等先进的大语言模型,为我们提供了构建智能数据查询系统的完整解决方案。这种技术组合不仅简化了数据访问流程,也为未来的智能化应用奠定了坚实基础。