YAOTU INSIGHTS

Spring AI Alibaba实战:自然语言转SQL如何加一道人工防线

Spring AI Alibaba实战:自然语言转SQL如何加一道人工防线
前阵子做内部报表平台接到一个很实在的需求运营同学想查“最近七天华东区退货率”但他不会SQL数仓那边需求排到下个月他就跑来问我们能不能做个“说话查数”的功能。一开始我们直接接了大模型的接口做自然语言转SQL效果确实惊艳——它能读懂中文问题能匹配字段能生成像模像样的查询语句。但真放到生产环境问题就来了模型偶尔会幻觉会把“4月”理解成“4月到现在的所有月份”会把“销量前10”写成“全部销量”甚至会把用户问题里的反话当真。那之后我们得出了一个结论自然语言转SQL这个能力单独给用户直接用是不负责任的。必须再加一道人工防线。于是就有了这个基于 Spring AI Alibaba 的实战落地AI 负责把提问变成 SQL 草稿校验器负责挡住危险语句人工审核负责最终放行。这套东西跑了大半年线上出数从“排队一周”变成了“分钟级出草稿 人工确认后立即返回”。这篇就把它掰开揉碎讲讲选型、状态机、核心代码和那些文档里不会写的坑。1. 运营要数、数仓排队、AI乱答这个问题为什么必须加一道人审先说清楚这件事的本质。企业内部查数看起来是“问答”实际是“决策链路上的一环”。运营要退货率可能是为了判断要不要调整活动策略财务要毛利数据可能是为了做下周的资金安排。如果 AI 生成的 SQL 少了一个where条件或者时间范围算错了一天返回的数据看着整齐漂亮实际上整条决策都在错误的地基上跑。让大模型直接生成 SQL 并且自动执行这个链路的问题非常具体模型不理解业务口径。同一个“成交额”电商团队可能指实付金额财务团队可能指确认收入市场团队可能指GMV。模型没见过你们内部的元数据字典它只会按字面意思猜。模型会一本正经地胡说。在测试集上正确率可能超过90%但剩下的10%恰恰是最要命的——多表 join 条件写错、聚合维度丢掉、时间字段写错别名、把字符串当数字比较。生成结果不可审计。出了问题你根本说不清是哪一步错了。用户问的问题、模型生成的 SQL、最终的执行结果如果没人把关事后复盘只能靠猜。所以我们的核心设计原则其实只有一句话AI 生成的内容永远只是“草稿”不是“结论”。系统可以在几秒内给出 SQL可以在页面上高亮展示但只有审核人点了“通过”它才会真正去数据源执行。这样AI 的效率和人的判断力各占一头谁也不替代谁。最初团队里有人觉得多此一举“既然模型不行那就换更强的模型啊。”试过几个不同规格的模型之后你会发现强模型也会在细粒度条件上翻车只是概率低一些。哪怕把错误率压到1%对于一天几千次查询的内部平台来说绝对数量依然不少。加人工防线不是对技术没信心而是对“未知错误”有敬畏。这个认知是整个设计的前提。2. Spring AI Alibaba 在这里解决什么不解决什么确定要加人工防线之后第二个问题就是技术选型。市面上做自然语言转 SQL 的方案不少有直接调模型 API 的有基于 LangChain 之类编排框架的也有自研 Prompt 管理系统的。我们最后选了 Spring AI Alibaba原因是它正好卡在“Spring 生态”和“国内模型服务可访问性”的交界处省掉了大量封装工作。2.1 选 Spring AI Alibaba 的三个核心理由第一个理由是接入成本足够低。我们的数据平台是典型的 Spring Boot 技术栈微服务、Nacos 配置、Sentinel 限流都是现成的。引入 Spring AI Alibaba 后只需要配置 API Key 和模型名称就能拿到一个统一的ChatClient对象。它实现了 Spring AI 的标准接口以后就算换模型服务商业务代码不用大动这点很关键。第二个理由是它对通义千问系模型做了一整套适配。Spring AI Alibaba 对应的模型服务走的是国内的百炼平台网络延迟和稳定性比直接连国外模型友好很多。企业内部数据平台最怕的就是“模型服务时而通时而不通”国内服务在合规和响应速度上都更可控。第三个理由是它保留了足够的灵活性。很多人以为框架会把 Prompt 和调用细节“包死”实际不是。你可以用PromptTemplate自定义提示词可以控制 temperature 参数可以设置超时和重试策略甚至可以在ChatClient外面再包一层自己的服务。我们其实就是把它当成一个“大模型 HTTP 客户端的高级封装”来用核心的 NL2SQL 逻辑全部自己控制。2.2 用 Spring AI Alibaba 之前要明确的边界选型容易踩的一个误区是以为引入框架就等于引入了“正确的 NL2SQL 能力”。框架只解决“怎么调用模型”这个问题不解决“怎么让模型生成对的 SQL”这个问题。真正决定 SQL 质量的是你喂给模型的表结构描述、业务口径说明、约束条件、Few-shot 示例。这些属于提示词工程和元数据管理的范畴框架管不了。同样的模型同样的框架不同的业务上下文描述生成质量能差出一个数量级。另一个边界是框架不会替你做数据安全。即使大模型生成了合法的 SELECT 语句它也可能查了一个你没有权限的表或者查出了一个敏感字段。所以我们在 Spring AI Alibaba 外面包了两层东西一层是 SQL 静态校验器另一层是人工审核状态机。这两层才是系统的安全核心。选型对比可以参考这个表格方案接入成本与 Spring 生态契合度可定制性我们的评价直接调模型 API低低中请求封装、超时、重试都要自己写后期维护麻烦LangChain 系中中高编排能力不错但和 Spring 项目的整合需要额外桥接Spring AI Alibaba低高高和现有技术栈无缝衔接国内模型服务延迟低最适合本项目总结一下我们的立场框架是个“省力工具”真正的护城河在人审流程和元数据管理上。这也是为什么本文标题里“再加一道人工防线”才是重点而不是 Spring AI Alibaba 本身。3. 整体流程与人工审核状态机从提问到出数的完整链路这部分的架构设计决定了整个项目的稳定性。我们不是简单地在“生成 SQL”和“执行 SQL”之间插一个 yes/no 按钮而是设计了一套完整的状态机和控制流程。3.1 一条查询请求的完整生命周期用户在前端输入一句自然语言然后系统要走的路径大致是这样接收提问前端把用户的问题、当前登录人、所选业务场景一起提交到后端。生成 SQL 草稿后端从元数据服务加载该业务场景下的精简表结构和用户问题一起组装成 Prompt调用 Spring AI Alibaba 的ChatClient拿到模型的原始输出。抽取与静态校验从模型返回内容里抽出 SQL去掉 Markdown 代码块等杂质然后走 SQL 校验器黑名单、解析器、表名白名单逐层过。写入待审核状态校验通过的 SQL 连同用户原始问题、生成时间、命中的规则信息一起落库状态置为PENDING_REVIEW。审核人处理审核人到待办列表里看到这条记录可以先看数据预览再决定通过或者驳回。执行与返回通过后系统拿着 SQL 去只读数据源执行把结果集返回给提问人驳回则把原因回传。这里最容易被忽略的一点是生成 SQL 和审核 SQL 必须是两个不同角色视角下的动作。如果审核人就是提问人本人那这道防线基本形同虚设——他大概率只会关心“有没有结果”而不是“SQL 写得对不对”。3.2 状态机的五个状态与流转规则我们用一张数据库表存状态状态字段status只有五种取值流转关系如下PENDING_REVIEW初始状态SQL 草稿等待审核。APPROVED审核通过等待执行。REJECTED审核驳回附带驳回原因。EXECUTEDSQL 已在数据源执行结果已生成或保存。EXPIRED待审核超时系统自动作废。状态流转规则写死在服务层不搞太复杂的状态机框架就是几个if判断加数据库乐观锁。核心规则是只有PENDING_REVIEW可以变成APPROVED或REJECTED。APPROVED之后只能变成EXECUTED。PENDING_REVIEW超过 24 小时没有审核动作由定时任务批量置为EXPIRED。为什么要有EXPIRED因为在实际业务里审核人可能休假、可能忘掉待办如果一条 SQL 永远躺着用户会一直以为“还没生成”。与其让用户在焦虑中等待不如明确告诉他“这条申请已过期请重新提问”。我们还做了个优化过期前 2 小时给审核人发一次企微提醒能救回不少请求。3.3 为什么人工审核要配“数据预览”审核人凭什么判断一条 SQL 对不对如果只看 SQL 文本对技术不熟的业务审核人非常痛苦。所以我们在审核页面加了一个“执行预览”按钮点击后后端用一个只读连接执行该 SQL 包了一层 limit 的版本取前 20 行展示给审核人。这个设计帮了大忙。审核人不用逐字读 SQL他看数据结果就能直观判断字段是不是预期的口径时间范围是不是对行数是不是合理。比如运营同学看到“退货率”列返回的是 0.999立刻就能意识到模型把这个指标的口径搞错了直接驳回并附一句原因比看 SQL 高效得多。当然预览执行本身也是有风险的所以我们把预览也限制在只读账号下并且强制套上外层LIMIT避免审核人一点按钮就把几千万行捞出来。关于怎么安全地让审核人“试跑”后面代码章节会具体写。4. 核心代码落地提示词、SQL生成、静态校验三件套做这种东西光讲设计没用代码才是地基。我会按整个请求链路的顺序把核心代码逐段贴出来。我们的实现非常朴素没有过度设计胜在逻辑清晰、好改好维护。4.1 Maven 依赖与最小配置先看最外层依赖。Spring AI Alibaba 的 starter 会把 DashScope 相关的自动配置带进来你需要做的只是在pom.xml里加依赖dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId version按你集成时的最新稳定版本/version /dependency然后是application.yml里的最小配置spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.1 max-tokens: 2048这里有个细节经验temperature必须调低我们固定用 0.1。NL2SQL 是确定性任务不需要创造性温度越高越容易生成语法不稳定的 SQL。max-tokens也要给足复杂查询加上注释可能会比较长2048 是起步值。4.2 元数据服务决定 SQL 质量的隐藏功臣很多人做 NL2SQL 喜欢把整个数据库 DDL 全量塞进 Prompt这是大忌。表一多、字段一多模型上下文就爆炸注意力分散生成的 SQL 质量反而下降。我们的做法是按业务场景维护精简表结构。SchemaMetadataService的核心逻辑如下Component public class SchemaMetadataService { private final MapString, String schemaCache new ConcurrentHashMap(); public String loadSchema(String scene) { return schemaCache.computeIfAbsent(scene, this::buildSchemaFromMetadata); } private String buildSchemaFromMetadata(String scene) { // 1. 从元数据表查出该场景关联的物理表、字段、字段注释 // 2. 过滤掉敏感字段如手机号、身份证、薪资 // 3. 拼成模型友好的文本格式 return 表名: orders 说明: 客户订单表 字段: - order_id: 订单ID主键 - customer_id: 客户ID关联 customer 表 - order_amount: 订单金额实付金额单位元 - order_status: 订单状态pending/paid/shipped/finished/cancelled - created_at: 订单创建时间datetime ; } }这个 Service 就是我们的“业务口径翻译层”。比如“成交额”对运营来说可能是order_amount的求和但财务口径可能是paid_amount减去退款。我们在字段注释里把这些口径限定好模型就不容易东猜西猜。敏感字段直接不放进 schema从源头上阻止模型生成包含敏感字段的 SQL。4.3 提示词模板约束放在哪里很讲究提示词我们写得很长但是每一步都有目的。核心模板如下public static final String NL2SQL_PROMPT 你是一个企业内部数据查询助手只负责根据下面的表结构生成只读 SQL。 不要回答与SQL无关的内容不要输出任何解释。 【表结构】 {schema} 【约束】 1. 只允许 SELECT 查询不允许 INSERT/UPDATE/DELETE/ALTER/DROP 等任何写操作。 2. 如果用户没有明确要求“全部数据”必须加上 LIMIT 100。 3. 时间范围要根据当前日期 {today} 计算例如“最近7天”表示 created_at DATE_SUB(CURRENT_DATE, INTERVAL 7 DAY)。 4. 如果用户提到“每个/各大/按X分组”必须使用 GROUP BY。 5. 涉及百分比、比率、均值时必须用明确的聚合函数并注明 COUNT 或 SUM 的分母。 6. 只输出 SQL不要使用 Markdown 代码块不要以 开头或结尾。 【示例】 问最近7天的支付订单数量是多少 答SELECT COUNT(*) AS pay_order_count FROM orders WHERE order_status paid AND created_at DATE_SUB(NOW(), INTERVAL 7 DAY); 问各城市的订单总金额Top10 答SELECT city, SUM(order_amount) AS total_amount FROM orders GROUP BY city ORDER BY total_amount DESC LIMIT 10; 【用户问题】 {question} ; }提示词设计的经验有一条特别重要不要让模型“发挥”要让模型“填空”。约束条件越多、示例越贴近业务真实场景生成质量就越稳。我们发现示例的作用比约束描述大得多所以每个业务场景至少要配 3 到 5 个高质量示例。4.4 生成服务的完整代码生成服务的实现是这一层的核心代码逻辑非常直白Service public class NaturalLanguageToSqlService { private final ChatClient chatClient; private final SchemaMetadataService schemaMetadataService; private final SqlExtractor sqlExtractor; private final SqlValidator sqlValidator; public NaturalLanguageToSqlService(ChatClient.Builder builder, SchemaMetadataService schemaMetadataService, SqlExtractor sqlExtractor, SqlValidator sqlValidator) { this.chatClient builder.build(); this.schemaMetadataService schemaMetadataService; this.sqlExtractor sqlExtractor; this.sqlValidator sqlValidator; } public GeneratedSql generateSql(String question, String scene) { String schema schemaMetadataService.loadSchema(scene); PromptTemplate promptTemplate new PromptTemplate(NL2SQL_PROMPT); Message message promptTemplate.createMessage(Map.of( schema, schema, question, question, today, LocalDate.now().toString() )); String raw chatClient.prompt(message).call().content(); String sql sqlExtractor.extract(raw); sqlValidator.validate(sql); return new GeneratedSql(question, sql, schema); } }这里每个环节都是独立的方便单独加日志、加监控、加限流。GeneratedSql是一个简单的 record包含用户问题、模型生成 SQL、命中的业务场景等字段后续会作为审核单的一部分落库。4.5 SQL 抽取先把模型输出的“杂质”去掉模型并不总是规规矩矩输出纯 SQL。它有时候会来一句“好的以下是您需要的SQL”然后一个 Markdown 代码块包住查询语句。如果不处理JSqlParser解析必挂。所以抽取这一步很有必要Component public class SqlExtractor { private static final Pattern CODE_BLOCK_PATTERN Pattern.compile((?s)(?:sql)?\\s*(.*?)); private static final Pattern LEADING_TEXT_PATTERN Pattern.compile((?i)^.*?(?(SELECT|WITH)\\b), Pattern.DOTALL); public String extract(String raw) { if (raw null || raw.isBlank()) { throw new SqlValidationException(模型返回结果为空); } String cleaned raw.trim(); // 1. 优先取 Markdown 代码块 Matcher codeMatcher CODE_BLOCK_PATTERN.matcher(cleaned); if (codeMatcher.find()) { cleaned codeMatcher.group(1).trim(); } else { // 2. 没有代码块就找第一个 SELECT/WITH 作为起点 Matcher sqlMatcher LEADING_TEXT_PATTERN.matcher(cleaned); if (sqlMatcher.find()) { cleaned sqlMatcher.group(1).trim(); } } // 3. 去掉末尾多余分号 cleaned cleaned.replaceAll(;$, ).trim(); return cleaned; } }从第一次跑通到稳定这个类迭代了非常多次因为模型输出格式一言难尽。加正则匹配的时候要特别小心(?i)保证大小写兼容(?s)保证跨行匹配否则代码块内换行就匹配不到了。4.6 SQL 静态校验黑名单、解析器、表白名单三层过滤SQL 抽取完之后就进入我们的安全核心SqlValidator。三层校验层层收紧Component public class SqlValidator { private static final ListString FORBIDDEN_KEYWORDS List.of( insert, update, delete, drop, alter, create, truncate, grant, revoke, merge, replace, rename ); private static final ListString ALLOWED_TABLES List.of( orders, order_items, customers, products, refunds ); public void validate(String sql) { // 第一层基于关键词的代价最低先拦住大多数危险 String lowerSql sql.toLowerCase() .replaceAll(/\\*.*?\\*/, ) .replaceAll(--.*?(\\n|$), ); for (String keyword : FORBIDDEN_KEYWORDS) { if (lowerSql.matches(.*\\b keyword \\b.*)) { throw new SqlValidationException(命中禁止关键字: keyword); } } // 第二层解析 SQL必须是单条 SELECT try { Statements statements CCJSqlParserUtil.parseStatements(sql); if (statements.getStatements().size() ! 1) { throw new SqlValidationException(一次请求只允许一条 SQL 语句); } Statement statement statements.getStatements().get(0); if (!(statement instanceof Select)) { throw new SqlValidationException(只允许 SELECT 查询); } } catch (JSQLParserException e) { throw new SqlValidationException(SQL 语法无法被解析无法执行); } // 第三层表名白名单校验防止模型拿到 schema 之外的幻觉表名 for (String table : extractTableNames(sql)) { if (!ALLOWED_TABLES.contains(table)) { throw new SqlValidationException(表名不在白名单中: table); } } } private SetString extractTableNames(String sql) { // 简单实现有条件可以基于 JSqlParser 的 Table 访问器做 // 我们的做法是遍历解析树把 FromItem/Join 里的 Table 名字取出来 // 这里省略代码逻辑不复杂关键是别漏掉 JOIN 和子查询里的表 } }这套校验器的逻辑其实可以更严谨比如解析树遍历去拿表名而不是正则提取。实际项目里我们用了解析器自带的TablesNamesFinder效果挺好。风险点在于SELECT ... FROM (SELECT ...)这种子查询里的表名遍历时也要能拿到否则白名单就是形同虚设。黑名单还有一个细节要处理/* 注释 */和-- 注释因为模型偶尔会在 SQL 里夹注释而注释里可能写着“DELETE”之类的字样这就会误伤。我们先把注释剥掉再做关键词匹配。5. 校验器和人工审核怎么配合既要少漏放也要少误杀静态校验器的一道道规则不应该是把 SQL 一棍子打死而应该是“能自动挡的就自动挡挡不了的就推给人审”。我们围绕这个思路把校验结果分成了三个等级对应不同的处理策略。5.1 三级校验结果设计在实际代码里SqlValidator.validate()会返回一个校验结果对象而不是只抛异常。三个等级如下等级含义处理方式PASSSQL 语法正确、符合所有自动规则进入人工审核WARNSQL 可以解析但有一些需人工注意的点如大型 JOIN、无 LIMIT进入人工审核并高亮风险点BLOCK命中危险关键词、多语句、非法表名等直接拒绝不进入人工审核PASS和WARN都会进入人工审核池。区别在于WARN会在审核页面用红色标签标出“无 LIMIT”“涉及 3 张表 JOIN”“包含子查询”等提示帮审核人快速聚焦风险点。这个划分非常重要。如果所有风险都一刀切 BLOCK会出现大量误杀——比如用户问“最近一年的订单总量”模型生成SELECT COUNT(*) FROM orders WHERE created_at DATE_SUB(NOW(), INTERVAL 1 YEAR)没带 LIMIT这其实没什么风险但因为无 LIMIT 被拦掉体验就很差。所以无 LIMIT 我们归为 WARN而不是 BLOCK让审核人自己判断。5.2 不依赖数据库权限的人工防线设计很多人会问既然有静态校验器为什么不直接把数据库账号设置成只读权限然后全自动执行这是一个常见的认知误区。数据库只读权限解决的是“改了数据怎么办”的问题。但 NL2SQL 场景下更大的风险是SQL 语义正确但业务口径错了。只读账号拦不住“把 2023 年数据当成 2024 年数据来统计”这种错误。这种错误只有懂业务的人能看出来。所以我们的分工非常明确校验器解决的是“能不能执行”的问题machinery safety人工审核解决的是“该不该执行”的问题business correctness。前者用代码保证后者用流程保证。两条腿缺一不可。5.3 审核队列的工程细节避免人工防线变成瓶颈人工防线最大的风险是延迟。我们做过一个很蠢的初版所有待审核任务放在一个公共队列里几个审核人全量可见结果没人认领互相“以为别人会处理”查询积压严重。后来改成指派制加抢单制结合按业务域指派每个业务场景指定一个默认审核组。比如订单域的查询自动指派给订单数据负责人。超时升级2 小时未审核自动抄送给该业务域的二线审核人24 小时未处理状态置为EXPIRED。审核记录留痕谁审的、什么时候审的、审的时候看了预览没有、驳回原因是什么全部写入审计表。这套机制跑起来之后平均审核时长从最初的几小时降到了十几分钟。关键不是“让审核人更快”而是“让正确的人更快看到属于他的那批待办”。5.4 审核页面上给审核人看什么审核页面的信息密度直接决定审核质量。我们踩过一版“只展示 SQL 文本”的坑审核人根本看不懂。后来页面改成四段式布局用户原始问题这是最重要的上下文审核人先看用户问什么。当前 SQL带高亮展示方便懂技术的人快速扫读。系统自动标注的风险点比如“无 LIMIT”“JOIN 表数 3”“WHERE 条件中使用了子查询”。数据预览 Tab审核人一键触发只读预览查看返回的前 20 行。这里面最有价值的是第四点。很多审核人其实不读 SQL他们只看预览数据对不对。比如问“最近 7 天退货率”预览返回的是 0.99 这种诡异数值他立刻就能意识到口径不对。人工防线不是把压力全给审核人而是把判断需要的信息尽量摆在他面前。6. 线上跑大半年踩过的坑与最终经验这个系统上线之后我们前前后后踩了不少坑。有些是模型行为导致的有些是解析器边界导致的还有些是流程设计上的盲区。这里挑最有价值的五个展开讲都是可以迁移到类似项目的经验。6.1 坑一模型返回格式花式翻车解析器天天报错我们先说一个最频发的坑。模型不是每次都输出干净的 SQL它可能说“好的这是一个示例查询”然后附代码块也可能把 SQL 放在一句话中间甚至偶尔会输出两个 SQL一个“演示版”一个“最终版”。解决办法就是我们前面写的SqlExtractor但这东西有个演化过程。一开始我们只做了trim()发现报错率奇高后来加了 Markdown 代码块提取好了一些再后来发现没有代码块的情况于是加了“从 SELECT/WITH 开始截取”的正则最后才稳定下来。经验是不要指望模型输出格式永远稳定要把清洗逻辑当成一等公民。任何一次模型升级都可能导致输出格式改变所以清洗逻辑要考虑模型升级回归测试。我们专门准备了几十条历史问题做回归每次换模型、改提示词都全量跑一遍。6.2 坑二WITH CTE 被误判为多语句第二个坑来自解析器。我们用的是 JSqlParser早期版本对WITH cte AS (...) SELECT ...这类公共表表达式的解析支持有限。parseStatements可能会把整个 SQL 解析出多个 statement导致误判“一次只允许一条 SQL”。一开始我们以为是模型生成了奇怪 SQL后来排查才发现是解析器版本太老。升级到新版之后WITH语句的解析才正常。这里有个排查思路遇到校验误报时先把原始 SQL 单独拿去解析器跑一遍区分是模型问题还是规则问题不要急着改提示词。另外即便解析通过了包含 CTE 的复杂查询对人工审核的压力也更大。我们对这类查询一律标为 WARN提醒审核人多看两眼。6.3 坑三LATERAL VIEW、JSON 函数和方言兼容性我们一开始的数据源比较单一用的是 MySQL 语法。后来要接入另一个来源的报表库模型的输出里出现了一堆方言函数——数组切片、JSON 提取、窗口函数等等。静态校验器只检查语法和关键词对这些“合法但可能慢到爆炸”的方言函数毫无办法。我们的对策是校验器加了一层“执行成本预估提示”。比如发现 SQL 里出现了窗口函数对整个大表做排序就自动打上标签“涉及全表排序谨慎执行”发现JSON_EXTRACT就提示“可能对非索引字段进行函数运算”。这些提示会跟着审核单一起出现。这个思路很朴素人审的价值就是做“机器做不了的价值判断”那我们就应该尽量把机器的判断结果喂给人。静态规则挡不住所有坏事但它能通过标注风险点显著提升人审的效率和准确性。6.4 坑四数据预览和最终执行的账号隔离我们把数据预览设计成“审核人点击后查询前 20 行”这里有个安全细节如果预览账号和最终执行账号是同一个高权限账号那“预览”功能本身就会成为绕过人工审核的后门。一个恶意或误操作的用户完全可以反复触发预览接口把一张大表的数据一点一点捞出来。所以我们在数据源层面做了三层隔离最终执行账号拥有业务表读权限但也受max_execution_time限制。预览账号额外包一层LIMIT 20且不能执行COUNT(*)这类全表聚合外的昂贵操作。审核人登录态预览必须带着审核人的登录信息审计日志里记录每一步操作。这听起来有些过度设计但企业内部数据安全就是这样宁可繁琐不能留下口子。人工防线的前提是连人审使用的工具本身也要有边界。6.5 坑五审核人不在场查询全部挂起怎么办上线初期我们经常收到业务同学抱怨“提交两个小时了还没出数。”一查是审核人开大会去了待办池里堆了一堆。我们做了几个补救增加过期自动作废和消息提醒这是前面提过的。增加快捷驳回模板审核人在手机上也能一键驳回并附上“时间口径不明确”“请补充业务场景”等常用原因。把审核队列按业务域值班表轮转而不是固定一个人。最核心的收获是人工防线要有人工防线的配套机制——排班、值班、升级、过期缺一不可。否则这条防线反而会成为系统里最脆弱的瓶颈。6.6 下一步方向上的一些思考系统稳定之后我们开始做一些增强。一个是把审核通过/驳回的样本回灌成 Few-shot 示例定期更新提示词模板里的示例让模型越用越懂本企业的口径。另一个是探索基于向量检索的表结构查询当业务表多到一定程度时先根据用户问题召回相关的表和字段再进入模型 Prompt而不是把所有表都塞进去。但不管怎么演进我们的底线始终没变模型生成的结果必须经过人审才能执行。即使未来正确率提升到 99.9%这 0.1% 的错误摊到每天几百次查询上依然是几十次有问题的出数。人工防线不是效率的敌人恰恰是对效率负责的守门员。最后想分享一个我在这个项目里最深的体会做 NL2SQL 这类功能时间花得最多的不是写调用模型的代码而是和业务方对齐口径、设计元数据、打磨审核流程。技术模型负责把自然语言翻成 SQL但这个“翻”的过程是否理解业务才是真正的产品壁垒。人工防线看起来是“技术不够才加人”实际上它是企业数据链路里责任闭环最朴素也最可靠的一种实现。这套方案不一定是最前沿的但它是我们跑了大半年之后依然觉得踏实的一版。