☰
LangChain实战:用GPT自然语言自动查询SQL数据库
2026/10/21 4:39:19 网站建设 项目流程

这个项目是我 LangChain 实战记录里的第八个动手实验,标题里的核心动作就一个:用 GPT 自动查询 SQL 数据库。换句话说,过去你要自己写SELECT+JOIN才能从库里拿数据,现在把问题用自然语言抛给模型,模型负责生成 SQL,程序负责执行并把结果整理回给用户。这个能力听起来很“唬人”,但拆开看其实是一套很标准的链路,也是目前很多数据分析助手、内部运营后台里面真正落地的一类玩法。

这个项目适合谁参考?我建议三类人重点看:一是后端开发,想给内部系统加一个“问数据”的入口;二是数据分析岗,日常被各种提数需求淹没,想用模型把简单查询自动化;三是正在系统学 LangChain 的初学者,想搞明白SQLDatabase、chain.invoke、提示词工程这些组件到底是怎样协作的。这篇笔记不会只丢一段能跑的代码,我会把选型逻辑、踩坑记录、权限边界一块儿讲清楚,因为自动查 SQL 最怕的不是模型不会写 SQL,而是模型写了个“看似正确但实际很危险”的 SQL。

1. 为什么让 GPT 自动查 SQL:需求与核心思路

1.1 这个能力想解决什么问题

先说一个我在实际项目中遇到过的场景:运营同学想看“最近 30 天各渠道的付费转化率”,他直接来了一句“你帮我拉个数”。这个需求如果要写成 SQL,至少涉及两张表做关联、要过滤时间窗口、要算转化率,还可能要考虑渠道维度怎么聚合。对于懂 SQL 的人来说,这不是多难的活,但问题在于:每天都有大量这种“拉个数”的需求,每一个都要人工去写、去排期、去解释结果。

让 GPT 自动查询 SQL 数据库,本质上是把“自然语言 → SQL → 执行结果 → 自然语言描述”这四步变成自动化流水线。模型的任务很简单:只负责把“用户想查什么”翻译成一条合法的SELECT语句。真正执行还是交给数据库引擎。这样有两个直接好处:第一,不懂 SQL 的业务方也能自助取数;第二,开发或者数据分析师可以从简单重复的提数需求里抽身出来。

当然,这个能力有一个适用范围。它擅长的是“清晰、具体的查询型问题”,比如“某部门有多少人”“某产品本月销售额是多少”“哪个城市订单量最大”。它不擅长的是“需要复杂业务判断”的问题,比如“这批数据质量到底行不行”“这个指标下滑的原因是什么”。前者需要的是语法准确,后者需要的是业务经验和上下文,模型目前还替代不了。

1.2 LangChain 在链路里的实际位置

有人可能会问:这不就是给 GPT 写个提示词,让它输出 SQL 吗?为什么要用 LangChain?这个疑问很合理,因为单论“生成 SQL”,确实用一个PromptTemplate + LLM就能做到。但 LangChain 在这个场景里多做了几件关键的事。

第一,它帮你管理数据库连接和元数据。LangChain 的SQLDatabase工具可以读取数据库的表结构、字段名、字段类型、主键关系,甚至抽取少量样本行。模型生成 SQL 之前,需要把这些元数据作为上下文塞进提示词,否则它根本不知道你的库里有哪些表、字段叫什么。第二,它提供了一套已经调好的链式逻辑,比如create_sql_query_chain会自动把 schema 注入提示词、自动解析输出,你不需要自己拼接提示词和解析逻辑。第三,它把“文本生成”和“代码执行”做了隔离:模型只输出 SQL,是否执行、怎么执行由你控制。

我用一个生活化类比解释下:LangChain 像一个“中间人”,它既懂数据库那边的门牌号(表名、字段名),又懂模型那边的说话规则(提示词格式)。你不需要亲手把数据库手册誊抄给模型,只需要告诉中间人“用户想问什么”,中间人准备好了上下文,再去问模型要答案。

1.3 方案选型时我在纠结什么

做这个项目之前,我其实纠结过两条路线。一条是完全手写,自己调数据库驱动读表结构,自己拼提示词,自己解析模型输出,再自己执行 SQL。另一条就是直接使用 LangChain 的现成组件。

先说结论:现在的项目我推荐用 LangChain,但不要迷信它。手写的最大优势是可控,你能精确控制每一步的输入输出,排查问题很直接。缺点也同样明显:你要手动处理方言差异、schema 截断、输出清洗、错误重试,这些琐碎工作加起来工作量远超预期。LangChain 的优势是把通用部分封装好了,但它也带来了抽象层,一旦出现问题,你得顺着封装源码一层层往里翻。

我在这个项目里选了 LangChain,是因为目标场景是“快速跑通功能验证”,不是“做一个对延迟和可控性有极致要求的线上系统”。如果有一天这个功能要上生产环境,我并不反对把它替换成手写版本,甚至用更轻量的思路,只借用 LangChain 的 SQL 工具链做 schema 抽取,其余全部自己实现。

2. 环境准备与组件选型

2.1 最小依赖清单

这个项目的依赖比想象中少,核心就几样:LangChain 主库、数据库连接工具、模型接口、SQLAlchemy 和对应的数据库驱动。

我用的环境参考版本是langchain0.3 起的新接口,如果你用的还是旧版本,部分类名和导入路径会不一样,需要注意。

pip install langchain langchain-community langchain-openai pip install sqlalchemy pymysql

pymysql是我这里连 MySQL 用的驱动。如果你连的是 PostgreSQL,换成psycopg2-binary;如果你直接跑本地 SQLite,那连驱动都不用装,LangChain 原生就支持。第一次做实验时,我建议先用 SQLite 跑通全流程,省掉数据库账号配置的麻烦,等逻辑通了再切换到真实业务库。

2.2 接入数据库的推荐姿势

LangChain 里接入数据库的标准方式是通过SQLDatabase.from_uri,而不是自己再单独建一套连接池。这种方式的好处是,你只需要给一个数据库连接字符串,SQLDatabase会自动完成表结构扫描、方言识别、基本信息抽取。

from langchain_community.utilities import SQLDatabase db = SQLDatabase.from_uri( "mysql+pymysql://reader:your_password@127.0.0.1:3306/analytics" )

在这里我想多说一句:连接数据库的账号,千万不要用高权限账号。后面我会专门用一节讲安全边界,但选型这一步就得先埋下伏笔——用只读账号、限定库名、限定表名,这些习惯要在一开始就养成。

还有一个参数我觉得很实用:sample_rows_in_table_info。它控制 LangChain 在抽取表结构时,顺带给每张表附带几行样本数据。这些样本数据放进提示词,能显著提高模型生成 SQL 的准确率,因为它能看到真实的数据长什么样、字段里存的到底是什么格式。缺点是样本数据有可能涉及敏感信息,生产环境要谨慎开启。

2.3 模型选型的小建议

text-to-SQL 这个任务,模型的能力差异非常大。我在实验里同时试过多款模型,直观感受是:推理能力强、指令遵循好的模型,生成的 SQL 质量明显更高。所以要尽量选当前主流的强模型,即使是“轻量版”也基本够用。

模型参数里有一个很容易踩的坑,就是temperature。做 SQL 生成这类任务,我强烈建议设置为 0,因为我们需要稳定、确定性的输出,不需要模型“发挥创意”。我曾经用默认 temperature 跑同一个问题,几次结果里 SQL 时对时错,把 temperature 降到 0 之后,输出的稳定性明显改善。

from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

如果你用的是国内可访问的模型服务,只要兼容 OpenAI 接口,也可以直接替换base_url和model,LangChain 这套链路并不绑定某一家厂商。

3. 核心链路实现:从自然语言到查询结果

3.1 第一版实现:用 SQLDatabaseChain 快速跑通

一开始我用的是 LangChain 早先版本里很常见的SQLDatabaseChain。它的设计思路非常“一条龙”:输入自然语言问题,自动生成 SQL,自动执行 SQL,再把执行结果和原始问题一起丢回模型,生成一段自然语言总结。用户看到的就是“我问问题,它直接给我答案”。

我搭的第一版代码大概长这样:

from langchain.chains import SQLDatabaseChain db_chain = SQLDatabaseChain.from_llm(llm, db, verbose=True) result = db_chain.invoke({"query": "哪个部门的平均薪资最高?"}) print(result)

第一次跑通时,我的感觉是“真香”:代码量极少,效果看起来不错。但用着用着问题就暴露出来了。第一,它把“生成 SQL”和“基于结果做总结”耦合在一个链里,你很难单独控制某一步。第二,它内部执行策略比较激进,问题稍有变化就可能生成非SELECT语句。第三,新版 LangChain 已经把它标为 legacy,官方推荐用更精细的create_sql_query_chain替代。

所以我的建议是:SQLDatabaseChain只适合拿来“验证可行性”,不适合做新项目的底子。你可以用它跑通一次感受效果,但最终实现要拆开做。

3.2 换成 create_sql_query_chain:把 SQL 生成和执行解耦

LangChain 新方案的核心是create_sql_query_chain。它只做一件事:根据用户问题生成 SQL。生成完之后,SQL 会不会执行、怎么执行、结果如何解读,都由你自己决定。这种解耦最大的好处是可控。

from langchain.chains import create_sql_query_chain chain = create_sql_query_chain(llm, db) sql = chain.invoke({"question": "哪个部门的平均薪资最高?"}) print(sql) # 输出可能是: # SELECT department, AVG(salary) AS avg_salary FROM employees GROUP BY department ORDER BY avg_salary DESC LIMIT 1;

这个输出结果在我看来非常有价值,因为我可以在 SQL 真正执行之前检查它。用户的问题被翻译成了什么,我是看得到的。如果生成明显不对,我可以在这一步拦下来,而不是直接放行执行。

create_sql_query_chain内部其实做了几件事:获取数据库 schema,拼装提示词,调用模型,从模型输出中解析出 SQL。它还会在提示词里明确要求模型“只输出 SQL,不要多余解释”。这个设计很实用,因为模型经常在输出里夹杂“根据您的需求,以下是一条查询语句……”这样的废话,链组件能把这些干扰项过滤掉。

3.3 自定义模板与关键参数调整

create_sql_query_chain开箱即用,但我实际跑下来发现,针对自己的业务库,定制提示词几乎必不可少。尤其是当数据库里表很多、字段命名不规范时,默认模板给出的效果会打折扣。

我把提示词模板改成下面这种:

from langchain_core.prompts import PromptTemplate sql_prompt = PromptTemplate.from_template( """你是一个精通 SQL 的数据分析师。根据以下数据库结构,把用户的问题转换成一条 SQL 查询语句。 要求: 1. 只输出 SQL,不要输出任何解释和前后缀。 2. 只能使用 SELECT 语句,禁止任何 INSERT、UPDATE、DELETE、DROP、ALTER 操作。 3. 如果问题中的条件不完整,请根据常识补充合理的默认条件。 4. 查询结果最多返回 200 行。 数据库结构: {schema} 用户问题: {question} SQL:""" )

然后把数据库 schema 单独提取出来:

context = db.get_context() schema = context["table_info"]

再组装成最终链路:

from langchain_core.output_parsers import StrOutputParser chain = sql_prompt | llm | StrOutputParser() sql = chain.invoke({"schema": schema, "question": "哪个部门的平均薪资最高?"})

这么做的好处是我可以对 schema 做预处理。比如说库里一共有 50 张表,全塞给模型既浪费 token 又容易产生干扰。我可以先对 schema 做白名单过滤,只保留业务相关的十几张表,模型准确率会明显提升。

3.4 结果后处理:把数据库记录变成人话

拿到 SQL 并执行之后,返回的是一堆原始记录。如果是技术人员,看这些记录没问题;但如果是业务方,他们更希望看到“运营部平均薪资是 18000 元,在全公司排第一”这种描述。所以我还增加了一个“结果翻译”环节。

先执行 SQL:

result = db.run(sql, include_columns=True) print(result)

include_columns=True的时候,返回结果会带上列名,这样后续做总结时,模型能知道每一列代表什么含义。接着把执行结果和原始问题交给模型做总结:

from langchain_core.prompts import ChatPromptTemplate summary_prompt = ChatPromptTemplate.from_template( """下面是针对问题“{question}”的查询结果,请用通俗易懂的中文总结出结论。 查询结果: {result} 要求:先给出直接答案,再简单解释关键指标,不要罗列所有数据。""" ) final_chain = summary_prompt | llm answer = final_chain.invoke({"question": question, "result": result}) print(answer.content)

这里我特别想强调一个细节:给模型做总结时,一定要给它“真实执行出来的结果”,而不是让它自己想象。有一个坑是,某些模型在生成 SQL 的同时,会顺带用常识去猜测答案。比如你问“订单量最大的城市是哪个”,它可能不执行 SQL 就告诉你“上海”。这种“幻觉式回答”非常危险。拆成“先执行、再总结”两步,能最大程度避免这个问题。

4. 权限、安全与可信度控制

4.1 数据库账号必须做只读隔离

这个项目里最值得花时间的地方,不是代码逻辑,而是安全边界。让大模型自动生成 SQL,如果权限控制不到位,等于把数据库的钥匙交到了一个会“一本正经胡说八道”的家伙手里。

首先要做的是数据库账号隔离。我单独创建一个只读账号,只授予目标库的查询权限:

CREATE USER 'llm_reader'@'%' IDENTIFIED BY 'strong_password'; GRANT SELECT ON analytics.* TO 'llm_reader'@'%'; FLUSH PRIVILEGES;

注意这里我连INSERT、UPDATE、DELETE都没给,更不用说 DDL。这个账号能做的事情只有SELECT,即使模型哪一天抽风生成了DROP TABLE,数据库也会直接拒绝,从源头防止灾难。

如果你的数据库支持会话级超时设置,也建议在连接参数里加上。比如 MySQL 可以设置:

db = SQLDatabase.from_uri( "mysql+pymysql://llm_reader:strong_password@127.0.0.1:3306/analytics", connect_args={"read_timeout": 10, "write_timeout": 10}, )

4.2 约束模型只能生成 SELECT

账号只读是底线,代码层还要再叠加一道检查。因为账号只读只是“执行不了”,并不能阻止程序发起一条昂贵的SELECT查询把数据库拖垮。我在代码里加了一个最基础的 SQL 白名单校验。

import re def validate_sql(sql: str) -> str: sql_clean = sql.strip().rstrip(";").strip() if not re.match(r"^(SELECT|WITH)\b", sql_clean, re.IGNORECASE): raise ValueError(f"非查询语句被拦截: {sql_clean[:50]}") return sql_clean

这个校验很粗糙,但能拦住绝大多数危险操作。如果你想做得更严谨,可以引入 SQL 解析器,把语句解析成语法树,检查核心操作类型。不过从实际收益来看,只读账号 + 前缀白名单已经能挡住 99% 的日常风险。

4.3 防止“查全表”这类操作把库拖垮

自动生成的 SQL 容易出现另一个问题:查询范围过大。用户问“今年所有订单明细”,模型很可能直接生成SELECT * FROM orders WHERE year = 2024,如果订单表有几千万行,这个查询能把数据库资源瞬间打满。

我的处理办法是在提示词里强制加“LIMIT 200”,同时在执行层再做一道兜底。如果模型生成的 SQL 没有LIMIT,我可以手动包一层子查询:

def enforce_limit(sql: str, max_rows: int = 200) -> str: if "LIMIT" not in sql.upper(): sql = f"SELECT * FROM ({sql}) AS _t LIMIT {max_rows}" return sql

这个方法不完美,因为原 SQL 可能本身就是聚合查询,包一层不会有太大语义变化。实测下来,聚合查询的结果行数通常很小,不会因为嵌套产生性能问题。最重要的是,它保护了数据库不被打爆。

提示:生产环境不要把“限制行数”这个责任完全交给模型,必须叠加代码层的强制兜底。模型再聪明,也不能保证每次输出都严格遵守提示词。

4.4 什么数据不该暴露给模型

还有一个很容易被忽略的安全问题:schema 本身可能就是敏感信息。get_context()会把表结构和样本数据都拉出来,如果不加限制,模型就能“看到”所有表,包括那些业务方其实没有权限看的列。

LangChain 的SQLDatabase.from_uri支持include_tables和ignore_tables参数。我在实验里把范围先锁定到几张核心表:

db = SQLDatabase.from_uri( "mysql+pymysql://llm_reader:strong_password@127.0.0.1:3306/analytics", include_tables=["employees", "departments", "orders", "products"], sample_rows_in_table_info=2, )

这样做有两点意义:一是减少 token 消耗,让模型更专注;二是避免敏感表进入模型上下文。如果你连“有哪些表”都不想让模型知道,那就必须在这里做白名单。

5. 常见问题与排查笔录

5.1 生成的 SQL 带方言错误

我最早踩的坑是方言问题。同样一句“取前 10 条”,MySQL 是LIMIT 10,SQL Server 是SELECT TOP 10,Oracle 又是FETCH FIRST 10 ROWS ONLY。模型如果不知道你用的是哪种数据库,很容易生成正确逻辑但在当前环境跑不通的 SQL。

排查思路很简单:先看模型输出的 SQL 能不能直接执行,不能执行就看报错信息。大多数情况下,错误出在LIMIT语法、字符串转义、日期函数差异上。解决办法是在提示词里明确数据库方言,比如写明“目标数据库是 MySQL 8.0,请使用 MySQL 方言”。create_sql_query_chain会自动带上方言信息,但自定义模板时容易漏掉。

5.2 模型一本正经地算错数

第二个常见问题很典型:模型不老实执行 SQL,而是自己“猜答案”。比如你问“平均客单价是多少”,它可能直接回答一个基于常识推测的数字,而这个数字跟数据库真实数据毫无关系。

我遇到过最诡异的一次,是模型生成 SQL 时把聚合函数写错了,AVG(price)写成了SUM(price),导致结果翻了几十倍。这时候只看最终的自然语言总结根本发现问题,所以我强烈建议在开发阶段开启中间过程可视化,把所有生成的 SQL 都打印出来。你可以在每个环节加一点日志,或者干脆在链里插入一个回调函数,把模型每一步的输出都记录下来。

5.3 表名/列名识别错乱

第三个问题出在表名词汇上。当我用中文表名或中文字段时,模型生成的 SQL 经常把字符串引号用错,导致查询失败。另外,如果表名是复数形式,模型可能自作主张换成单数。

这个问题没有银弹。最有效的办法是在 schema 信息里把每个表的中文注释一并放进去,让模型“理解”表结构。如果字段名本身命名不规范,比如c01、c02这种无意义命名,建议在提供给模型前做一次“字段别名映射”,把c01转成user_id这样的可读名称。这个映射表也可以由模型辅助生成,但初期手动维护更可靠。

5.4 常见问题速查表

现象可能原因解决办法
生成的 SQL 不执行,报语法错误方言不匹配、缺少引号提示词里明确数据库类型,少用多行注释
返回结果数量巨大没有加 LIMIT代码层强制包一层LIMIT
回答与真实数据不符模型直接猜测答案,绕过执行拆成“生成 SQL”和“总结结果”两段
表名/列名不存在schema 信息不足或模型臆造白名单表,附带字段注释和样本值
模型输出夹杂解释文字提示词约束不够强开启输出解析,或用正则截取 SQL 片段
查询超时或拖慢业务库查询太重只读账号 + 超时设置 + restrict schema

6. 中文场景下的优化和进阶玩法

6.1 用中文把问题说清楚

这个项目的输入输出都是中文场景,所以提示词模板我全部使用中文,效果比中英混合模板好得多。有一个值得注意的细节:用户问题“这个月哪个产品卖得好”其实不够精确,模型需要猜测“这个月”指的是自然月还是近 30 天。我在模板里加了一条规定:“如果问题时间语义不明确,默认取最近 30 天。”这种默认规则的设置,能让输出更符合业务期望。

如果你有多个不同业务口径,最好在系统层先把问题补全,再送入生成 SQL 的链路。比如设置一个“查询意图改写”前置环节:

rewrite_prompt = PromptTemplate.from_template( """把下面的用户问题改写成适合 SQL 查询的明确问题,补齐时间、地域、指标等条件。 原问题:{question} 改写后:""" )

这样做的好处是,原问题再口语化,进入 SQL 生成环节时也会是一个结构清晰的问题,大幅提高生成成功率。

6.2 用 Few-shot 示例稳住输出格式

我自己试下来,最有用的优化是给模型几个“示例问答”。你要让模型知道“什么样的提问对应什么样的 SQL”,尤其是一些复杂查询模式,比如“各区域销售额对比”“上季度环比变化”。给两个示例,效果立竿见影。

examples = [ { "input": "哪个城市订单量最大?", "query": "SELECT city, COUNT(*) AS order_count FROM orders GROUP BY city ORDER BY order_count DESC LIMIT 1;" }, { "input": "最近7天每日新增用户数", "query": "SELECT DATE(created_at) AS day, COUNT(*) AS new_users FROM users WHERE created_at >= NOW() - INTERVAL 7 DAY GROUP BY DATE(created_at) ORDER BY day;" } ]

把这些示例拼接到提示词里,模型在遇到相似提问时,会倾向于模仿示例的写法,而不是自己创造一套新的 SQL 风格。这在团队协作里也很有价值,能让生成的 SQL 风格更统一。

6.3 增加一步可选的查询改写

对于多轮会话,用户经常说“那同比呢”“换个渠道再看”,这些省略句如果直接送进 text-to-SQL 链路,多半会失败。我的做法是在主链路之前加一个“上下文改写”节点,把前几轮对话和当前问题放一起,让模型生成一个完整的独立问题,然后再进入 SQL 生成。

这个做法本质上是把“对话管理”和“SQL 生成”解耦。对话状态出问题,只影响改写环节,不会污染 SQL 生成环节。我现在这个实验里只做了一个简化版,如果你要做多轮查询助手,这块很值得深入扩展。

6.4 还能往哪里扩展

这个项目跑通后,我顺带验证了几个扩展方向:一是把查询结果结合图表渲染,让模型输出“用什么图表展示”,但这里我提醒一句,模型推荐的图表类型不一定合理,还是要靠前端规则做兜底;二是把慢查询日志和模型生成 SQL 的耗时做成监控看板,方便追踪质量;三是把“数据库结构变更”做成自动刷新,不需要每次重启服务才更新 schema。

另外我还试过把整个过程封装成一个工具函数,注册给另一个 Agent 使用,这样 Agent 就可以在对话中自主查库。效果很惊艳,但也更考验安全设计——Agent 自主决定查询哪些表,失控风险会更高。

这个实验我从跑通到真正用得顺手,大概花了一周。如果你自己去实现,我的建议是:第一版别追求完美,直接用默认模板跑通链路;第二版再慢慢加权限校验、few-shot 示例和结果总结。真正决定这个功能能不能长期稳定使用的,不是模型写 SQL 有多强,而是你能不能把“什么是允许的、什么需要拦截”这件事用代码彻底锁死。最后分享一个我自己常用的检查习惯:无论模型输出看起来多合理,永远先在测试库执行一遍,并且把生成的 SQL 打印出来人工瞄一眼,这个习惯救过我很多次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询