通过具体案例说明 LLM 生成 SQL 直接操作生产库的四类致命问题——歧义、Schema 变更、权限逃逸、状态丢失,并给出 API 中间层隔离的正确架构。
让聊天机器人直接生成 SQL 并对生产数据库执行,在演示环境里效果拔群,上线第一周就会崩得一塌糊涂。本文介绍我们实际使用的架构:Microsoft Teams 作为聊天窗口、Copilot Agent 将问题转为草稿查询、一层介于中间的定制 API 决定什么可以真正执行,以及底层的 MySQL——它从不与任何东西直接对话,只通过那个 API 访问数据库。
四个问题几乎会让所有简陋的"LLM-to-SQL"方案立刻失效:
自然语言充满未明说的假设("本月"、"地区"、"销售额"对不同人含义各不相同)
模型不了解你的 schema 历史——它完全不知道 orders 表去年已经被 orders_v2 替代了
没有任何机制阻止一条恶意 prompt 变成全表扫描或跨越租户边界的查询
无状态的机器人一旦你追问,"那个"指什么立刻就忘了
这些问题靠写更好的 prompt 解决不了。得靠架构。
"把 LLM 直接接数据库"的问题
每个数据分析团队都收到过同样的请求:"我能直接问而不是提工单吗?"自助 BI 这件事已经承诺了很多年,大多数尝试在第一次遇到工具没预料到的问题时就崩了。
通常是这样发生的:团队拿了个聊天机器人模板,接上一个 LLM,把数据库连接字符串交给 LLM,然后就上线了。前十个问题回答得漂漂亮亮——就是演示脚本里的那些问题。然后真实用户问了个模棱两可的问题、引用了昨天的对话、或者要了不该看到的东西,整个系统就会悄无声息地给出一个完全自信的错误答案。
这可以说比没有工具还糟。一个拿到自信错误数据的业务用户,从此不会再信任系统的任何数字。
真正出问题的地方
藏在自然语言里的歧义。"本月各地区销售额"听起来没有歧义,直到你追问:日历月还是财月?地区指的是销售区域、发货地址还是账单地址?模型默默选了一种解释,这不是在帮忙——是在猜,而且祈祷别猜错。
没人喂给它的 schema 知识。真实的生产数据库不是教程里的三表清洁示例。它们充满了重命名的列、没人清理的软删除行,以及悄悄在十八个月前成为数据来源的 _v2 表。LLM 对这些毫无感知,除非每次调用时你把上下文都传给它。
查询能做什么没有上限。一旦自然语言变成了可执行的 SQL,你离一次 DELETE、一次无索引大表扫描、或一次泄漏租户边界数据的 JOIN 只差一个糟糕的解读。如果模型判断是生产数据与拼写错误之间的唯一防线,那这不是安全机制——这是在祈祷。
对话没有记忆。"现在按产品线拆分"只有在系统记得"那个"是什么时才有意义。无状态的请求/响应机器人瞬间就会丢失上下文,一旦丢失,人们就不再信任它能处理任何超出单一孤立问题的请求。
我们实际构建的方案:四层,每层只做一件事
解决方案不是更聪明的 prompt。而是分离关注点,让离数据最近的那层同时是最严格的一层——而不是最信任模型的那层。
[ Microsoft Teams ] → [ Copilot Agent ] → [ Custom API ] → [ MySQL ]
chat surface intent + SQL the actual gate system of record
Teams 是人们已经在的地方,所以自然成了提问的地方——但它的职责应该止步于渲染聊天界面、保持对话/租户上下文、以及将结果展示为表格或卡片。这里不发生任何推理。只要保持足够轻薄,相同的后端以后完全可以挂在 Slack 或 Web 应用后面,无需重构。
这层负责将"给我看看各地区销售额"转换为一个真实查询——但必须等到它用领域知识和对话中前面的内容解决了用户真正在问什么之后。意图确定之后,它会起草一个默认只读的查询,在离开 Agent 之前完成验证和优化。然后一个编排器将请求交给 API 层,负责优雅地失败——在聊天里追问澄清,而不是抛出一堆堆栈跟踪。
这是演示从来不会展示的部分,却是最重要的一部分。少量端点——类似 /executeQuery、/metadata、/health——坐落在 Agent 和数据库之间。每个请求在执行前都会经过认证、租户范围限定、验证和日志记录。只有完成这些之后,才会由查询执行器针对 MySQL 运行查询并格式化结果。如果说 Agent 是 SQL 被编写的地方,这里就是它必须获得运行许可的地方。
数据库本身——表、视图、存储过程——只能通过查询执行器的安全连接来触碰。它从不直接见到 Agent,也从不见到自然语言。当一条查询到达 MySQL 时,它已经过验证、认证并强制为只读。
贯穿所有层的第五层"基础设施"
这条链中的每一跳——Teams 到 Agent、Agent 到 API、API 到数据库,以及响应流回——都运行在 HTTPS 上,每一步都强制执行只读访问。日志和监控不是事后补上的,而是贯穿每一层,这样当问题真正发生时,有一条实际的追溯路径回到引发问题的请求。
我们始终回归的那条规则:自然语言界面的可信程度,取决于模型无法绕过的访问控制层。
把 Agent 层做对
在类似这样的项目中,诱惑在于让模型做太多——写 SQL、决定谁能看到结果、直接运行。三个不同的职责(理解、授权、执行)被压缩进一条 prompt。Prompt 不是访问控制。
真正经得住考验的模式是清晰分离这三者:
Agent 理解语言并起草 SQL。仅此而已。
API 拥有认证、租户授权和验证——而且是唯一持有数据库凭证的东西。
数据库只执行已经过检查、限定范围并强制为只读的查询。
每一层对下层"信任"的程度,恰好等于它实际验证过的程度——永远不会更信任。
这也告诉了你重试逻辑应该放在哪里。如果生成的查询在验证时失败或超时,正确的做法是在聊天里进行精化尝试或追问澄清("您指的是财月还是日历月?"),而不是静默降级为一个恰好能返回某些结果的更宽泛、无边界的查询。
什么该建 vs. 什么该租
Microsoft 的 Copilot Studio 和 Teams SDK 把对话管道——聊天 UI、会话处理、Adaptive Cards——基本上白送了。那部分是通用基础设施,租用是显而易见的正确选择。
非通用的是你定制 API 内部的一切:验证规则、租户隔离、只读强制执行、审计日志。那部分逻辑编码了你所在组织实际如何治理数据访问,它不应该存在于你无法控制的一个通用连接器里。
一个简单的判断测试:如果一层的职责是"让某人输入一条消息并得到响应",买它。如果一层的职责是"决定这个请求是否被允许触碰这个数据",自己构建,并且保留控制权。
"生产就绪"实际上是什么样的
把所有这些整合在一起,一个值得信赖的系统有几个共同特征:
意图在 SQL 生成之前就解决了,所以歧义在早期就被捕获,而不是被 baked 进一条错误的查询
每条生成的查询默认只读,并在执行前经过验证
一个专用 API 层负责认证、按租户授权、记录每次调用——而且它是唯一持有数据库凭证的东西
MySQL 只运行已经被检查过的内容
整个路径运行在 HTTPS 上,有一条安全团队真正认可的监控轨迹
这些组件单独看都没有什么 exotic。真正的工程在于拒绝让模型的流畅性替代一个只需要建一次的访问控制层。
为什么不直接把 LLM 指向数据库?因为那样的话,用户措辞和生产数据之间除了模型的判断之外就什么都没有了。Agent 和 MySQL 之间的一层专用 API 才是真正强制执行只读访问、认证和租户隔离的东西。模型永远不应该持有凭证。
Agent 决定某人能看到什么数据吗?不应该,而且在这个架构里它确实不这样做。授权逻辑在 API 层,它在查询运行之前检查身份和租户。Agent 的职责是理解意图和起草 SQL——不是守门。
"现在按产品拆分"这样的追问是如何工作的?会话和对话上下文存在于 Teams 层,并被传给 Agent,Agent 保留足够的历史,使得追问能针对前一条查询解析,而不是从空白状态重新开始。
这个方案能迁移到 Slack 或普通 Web 应用而不是 Teams 吗?能——只要 Teams 保持轻薄。如果所有实际推理都存在于 Agent 和 API 层,而不是泄漏到 Teams 特定代码中,换前端只是交付层的变化,不是重建。
我们在 Bitcot 为企业团队构建 AI Agent 和数据集成系统。如果你正在把 LLM 接到真实数据库上,想听听第二意见关于防护栏应该放在哪里,欢迎聊聊。