ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AI Alibaba实现Text-to-SQL人工审核防线,生产级落地实践

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 Alibaba

2.1 Maven 依赖与配置项

我用的核心依赖很简单,主要就是 spring-ai-alibaba-starter,再加上数据库连接和 Web 相关依赖:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>${spring-ai-alibaba.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-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: 2048

temperature 我特意调低到 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. 根据问题匹配相关表 List<TableSchema> 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 List<Map<String, 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( (ConnectionCallback<List<Map<String, Object>>>) connection -> { connection.setReadOnly(true); try (PreparedStatement ps = connection.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { ResultSetMetaData metaData = rs.getMetaData(); int colCount = metaData.getColumnCount(); List<Map<String, Object>> rows = new ArrayList<>(); while (rs.next()) { Map<String, 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 Result<String> 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 Result<TaskStatusResult> 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 系统才能从“能跑”变成“敢跑”。如果你也在搭类似的系统,我建议先跑通最小闭环,再逐步加规则、加监控,别一步到位追求全自动,稳定永远是第一位的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进