1. 概述

1.1 项目介绍

本项目演示了一个典型的 Text-to-SQL 应用场景:利用大语言模型将用户的自然语言问题转换为结构化查询语言(SQL),并在数据库上执行,最终将查询结果返回给用户。

核心业务流程:
用户通过 POST /sql 接口提交一个自然语言问题,系统后台将自动完成以下步骤:

  1. 理解上下文:加载数据库的表结构定义(DDL)。
  2. 生成查询:结合用户问题和 DDL,调用大模型生成一个安全的 SELECT 语句。
  3. 安全执行:对生成的 SQL 进行校验,防止恶意操作,确保只执行只读查询。
  4. 返回结果:执行 SQL 并将结构化的查询结果返回给用户。

1.2 项目结构

spring-ai-alibaba-sql-example/
├── src/main/
│   ├── java/com/alibaba/example/sql/
│   │   ├── SQLApplication.java                # 应用主入口
│   │   ├── SQLGenerationException.java        # 自定义业务异常
│   │   ├── SQLGenerationExceptionHandler.java # 全局异常处理
│   │   └── controller/
│   │       └── SQLController.java             # 核心API控制器
│   └── resources/
│       ├── application.yml                    # 应用主配置
│       ├── schema.sql                           # 数据库表结构定义
│       ├── data.sql                             # 数据库初始化数据
│       └── prompts/                             # (建议) Prompt模板目录
│           └── sql-prompt-template.st          # SQL生成的Prompt模板
└── pom.xml                                      # Maven依赖管理

优化点: 建议在 resources 下建立 prompts/ 目录,将Prompt模板文件集中管理,与静态资源分离,结构更清晰。

1.3 技术栈

组件 技术/版本 用途
应用框架 Spring Boot 3.x 提供自动配置、依赖注入、Web服务能力
AI 集成 Spring AI Alibaba 封装DashScope模型调用,提供 ChatClient
数据库 H2 Database (In-Memory) 作为可在内存中运行的临时数据库,用于演示
数据库访问 Spring JDBC (JdbcTemplate) 执行生成的SQL并获取结果
API密钥 DashScope (通义千问) 提供大模型服务,负责自然语言到SQL的转换

1.4 数据模型

本示例模拟了一个简单的图书管理系统,其核心实体关系如下:

writes

publishes

Authors

int

id

PK

varchar

firstName

varchar

lastName

Publishers

int

id

PK

varchar

name

Books

int

id

PK

varchar

isbn

varchar

title

int

author_ref

FK

int

publisher_ref

FK

表关系说明:

  • 一个作者(Author)可以写多本书(Books),两者通过 author_ref 外键关联。
  • 一个出版商(Publisher)可以出版多本书(Books),两者通过 publisher_ref 外键关联。

2. 架构设计分析

2.1 系统架构与调用流程

SQLController 是请求的入口,它不直接处理复杂的业务逻辑,而是作为编排者,协调各个组件完成工作。

JdbcTemplate (H2) SQL验证器 ChatClient (AI模型) 文件系统 SQLController JdbcTemplate (H2) SQL验证器 ChatClient (AI模型) 文件系统 SQLController alt [SQL 不安全或无效] [SQL 安全] 客户端 POST /sql { "question": "..." } 1. 读取 schema.sql (DDL) 返回DDL字符串 2. 读取 Prompt 模板 返回模板内容 3. 调用AI,传入 {question} 和 {ddl} 拼接Prompt并请求DashScope大模型 返回生成的 SQL 字符串 4. 验证SQL安全性(仅SELECT) 验证失败 抛出 SQLGenerationException 验证通过 5. 执行 SQL 查询 返回 List<Map> 结果 6. 组装 Answer 对象 返回 SQL 和查询结果 客户端

流程详解

  1. 同步加载与渲染:Controller 在接收到请求后,首先同步地从 classpath 加载 DDL 和 Prompt 模板。Spring AI 的 Resourceuser 方法支持字符串模板的占位符替换,将 {question}{ddl} 替换为具体内容。
  2. 模型调用ChatClient 封装了与 DashScope 的交互细节。它会将渲染好的 Prompt 作为 user message 发送给大模型,并等待模型的 assistant message 响应。
  3. 后置处理:在获得生成的 SQL 后,进行“二次验证”是至关重要的。不能完全信任大模型生成的内容,必须在服务端进行规则校验。
  4. 结果封装: 最终将生成的SQL脚本和查询结果封装成一个 Answer 对象返回。

2.2 核心组件职责

  • ChatClient: 是 Spring AI 的核心接口,负责与 AI 模型进行对话。在本项目中,它被配置为调用阿里云 DashScope 的模型。
  • JdbcTemplate: 是 Spring 框架提供的核心类,用于简化 JDBC 操作。它封装了连接获取、Statement 创建、结果集遍历和异常转换等样板代码,开发者只需编写 SQL 和参数。
  • schema.sql & data.sql: 这是 Spring Boot 的自动配置项。当 Spring Boot 检测到 H2 这种嵌入式数据库时,会自动在应用启动时按顺序执行 schema.sql(定义表结构)和 data.sql(插入初始化数据)。

3. 核心原理详解

3.1 Prompt Engineering (提示工程)

提示工程是 Text-to-SQL 应用成败的关键,它相当于给 AI 模型的“说明书”。

模板内容分析 (sql-prompt-template.st):

Given the DDL in the DDL section, write an SQL query to answer the question...
QUESTION
{question}
DDL
{ddl}

原理解析:
这段 Prompt 实际上构成了一个“系统指令”和“用户问题”的结合体。它通过明确的角色设定和上下文隔离,引导模型行为:

  • 角色设定: Given the DDL... write an SQL query 明确指示模型扮演 SQL 专家的角色,任务是将自然语言转换为 SQL。
  • 上下文注入: DDL {ddl} 将数据库的“知识”注入给模型。模型本身不知道你的数据库结构,通过 DDL,它才能知道有哪些表、列以及它们的关系。这就像给了模型一张数据库地图。
  • 输入引导: QUESTION {question} 规范了用户问题的注入位置,让模型明确识别出需要被转换的目标。
  • 安全约束(含蓄): 虽然模板没有明说,但结合代码中的检查,整个设计意图是只生成 SELECT 语句。一个更优化的 Prompt 会明确加上 Only write SELECT queries

3.2 ChatClient 模板渲染原理

ChatClientuser(...).text(...).param(...) 链式调用时,内部经历了以下步骤:

  1. 模板解析:Spring AI 会将 .text(sqlPromptTemplateResource) 指定的文件内容作为一个模板字符串加载。
  2. 占位符替换:它使用模板引擎(如默认的 StringTemplate)将 .param("question", ...).param("ddl", ...) 传入的值,去替换模板中的 {question}{ddl} 占位符。
  3. 消息构造:替换后的完整字符串被构造成一个 UserMessage 对象。
  4. 请求发送UserMessage 作为对话的一部分,通过 call() 方法发送给远端的大模型服务。

3.3 JdbcTemplate.queryForList 原理

jdbcTemplate.queryForList(query) 是一个非常高级的数据库操作方法。

  • 封装过程:
    1. 从配置的 DataSource 连接池获取一个数据库连接。
    2. 在此连接上利用传入的 SQL 字符串创建一个 PreparedStatement
    3. 执行 executeQuery() 方法,获得 ResultSet 结果集。
    4. 遍历结果集 ResultSet 的元数据信息,获取每一列的名称
    5. 循环遍历 ResultSet 的每一行数据。
    6. 对于每一行,以列名为键(Key),以该列的值作为值(Value),构建一个 Map<String, Object>
    7. 将每一行的 Map 添加到 List 中。
    8. finally 代码块中安全地关闭 ResultSetStatement 和数据库连接。
  • 最终返回: List<Map<String, Object>>,其中每个 Map 代表一行记录。例如 [{"COUNT(*)": 4}]

4. 代码问题深度分析与优化方案

4.1 问题1: SQL验证过于虚弱,无法防御语义注入

【错误识别与风险分析】
源代码仅检查了 SQL 字符串是否以 "select" 开头,这是一种极易被绕过的安全策略。

  • 潜在风险:
    1. 无语法校验: 如果 AI 返回 "SELECT FROM Books"(少了个 * 或列名),该 SQL 执行会直接报错,导致用户得到一个不友好的数据库异常信息。
    2. 可通过注释绕过: 攻击者或出错的大模型可能生成如下输入:
      select '; DROP TABLE Books; --' from dual
      
      虽然以 select 开头,但其内部语义是恶意的。直接执行可能造成严重后果。
    3. 子查询或者UNION注入更难以防范

【优化替代方案】

必须引入白名单解析级验证相结合的方案。推荐使用 JSqlParser 库进行 SQL 语法解析,它能将 SQL 字符串解析为语法树(AST),从而准确判断语句的类型和结构。

优化后代码:

// 1. 添加 maven 依赖
// <dependency>
//     <groupId>com.github.jsqlparser</groupId>
//     <artifactId>jsqlparser</artifactId>
//     <version>4.9</version>
// </dependency>

import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.Select;
import org.springframework.stereotype.Component;

@Component
public class SQLValidator {

    /**
     * 使用 JSqlParser 进行严格的白名单验证,只允许 SELECT 语句。
     * @param sql 待验证的 SQL 字符串
     * @return true 如果是一个合法的 SELECT 语句,否则 false
     */
    public boolean isValidSelectStatement(String sql) {
        if (sql == null || sql.isBlank()) {
            return false;
        }
        try {
            // 1. 解析 SQL 字符串为 AST (抽象语法树)
            Statement statement = CCJSqlParserUtil.parse(sql);
            
            // 2. 核心白名单逻辑:检查解析出的顶层语句是不是 Select 类型
            //    这能从根本上杜绝 DROP、UPDATE、DELETE 等操作,因为它们的 AST 节点类型不同。
            return (statement instanceof Select);
        } catch (Exception e) {
            // 3. 任何解析异常(语法错误、不支持的语句)都视为不安全
            return false;
        }
    }
}

优化方案分析:

  • 优势: 这是最彻底、最安全的方案。JSqlParser 能准确区分 SELECTINSERTDELETE 等语句。任何试图在 SELECT 后通过注释、分号注入恶意语句的尝试,都会导致 CCJSqlParserUtil.parse() 无法解析成一个顶层的 Select 对象。
  • 成本: 引入了新的第三方库,需要学习其API。

4.2 问题2: 缺少查询结果限制,存在内存溢出风险

【错误识别与分析】
源代码 jdbcTemplate.queryForList(query) 会尝试将整个查询结果集加载到 JVM 内存中的一个 List 里。如果用户问了一个很宽泛的问题(如 “查询所有书的内容”),且表中有数百万条数据,会瞬间导致服务内存溢出(OOM)。

【优化替代方案】

在生成的 SQL 上强制附加 LIMIT 子句,或对 queryForList 返回的集合大小进行截断。

优化后代码(方案A: SQL层面限制,推荐):

// 在 SQLController 中

private static final int MAX_RESULT_SIZE = 100; // 最多返回100条记录

@PostMapping(path = "/sql")
public Answer sql(@RequestBody SqlRequest sqlRequest) throws IOException {
    // ... (加载DDL、调用AI生成SQL) ...
    
    String query = chatClient.prompt()...call().content();
    
    // 1. 安全验证
    if (!sqlValidator.isValidSelectStatement(query)) {
        throw new SQLGenerationException("Generated SQL is invalid or not a SELECT: " + query);
    }

    // 2. 限制查询结果数量,防止OOM
    String limitedQuery = addOrReplaceLimit(query, MAX_RESULT_SIZE);
    
    List<Map<String, Object>> results = jdbcTemplate.queryForList(limitedQuery);
    return new Answer(limitedQuery, results);
}

/**
 * 为 SELECT 语句添加或替换 LIMIT 子句。
 * 解析SQL字符串,如果已有LIMIT,则取两者中较小的值;否则,追加 LIMIT。
 */
private String addOrReplaceLimit(String query, int limit) {
    String upperQuery = query.toUpperCase().trim();
    // 简单实现:如果末尾已有LIMIT,则做复杂处理;否则直接追加。
    // 更稳健的方案仍然是用 JSqlParser 修改AST。
    if (upperQuery.endsWith(";")) {
        query = query.substring(0, query.length()-1); // 移除末尾分号
    }
    if (upperQuery.contains("LIMIT")) {
        // 有LIMIT时,尝试替换成一个更小的或相同的值,逻辑复杂
        // 这里提供一个简化版:直接关闭解析,提示用户修改问题
        throw new SQLGenerationException("AI generated query contains a LIMIT clause. Please ask a more specific question or modify the query template.");
    }
    return query + " LIMIT " + limit;
}

优化方案分析:

  • 优势: 从源头控制了数据量,保证了服务的稳定性。
  • 权衡: AI 生成的 SQL 如果已经包含了 LIMIT,处理会比较麻烦。一个更优雅的方法是修改 Prompt 模板,明确告诉 AI “Do not include a LIMIT clause”,然后在服务端统一添加。

4.3 问题3: 缺少ChatClient配置,模型行为不受控

【错误识别与分析】
原文档的分析部分只创建了空的 ChatClient bean,没有设置任何参数。大模型的 temperature(创造性)等参数会影响生成结果的质量。默认情况下,temperature 可能较高,导致生成 SQL 时不准确、有创造力但不符合语法。

【优化替代方案】

通过 ChatClient.Builder 为 ChatClient 设置默认参数,特别是降低 temperature

优化后代码:

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.ai.dashscope.DashScopeChatOptions;

@Configuration
public class AIConfiguration {

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder) {
        return builder
            .defaultOptions(DashScopeChatOptions.builder()
                .withTemperature(0.1) // 关键优化:将随机性降到最低,使SQL生成更确定、精准
                .withMaxToken(1024)   // 限制生成的最大长度,防止模型“胡言乱语”
                .withModel("qwen-max") // 指定模型,确保使用能力最强的模型处理逻辑任务
                .build())
            .build();
    }
}

优化方案分析:

  • temperature(0.1): 这是最重要的优化。Text-to-SQL 是一个需要精确性的逻辑任务,不需要模型的创造性。接近于0的 temperature 能让模型每次都选择概率最高的下一个词,从而生成最“正确”的 SQL。
  • maxToken(1024): 为 SQL 生成设置一个合理的上限。标准的 SQL 查询很少会超过几百个 token,此设置可以防止模型生成冗长的无用文本,并控制成本。

4.4 问题4: 硬编码模板路径与缺乏缓存,可维护性和性能欠佳

这两点原分析已提及,但未给出深度解决方案。

【分析与优化】

  • 可维护性: 将 @Value("classpath:/sql-prompt-template.st") 硬编码在业务代码中,如果模板路径变更,需要修改代码并重新编译。

    • 替代方案: 采用配置化。
      # application.yml
      app:
        sql:
          prompt-template: classpath:/prompts/sql-prompt-template.st
      
      @Value("${app.sql.prompt-template}")
      private Resource sqlPromptTemplateResource;
      
      这样,模板路径的变更只需修改配置文件,无需改动代码。
  • 性能优化: 每次请求都重复读取不变的 schema.sql,并请求大模型为类似的问题生成 SQL,这是巨大的计算和IO浪费。

    • 替代方案: 引入本地缓存(如 Caffeine)缓存 DDL 和 AI 生成的 SQL 结果。
      // DDL 内容缓存
      private final Cache<String, String> ddlCache = Caffeine.newBuilder()
          .maximumSize(1).build();
      
      // SQL 生成结果缓存
      private final Cache<String, String> sqlCache = Caffeine.newBuilder()
          .maximumSize(100)
          .expireAfterWrite(1, TimeUnit.HOURS)
          .build();
      
      private String getDdl() {
          return ddlCache.get("schema", key -> {
              try { return ddlResource.getContentAsString(Charset.defaultCharset()); } 
              catch (IOException e) { throw new RuntimeException(e); }
          });
      }
      
      public String getOrGenerateSQL(String question, String ddl) {
          return sqlCache.get(question, key -> {
              // 调用 ChatClient 生成 SQL 的逻辑
              return chatClient.prompt()...call().content();
          });
      }
      
      对于一个演示应用,DDL 很少变化,缓存可以显著提升响应速度并降低成本。

总结

本项目清晰地演示了 Spring AI Alibaba 在 Text-to-SQL 场景的核心能力。通过本次优化分析,我们不仅理解了其工作流程,更识别了其中潜在的安全风险与性能短板,并提出了严谨、可落地的优化方案。特别是基于 AST 的 SQL 白名单校验降低模型随机性的配置,是此类应用从“演示”走向“生产”的两个关键步骤。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐