【第33篇】Text-to-SQL
1. 概述
1.1 项目介绍
本项目演示了一个典型的 Text-to-SQL 应用场景:利用大语言模型将用户的自然语言问题转换为结构化查询语言(SQL),并在数据库上执行,最终将查询结果返回给用户。
核心业务流程:
用户通过 POST /sql 接口提交一个自然语言问题,系统后台将自动完成以下步骤:
- 理解上下文:加载数据库的表结构定义(DDL)。
- 生成查询:结合用户问题和 DDL,调用大模型生成一个安全的
SELECT语句。 - 安全执行:对生成的 SQL 进行校验,防止恶意操作,确保只执行只读查询。
- 返回结果:执行 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 数据模型
本示例模拟了一个简单的图书管理系统,其核心实体关系如下:
表关系说明:
- 一个作者(Author)可以写多本书(Books),两者通过
author_ref外键关联。 - 一个出版商(Publisher)可以出版多本书(Books),两者通过
publisher_ref外键关联。
2. 架构设计分析
2.1 系统架构与调用流程
SQLController 是请求的入口,它不直接处理复杂的业务逻辑,而是作为编排者,协调各个组件完成工作。
流程详解
- 同步加载与渲染:Controller 在接收到请求后,首先同步地从
classpath加载 DDL 和 Prompt 模板。Spring AI 的Resource和user方法支持字符串模板的占位符替换,将{question}和{ddl}替换为具体内容。 - 模型调用:
ChatClient封装了与 DashScope 的交互细节。它会将渲染好的 Prompt 作为user message发送给大模型,并等待模型的assistant message响应。 - 后置处理:在获得生成的 SQL 后,进行“二次验证”是至关重要的。不能完全信任大模型生成的内容,必须在服务端进行规则校验。
- 结果封装: 最终将生成的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 模板渲染原理
当 ChatClient 的 user(...).text(...).param(...) 链式调用时,内部经历了以下步骤:
- 模板解析:Spring AI 会将
.text(sqlPromptTemplateResource)指定的文件内容作为一个模板字符串加载。 - 占位符替换:它使用模板引擎(如默认的
StringTemplate)将.param("question", ...)和.param("ddl", ...)传入的值,去替换模板中的{question}和{ddl}占位符。 - 消息构造:替换后的完整字符串被构造成一个
UserMessage对象。 - 请求发送:
UserMessage作为对话的一部分,通过call()方法发送给远端的大模型服务。
3.3 JdbcTemplate.queryForList 原理
jdbcTemplate.queryForList(query) 是一个非常高级的数据库操作方法。
- 封装过程:
- 从配置的
DataSource连接池获取一个数据库连接。 - 在此连接上利用传入的 SQL 字符串创建一个
PreparedStatement。 - 执行
executeQuery()方法,获得ResultSet结果集。 - 遍历结果集
ResultSet的元数据信息,获取每一列的名称。 - 循环遍历
ResultSet的每一行数据。 - 对于每一行,以列名为键(Key),以该列的值作为值(Value),构建一个
Map<String, Object>。 - 将每一行的
Map添加到List中。 - 在
finally代码块中安全地关闭ResultSet、Statement和数据库连接。
- 从配置的
- 最终返回:
List<Map<String, Object>>,其中每个Map代表一行记录。例如[{"COUNT(*)": 4}]。
4. 代码问题深度分析与优化方案
4.1 问题1: SQL验证过于虚弱,无法防御语义注入
【错误识别与风险分析】
源代码仅检查了 SQL 字符串是否以 "select" 开头,这是一种极易被绕过的安全策略。
- 潜在风险:
- 无语法校验: 如果 AI 返回
"SELECT FROM Books"(少了个*或列名),该 SQL 执行会直接报错,导致用户得到一个不友好的数据库异常信息。 - 可通过注释绕过: 攻击者或出错的大模型可能生成如下输入:
虽然以select '; DROP TABLE Books; --' from dualselect开头,但其内部语义是恶意的。直接执行可能造成严重后果。 - 子查询或者UNION注入更难以防范
- 无语法校验: 如果 AI 返回
【优化替代方案】
必须引入白名单与解析级验证相结合的方案。推荐使用 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能准确区分SELECT、INSERT、DELETE等语句。任何试图在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 很少变化,缓存可以显著提升响应速度并降低成本。// 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(); }); }
- 替代方案: 引入本地缓存(如 Caffeine)缓存 DDL 和 AI 生成的 SQL 结果。
总结
本项目清晰地演示了 Spring AI Alibaba 在 Text-to-SQL 场景的核心能力。通过本次优化分析,我们不仅理解了其工作流程,更识别了其中潜在的安全风险与性能短板,并提出了严谨、可落地的优化方案。特别是基于 AST 的 SQL 白名单校验和降低模型随机性的配置,是此类应用从“演示”走向“生产”的两个关键步骤。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)