基于Spring AI的Text-to-SQL实战:自然语言生成SQL全方案
做Text-to-SQL这个需求最初是公司内部一个BI平台要接自然语言查询。业务方提得很直接运营人员想看数据但不会写SQL每次都找研发写临时查询一条报表要等半天。我当时就在想如果能让业务人员直接用大白话问数据库系统自动生成SQL并执行这个效率能提升多少。调研了一轮之后我决定用Spring AI来做这件事。项目内部代号就叫Super-SQL整体思路是Spring AI负责与大模型对话、解析自然语言通过精心设计的Prompt把自然语言转换成SQL再经过一层安全校验后交给数据库执行最后把结果返回给前端展示。这个项目从原型到可用版本前后踩了不少坑今天把完整方案和踩过的坑一并分享出来代码可以直接参考。适合谁看如果你正在做大模型应用开发或者对Text-to-SQL感兴趣尤其是Java技术栈、考虑用Spring AI集成通义千问、DeepSeek等模型的朋友这篇内容应该能帮你少走很多弯路。1. 为什么用Spring AI做Text-to-SQL1.1 Text-to-SQL到底难在哪Text-to-SQL这个概念出现得很早早在大模型爆发之前学术界就有很多尝试比如用序列到序列模型把自然语言翻译成SQL。但传统方案的成功率一直上不去主要卡在几个地方。第一个是语义对齐问题。自然语言天生就是模糊的比如用户问“本月销售额前10的商品”这里“本月”到底是指自然月还是最近30天“销售额”是订单金额还是实付金额数据库里根本没有“销售额”这么一张表它可能是订单表里的金额字段经过聚合计算得出的结果。传统规则系统遇到这种表达几乎无能为力。第二个是SQL本身的复杂度。简单查询还好一旦涉及多表JOIN、子查询、窗口函数、GROUP BY多维聚合或者要按不同维度分组统计就算把SQL语法规则全部硬编码进去规则也会膨胀到一个无法维护的规模。第三个是数据库结构的多样性。每个项目的表结构、字段命名习惯都不一样。A公司的用户表可能叫t_userB公司可能叫member_info字段命名可能是create_time、created_at、gmt_create三套体系并存。这意味着Text-to-SQL系统必须能够感知当前数据库的元数据而不是背一套通用的映射规则。大模型的出现把语义理解和SQL生成这两个关键环节都解决了。模型本身有很强的泛化能力只要把表结构、字段说明、业务口径塞进Prompt它就能根据自然语言生成基本可运行的SQL。这也是我选择Spring AI的底层逻辑不需要自己训练模型不需要维护复杂的规则引擎只要把模型的推理能力接进来再做好约束和校验就行。1.2 为什么选Spring AI而不是逐个对接SDK做Java后端的人都知道如果直接对接各家大模型API最大的痛点不是写HTTP请求而是生态割裂。今天项目要接通义千问明天客户说要用DeepSeek后天可能还要兼容OpenAI格式的本地服务。每个平台的SDK风格不同参数不同切换模型就得改一堆代码。Spring AI把这个问题的解法思路和Spring Boot对整个Java生态的解法一样搞一层统一的抽象。它的ChatClient接口类似RestTemplate所有模型接入都走同一个接口切换底层模型只需要改配置或者换一个Starter依赖。这个项目里我用的是Spring AI配合DashScope API接通义千问同时也测试过DeepSeek。因为Spring AI底层的OpenAiChatModel支持自定义base-url只要API格式兼容OpenAI规范换模型基本就是改两行配置的事情Service层代码一行不用动。原始SDK方案业务代码 - 千问SDK - 千问API Spring AI方案业务代码 - ChatClient统一接口 - 千问/DeepSeek/OpenAI兼容端点另外Spring AI还自带一些实用功能比如Prompt模板管理、聊天记忆、输出解析器等。对于Text-to-SQL这种场景输出解析器特别重要因为模型返回的SQL经常被Markdown代码块包裹手动去字符串截取太脆弱了后面我会详细说这个问题。2. 整体方案设计与技术选型2.1 Super-SQL项目架构Super-SQL的整体架构并不复杂核心链路是一条直线用户输入自然语言 - 组装Prompt并携带数据库元数据 - 调用大模型生成SQL - 解析和校验SQL - 执行SQL - 返回结果。但这条直线上有几个必须处理好的环节我逐个说一下。元数据管理模块。Text-to-SQL要生成正确SQL模型必须知道数据库里有什么表、每张表有哪些字段、字段的含义是什么、表之间的关联关系是什么。我最初的做法是把建表语句直接塞给模型后来发现效果不好因为DDL太长太杂模型容易被无关信息干扰。后来改成维护一份精简的元数据描述每个表只保留表名、字段名、字段注释、主键、外键关系剔除索引、字符集等信息。这样Prompt更紧凑模型更容易聚焦。SQL生成与解析模块。这里接的是Spring AI的ChatClient把用户问题、元数据、业务规则、历史对话拼接成Prompt发给模型拿到回复后解析出纯净的SQL语句。安全校验模块。这是整个系统里我最坚持要做的部分。让大模型直接生成的SQL去操作生产数据库风险系数极高。必须加一道校验层把非查询类操作、可疑语句、超长结果集都拦截掉。这个模块是我在生产环境稳住的功臣后面专门讲。执行与格式化模块。校验通过后的SQL交给MyBatis或者JdbcTemplate执行结果集拿到后统一包装成JSON接口返回给前端。如果查询耗时比较长还做了异步化处理避免阻塞接口线程池。2.2 模型接入以千问和DeepSeek为例模型选择上Text-to-SQL对模型的SQL能力要求其实挺高的。我之前试过几个小尺寸模型生成的SQL经常出现字段名幻觉比如模型凭空造一个order_amount字段但数据库里根本没有这个字段。后来换了千问的qwen-plus和DeepSeek的deepseek-chat效果才明显好起来。在Spring AI中接入千问需要引入DashScope的Starter。这里要说明一下Spring AI官方对阿里云百炼平台的适配做得比较完整直接用spring-ai-starter-model-qwen就行。如果要用DeepSeek可以通过OpenAI兼容模式接入DeepSeek的API格式兼容OpenAI规范所以配置一个base-url和api-key就可以。spring: ai: model: qwen: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.1上面这个配置里temperature设置成0.1是我反复测试后确定的。Text-to-SQL和写诗不一样它需要的是确定性输出。temperature过高会让模型发挥不稳定同一个问题两次生成的SQL可能结构都不一样调低到0.1左右输出的稳定性会好很多。我用0.2试过一段时间偶尔还是会出现谓词不一致的情况调到0.1之后基本稳定了。DeepSeek的接入方式是这样spring: ai: model: openai: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com chat: options: model: deepseek-chat temperature: 0.1注意Spring AI的OpenAI模型Starter默认的base-url是OpenAI官方地址如果要用DeepSeek必须显式覆盖base-url否则请求会打到OpenAI那边去直接报认证失败。2.3 Prompt设计成功的关键Text-to-SQL的效果一半看模型能力另一半看Prompt设计。好的Prompt能把模型的发挥上限激发出来不好的Prompt即使模型很强也答不对。我在Super-SQL里采用的Prompt结构大致分为五个部分。第一部分是角色设定明确告诉模型“你是一个精通SQL的数据库专家”。第二部分是任务约束说明只做自然语言到SQL的转换不做其他事情。第三部分是数据库元数据把表和字段的信息完整给出。第四部分是业务规则比如“销售额统计的是已支付订单不含退款”“金额字段单位是分查询时需要除以100”这种口径。第五部分是输出格式要求明确规定只输出SQL不要输出多余的说明文字。system: 你是一个专业的SQL生成助手。根据用户问题和给定的数据库元数据表结构生成符合业务规则的SQL查询语句。 约束 1. 只能使用元数据中存在的表和字段禁止虚构字段 2. 只能输出SQL语句本身不要输出任何解释性文字 3. 禁止使用DELETE、UPDATE、INSERT、DROP、ALTER等非查询语句 4. 如果用户的问题与数据查询无关请回复仅支持数据查询类问题 元数据 {table_metadata_json} 业务口径 [金额字段单位统一为分展示时除以100] [默认只统计status1的有效订单] 用户问题 [这里拼接用户输入的问题] 请生成对应SQL元数据部分我项目里用Jackson把表结构、字段信息序列化成JSON塞到Prompt的{table_metadata_json}位置。这里有个细节要注意元数据JSON不能太长。我之前尝试过把整库30张表的字段全部塞进去Prompt直接超过模型上下文长度限制而且模型会“分心”生成SQL时反而容易用错表。后来我加了一层过滤根据用户问题里的关键词只选择相关的表和字段拼接进Prompt效果提升明显。3. 核心代码实现3.1 Maven依赖与环境准备项目基于Spring Boot 3.2.xJDK 17。依赖上用了Spring AI的千问Starter、MyBatis-Plus、MySQL驱动还有一个HuTool做字符串处理。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-qwen/artifactId version1.0.0-M6/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-alibaba-dashscope/artifactId version1.0.0-M6/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-core/artifactId version5.8.25/version /dependency这里要提醒一个问题Spring AI目前版本迭代非常快不同版本的API差异很大。我这篇博文用的ChatClient.Builder是在1.0.0-M6版本下实现的如果你下载的版本更新比如1.0.0正式版之后API可能略有变化。官方文档目前推荐通过getOrCreate方式获取Builder具体以你引入的版本为准。3.2 核心Service代码Super-SQL的核心Service代码并不复杂核心逻辑就是拼Prompt、调模型、拿SQL。Service public class SqlGenerationService { private final ChatClient chatClient; private final MetadataService metadataService; private final SqlValidator sqlValidator; private final SqlExecutorService sqlExecutorService; public SqlGenerationService(ChatClient.Builder chatClientBuilder, MetadataService metadataService, SqlValidator sqlValidator, SqlExecutorService sqlExecutorService) { this.chatClient chatClientBuilder.build(); this.metadataService metadataService; this.sqlValidator sqlValidator; this.sqlExecutorService sqlExecutorService; } public QueryResult ask(String question) { // 第一步加载数据库元数据 String metadataJson metadataService.loadRelevantMetadata(question); // 第二步组装Prompt并调用大模型 String sql generateSql(question, metadataJson); // 第三步安全校验 sqlValidator.validate(sql); // 第四步执行SQL并返回结果 return sqlExecutorService.execute(sql); } private String generateSql(String question, String metadataJson) { String prompt buildPrompt(question, metadataJson); String response chatClient.prompt() .user(prompt) .call() .content(); return extractSql(response); } private String buildPrompt(String question, String metadataJson) { return System: 你是一个专业的SQL生成助手。... 元数据%s 用户问题%s 请生成对应SQL.formatted(metadataJson, question); } private String extractSql(String modelResponse) { // 处理Markdown代码块 if (modelResponse.contains(sql)) { String sql modelResponse.substring( modelResponse.indexOf(sql) 6, modelResponse.lastIndexOf()); return sql.trim(); } // 处理无代码块直接返回SQL的情况 return modelResponse.trim(); } }这里ChatClient的注入方式需要注意。Spring AI的自动配置会注入一个ChatClient.Builder但默认情况下这个Builder已经绑定了配置文件中指定的模型。如果你在配置里同时配了千问和DeepSeekSpring会创建多个模型Bean这时候需要显式指定用哪个模型否则会报歧义错误。最简单的做法是只配一个模型或者用Qualifier指定。实际生产环境中我更推荐用ChatClient.Builder的clone()方法在构造Service时传入不同的模型配置这样可以灵活地按业务场景切换模型。比如简单查询走轻量模型省钱复杂多表JOIN走强模型保证准确率。3.3 SQL安全校验实现这个部分我觉得是每个做Text-to-SQL落地的人都必须认真对待的。模型生成的SQL直接执行一旦模型被诱导输出了危险语句后果不堪设想。Super-SQL的安全校验做了三层防护。第一层是语句类型白名单校验SQL是否以SELECT或WITH开头其他类型一律拒绝。第二层是关键字黑名单扫描SQL中是否包含DELETE、UPDATE、DROP、ALTER、TRUNCATE、EXEC、INTO OUTFILE等危险操作。第三层是长度和后缀控制限制单条SQL长度不超过2000字符同时禁止分号后拼接多条语句。Component public class SqlValidator { private static final ListString FORBIDDEN_KEYWORDS List.of( DELETE, UPDATE, DROP, ALTER, TRUNCATE, EXEC, EXECUTE, INSERT, INTO OUTFILE, CREATE ); private static final ListString ALLOWED_PREFIXES List.of(SELECT, WITH); public void validate(String sql) { if (sql null || sql.isBlank()) { throw new BusinessException(生成的SQL为空); } String normalized sql.trim().toUpperCase(); // 第一层只能以SELECT或WITH开头 boolean allowedPrefix ALLOWED_PREFIXES.stream() .anyMatch(prefix - normalized.startsWith(prefix)); if (!allowedPrefix) { throw new BusinessException(SQL语句类型不允许执行); } // 第二层危险关键字拦截 String upperSql sql.toUpperCase(); for (String keyword : FORBIDDEN_KEYWORDS) { if (upperSql.contains(keyword)) { throw new BusinessException(SQL包含禁止的操作: keyword); } } // 第三层分号结束符检查 if (upperSql.endsWith(;) upperSql.indexOf(;) ! upperSql.length() - 1) { throw new BusinessException(不允许执行多条SQL语句); } } }注意一个细节危险关键字检查不能光看contains因为字段名或者注释里可能包含“UPDATE”这种单词。我在生产环境就碰到过一张表名叫update_log模型生成SELECT * FROM update_log时直接被拦截了后来改成按单词边界匹配才解决。4. 踩坑实录与排查技巧4.1 模型输出格式不稳定最大的坑没有之一就是模型输出不稳定。上午测得好好的SQL下午同样的输入模型抽风加了一段这是您要的SQL请注意XXX的前缀或者把SQL包在sql和中间还多带了个python标签解析逻辑直接炸掉。我最初用简单的substring按sql截取后来发现模型偶尔会输出而非sql或者里面还有换行缩进问题。解决方案是做多重兼容先尝试sql标签解析再尝试代码块解析最后如果都没有代码块就把整段内容当作SQL处理前提是通过后面的安全校验。还有一个更稳定的做法就是调整Prompt强制要求输出JSON格式。让模型返回一个JSON对象SQL放在sql字段里然后用Jackson去解析。模型对JSON格式的遵从性比对纯文本要高很多。不过要注意模型生成的JSON偶尔会有多余的逗号或者注释需要开启Jackson的容错配置。4.2 超时与流式响应问题大模型接口的延迟和传统API完全不是一个量级。qwen-plus平均响应在2到5秒复杂问题可能要8秒以上。而Spring Boot默认的HTTP超时设置对这种情况很不友好很容易就超时了。我遇到过的最典型问题就是前端请求1秒就断了因为网关层默认超时设置太短。排查了半天发现服务端其实还在等大模型返回。解决方案是把整个链路都收紧接口通过异步任务执行先返回“查询中”的状态大模型返回后通过WebSocket或者前端轮询获取结果如果要同步请求网关超时和HTTP客户端超时必须调到10秒以上。另一个是流式响应。如果用户输入的问题很长、元数据很大普通call()方法会在内存里拼完整结果再返回这一等可能就是十几秒。Spring AI的ChatClient支持.stream()流式响应能让数据边生成边返回给前端体验好很多。但流式响应也会带来一个新问题就是会话上下文的token管理更复杂后面单独讲。4.3 幻觉SQL和数据安全问题模型生成SQL最常见的幻觉是凭空创造字段。比如元数据里只有order_money模型可能给你生成一个order_amount这在数据库执行时直接报“字段不存在”。轻则报错重则如果字段名和某些安全机制有关可能绕过校验。针对字段幻觉我后来在元数据里把每个字段的别名和可能说法都给了模型比如“订单金额 order_money别称有订单金额/实付金额/成交金额”。这样一来模型生成的SQL里字段名匹配概率大幅度提升。另外我还加了一个兜底校验解析SQL里的字段名跟元数据字段集合做比对发现不在集合里的字段就拦截并提示用户“问题中提到的字段可能不存在”。安全这块再强调一遍Text-to-SQL上线前一定要先确认目标数据库的账号权限。我给Super-SQL单独创建了一个数据库账号只授予SELECT权限从源头上杜绝了非查询操作的可能性。就算模型生成的SQL真有问题数据库权限也是最后一道物理防线。4.4 常见问题速查表问题现象可能原因解决方案模型返回内容包含大量markdownPrompt约束不够system提示词中明确禁止输出多余文字点击查看示例生成的SQL字段名不存在元数据不完整或模型幻觉完善字段别名描述增加字段名合法性校验同一个问题多次查询结果不一致temperature过高将temperature调低至0.1以下接口请求经常超时同步调用等待时间过长改造为异步任务或使用流式响应多模型配置时报Bean冲突未指定具体模型使用Qualifier或拆分为不同ServiceSQL被误拦截关键字检查过于宽松改为按单词边界匹配5. 深度优化与扩展思路5.1 上下文管理与多轮对话第一版Super-SQL只支持单轮问答用户问一次生成一次SQL。实际用下来发现业务方经常要追问比如先问“上个月各渠道的销售额”接着问“那华东区呢”如果第二句没有上下文模型根本不知道“那”指代什么。引入多轮对话后需要把历史对话记录一起放进Prompt。但这里有个矛盾历史记录越长token消耗越大响应越慢。我做了一个简化版的记忆管理器只保留最近三轮对话并自动截断过长的历史消息。Spring AI官方其实提供了ChatMemory接口和MessageWindowChatMemory实现可以直接缓存对话历史。用起来很简单按会话ID存取基本不需要自己造轮子。但我提醒一句Text-to-SQL场景下历史对话里的SQL语句也是要校验的不能因为历史消息就放松安全策略。5.2 与大模型之外的生态结合Super-SQL跑通之后我又在这个架构上延展出了几个小工具。一个是我把同样的Spring AI链路接到了一个Agent应用里通过Function Calling让Agent具备查数能力这样用户可以直接在对话里说“帮我看看华东区这个月KPI完成情况”Agent自己决定调用哪个数据查询函数。这就是现在比较流行的大模型Agent开发范式Spring AI从1.0.0-M6版本开始内置了Function Calling支持用起来比较顺。另一个是把这套能力做成了微服务模块。之前的单机版本只是内部工具后来要暴露给更多业务系统我单独把SQL生成和SQL执行拆成了两个服务中间通过消息队列解耦避免大模型的延迟拖垮其他接口。如果你也有类似的规划建议提前把“生成”和“执行”分开设计后面扩展会容易很多。5.3 对Super-SQL后续迭代的一些思考做了一段时间Text-to-SQL后我最大的感受是这玩意真正难的不是“从自然语言到SQL”这一跳而是怎么让一个面向真实系统的SQL安全、准确、高效地落地。语义解析、字段映射、结果返回每一步都有无数细节在等你踩。后续我计划做的优化有三个方向。第一个是自动学习高频问题把用户常问的问题和模型生成的正确SQL沉淀成一个缓存下次碰到相似问题直接走缓存省去大模型调用的时间和成本。第二个是引入RAG把更复杂的业务知识比如口径解释、计算逻辑向量化存起来与元数据一起参与Prompt构建解决一些碎片化知识的问题。第三个是把模型能力升级到更强的新版本最近DeepSeek和千问都有新模型发布SQL生成能力又有提升值得重新评测一遍。这几个方向各有各的坑等我把缓存和服务化的版本跑通了再回来写一篇对比分析。最后再分享一个个人经验做这类大模型应用开发千万不要一上来就追求完美。先用一个业务场景、一个数据源、一个模型把最小闭环跑通跑通之后再逐步丰富。每加一个功能点都要优先考虑“如果模型抽风了系统会不会崩”。大模型应用和传统应用的容错设计思路差别很大越早意识到这一点后面维护成本越低。