news 2026/9/13 4:21:15

从零搭建Text-to-SQL最小闭环:Python+大模型+SQLite实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零搭建Text-to-SQL最小闭环:Python+大模型+SQLite实战指南

很多人在接触大模型之后,第一个想做的落地小项目就是 Text-to-SQL,核心诉求很直接:我连 SQL 都不用写,把问题描述给大模型,它把 SQL 给我,我拿去执行,结果就出来了。听起来很爽,但真去搜资料的时候,要么是讲大模型的原理讲得特别深,要么是直接甩一个企业级数据中台方案,中间缺了最关键的一块——一个最小、能跑通、能让你理解完整链路的闭环。

这篇文章我就用最接地气的方式,带你从零搭一个可运行的 Text-to-SQL Demo。不扯复杂的分布式架构,也不做模型微调,就用 Python + 大模型接口 + SQLite,跑通“自然语言 -> SQL -> 执行结果”的完整链路。适合刚入门大模型应用开发的人,也适合想快速评估大模型生成 SQL 能力的人。你会看到完整的代码、Prompt 设计思路、常见坑,以及怎么让结果从“偶尔能用”变成“稳定可用”。

1. Text-to-SQL 到底在解决什么问题

1.1 从“人写 SQL”到“模型写 SQL”

传统开发中,写 SQL 的负担一直在人身上。你要知道数据库有哪些表、每张表有哪些字段、字段之间怎么关联、业务上的筛选口径是什么,然后才能写出正确的查询。这还只是单表查询的情况,一旦涉及多表 JOIN、子查询、窗口函数,门槛就更高了。

Text-to-SQL 想做的事情,就是把“查数据”这个动作从“写代码”变成“说人话”。你把问题给模型,比如“上个季度每个品类的销售总额是多少”,模型根据数据库结构生成对应的 SQL,你去执行并返回结果。这样,做数据分析的人不用再等开发排期,业务人员也不用把需求翻译成技术语言,SQL 生成这个环节被大模型替代了。

但这里要泼一盆冷水:大模型生成 SQL 不是万能的。它不会凭空知道你的表里有哪些字段,也不知道你的业务口径是“含税还是不含税”。所以完整的 Text-to-SQL 系统,核心工作其实是两件事:让模型充分理解数据库结构,以及把业务约束有效地传给模型。

1.2 一个最小闭环包含哪些环节

一个最小可运行的 Text-to-SQL 闭环,实际上由五个环节组成:

环节作用对应问题
问题输入接收用户的自然语言问题“这个月各分类的订单数是多少?”
Schema 信息告诉模型数据库有哪些表、字段、类型表结构、字段注释、关联关系
SQL 生成大模型根据问题 + Schema 生成 SQLSELECT ... FROM ... WHERE ...
SQL 执行在真实数据库上执行模型生成的 SQLsqlite3 / MySQL / PostgreSQL
结果返回将查询结果返回给用户表格、JSON、文本

很多人第一次做 Text-to-SQL 时,只关注“调用大模型”这一步,以为把问题丢进去让模型输出 SQL 就完事了。但实际上,Schema 信息的组织方式往往决定了 SQL 生成质量的上限,SQL 执行环节的安全校验则决定了这系统能不能真正用在生产环境。这五步,哪一步都省不了。

2. 方案选型:先跑通再谈优化

2.1 三条技术路线的对比

目前做 Text-to-SQL,业内基本有三条路线:纯 Prompt 工程、模型微调、Agent + 工具调用的多轮方案。我先把它们放在一起对比一下:

技术路线实现成本生成效果维护成本适用场景
大模型 API + Prompt 工程低,几个小时内能跑通结构化表上效果良好低,改 Prompt 即可迭代中小型项目、报表系统
开源模型微调(如 SQLCoder、Qwen-Coder)高,需要标注数据和 GPU领域内效果较好较高,数据变化需重训数据敏感、需私有化部署
Agent + 多轮纠错中高,需要编排多步逻辑复杂问题处理能力更强中,逻辑链条长,排查难复杂查询、跨多库查询

对于想入门的人来说,我强烈建议第一条路线起步,原因很简单:它用最小的成本把整个链路跑通,让你理解 Text-to-SQL 的边界在哪里。当你把 Prompt 调到一定程度,发现效果上不去了,再考虑微调或 Agent 方案也不迟。

2.2 我为什么推荐先走“API + Prompt + SQLite”路线

选 SQLite 作为数据库,有两个理由:第一,它是 Python 内置的,不需要额外装数据库服务;第二,它的 SQL 语法足够覆盖大部分入门场景,而且支持标准的 SQL 查询。等你把链路跑通了,把连接方式从 sqlite3 换成 pymysql 或 psycopg2,代码改动量很小。

大模型接口的选择也很灵活。只要支持 OpenAI 兼容的 Chat Completions 接口,不管是哪家的模型都能用这套逻辑。建议选最新旗舰模型,SQL 生成这类强逻辑任务,模型能力差距非常明显,省这点钱反而浪费时间。

3. 最小闭环搭建:从零到出结果

3.1 准备数据表与连接环境

我们先建一个极其简单的业务库,两张表:商品表(products)和订单表(orders)。这样既能看到单表查询,也能演示 JOIN 关联。

建表语句和示例数据如下:

CREATE TABLE products ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, price REAL NOT NULL ); CREATE TABLE orders ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, product_id INTEGER NOT NULL, amount INTEGER NOT NULL, order_date TEXT NOT NULL, status TEXT NOT NULL );

这里我刻意把 order_date 设计成 TEXT 类型,而不是标准的 DATE 类型。很多实际业务系统里,日期就是按字符串存的,所以这里模拟真实情况,后续你会发现这对模型生成日期过滤条件是有影响的。

插入几条测试数据,方便验证:

INSERT INTO products (id, name, category, price) VALUES (1, 'iPhone 15', '手机', 6999), (2, 'MacBook Air', '笔记本', 7999), (3, 'AirPods Pro', '耳机', 1899), (4, 'iPad Air', '平板', 4799); INSERT INTO orders (id, user_id, product_id, amount, order_date, status) VALUES (1, 101, 1, 1, '2024-01-10', '已完成'), (2, 102, 2, 1, '2024-01-15', '已完成'), (3, 101, 3, 2, '2024-02-05', '已完成'), (4, 103, 1, 1, '2024-02-18', '已取消'), (5, 102, 4, 1, '2024-03-02', '已完成'), (6, 104, 3, 3, '2024-03-20', '已完成');

在 Python 中创建连接,使用只读模式打开数据库,这是第一个防呆手段,避免模型生成出 INSERT、UPDATE、DELETE 语句把数据改了。

import sqlite3 conn = sqlite3.connect('file:demo.db?mode=ro', uri=True) conn.row_factory = sqlite3.Row cursor = conn.cursor()

3.2 让大模型生成 SQL:Prompt 设计

大模型生成 SQL 依赖的上下文,至少包含三部分:数据库 Schema、用户问题、输出格式约束。Schema 信息是最容易被低估的,很多人只是简单贴一下建表语句,但这远远不够。

我常用的 Schema 组织方式是把字段的总表和关联关系都说清楚:

数据库有两个表: 1. products 商品表 - id: 商品ID,主键 - name: 商品名称 - category: 商品分类 - price: 单价(元) 2. orders 订单表 - id: 订单ID,主键 - user_id: 用户ID - product_id: 商品ID,关联 products.id - amount: 购买数量 - order_date: 下单日期,格式为 YYYY-MM-DD 字符串 - status: 订单状态,取值范围为 [已完成, 已取消] 注意: - 金额计算 = 单价 price * 数量 amount - 统计已完成的订单,需要加条件 status = '已完成'

Prompt 模板也建议固定下来,方便后续复用:

你是 SQL 专家,请根据用户的自然语言问题生成 SQLite SQL 查询语句。 数据库 Schema 如下: {schema} 用户问题:{question} 要求: 1. 只输出 SQL,不要输出任何解释。 2. 根据字段注释和取值约束生成 SQL。 3. 如果无法生成,输出 SELECT NULL;

把 schema 字符串单独抽出来,用 f-string 拼进去,这样换数据库时只需改 schema 部分,不需要动 Prompt 框架。这里有一个关键点:模型的 Temperature 参数要设为 0,因为 SQL 生成是确定性任务,不需要创造性,温度越高越容易编造字段名。

3.3 执行 SQL 并返回结果

调用大模型之后,拿到的返回值可能包含 markdown 代码块标记,比如 ```sql 开头,需要清洗一下。我用正则把 SQL 语句提取出来:

import re def extract_sql(text): pattern = r"```(?:sql)?\s*(.*?)\s*```" match = re.search(pattern, text, re.DOTALL) if match: return match.group(1).strip() return text.strip()

然后是对生成 SQL 的安全检查,这是我强烈建议加的一步。最少要检查两条:是否只包含 SELECT 关键字、是否包含注释符或堆叠查询,防止模型出力不讨好生成带副作用的语句。

def validate_sql(sql): sql_upper = sql.strip().upper() if not sql_upper.startswith('SELECT'): raise ValueError('只允许执行 SELECT 查询') dangerous_keywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE', 'ATTACH', 'PRAGMA'] for kw in dangerous_keywords: if re.search(r'\b' + kw + r'\b', sql_upper): raise ValueError(f'检测到危险关键字: {kw}') return sql

这里要说明一下,只检查是否以 SELECT 开头是有局限的,某些情况下恶意 SQL 可以绕过,但对于本地 Demo 排查和入门学习,这层检查足够拦住大多数手误。真正生产环境要做的是独立只读账号 + 白名单 + 数据库备份。

执行查询并格式化结果:

def run_query(sql): cursor.execute(sql) columns = [desc[0] for desc in cursor.description] rows = [dict(zip(columns, row)) for row in cursor.fetchall()] return rows

这样就完成了一个最小闭环:自然语言问题放进来了,SQL 由大模型生成,经过安全检查后执行,最后以结构化 JSON 返回结果。

3.4 完整调用逻辑示例

把上面几段串起来,核心调用代码大概长这样:

def text_to_sql(question): prompt = f"""你是 SQL 专家,请根据用户的自然语言问题生成 SQLite SQL 查询语句。 数据库 Schema 如下: {schema_str} 用户问题:{question} 要求: 1. 只输出 SQL,不要输出任何解释。 2. 根据字段注释和取值约束生成 SQL。 3. 如果无法生成,输出 SELECT NULL; """ response = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0 ) raw_sql = response.choices[0].message.content sql = extract_sql(raw_sql) sql = validate_sql(sql) return sql, run_query(sql)

这个函数就是整个最小闭环的核心,输入用户问题,输出查询结果。后续所有的优化都是围绕这个函数展开的。

4. 实战案例:3 个业务问题从问法到 SQL

4.1 单表简单查询

先试一个最简单的:“有多少个已完成订单?”

模型生成的 SQL:

SELECT COUNT(*) AS completed_order_count FROM orders WHERE status = '已完成';

这种单表查询对任何主流大模型来说都没难度。但要注意,这里有个细节:如果你把状态字段注释写成“订单状态,取值范围 [已完成, 已取消]”,模型大概率会正确加 WHERE 条件;如果不写,模型有可能会漏掉条件,把全部订单都统计进去。字段取值约束是 Prompt 中最容易见效的信息。

4.2 多表关联与聚合

再来一个稍微复杂一点的:“各品类的销售额是多少?”

由于销售额需要商品单价乘以订单数量,而且只有已完成的订单才算销售,所以这条 SQL 需要 JOIN 两张表:

SELECT p.category, SUM(p.price * o.amount) AS total_sales FROM orders o JOIN products p ON o.product_id = p.id WHERE o.status = '已完成' GROUP BY p.category;

我实测的体验是,只要 Schema 信息里写清楚了“price 单价”和“amount 数量”,以及“金额计算 = 单价 * 数量”,模型基本都能正确写出 JOIN 条件和聚合逻辑。但如果 Schema 里没写清楚两张表的关联字段,模型就可能猜错连接条件,比如用o.id = p.id这种完全错误的方式关联,所以关联关系在 Schema 描述中务必明示。

4.3 带业务口径的复杂查询

第三类问题考验的是“业务口径理解能力”:“2024 年 2 月购买耳机超过 1 个的用户有哪些?”

模型需要同时处理三件事:过滤 date 字段对应 2 月、关联商品表拿到耳机分类、用 HAVING 对聚合结果做条件筛选。

SELECT o.user_id FROM orders o JOIN products p ON o.product_id = p.id WHERE p.category = '耳机' AND o.order_date LIKE '2024-02%' AND o.status = '已完成' GROUP BY o.user_id HAVING SUM(o.amount) > 1;

这里最容易出错的地方是日期过滤条件。如果 order_date 是标准 DATE 类型,模型一般会写成BETWEEN '2024-02-01' AND '2024-02-29';但因为它是 TEXT 类型,模型可能会选择LIKE '2024-02%'这种写法。两种写法都能查询,但我建议在 Schema 注释里明确写明“日期按字符串存储,格式 YYYY-MM-DD”,这样模型会优先选择字符串匹配,减少类型转换出错的可能。

5. 提质技巧:从“能用”到“好用”

5.1 Schema 信息要结构化,而不是堆字段

第一版我做 Schema 提示的时候,直接把完整的 CREATE TABLE 语句贴进 Prompt,这样做能用,但有两个问题:一是模型对无关字段的注意力会被分散,二是缺少对字段语义的解释。后来我改成“字段名 + 类型 + 注释”的文本结构,给模型的信息更精炼,生成正确率明显提升。

一个推荐的 Schema 描述模板:

orders 订单表: id: INTEGER, 订单ID, 主键 user_id: INTEGER, 下单用户ID product_id: INTEGER, 商品ID, 关联 products.id amount: INTEGER, 购买数量 order_date: TEXT, 下单日期, 格式 YYYY-MM-DD status: TEXT, 订单状态: 已完成/已取消

对于字段量很大的表,建议只放常用字段,不要一股脑全塞进去。字段超过 20 个之后,大模型的注意力会明显下降,生成的 SQL 反而更容易出错。

5.2 用 Few-shot 示例教会模型写口径

如果你发现模型在某种类型的查询上反复出错,比如日期区间、百分比计算、窗口函数排名,最有效的解决方式不是修改系统提示词,而是给几个“问题 -> SQL”的示例。

示例1: 问题:上个月销售额最高的商品是哪个? SQL:SELECT p.name, SUM(o.amount * p.price) AS sales FROM orders o JOIN products p ON o.product_id = p.id WHERE o.status = '已完成' AND strftime('%Y-%m', o.order_date) = strftime('%Y-%m', 'now', '-1 month') GROUP BY p.name ORDER BY sales DESC LIMIT 1; 用户问题:{question}

Few-shot 的本质是给模型一个“模仿模板”,它能大幅降低模型理解业务口径的成本。示例不在多,两三个覆盖常见复杂度的就够了,关键是每个示例都要和用户真实问题在结构上相似。

5.3 加一层 SQL 校验与执行保护

生成 SQL 之后,在执行之前加保护,这个是生产环境必须做的。哪怕只是入门 Demo,我也建议至少做三件事:只允许 SELECT、给查询加 LIMIT 上限、设置查询超时。

def safe_execute(sql, limit=200): sql = validate_sql(sql) if 'LIMIT' not in sql.upper(): sql = sql.rstrip(';') + f' LIMIT {limit};' cursor.execute(sql) return cursor.fetchall()

这里的逻辑很简单:模型生成的查询如果没有 LIMIT,默认帮你加一个,防止用户无意间查全表导致卡死。如果表数据量很大,这种保护能从根源上避免慢查询拖垮服务。

5.4 上线前必须做的评测

Text-to-SQL 是一锤子买卖还是能真正落地,靠感觉是不行的,要建立一套自己的评测集。拿 20 条经典问题,覆盖单表查询、多表 JOIN、聚合计算、日期过滤、排序分页这几类,跑完记录“执行正确”的条数。我用的是执行准确率,也就是 SQL 执行结果和标准答案是否一致,这个指标最贴近用户真实感受。

测试集固定下来之后,每次调整 Prompt 或者更换模型,都跑一遍同一套测试集做对比,改动是提升还是回退一目了然。这比“我试了感觉好用”靠谱得多。

6. 常见问题与排查实录

6.1 模型生成的 SQL 有语法错误怎么办

这是最常遇到的问题,尤其是表名或字段名比较长的时候,模型偶尔会凭空捏造一个相似的字段名出来。我遇到最多的情况是,模型把products.category写成了products.product_category,这种一看就是幻觉。

排查思路:把生成 SQL 的报错信息带回给大模型,让它自行修正。做法是把原始 SQL 和数据库报错一起作为新消息传给模型。

messages = [ {"role": "user", "content": f"请根据问题生成 SQL:{question}\n{schema_str}"}, {"role": "assistant", "content": sql}, {"role": "user", "content": f"执行这个 SQL 时报错:{error_msg},请修正后只输出新 SQL"} ]

实测下来,第二轮纠错成功率很高。这也是 Agent 方案的核心思想:执行 -> 报错 -> 反馈 -> 重新生成。

6.2 模型编造不存在的表名或字段

表名和字段名是模型的幻觉重灾区。原因是自然语言描述和实际字段名之间存在差异,比如用户说“日期”,表里叫 order_date,模型可能猜成 order_time。

最有效的解决方法是给字段加“语义别名”,在 Schema 注释里写明:

order_date: TEXT, 下单日期(即用户说的“日期/下单时间”) status: TEXT, 订单状态(即用户说的“状态/是否为有效订单”)

把问题中的口语化表达和字段名建立对应关系,幻觉概率会大幅下降。这个方法在没有任何额外代码的情况下,能显著提升生成质量。

6.3 日期和字符串过滤总是出问题

日期过滤出错的本质是模型不了解字段的实际存储格式。ORDER BY 一个 TEXT 类型的日期字段时,如果格式不统一,排序结果可能是错的。这类问题的一个通用解法是先做一个“字段格式探测”,把每个字段的几个样例值放进 Prompt 里,模型看到样例值后生成正确格式的概率会高很多。

order_date 示例值: ['2024-01-10', '2024-03-20']

6.4 SQL 执行超时或返回数据量过大

一个自然语言问题翻译成 SQL 后,可能变成对整个大表做全量扫描。比如用户说“统计所有人的总订单金额”,模型生成的是没有 WHERE 条件的聚合查询,如果表有几亿行,这查询能把数据库拖垮。

解决思路是把 demo 级别保护当成底线:默认加 LIMIT、设置数据库查询超时时间、对没有 WHERE 条件的 SELECT 进行拦截。在入门阶段,把保护加好,之后上线到生产才会省心。

7. 本地模型方案:不想调 API 的替代路径

如果你所在的环境不能调用外部 API,或者数据敏感必须本地处理,也有办法跑通这个闭环。当前的开源模型里,Qwen2.5-Coder 和 SQLCoder 在 SQL 生成上效果都不错。用 Ollama 跑本地模型:

ollama run qwen2.5-coder:7b

Python 调用方式也简单:

import ollama response = ollama.chat( model='qwen2.5-coder:7b', messages=[{'role': 'user', 'content': prompt}] ) raw_sql = response['message']['content']

本地模型和 API 模型的差异在于:API 模型的指令跟随能力强,开箱即用效果好;本地模型需要更详细的 Schema 描述,对提示词措辞更敏感。但它的好处是免费、私有、无网络依赖。把上面的 Prompt 和链路原封不动切换即可。如果你手头有 16G 显存的 GPU,推荐至少试一下 7B 或 14B 的模型,在 SQL 这个任务上,效果已经足够日常使用。

我在实际项目中踩过不少坑,最值得分享的一个经验是:不要一上来就折腾模型微调,先把 Prompt 工程做到位。很多效果问题其实出在 Schema 信息不完整、字段注释不清晰上,而这些问题用提示词就能解决。另外,我的习惯是让模型在输出 SQL 的同时,顺带生成一段中文解释,比如“该查询会统计已完成订单中,各商品分类的总销售额”,这样你一眼就能发现模型理解错了业务口径,这比直接拿 SQL 去执行再核对结果要快得多。等这块闭环跑顺了,你自然就知道下一步该往哪个方向卷了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 4:17:03

C/C++宽字符处理:wchar_t与_T()宏详解

1. wchar_t基础解析wchar_t是C/C中用于表示宽字符(wide character)的数据类型,它被设计用来支持扩展字符集。与普通的char类型(通常为8位)不同,wchar_t的大小取决于具体实现:在Windows平台上,wchar_t通常是16位(2字节)&#xff0c…

作者头像 李华
网站建设 2026/9/13 4:15:32

Python内置sqlite3实战:从建库到百万数据稳定写入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 4:14:53

QMK 键盘固件中 IS31FL3218 LED 驱动器的完整配置与 API 实战指南

QMK 键盘固件中 IS31FL3218 LED 驱动器的完整配置与 API 实战指南 【免费下载链接】qmk_firmware Open-source keyboard firmware for Atmel AVR and Arm USB families 项目地址: https://gitcode.com/GitHub_Trending/qm/qmk_firmware IS31FL3218 是 Lumissil 出品的 I…

作者头像 李华
网站建设 2026/9/13 4:13:55

Ehlib12.0分组功能详解与Delphi数据网格优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 4:12:56

开源相控阵雷达PLFM_RADAR:低成本高性能实现路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华