Spring AI Alibaba实现Text-to-SQL人工审核防线,生产级落地实践
“自然语言直接换SQL然后自动执行”听起来很爽但这在真实系统里就是个雷区。我最近用 Spring AI Alibaba 重做了一套内部数据分析入口把链路从“自然语言→SQL→执行”改成“自然语言→SQL→人工确认→执行”从功能演示跨到了生产可用。这一篇把架构思路、关键代码、人工审核防线的设计以及实际踩坑过程完整拆出来给同样在折腾 Text-to-SQL 落地的朋友做个参考。这个方案适合谁如果你也在做类似的数据查询助手、内部 BI 对话入口或者想让非技术人员用自然语言查库但又不放心让大模型直接碰生产库这篇内容可以直接照着改。全文围绕 Spring AI Alibaba 框架展开重点是“SQL 生成”和“人工防线”这两块的工程实现不涉及底层模型训练。1. 整体设计思路为什么必须加人工防线1.1 从“自动执行”到“人工确认”的转变最早原型阶段我把用户提问丢给大模型拿到 SQL 直接执行然后返回结果。演示效果很惊艳但一旦接真实业务库问题就接踵而来。大模型生成的 SQL 偶尔会出现语义偏差比如用户问“这个月活跃用户数”模型可能用了上个月的日期范围因为当前时间戳没传对。也可能生成了不带 WHERE 的全表扫描或者 group by 字段和 select 字段不一致。最要命的是模型的上下文窗口只塞得下部分表结构遇到同名表、旁路字段时它可能在多张表之间乱 join查出来的结果根本不敢信。这还只是“错误”层面的问题。真实生产环境还有权限边界某个业务小组只能看自己模块的数据但生成式 SQL 无法天然感知到你私有的行级权限策略。如果把生成结果直接执行一个措辞模糊的问题就可能把全量数据捞出来或者跨模块越权查询。所以我把“自动执行”改成“人工确认”让 SQL 先出现在审核列表里由熟悉数据口径的人点了通过才会落到数据库执行结果再回传给提问者。1.2 整条链路的模块拆解整个系统大概分成四块输入与预处理接收自然语言问题做字段脱敏、长度限制、关键词过滤顺便识别当前用户的身份和数据权限范围。SQL 生成引擎基于 Spring AI Alibaba 的 ChatClient 调用大模型把数据库 schema 上下文、业务口径、安全约束、用户问题拼进 Prompt让模型只输出 SQL不带多余解释。人工审核台把生成的 SQL 连同“用户原问题、命中的表、涉及的业务范围、生成耗时”一起展示审核人选择“通过”或“驳回”驳回需要填原因。执行与结果反馈只有审核通过的 SQL 才会进入执行器执行器强制只读事务、限制返回行数、记录执行日志再把结果集或错误信息装配成自然语言回复。这样做的好处是把“大模型的能力”和“业务的确定性”分开。模型负责把自然语言转换成候选 SQL这个环节允许出错人的职责是校验语义、校验权限、校验口径这个环节不允许出错。前端可以做成一个简单的待办列表也可以嵌到内部协作工具里。1.3 为什么选 Spring AI Alibaba 而不是自己拼 HTTP最早我用原生 HTTP 请求调用模型接口也能跑通但做到后面发现自己维护的东西太多对话上下文的记忆、Prompt 模板的变量填充、流式输出、以及不同模型厂商 API 的适配。Spring AI Alibaba 在 Spring Boot 体系里把这些抽象掉了尤其是 ChatClient 这种流式 API写业务代码时可以像操作一个本地 Service 一样自然。它还支持把模型调用封装成统一接口之后换模型供应商只需要改配置和少量参数业务代码基本不动。另一个很关键的点是它可以直接复用 Spring 的依赖注入和配置体系后续做审核流程、权限控制、事件通知都在同一个容器里不需要额外搞一套胶水层。对 Java 技术栈的团队来说工程集成成本很低。2. 核心依赖与基础配置跑通 Spring AI Alibaba2.1 Maven 依赖与配置项我用的核心依赖很简单主要就是 spring-ai-alibaba-starter再加上数据库连接和 Web 相关依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId version${spring-ai-alibaba.version}/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency配置文件的要点是模型相关参数我用的模型服务声明为 DashScope 兼容具体 application.yml 如下spring: ai: alibaba: dashscope: api-key: ${DASHSCOPE_API_KEY} model: chat: options: temperature: 0.2 max-tokens: 2048temperature 我特意调低到 0.2因为生成 SQL 是确定性任务不是创意写作温度太高会产生随机字段名和莫名其妙的条件。如果你用别的模型同样建议把 temperature 调到 0 附近。2.2 数据库连接和 schema 信息准备Text-to-SQL 质量很大程度上取决于喂给模型的表结构信息。我在项目里没有直接暴露整库 DDL因为 token 有限而且信息太多模型抓不住重点。更好的方式是维护一个轻量级的“元数据表”记录每张表的业务含义、核心字段、常用查询条件、敏感级别组装 Prompt 时按需加载。我建了一张类似metadata_table_schema的表核心字段包括表名、字段名、字段类型、字段注释、是否敏感、示例值、关联关系。然后在代码里写一个 SchemaLoader启动时加载到本地缓存这样省得每次请求都查一次库。注意数据库表结构变更后要刷新缓存我后来加了个定时任务每十分钟重建一次避免线上表和缓存不一致。3. 自然语言转 SQL 的核心实现3.1 设计 Prompt 模板把规则讲透文本转 SQL 最核心的是 Prompt 怎么写。我维护了一套可配置的模板放在 resources 目录下面分成几块内容。第一块是“角色设定”明确告诉模型你是数据库分析师只输出 SQL。第二块是“数据库上下文”列出涉及到的数据表及字段优先放和用户问题相关的表不要一次性全部塞进去。第三块是“业务口径规则”例如“订单状态只有 0 和 1其中 1 代表已支付”“金额单位是元不要换算成分”。第四块是“安全约束”明确禁止生成 INSERT、UPDATE、DELETE、DROP、TRUNCATE并用 LIMIT 限制返回行数。我实际使用的模板大致长这样你是一名资深的数据库 SQL 分析师。请根据用户的问题和提供的表结构生成一条安全、正确的 SQL 查询语句。 数据库表结构 {table_schema} 业务规则 {business_rules} 安全要求 1. 只允许 SELECT 查询禁止任何写入、修改、删除语句。 2. 除非用户明确要求统计全量否则自动添加 LIMIT 100。 3. 不要使用注释不要输出除 SQL 之外的任何解释内容。 用户问题 {user_question} 请直接输出 SQL 语句这里的{table_schema}不是把所有表的建表语句都拼上去而是先通过一连串意图识别和关键词匹配挑选出可能涉及的表再拼到上下文里。减少噪音模型生成准确率会明显更高。3.2 实现 SQL 生成 Service在 Spring AI Alibaba 中我直接用 ChatClient 来发起单轮对话不需要维护历史消息。每一条用户请求都是一次干净的、无状态生成避免之前的闲聊内容污染 SQL 输出。代码如下Service public class SqlGenerationService { private final ChatClient chatClient; private final SchemaLoader schemaLoader; public SqlGenerationService(ChatClient.Builder chatClientBuilder, SchemaLoader schemaLoader) { this.chatClient chatClientBuilder.build(); this.schemaLoader schemaLoader; } public SqlCandidate generate(String userQuestion, String userId) { // 1. 根据问题匹配相关表 ListTableSchema relatedTables schemaLoader.matchTables(userQuestion); String schemaText buildSchemaText(relatedTables); // 2. 获取业务口径规则 String businessRules buildBusinessRules(relatedTables); // 3. 构造完整 Prompt String prompt 你是一名资深的数据库 SQL 分析师。请根据用户的问题和提供的表结构生成一条安全、正确的 SQL 查询语句。 数据库表结构 %s 业务规则 %s 安全要求 1. 只允许 SELECT 查询禁止任何写入、修改、删除语句。 2. 除非用户明确要求统计全量否则自动添加 LIMIT 100。 3. 不要使用注释不要输出除 SQL 之外的任何解释内容。 用户问题 %s 请直接输出 SQL 语句 .formatted(schemaText, businessRules, userQuestion); String response chatClient.prompt(prompt).call().content(); // 4. 提取纯 SQL String sql extractSql(response); // 5. 构造候选对象进入待审核状态 return SqlCandidate.builder() .userQuestion(userQuestion) .sql(sql) .tableNames(relatedTables.stream().map(TableSchema::getTableName).toList()) .userId(userId) .status(AuditStatus.PENDING) .build(); } private String extractSql(String response) { // 去掉可能的 markdown 代码块标记 if (response.contains()) { return response.replaceAll(sql|, ).trim(); } return response.trim(); } }这里的SqlCandidate是一个领域对象从生成那一刻起就带着状态。我一开始把 SQL 生成、人工审核、执行逻辑全混在一个 Service 里后来拆成不同状态的流转才变得清晰。3.3 模型输出不一定可信先做一道机器校验在人工审核之前我先加了一层“机器预校验”作为粗筛目的是把明显不合规的 SQL 直接挡掉减少审核人看垃圾单子的时间。主要做三件事检查是否只包含 SELECT、是否包含 LIMIT除非是 count 查询、是否包含注释符和分号分隔的多语句。我写了一个简单的 SQL 校验工具public class SqlValidator { private static final Pattern DANGEROUS_PATTERN Pattern.compile((?i)(insert|update|delete|drop|alter|truncate|exec|call|;\\s*select)); public static boolean isSafe(String sql) { if (sql null || sql.isBlank()) { return false; } // 去掉字符串和注释内容后再检查危险关键词 String normalized removeCommentsAndQuotedStrings(sql); if (DANGEROUS_PATTERN.matcher(normalized).find()) { return false; } return true; } private static String removeCommentsAndQuotedStrings(String sql) { return sql.replaceAll(--.*?(\\n|$), ) .replaceAll(/\\*.*?\\*/, ) .replaceAll([^]*, ) .replaceAll(\[^\]*\, \\); } }注意这只是粗校验不能替代人工审核。正则天然无法覆盖所有 SQL 语法比如子查询、 Union 联合查询、窗口函数等复杂情况。它的意义在于把“百分之百不能放的”先拦掉让审核人员把精力放在语义正确性上。4. 人工防线审核状态机与执行控制4.1 审核单的数据模型设计审核单时我把用户问题、生成的 SQL、生成时间、状态、审核意见都放进去这是最基础的字段。但真正运行一段时间后发现还需要记录“SQL 涉及的敏感表”和“当前用户的权限域”这样审核人在看单子时能快速判断这个请求是否越权。因此我的审核单表结构大致如下CREATE TABLE sql_audit_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_question VARCHAR(1000) NOT NULL, generated_sql TEXT NOT NULL, sql_hash CHAR(64) NOT NULL, related_tables VARCHAR(500), creator_id VARCHAR(64) NOT NULL, creator_department VARCHAR(64), status TINYINT NOT NULL COMMENT 0待审核 1已通过 2已驳回 3执行失败, audit_user_id VARCHAR(64), audit_comment VARCHAR(500), created_at DATETIME NOT NULL, audited_at DATETIME, executed_at DATETIME );我把执行状态也放到同一张表里好处是后续统计生成率、通过率、执行成功率时只需要查一张表。坏处是行数会涨得很快但一般内部系统的量级完全扛得住定时归档即可。4.2 审核状态的流转与代码实现状态流转要清晰地控制避免出现“已通过又被驳回”这种矛盾状态。我用一个枚举描述状态public enum AuditStatus { PENDING(0, 待审核), APPROVED(1, 已通过), REJECTED(2, 已驳回), EXECUTION_FAILED(3, 执行失败); private final int code; private final String description; }审核 Service 是向外部暴露的核心方法包含 approve 和 reject 两个动作。approve 时状态必须为 PENDING并且同时把审核人信息写入然后调用执行逻辑。reject 时必须填写原因。整个更新操作放到一个事务里用乐观锁或者状态字段做条件更新防止两个审核人同时处理同一张单子。Service public class AuditService { private final AuditTaskMapper auditTaskMapper; private final SqlExecutionService executionService; Transactional public void approve(Long taskId, String auditorId) { AuditTask task auditTaskMapper.selectByIdForUpdate(taskId); if (task null) { throw new BizException(审核单不存在); } if (task.getStatus() ! AuditStatus.PENDING) { throw new BizException(当前状态不允许审批); } task.setStatus(AuditStatus.APPROVED); task.setAuditUserId(auditorId); task.setAuditedAt(LocalDateTime.now()); auditTaskMapper.updateById(task); // 审核通过后立即执行 SQL也可以改为异步执行 executionService.executeWithAudit(task); } Transactional public void reject(Long taskId, String auditorId, String comment) { AuditTask task auditTaskMapper.selectByIdForUpdate(taskId); if (task null) { throw new BizException(审核单不存在); } if (task.getStatus() ! AuditStatus.PENDING) { throw new BizException(当前状态不允许驳回); } task.setStatus(AuditStatus.REJECTED); task.setAuditUserId(auditorId); task.setAuditComment(comment); task.setAuditedAt(LocalDateTime.now()); auditTaskMapper.updateById(task); } }实际开发中还有个细节selectByIdForUpdate 在高并发下会锁行但如果单子已经被人抢先审批后续再打开确认会导致请求堆积。内部系统并发不大可以用“条件更新影响行数”替代比如update ... set status APPROVED where id ? and status PENDING影响行数为 0 就说明竞争失败。这里我用 select for update 只是因为在一个示例里好理解真实项目建议用乐观锁。4.3 执行控制强制只读与限量即使人工审核通过了执行层仍然要继续设防。我做了三件事数据库账号用只读账号连接串只允许 SELECT每条 SQL 执行前在应用层强制包装一层 LIMIT所有查询跑在事务里并设置为只读事务。应用层包装 LIMIT 的代码要小心不能简单地字符串拼接否则遇到包含分号或者注释的输入会破坏语义。我采用解析方式先利用数据库连接创建的PreparedStatement来绑定参数但 SQL 本身是动态的参数化无法改变 SQL 结构。所以我用的是简单而谨慎的方法如果 SELECT 语句中没有 LIMIT就在末尾补一个LIMIT 100如果是嵌套或者带括号的复杂语句宁可提示审核人手工补充也不随意修改。Service public class SqlExecutionService { private final JdbcTemplate jdbcTemplate; public ListMapString, Object executeWithAudit(AuditTask task) { String sql task.getGeneratedSql(); if (!SqlValidator.isSafe(sql)) { throw new BizException(SQL 未通过安全校验); } if (!sql.toLowerCase().contains(limit)) { sql sql LIMIT 100; } // 强制只读事务 return jdbcTemplate.execute( (ConnectionCallbackListMapString, Object) connection - { connection.setReadOnly(true); try (PreparedStatement ps connection.prepareStatement(sql); ResultSet rs ps.executeQuery()) { ResultSetMetaData metaData rs.getMetaData(); int colCount metaData.getColumnCount(); ListMapString, Object rows new ArrayList(); while (rs.next()) { MapString, Object row new LinkedHashMap(); for (int i 1; i colCount; i) { row.put(metaData.getColumnLabel(i), rs.getObject(i)); } rows.add(row); } return rows; } }); } }这里有个非常重要的点connection.setReadOnly(true)对 MySQL 来说只是提示驱动并不是硬性安全措施。真正能阻止写操作的是数据库账号本身的权限。我单独建了一个query_only账号只授予SELECT权限因为应用层的代码永远可能被绕过或写错。4.4 审核展示与留痕审核页面只需要提供一个简单的接口返回待审核列表和状态列表。我做了两个筛选维度一个是按状态筛选方便审核人只处理待审核另一个是按部门筛选让不同团队只能看到和自己相关的单子。每个审核项展示五部分用户的原始表述、大模型生成的 SQL、涉及的数据库表、操作人、操作时间。表格样式如下展示状态状态含义后续处理动作0 待审核已生成 SQL等待人工确认审核人可通过或驳回1 已通过人工确认无误已进入执行等待执行结果返回2 已驳回审核人认为语义错误或越权用户可查看驳回原因后重新提问3 执行失败审核通过但数据库执行时报错需要查看日志定位原因这种展示方式一开始我只是为了自己排查方便后来发现审计合规也需要留痕。改了哪些数据、谁生成的、谁审批的全都在表里出问题时能追溯。5. 实操过程从用户提问到结果返回的完整流程5.1 自然语言提问接口设计前端输入框很简单用户提交问题后端异步返回结果。因为生成 SQL 需要几秒到十几秒不能同步卡住 HTTP 请求。我采用了“先返回任务 ID再轮询结果”的模式。用户 POST 之后立即拿到一个taskId前端基于这个 ID 每秒轮询一次状态状态从 PENDING 变为 APPROVED 且执行完成后再展示数据结果。如果被驳回返回驳回原因。接口代码如下RestController RequestMapping(/api/query) public class QueryController { private final SqlGenerationService sqlGenerationService; private final AuditTaskService auditTaskService; PostMapping public ResultString submitQuery(RequestBody QueryRequest request, RequestHeader(X-User-Id) String userId) { SqlCandidate candidate sqlGenerationService.generate(request.getQuestion(), userId); Long taskId auditTaskService.savePending(candidate); return Result.success(taskId.toString()); } GetMapping(/status/{taskId}) public ResultTaskStatusResult getStatus(PathVariable Long taskId) { return Result.success(auditTaskService.buildStatus(taskId)); } }这个接口只是入口真正的链路要经过生成、校验、存库、审核、执行等多个环节。前端看到的状态字段可以直接从数据库查无需引入额外的消息队列。5.2 一个实际业务问题的完整链路演示我拿内部“订单表用户表”举例。用户提问“上个月每天的新增订单数是多少” 系统流程如下模型根据关键词“订单”“新增”匹配到订单表和用户表加载相关字段元数据。生成出的初始 SQL 可能是SELECT DATE(create_time) AS order_date, COUNT(*) AS order_cnt FROM orders WHERE create_time DATE_SUB(CURDATE(), INTERVAL 1 MONTH) GROUP BY DATE(create_time) ORDER BY order_date;乍一看没问题但审核人一看业务口径就发现不对。用户说“上个月”指的是从本月 1 号往前推一个自然月而这个 SQL 用的是“最近 30 天”两个范围在月中和月末会有偏差。审核人驳回并填写原因“上个月应为自然月请使用 DATE_FORMAT(create_time, %Y-%m-01) 定位上月范围。” 用户看到原因后重新提问模型再次生成这次可能就带着自然月条件了。没有人工防线这种隐性错误就会直接推给业务方还难以复现。类似问题还有“统计连续登录天数”“环比和同比的区别”等模型大概率在不给口径提示时会按默认直觉生成这恰恰是规则化口径需要沉淀到业务规则库里的原因。5.3 审核效率怎么提升一个常见的顾虑是加人工防线之后本来秒回的结果变成要等审核人处理效率太低。我的做法是把“审核”和“执行”拆成两种模式低风险问题例如单表简单查询、明确不涉及敏感字段可以配置成“人工抽审”模式比如随机抽 20% 推送审核剩下的直接执行。高风险问题一旦识别到问题中涉及金额、手机号、身份证号、多表 join 等关键词强制进入人工审核不允许跳过。风险等级的判定不能完全依赖大模型我在代码里维护了一套基于正则和规则引擎的敏感词表命中即升级为高优先级任务。审核界面里置顶展示高风险待审项并支持批量通过语义明显一致的简单查询减轻审核人负担。5.4 审计日志与指标监控上线后一定要配监控指标我埋了几个核心点SQL 生成耗时、审核通过率、驳回原因分布、执行失败率。驳回原因分布特别有用它直接反映了模型的弱点。比如连续几天驳回原因都集中在“时间范围理解错误”我就去更新业务口径规则在 Prompt 里加特定时间指代词的解释。这个循环跑起来之后审核通过率能逐步上升。Git 日志和数据库表都记录了每次 Prompt 变更和业务规则调整。我们有一个临时表存历史 Prompt 版本通过对比不同版本的通过率来决定要不要正式切换新的规则文本。6. 常见问题与排查实录6.1 模型生成 SQL 方言和数据库不匹配这个问题在第一批测试时特别明显。模型输出带的可能是标准 SQL但我们的库用了 MySQL 特有语法比如DATE_FORMAT、GROUP_CONCAT、反引号包裹表名。我一开始在 Prompt 里写“方言为 MySQL”但效果不够稳定后来直接改成了在生成后用本地 SQL parser 做语法级校验再用一个轻量格式化器把LIMIT和反引号处理掉。方言不匹配越早通过测试暴露越好别等到上线后再修。6.2 LLM 输出 markdown 代码块导致解析失败最初版本没有做extractSql的清理工作模型偶尔会返回SELECT * FROM users;这种带代码块包裹的文本直接拿去执行必然报错。后来统一用正则去掉开头结尾的sql标记并 trim 一次问题就解决了。注意不要只匹配sql小写还要兼容SQL和空代码块。6.3 人工审核列表积压审核列表一变多就容易出现用户提交的 SQL 很久没人处理。排查下来发现是推送机制不够。一开始只是列表页轮询审核人不会主动一直开着页面。后来我加了一条业务消息通知新审核单到达时推送一条通知给值班审核人处理时效明显好转。还有就是要给审核人提供“只看高优”和“只看自己的部门”过滤条件不然一眼望过去全是与自己无关的请求审核意愿会下降。6.4 长上下文导致表结构截断表结构字段较多时模型输入超长生成结果开始出现幻觉表名和错误列名。我没法无限扩大上下文因为成本也会增加。折中方案是做一个“精选 schema”策略每张表只保留前 20 个最常用字段并在元数据中标记字段的使用频次。当用户问题涉及较少使用的字段时可能无法命中此时会让审核人手动修改 SQL或者引导用户换一种更明确的说法。6.5 执行结果过大撑爆接口即使加了LIMIT 100如果单行记录里包含大文本字段输出也可能非常庞大。我在执行器返回前做了一个轻量裁剪超过 100 行时截断并对文本类型字段做了长度限制。这个细节不处理前端页面会卡死。6.6 敏感数据脱敏人工审核时审核员能看到原始 SQL 结果但普通用户只能看到脱敏后的结果。我在执行层增加了一个 ResultMasker根据字段元数据判断是否属于手机号、身份证号等敏感信息对应用户角色决定显示明文还是掩码。这个不能只依赖 SQL 结果集的列名因为列名可能被别名覆盖所以还要结合查询计划中的原始表字段来判定。目前我们内部的做法是简化处理凡是命中敏感表字段的查询非管理员用户结果全部做掩码。7. 最后再分享两点个人体会一个是 Prompt 不是一次性写好的它和业务口径一样需要迭代。我最初把希望全部寄托在框架和模型上结果线上通过率只有六成。后来每次驳回都会拉出原因逐条更新到业务规则提示里几个月下来通过率稳定在九成左右。人工防线并不妨碍模型进化反而给它提供了高质量的训练数据。另一个是不要把人工防线当作纯粹的二道闸门它更是系统反馈的入口。每一位审核人的每次驳回都等于给系统标记了一次“模型哪里没想明白”。把这个反馈闭环做起来Text-to-SQL 系统才能从“能跑”变成“敢跑”。如果你也在搭类似的系统我建议先跑通最小闭环再逐步加规则、加监控别一步到位追求全自动稳定永远是第一位的。