跳到主要内容

SQL Agent 为何在生产环境中失败:针对实时关系型数据库的 LLM Grounding

· 阅读需 13 分钟
Tian Pan
Software Engineer

Spider 基准测试看起来很棒。GPT-4 在数百个测试查询中的 text-to-SQL 转换得分超过 85%。团队看到这些数字,配置好 LangChain 的 SQLDatabaseChain,然后上线“询问你的数据”功能。两周后,一位分析师关于按地区划分收入的无心提问触发了全表扫描,导致报表系统宕机 30 分钟。

基准测试数字是真实的。问题在于,基准测试不使用你的模式 (schema)。

Spider 1.0 在包含 5–30 个表和 50–100 个列的数据库上测试模型。而你的生产数据仓库有 200 个表、700 多个列,根据你查询的系统有三种 SQL 方言,以及在四年前编写代码的工程师看来有意义,但对其他任何人来说都毫无意义的列名。当研究人员推出 Spider 2.0——一个具有企业级规模 schema 和现实复杂性的基准测试时——GPT-4o 的成功率从 86.6% 下降到 10.1%。这种断崖式下跌才是生产环境的真实写照。

这篇文章讨论的是安全部署 SQL Agent 所需的工程设计——不是作为一个模型调用的包装器,而是一个具有 schema 基础化 (grounding)、运行时验证和分层权限的系统。如果其中任何一个环节出错,你要么会得到极其自信的错误答案,要么更糟,在实时数据库上运行了非预期的查询。

SQL 访问不是文档 RAG

当你构建文档 RAG 系统时,近似答案是可以接受的。检索缺失会返回一段相关性稍低的文章;模型用合理的推理填补空白。用户得到一个足够接近的响应,最糟糕的失败模式也只是一个模糊的答案。

SQL 则完全不同。查询要么映射到正确的表名和列名,要么无法执行——或者更糟,它针对错误的数据成功执行。一个混淆了 user_eventsuser_sessions,或者使用 order_date 而不是 created_at 的 Agent,不会返回一个略显不精确的答案。它会返回一个充满自信但完全错误的任务。分析师信任它。决策据此做出。

根本区别在于确定性。文档 RAG 在检索时容忍语义近似。SQL 生成则需要精确的 schema 映射——表名、列名、连接条件、过滤语义——而且没有“同情分”。一个“几乎”能写对 SQL 查询的 LLM,生成的查询看起来很合理,解析也正确,但会静默地返回错误结果。

交付过 text-to-SQL 系统的生产团队一致报告了这一差距。Pinterest 发现,生产环境中的初次尝试接受率约为 20%,而基准测试数字则显示性能要高得多。这些查询并非无法执行——它们执行了并返回了结果,然后被用户标记为错误。

Agent 如何产生 Schema 幻觉

通常的假设是将数据库 schema 注入模型的上下文就能解决基础化 (grounding) 问题。事实并非如此。

当一个 schema 在 200 个表中包含 700 个列时,模型必须在编写一行 SQL 之前,识别出哪些 5–10 个列与给定查询相关,构建正确的连接 (join),并应用正确的过滤条件。即使完整的 schema 都在上下文中,在企业级规模的基准测试中,Schema 链接(将自然语言短语映射到特定的表/列名)仍占 SQL 错误的约 27%。

一旦你看过这些失败模式,它们就是可以预见的:

命名规范漂移。acct_revenue_usd_net_30d 这样的列名内部是一致的,但对于创建它们的团队之外的人来说是不透明的。模型会将“上个月的净收入”映射到听起来合理但可能存在也可能不存在的替代方案。当它们不存在时,你会得到一个错误。当它们存在但含义略有不同时,你会得到一个错误的数字。

组合推理失败。 涉及多个连接、子查询或聚合的查询,要求模型在处理日益复杂的 SQL 结构时,保持原始问题的语义意图。在处理单表查询时具有高准确率的模型,在被迫正确组合多个子句时,准确率会大幅下降。

跨表幻觉。 模型知道某个概念存在于 schema 的某个地方,但不确定哪个表拥有它。它会选择听起来最接近的表。有一半的时间,那个表是存在的。另一半时间,它是来自不同但相关系统的一个表,恰好共享命名模式。

解决方案不是换一个更大的模型。企业级 schema 上的性能崩溃源于 schema 的复杂性和业务上下文的缺失,而不是模型的底层推理能力。在生产环境中达到 90% 以上准确率的团队,是通过 schema 管理而非模型选择来实现的。

Schema 子集化:给 Agent 更少的信息

解决 schema 幻觉的一个反直觉的方法是向模型展示更少的 schema,而不是更多。

这种方法被称为 Schema 子集化 (schema subsetting),它包含两个部分。首先,构建一个检索层,在生成任何 SQL 之前识别哪些表与给定查询相关。这利用了表摘要、列描述和示例查询的嵌入 (embeddings) 来检索候选表集——类似于文档 RAG 检索相关片段的方式,但操作对象是 schema 元数据而非文档块。

Pinterest 的实现具有启发性。他们发现,嵌入表文档(对表内容的平实语言描述)比嵌入原始 DDL 更有效。文档提供了将自然语言问题链接到表的语义信号;DDL 则为 SQL 构建提供了结构细节。他们在检索中给予文档更高的权重,在妥善投入元数据质量后,看到搜索命中率从 40% 提高到 90%。

其次,一旦识别出相关表,在将 schema 注入模型上下文之前,将其精简为仅包含这些表及其相关列。关于客户流失的查询不需要看到支付 schema。关于订单履行时间的查询不需要用户身份验证表。这不仅仅是 token 优化——它减少了模型必须推理的噪声,并降低了幻觉的可能性,因为上下文中的错误答案变少了。

子集化方法需要对 schema 文档进行投资。如果你的表没有描述,列名又像 flg_prm_a 这样,那么检索就是在瞎猜。元数据层是承重的基础设施,而不是可有可无的东西。

执行前的运行时验证

即使进行了模式子集化(schema subsetting),代理(agents)仍会生成不应执行的查询。运行时验证层可以在这些查询到达数据库之前将其拦截。

验证链遵循特定的顺序:

解析 SQL,而不是用正则匹配。 使用真实的 SQL 解析器(而非模式匹配)进行结构验证,可以捕捉到在执行时会失败的格式错误查询。这也是你验证查询是否仅引用了允许集合中的表和列的地方。

强制执行白名单。 维护一份显式的表和列清单,即代理被允许查询的对象。任何引用白名单外内容的查询都无法通过验证。这与数据库权限是分开的——它是一个应用层检查,能及早失败并提供特定的错误消息,供模型进行自我修正。

封禁违禁操作。 对于只读代理,在解析阶段就封禁任何包含 INSERTUPDATEDELETEDROPALTERTRUNCATE 的查询。不要指望模型不会生成这些操作——要验证它们确实不存在。

在执行前运行 EXPLAIN 一个在不使用索引的情况下扫描十亿行表的查询最终会返回结果,但它会降低系统中其他所有用户的性能。运行 EXPLAINEXPLAIN ANALYZE(不执行)来检查预估成本。如果查询规划器预估会对大表进行全表扫描,请拒绝该查询,并要求模型添加适当的过滤器或使用索引列。

默认强制执行 LIMIT 为任何不包含 LIMIT 子句的查询追加一个 LIMIT 子句。在分析表上执行无限制的 SELECT * 不是分析师的错——系统本应捕捉到这一点。

这个验证层同时也充当了自我修正的反馈信号。当查询验证失败时,错误消息会准确地告诉模型它错在哪里:“表 orders 中未找到列 order_ts。可用列为:order_datecreated_atupdated_at。”模型可以在下一步生成修正后的查询。与直接显示原始数据库错误消息或什么都不显示的系统相比,实施结构化错误反馈的团队在多轮准确率方面有显著提升。

只读边界不是可选的

连接到具有读写权限数据库的 SQL 代理是一个在出现问题时可能损坏或删除生产数据的系统。而问题总会出现。

只读边界是在数据库层强制执行的,而不是应用层。创建一个专用的数据库用户,仅授予 SELECT 权限,并将其作用域限定在代理应访问的特定表上。即使代理生成了某种程度上绕过应用层验证的 DELETE 语句,数据库也会拒绝它。这就是深度防御:你的代码可能会有 bug,但数据库的权限模型不会。

“只读”的范围值得更精确的定义。对所有表都拥有 SELECT 权限的数据库用户并非遵循最小权限原则——它是尽可能广泛的读取权限。构建权限结构,使代理的数据库用户仅对允许的模式子集中的表拥有 SELECT 权限。客户分析代理不应能够查询内部工具模式;产品使用代理不应触碰财务数据。

对于既需要读取又需要写入权限的代理——例如作为自动化工作流的一部分生成 SQL 更新——权限结构会发生显著变化。写入权限需要:

  • 仅限于特定表的作用域
  • 任何变更查询在执行前都需要人工审批步骤
  • 维护所有执行查询及其产生的自然语言请求的完整审计日志
  • 尽可能限制为仅限 INSERT(追加),而非 UPDATE/DELETE

审计追踪往往被低估。当出现问题时——对于写入权限,“当”比“如果”更合适——你需要准确地重建代理执行了什么以及为什么要执行。仅存储 SQL 查询的日志是不够的;你需要原始用户请求、代理考虑过的中间查询、验证结果以及最终执行结果。

出错的真实代价

糟糕的文档检索会返回无关的段落。而糟糕的 SQL 查询则具有不同的成本特征:

对 500GB 的表进行全表扫描会占用只读副本,并降低其他所有用户的查询性能,直到超时或完成。在没有资源控制的系统中,单个代理查询就能让报表集群宕机 20–30 分钟。

针对错误租户数据的查询——因为代理未能应用行级安全(row-level security)过滤器——属于数据泄露。代理拥有 SELECT 权限;它只是使用不当。客户数据是否被泄露是一个法律问题,而不仅仅是工程问题。

一个导致下游决策的、自信的错误答案是最难检测的失败模式。查询运行了,返回了结果,数字看起来很合理。代理幻觉认为 net_revenue(净收入)不包含退款,而事实并非如此。分析师据此制作了董事会简报。

文档 RAG 与 SQL 代理之间的不对称性归结为:糟糕的检索产生的结果显然是不精确的。而糟糕的 SQL 产生的结果在有人检查底层数据之前,与正确答案是无法区分的。

迈向生产级的可靠性

在生产级 Text-to-SQL 系统上达到 90% 以上准确率的团队,通常都采用了一种共同的架构,而这并非靠什么提示词技巧。

其基石是语义层(Semantic Layer):为智能体可以访问的每个表和列提供集中且维护良好的定义,使用平实的语言将业务术语映射到模式(Schema)元素。例如,“月活跃用户”映射到 user_events 行的特定组合,而不是靠模型去猜测。这种元数据正是检索系统所使用的,也是模型进行推理的基础,它让模式子集的划分变得精准。

在此之上,多智能体架构的表现优于试图处理一切的单个智能体。一个模式智能体负责识别相关的表,一个 SQL 生成智能体专注于构建查询,还有一个验证智能体在将结果呈现给用户之前进行检查。每个智能体都有狭窄且明确的职责,并且可以独立测试。模式选择中的错误不会静默传播到 SQL 生成步骤中,而是在边界处被拦截,以便你捕获并纠正它们。

最后,对于 SQL 智能体来说,评估比大多数 LLM 应用更为重要。评估信号非常明确——查询要么返回正确的结果,要么不返回。针对已知查询的测试集进行持续评估,能为你提供真实的信号,告诉你对模式文档、模型版本或提示词结构的更改是提升了准确率还是导致了回退。缺乏这种信号的团队就像是在盲目飞行,仅仅针对基准测试数字进行优化,而任由生产环境的准确率产生偏差。

那些交付了可靠 SQL 智能体的工程师,并不是因为他们找到了更好的提示词,而是因为他们围绕模型构建了更好的基础设施。


SQL 智能体非常值得构建。让分析师无需编写 SQL 就能查询数据具有真正的价值,而且底层模型的能力也是实实在在的。但是,如果在部署时没有模式子集划分、运行时验证和数据库最小权限原则,这不仅仅是在走捷径,更是跳过了那些确保系统安全运行的关键环节。

References:Let's stay in touch and Follow me for more thoughts and updates