十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Spring AI的Text-to-SQL实践:从零搭建与踩坑实录

基于Spring AI的Text-to-SQL实践:从零搭建与踩坑实录 Spring AI 实现 Text-to-SQLSuper-SQL 从零到能用的完整踩坑实录抛开各种概念包装Text-to-SQL 要解决的问题其实很朴素让业务人员用大白话问一句“上个月华东区卖得最好的三款商品是什么要带销售额和环比”系统自动生成一条正确的 SQL查库把结果用同样的大白话还回去。这个事听起来简单真正做起来坑不少。大模型写 SQL 会一本正经地编字段名、把 DESC 写成 DASC、 JOIN 条件缺一半、日期函数用错方言、查出来的结果还不告诉你它其实猜了三个表名。我基于 Spring AI 把整条链路从零搭了一遍内部代号叫 Super-SQL前前后后踩了二十多个坑把能跑通的方案、代码和排查思路整理成这篇文章。适合谁看如果你正在做 AI 应用开发想在 Java 生态里快速接一个大模型能力或者想把自然语言查数这个需求落地这篇文章应该能帮你少走不少弯路。全文没有炫技的东西都是能直接抄的代码和配置。1. 为什么用 Spring AI 来做 Text-to-SQL1.1 Text-to-SQL 到底难在哪先说结论Text-to-SQL 绝对不是“把问题丢给大模型然后等 SQL”这么简单。我在项目里把它拆成五个环节每个环节都能让整个流程崩掉。第一是表结构的理解。模型不知道你的库里有几张表、每张表哪些字段、字段是什么含义。比如“用户数”这个指标在订单表里是order_count在用户表里是total_users模型选错了表出来的 SQL 跑起来很顺结果完全不对。第二是业务口径的统一。同一个“销售额”可能是含税、不含税、退款后净额、下单口径、支付口径。模型没有业务知识你不在提示词里约束它每一次给的答案口径都可能不一样。第三是SQL 方言的适配。MySQL 的DATE_FORMAT、PostgreSQL 的TO_CHAR、SQL Server 的CONVERT模型训练数据里都见过但它不知道你生产库用的是什么。默认情况下它经常生成“混合方言”的 SQL大多数情况是能跑但诡异的小错误也会让人抓狂。第四是结果的准确性验证。模型生成的 SQL 语法没错不代表逻辑正确逻辑正确不代表符合业务提问的意图全链路没有验证环节基本等于裸奔。第五是安全性。允许用户用自然语言查库实际上等于把一个 SQL 执行器交到了用户手里。没有权限控制、没有只读限制、没有结果集大小限制一次恶意提问就能让你的数据库 CPU 飙满。这五个问题不是单纯调 Prompt 就能全部解决的需要一个整体方案。这也是我做 Super-SQL 的初衷把这些问题都固化成一套可复制、可维护的流程。1.2 选 Spring AI 而不是自己封装 HTTP 调用做这个功能之前团队里有过一次讨论。有同事说直接用 HTTP Client 调大模型 API 不就完了为什么还要引一个 Spring AI 框架我的理由有三点。第一Spring AI 把“模型对话”抽象成了非常简洁的接口。在多模型切换、模型版本升级、流式输出这些场景下框架帮你屏蔽了底层差异。你不需要关心 OpenAI 的请求体长什么样、DeepSeek 的消息格式有没有区别只需要面向ChatClient编程。第二Spring AI 对结构化输出的支持比较成熟。Text-to-SQL 的完整链路里需要让大模型返回 JSON、返回 SQL、返回自然语言解释这几个场景框架都有对应的解析能力比自己手写 JSON 解析可靠得多。第三跟 Spring Boot 生态无缝集成。配置项、自动装配、可观测性、AOT 编译支持都是现成的对 Java 后端团队来说学习成本低、维护成本低。另外提一句目前国内用得比较多的模型如 DeepSeek、通义千问、智谱等都兼容 OpenAI 协议Spring AI 通过 OpenAI 风格的客户端就能对接。如果用的是阿里云百炼平台也可以直接引入spring-ai-alibaba-starter整个接入会更顺滑。这几种方式在代码层面差异很小这也是我推荐框架的另一个原因换模型不用改业务代码。1.3 Super-SQL 到底解决什么问题Super-SQL 是我给这套方案起的内部代号核心思路可以用一句话概括用 Schema 约束 业务词汇表 Few-Shot 示例 结果校验把大模型写 SQL 的不确定性压缩到可控范围。具体来说它做了四件事Schema 上下文裁剪不把整个数据库几百张表全丢给模型而是根据用户问题先做一次表筛选只把相关的表结构、字段注释、索引信息放进去。业务口径映射在提示词里内置一份“业务词汇表”告诉模型“销售额支付成功的订单金额不含退款”避免每一次回答凭感觉。Few-Shot 示例注入每个业务域准备 3-5 条“问题-正确SQL”的示例让模型照着示例的写法来生成。执行反馈闭环生成的 SQL 先解析语法再执行执行报错就把错误信息回传给模型让它自纠最多重试两次。这四个能力合在一起才是一个真正能在生产环境里用的 Text-to-SQL。后面的代码和实操都是围绕这四点展开的。2. 核心技术拆解Prompt 模板、Schema 裁剪与结构化输出2.1 三层 Prompt 结构系统层、业务层、动态层Text-to-SQL 的 Prompt 设计我最终采用的是三层结构。这个结构踩了很多次坑才稳定下来。系统层是固定不变的定义大模型扮演的角色和基本规则。业务层是半固定的包含业务词汇表、SQL 方言要求、安全约束规则。动态层是每次请求实时拼装的包含裁剪后的表结构、Few-Shot 示例和用户问题。为什么要分三层因为太长的固定 Prompt 会拖慢每次请求的响应速度、白白消耗 Token。系统层和业务层可以提前拼接并做缓存只有动态层是每次实时生成的。实测下来把固定部分缓存之后接口响应时间能降低 15% 左右这个优化对高频调用场景还是很明显的。我用的系统层 Prompt 模板大致长这样你是一名资深 SQL 工程师负责将用户的自然语言问题转换为合法的 SQL 查询。 你的回答必须严格遵循以下规则 1. 只能使用提供的表和字段严禁臆造不存在的表名、字段名 2. 只能输出 SELECT 查询严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER 等任何写操作 3. SQL 方言必须严格使用 {dialect} 4. 如果用户问题涉及到业务口径必须按照业务词汇表理解 5. 输出格式必须是 JSON格式为 {sql: SELECT ..., explanation: 用一句话解释查询逻辑}业务层里最关键的是一份业务词汇表。这个词汇表需要业务方配合整理把高频指标的准确口径写清楚。比如业务词汇表 - 销售额GMV指支付成功的订单总金额不含退款订单、不含未支付订单 - 用户数指注册用户表中状态为正常status1的用户去重数量 - 复购率指统计周期内购买次数2的用户数 / 购买次数1的用户数 - 客单价销售额 / 支付成功的订单数有了这份词汇表模型对“销售额”这类口径模糊的词才能有一个明确的 SQL 映射方向。这是整个 Prompt 工程里性价比最高的一项投入。2.2 Schema 裁剪不让模型看整库只看它该看的表最开始我想得很简单把数据库所有表的SHOW CREATE TABLE结果全塞给模型让它自己挑。结果第一个问题就是 Token 超限。我这边一个中型业务库几十张核心表表结构加起来接近 3 万个 Token根本塞不下。后来改成“关键词匹配 列名相似度”的裁剪方案把每张表的表名、字段名、注释拼成一段文本与用户问题做关键词匹配和余弦相似度打分取 Top N 张表作为候选表。这个方案效果不错同时大幅压缩了 Token 消耗。裁剪后的 Schema 信息我会格式化成下面的结构传给模型数据库表结构 表名orders订单表 字段 - id BIGINT 主键 - user_id BIGINT 用户ID - product_id BIGINT 商品ID - amount DECIMAL(10,2) 订单金额【单位元】 - status INT 订单状态 0-未支付 1-已支付 2-已退款 - created_at DATETIME 下单时间 索引idx_user_id, idx_created_at 相关表order_items订单明细表注意几个细节字段注释一定要带上单位一定要标注枚举值一定要写清楚含义索引信息可以告诉模型哪些条件能命中索引。这些信息看起来不起眼但直接影响生成 SQL 的质量。模型知道status的取值范围后写出来的WHERE条件准确率高很多。2.3 结构化输出从裸 SQL 到 JSON 的解析之路Spring AI 里做结构化输出我最常用的是StructuredOutputConverter。Text-to-SQL 场景下我们期望模型返回{sql: ..., explanation: ...}这样的 JSON然后把它映射到一个 Java 记录类上。这里有个特别容易踩的坑直接让模型输出 JSON它经常在 JSON 前后加代码块标记比如json开头。我见过好几个项目卡在这一步解析不了就怀疑是不是模型太差。其实解决办法很简单Spring AI 的StructuredOutputConverter里内置了cleanJson逻辑能把代码块标记、多余空格清掉。如果自己做解析记得用正则先把反引号和json标记去掉。此外还要设置合理的超时时间。Text-to-SQL 的 Prompt 较长、模型生成内容也较长默认超时经常不够用。DeepSeek 或通义千问这类国内模型在服务高峰期可能要 20-30 秒才能返回完整结果。我最后把超时时间定在 60 秒重试参数调成 0宁可超时不重试也不要让用户在接口层无限等待。3. 从零搭建 Spring AI 工程完整代码可直接抄3.1 依赖引入与配置我用的是 Spring Boot 3.2 Spring AI 1.0.0 GA 版本。如果你用 Maven核心依赖是这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency这里说明一下Spring AI 的 OpenAI starter 不仅能连 OpenAI也兼容所有 OpenAI 协议的大模型服务DeepSeek、通义千问、智谱、Moonshot 都能这么接。如果你用的是阿里云百炼可以直接把依赖换成dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId /dependency配置文件application.yml长这样spring: ai: openai: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.1 max-tokens: 2048 datasource: url: jdbc:mysql://localhost:3306/business_db?useSSLfalseserverTimezoneAsia/Shanghai username: readonly_user password: ${DB_PASSWORD}有两点要注意。第一temperature必须设置得很低。写 SQL 这件事本质上是逻辑推理任务不是创意生成任务温度越高幻觉越严重。我试过 0.7 的温度同一个问题有时候能给出三种不同结构的 SQL。调成 0.1 之后稳定很多。第二数据库账号务必使用只读账号这在后面安全性部分还会再强调。3.2 核心 ServiceSQL 生成 执行 自纠Text-to-SQL 的核心逻辑集中在两个类里SqlGenerationService负责调用大模型生成 SQLSqlExecutionService负责验证和执行 SQL。Service public class SqlGenerationService { private final ChatClient chatClient; private final SchemaService schemaService; private final SqlPromptTemplate promptTemplate; public SqlGenerationService(ChatClient.Builder builder, SchemaService schemaService, SqlPromptTemplate promptTemplate) { this.chatClient builder.build(); this.schemaService schemaService; this.promptTemplate promptTemplate; } public SqlResult generateSql(String userQuestion) { // 1. 根据用户问题筛选候选表 ListTableSchema candidates schemaService.findCandidateTables(userQuestion); // 2. 拼接动态 Prompt String prompt promptTemplate.buildPrompt(userQuestion, candidates, schemaService.loadBusinessGlossary()); // 3. 调用模型要求结构化输出 String output chatClient.prompt() .system(systemPrompt()) .user(prompt) .call() .content(); // 4. 解析 JSON return parseResponse(output); } }有一个很容易忽略的细节Spring AI 的ChatClient支持system()和user()分开传参这样做比把系统提示词拼在 user 消息里要好。为什么因为很多模型对 system 消息的角色约束更强。实测同一个 Prompt拆成 system user 比全部放在 user 里SQL 生成的稳定性要高一些也更不容易被用户的提问带偏。SqlPromptTemplate的核心代码负责把裁剪后的表结构、业务词汇表和 Few-Shot 示例拼接成最终的 promptComponent public class SqlPromptTemplate { public String buildPrompt(String question, ListTableSchema tables, String businessGlossary) { StringBuilder sb new StringBuilder(); sb.append(业务词汇表必须严格遵守\n).append(businessGlossary).append(\n\n); sb.append(可用的数据库表结构\n); for (TableSchema table : tables) { sb.append(table.toPromptString()).append(\n); } sb.append(\nFew-Shot 示例\n).append(buildFewShotExamples()); sb.append(\n用户问题).append(question).append(\n); sb.append(请严格按照要求输出 JSON 格式结果。); return sb.toString(); } private String buildFewShotExamples() { return 示例1 问题上个月销售额最高的5个城市是哪些 SQLSELECT city, SUM(amount) AS total_amount FROM orders WHERE status 1 AND created_at DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), %Y-%m-01) AND created_at DATE_FORMAT(CURDATE(), %Y-%m-01) GROUP BY city ORDER BY total_amount DESC LIMIT 5; ; } }这里要重点说一下 Few-Shot 示例。我在项目里试过只放一个示例、放五个示例、放十个示例效果差异非常明显。一个示例基本没什么约束力五个示例能让模型稳定地遵循“只看状态为1的订单”这类隐含条件十个示例之后提升就不明显了反而增加 Token 消耗。所以我的建议是每个业务域准备 3-5 条高质量示例覆盖最常见的查询模式比如时间范围过滤、分组聚合、排序取 Top N、多表关联。3.3 Schema 信息的获取与缓存Schema 信息不能每次都实时查数据库元数据那样性能太差。我的做法是启动时加载一次、缓存到内存里同时提供一个定时任务每 6 小时刷新一次。先定义一个TableSchema记录类public record TableSchema( String tableName, String comment, ListColumnSchema columns ) { public String toPromptString() { StringBuilder sb new StringBuilder(); sb.append(表名).append(tableName).append().append(comment).append(\n); sb.append(字段\n); for (ColumnSchema col : columns) { sb.append(- ).append(col.name()).append( ) .append(col.type()).append( ) .append(col.comment()).append(\n); } return sb.toString(); } } public record ColumnSchema(String name, String type, String comment) {}然后是读取数据库元数据的 Mapper用 JDBC 的DatabaseMetaData实现Repository public class SchemaMapper { private final JdbcTemplate jdbcTemplate; public ListTableSchema loadAllTables() { return jdbcTemplate.execute(conn - { DatabaseMetaData metaData conn.getMetaData(); ListTableSchema tables new ArrayList(); try (ResultSet rs metaData.getTables(conn.getCatalog(), null, %, new String[]{TABLE})) { while (rs.next()) { String tableName rs.getString(TABLE_NAME); String tableComment rs.getString(REMARKS); ListColumnSchema columns loadColumns(metaData, conn.getCatalog(), tableName); tables.add(new TableSchema(tableName, tableComment, columns)); } } return tables; }); } private ListColumnSchema loadColumns(DatabaseMetaData metaData, String catalog, String tableName) throws SQLException { ListColumnSchema columns new ArrayList(); try (ResultSet rs metaData.getColumns(catalog, null, tableName, %)) { while (rs.next()) { String colName rs.getString(COLUMN_NAME); String colType rs.getString(TYPE_NAME); String colComment rs.getString(REMARKS); columns.add(new ColumnSchema(colName, colType, colComment)); } } return columns; } }这段代码本身不复杂但有一个非常关键的坑MySQL 的表注释和字段注释能不能读出来取决于 JDBC URL 里有没有加useInformationSchematrue。不加这个参数REMARKS返回的全是 null导致模型完全不知道表是干什么的、字段是干什么的生成 SQL 的准确率会直线下降。这个参数加上之后注释读取才正常。3.4 执行与校验不要让用户白等拿到模型生成的 SQL 之后直接丢给数据库执行之前必须做三层校验。第一层是语法校验。可以用 Druid 的 SQL Parser 或者 JSqlParser在内存里解析一下语法不对直接进入重试流程不用真的去数据库跑一遍浪费资源。第二层是只读校验。用正则检查 SQL 里有没有出现INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、GRANT等关键字。虽然系统提示词里已经要求了只输出 SELECT但你不能指望模型 100% 遵守规则。这里多说一句不能用简单的正则去匹配关键字开头因为可能出现INSERT INTO被换行折叠、注释夹带等绕过方式。我用的是先去注释、再取第一个非空 Token 的方式判断 SQL 类型。第三层是结果集保护。不管模型生成的 SQL 里有没有 LIMIT统一在外面套一层限制public ListMapString, Object executeQuery(String sql, int limit) { // 去掉原有分号 String trimmedSql sql.trim().replaceAll(;$, ); // 强制限制行数 String limitedSql SELECT * FROM ( trimmedSql ) AS __t LIMIT limit; return jdbcTemplate.queryForList(limitedSql); }这样即使模型生成了不带 LIMIT 的全表查询实际执行也只返回前 100 行不会把内存撑爆。执行出错的时候处理逻辑是这样的把错误信息回传给模型让模型根据错误修改 SQL再执行一次。最多两次再多就放弃直接返回“暂时无法回答请重新描述问题”。这一步非常关键因为很多时候模型只是写错了字段名导致 SQL 报错给它一次自纠机会就能得到正确结果。3.5 Controller 层的完整示例最后是 Controller提供给前端一个简单的查询接口RestController RequestMapping(/api/text2sql) public class Text2SqlController { private final SqlGenerationService generationService; private final SqlExecutionService executionService; public Text2SqlController(SqlGenerationService generationService, SqlExecutionService executionService) { this.generationService generationService; this.executionService executionService; } PostMapping(/query) public Result query(RequestBody QueryRequest request) { String question request.question(); // 1. 生成 SQL SqlResult sqlResult generationService.generateSql(question); // 2. 执行 SQL只读 行数限制 ListMapString, Object data executionService.safeExecute(sqlResult.sql()); // 3. 返回结果 return Result.success(data); } }到这里一个最小可用的 Text-to-SQL 服务已经跑通了。但如果你直接把这个版本放到生产环境大概率还会在细节上被坑。下面我整理一下这段时间遇到的高频问题以及对应的解决方案。4. 踩坑实录这些问题我帮你提前踩过了4.1 坑一Schema 信息一次性全塞给模型Token 直接爆了这个问题在第 2.2 节已经提过这里再详细展开一下。最初我直接读全库的表结构发现光把SHOW FULL COLUMNS FROM的结果拼起来就上万行模型输入 Token 直接超出上下文窗口。后来我用两步优化解决了这个事。第一步是列级裁剪。很多表有几十个字段但真正在业务查询里用得到的就那几个。我把每个字段的注释和名称扫描一遍只保留与用户问题命中频率高的字段。比如用户问“最近的订单”created_at会命中但internal_remark这种内部备注字段就不会进候选。第二步是表级筛选。先用关键词匹配把可能相关的表捞出来再用向量相似度排序。这里有人可能想用 Embedding 模型做语义搜索但实测下来表结构相似度用词频就够用了因为表名和字段名往往是业务词汇的直译。引入 Embedding 反而增加了延迟和额外费用。处理完这两步之后每次请求传给模型的 Schema 信息从 3 万个 Token 降到了 3000-4000 Token首字延迟显著下降。4.2 坑二模型编造不存在的表名和字段名这是 Text-to-SQL 最“致命”的幻觉。模型看到用户问“查一下最近的上架商品”如果你没有把products表的结构给它它可能自己编一个product_onshelf字段出来。我的排查思路是把生成 SQL 里的表和字段跟 Schema 缓存里的真实元数据做一次全量比对。如果出现不在 Schema 里的对象名直接判定为幻觉 SQL不执行、不重试重新让模型生成一次。这样至少保证拿到数据库的 SQL 都是基于真实结构的。比对逻辑很简单就是一个集合包含判断。但要注意大小写问题MySQL 在 Linux 下表名是大小写敏感的模型生成的表名如果大小写跟真实表不一致执行时会直接报错。我的方案是在比对时统一转成小写执行时用真实的表名替换回去。4.3 坑三模型输出 JSON 不标准解析失败率特别高我在 2.3 节提到过代码块标记问题这里再补充一个更隐蔽的问题模型输出被截断。SQL 较长的时候特别是 JOIN 三张表加复杂的子查询模型可能只生成到一半就因为达到max_tokens上限被截断了。这种情况下输出的 JSON 是不完整的解析必然失败。但有个很迷惑的现象解析失败之后如果直接重试模型大概率能正确生成。为什么因为重试时它会重新审视整个上下文而且前面的错误解析结果会提示“上次输出格式不正确”模型会集中注意力在格式上。不过不能把重试当万能药。我在重试逻辑里加了一个判断如果解析失败的原因是 JSON 不完整直接把max_tokens提到 4096 再重试一次不要用原来的参数。这样避免“生成不完整 - 重试 - 又不完整”的死循环。4.4 坑四时间函数和日期格式的方言问题这个坑非常隐蔽刚开始坑了我很久。同一个问题“上个月的数据”模型有时候生成DATE_SUB(CURDATE(), INTERVAL 1 MONTH)MySQL 方言有时候生成CURRENT_DATE - INTERVAL 1 monthPostgreSQL 方言。虽然我数据库是 PostgreSQL但模型明显更习惯写 MySQL 的日期函数。解决思路是两层。第一层在系统 Prompt 里明确指定方言并且把数据库版本的标志性差异函数列出来。第二层在执行前做一个方言预检用正则匹配目标方言中不可能出现的关键字。比如 PostgreSQL 里不应该出现反引号不应该出现CURDATE()把这些模式匹配出来命中就重试。预检代码大致是这样private static final MapDialect, ListPattern FORBIDDEN_PATTERNS Map.of( Dialect.POSTGRESQL, List.of( Pattern.compile(\\), Pattern.compile(CURDATE\\s*\\(), Pattern.compile(DATE_FORMAT\\s*\\(), Pattern.compile(IFNULL\\s*\\() ) );这个方案的准确率很高而且能节省一次数据库交互。你想想看如果一条 SQL 用错方言真正执行的时候 PostgreSQL 会立刻报错然后重试交给模型自纠。虽然也能跑通但多一次大模型调用就是多几秒延迟对于一些追求响应速度的场景来说很要命。4.5 坑五并发请求下 Spring AI 的 ChatClient 线程安全问题Spring AI 的ChatClient设计上是线程安全的可以单例使用。但我实际测试发现在高并发场景下如果使用流式调用需要特别注意每个请求的上下文隔离问题。问题出在 Prompt 拼接上。如果你不小心把用户问题放到了类级别的可变字段里并发时就会串话。A 用户的问题还没处理完B 用户的请求把问题覆盖了最后 A 拿到的是 B 的 SQL。做并发测试的时候我还发现一个细节同样的 SQL 生成请求并发数上到 20 之后DeepSeek 的响应时间从 5 秒涨到了 15 秒。这不是框架的问题是模型服务的限流和排队导致的。解决方案是加一层简单的信号量限流控制同时进行中的大模型请求数。private final Semaphore semaphore new Semaphore(5); public String callWithLimit(ChatClient client, String prompt) { try { semaphore.acquire(); return client.prompt().user(prompt).call().content(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(请求被中断, e); } finally { semaphore.release(); } }限流之后虽然最大并发数降低了但每个请求的响应时间稳定了很多整体吞吐反而更高。5. 安全与权限Text-to-SQL 上生产前必须做的事5.1 数据库账号必须是只读账号这是整个方案里最重要的安全底线。不管模型能力多强、提示词写得多严格都不能保证它 100% 不生成危险 SQL。所以从账号层面就要彻底封死写操作。我的做法是单独创建一个只读账号只授SELECT权限CREATE USER ai_reader% IDENTIFIED BY your_password; GRANT SELECT ON business_db.* TO ai_reader%; FLUSH PRIVILEGES;应用配置里连的就是这个ai_reader账号。这样即使模型真的生成了DROP TABLE数据库层面也会拒绝执行不会产生实际危害。5.2 拦截危险 SQL不能把命交给模型自觉性只读账号堵住了写操作但还有一些查询会让数据库扛不住比如不带 WHERE 条件的大表全扫、多张大表笛卡尔积、超深的子查询嵌套。我在执行层做了这样几个拦截强制外层包一层SELECT * FROM (...) LIMIT 100控制结果集大小。检查 SQL 里是否出现了多个逗号分隔的表名可能存在隐式笛卡尔积如果出现并且没有 JOIN 条件拒绝执行。对LIMIT子句做解析如果模型生成的 LIMIT 数值大于 5000自动替换成 100。statementTimeout设置为 10 秒超时强制中断。5.3 敏感字段脱敏查得出来但不能让人看清Text-to-SQL 跟业务数据直接打通之后一个很现实的问题就是用户能问出“手机号是 138 开头的用户有几个”SQL 生成了结果也查出来了。这个功能如果对内部员工开放问题不大但如果未来面向外部用户手机号、身份证这类字段必须脱敏。我的方案是在 Schema 信息里给每个字段增加一个sensitive标记。生成 SQL 时如果查询涉及的字段里有敏感字段系统提示词里追加一条规则查询结果中敏感字段必须用CONCAT(LEFT(phone,3), ****, RIGHT(phone,4))这类函数脱敏后返回。这个功能目前我在项目里还只是标记了字段没有严格执行但建议计划落地 Text-to-SQL 的团队一开始就把这个机制设计进去。等数据量大了再回头补安全成本高得多。6. 目前的运行效果与优化方向6.1 真实业务测试的准确率我在一个真实的中型业务库约 30 张表上跑了 100 条运营提的测试问题统计结果大概是这样的指标数值一次生成 SQL 语法正确率81%一次生成 SQL 执行成功率76%自纠一次后执行成功率88%最终结果业务准确率79%平均响应时间6.8 秒86 行 SQL 生成的准确率看着还行但业务准确率只有 79%说明有五分之一的问题是“SQL 能跑、数据不对”。这些错误大多集中在口径理解上比如用户问“新客”模型理解成“第一次下单的用户”但业务上的定义可能是“首次注册当天有下单的用户”。提升这个指标的办法主要还是靠在业务词汇表里继续补充口径定义以及增加 Few-Shot 示例的覆盖场景。这是一个持续运营的工作不是上线就完事的。6.2 后续打算引入执行反馈的自动评测目前我在做一个离线的评测集把历史问题和正确 SQL 攒起来每次调整 Prompt 模板或者切换模型都跑一遍评测集对比准确率变化。只有建立这个基准后续优化 Prompt 才不是“感觉变好了”而是“数字确实变好了”。另外还在尝试用流式输出把首字延迟降下来。Text-to-SQL 场景虽然最终要等完整 SQL 才能执行但如果是语义解释部分可以先流式返回给用户让用户感觉系统响应更快。6.3 给计划做 Text-to-SQL 的朋友几点经验第一别一上来就追求全自动。先做“用户提问 - 生成 SQL - 人工确认 - 执行”的半自动模式跑一段时间积累足够多的样本再逐步放开。第二Prompt 模板一定要版本管理。我踩过很惨的坑改了一句提示词之后 SQL 准确率从 80% 掉到 60%但已经想不起来上一版是什么了。现在所有 Prompt 模板都放在 Git 里管理。第三把模型厂商的限流和计费搞清楚再上线。DeepSeek 的百万 Token 价格虽然便宜但一次 Text-to-SQL 调用含 Schema 注入大概要消耗 3000-5000 Token100 个请求就是 50 万 Token。如果用量大注意设计缓存和优先用便宜模型做初筛。关于模型选型我的实际感受是DeepSeek 在中文理解和 SQL 生成方面的综合表现不错成本也低通义千问在中文场景下也不错而且通过spring-ai-alibaba-starter接入最方便如果预算充足GPT 系模型在复杂 SQL 的生成上确实有优势。选择哪家取决于你的业务场景、预算和合规要求Spring AI 的好处就是换模型只需要改配置和 API Key业务代码基本不用动。最后再分享一个小技巧在 Spring AI 的ChatClient上开启日志把每次发送的 Prompt 和接收到的输出都记录下来。一开始这可能显得很繁琐但等到线上出了诡异问题你会发现这些日志是唯一能还原现场的线索。配合独立的 TraceId整个链路哪里出问题一目了然。
返回列表