从零搭建Text-to-SQL最小闭环:模型调用、Schema注入与错误重试
写在前面从一个让人抓狂的需求说起我在一家数据团队待了几年最常听到的一句话不是“这个报表怎么做”而是“帮我写个SQL我要看XX数据”。业务同学手里攥着需求开发同学排着队写查询一张简单的订单明细表一天能被人问八遍。后来我认真想过这件事与其继续当“人肉SQL转换器”不如让大模型来干这个活。Text-to-SQL 这两年火得不是没道理。它的目标很简单——把“用自然语言描述查询需求”直接转成“能跑的SQL语句”。比如你输入“查一下上个月每个城市的销售额和订单量”模型就应该吐出一句在 MySQL 或 PostgreSQL 上可以直接执行的 SQL。这篇文章我想和你分享的不是那种包装精美的框架和论文而是我从零搭起来的一个最小闭环模型调用、Schema 注入、SQL 生成、结果校验、错误重试每一步都聊清楚为什么这么设计以及中间踩过哪些坑。这篇文章适合谁如果你刚开始接触大模型应用开发或者天天被 SQL 查询需求折磨又或者你想知道“大模型写SQL到底靠不靠谱”那这篇内容应该能给你一个比较完整的答案。1. Text-to-SQL 到底在解决什么问题1.1 一句话说清楚 Text-to-SQL 是什么Text-to-SQL 的价值不是“炫技”而是把“人懂业务、但不懂数据库结构”和“系统懂数据库、但不懂业务语义”这两个世界连接起来。一句话解释你输入一句普通中文它输出一条符合目标数据库语法的 SQL 语句。举个例子数据库里有一张orders表字段包括order_id、user_id、amount、created_at还有一个users表存用户性别和城市。以前你想知道“北京地区女性用户的下单总金额”得自己 join 两张表、写聚合函数。有了 Text-to-SQL你只需要说一句人话模型会负责理解“北京”对应users.city“女性”对应users.gender,“下单总金额”对应SUM(orders.amount)然后把 JOIN、GROUP BY 全部给你拼好。这个能力放到实际场景里是非常有用的。数据分析平台可以让业务同学直接用对话查数后台系统可以内置一个“自然语言查询助手”连 Excel 用户都能受益。说白了它不是替代数据分析师而是把“写SQL”这个体力活从日常沟通里剥离掉。1.2 为什么这项任务没有想象中那么简单很多人第一次听说 Text-to-SQL第一反应是“这有什么难的不就是让大模型输出一段SQL吗”。等你真去试一次就明白了坑比想象中多得多。首先模型需要理解数据库结构也就是 Schema。它得知道有哪些表、每个表有什么字段、字段类型是什么、表之间靠什么关联。这些信息如果你不主动喂给模型它就只能靠猜猜出来的结果大概率是错的。其次大模型不是数据库它没见过你的业务表它甚至可能把自己训练时见过的那些“经典表结构”套在你的数据上。比如你的表叫t_order_info它会自作聪明地写FROM orders然后告诉你“执行出错表不存在”。再有就是 SQL 方言问题。MySQL 的写法、PostgreSQL 的写法、SQL Server 的写法不完全一样LIMIT和TOP就够让模型懵一阵。如果模型不知道你在用什么数据库它会按照自己最熟悉的语法来最后在你的 SQL Server 上报一堆语法错误。所以你会发现Text-to-SQL 真正要解决的不是“让大模型会写SQL”而是“让大模型在限定条件下写对SQL”。这个“限定条件”包括准确的 Schema 信息、明确的 SQL 方言、清晰的业务术语映射以及必要的示例。1.3 怎么评价模型写得好不好做技术的人都明白没有评价标准就没有优化方向。Text-to-SQL 领域有两个最常用的指标。一个是 Execution Accuracy也就是执行准确率。把模型生成的 SQL 真实跑一遍看结果跟标准答案是否一致一致就是对了。这个指标最实用因为它不关心 SQL 长什么样只关心结果对不对。另一个是 Exact Match要求模型生成的 SQL 和人工标注的标准 SQL 在文本层面完全一致或经过规范化后一致。这个指标非常严格但实际意义没那么大。因为同一个查询需求本来就有多种等价写法结果对就行没必要要求每个字符都一样。实际项目里我习惯以“执行准确率”为主辅以“人工抽检”。毕竟大模型生成的 SQL 风格可能和团队规范不一致但只要结果正确、性能可接受就值得用。2. 最小闭环的架构设计与技术选型2.1 完整链路长什么样先给大家画一下我心里那个“最小闭环”的链路非常简单但该有的环节一个不少用户输入一句自然语言问题 → 系统把数据库 Schema 和问题拼成一个 Prompt → 调用大模型生成 SQL → 在目标数据库执行 SQL → 把执行结果或报错信息返回给用户。这五步看起来平平无奇但每一步都有它的门道。Schema 怎么拼才能让模型看懂模型输出了一堆 Markdown 包裹的 SQL怎么干净地提取出来SQL 执行报错了是直接甩给用户还是让模型自己看报错信息再改一遍这些细节决定了一个 demo 能不能变成真正好用的工具。我见过不少人跑通第一步就觉得自己完成了其实那只是万里长征第一步。一个能用的 Text-to-SQL 系统必须有“执行和反馈”这个环节。让模型生成 SQL然后真去数据库里跑跑不通就让模型读报错重写跑通了才把结果给用户——这个循环才是闭环的“闭”字所在。2.2 模型选型API 还是本地模型先说 API 方案。现在国内外的模型厂商基本都提供了兼容 OpenAI 格式的接口你只需要拿到 API Key用requests或者官方 SDK 就能调用。这种方式胜在省事、效果稳定适合快速验证和中小流量场景。再说本地模型。像通义千问的 Qwen 系列、阿里的 Qwen2.5-Coder 这类专门优化过代码能力的模型都可以通过 Ollama 或者 vLLM 部署在本地。本地部署的好处是数据不出内网、长期使用没有按量费用但需要一张像样的显卡而且整体效果和顶级 API 模型还有差距。我的建议是第一版先用 API 模型把流程跑通验证业务方对这个能力是否买账。如果效果不错、调用量上来了再做模型替换或者本地化部署。不要一上来就买显卡跑微调很可能钱花了不少需求本身却并不成立。顺带多提一句选模型的时候重点关注代码能力和指令跟随能力而不是看它的通用对话分有多高。你可以拿几条典型的查询需求做一个小测试集把几个模型都跑一遍对比一下执行准确率再决定用哪个。2.3 三个关键设计原则第一个原则是“把数据库结构当成上下文喂给模型”而不是让模型猜。我见过很多人写 Prompt 时只写一句“你是SQL专家”然后直接把用户问题扔进去这基本上就是在开盲盒。没有 Schema 信息再强的模型也写不对。第二个原则是“让模型输出结构化内容”。你可以让模型返回一个 JSON里面包含sql、explanation等字段这样程序解析起来非常方便不用靠正则去 Markdown 代码块里捞 SQL。第三个原则是“执行失败不要直接放弃要反馈给模型重写”。实测下来很多第一次生成错误的 SQL把报错信息贴回去让它改第二次就能跑通。这个机制成本极低但能显著提升成功率。3. Prompt 与 Schema 注入这套方案的核心3.1 把数据库结构变成模型能看懂的文本我们先从最基础的一步开始怎么把数据库结构变成 Prompt 的一部分。假设我有一张用户表和一个订单表它们的建表语句长这样CREATE TABLE users ( user_id INT PRIMARY KEY, name VARCHAR(50), gender VARCHAR(10), city VARCHAR(50) ); CREATE TABLE orders ( order_id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), created_at DATETIME );最简单粗暴但很有效的做法是直接把建表语句贴给模型。因为CREATE TABLE语句里包含了表名、字段名、字段类型、主键信息模型对这种格式非常熟悉它自己训练语料里就有海量建表语句所以理解起来毫无压力。但这里有一个细节如果表非常多比如有上百张表那把全部建表语句都塞进 Prompt 会让上下文爆炸而且模型容易被无关信息干扰。更合理的做法是先做一个“Schema 检索”根据用户问题里的关键词只把相关表的结构拼进 Prompt。比如用户问“上个月每个城市的销售额”你应该把orders表和users表的结构给他而不需要把product、inventory这些无关表也灌进去。这一步在早期可以先用简单的关键词匹配后面可以换向量检索逻辑是一样的。3.2 把约束条件和示例一起告诉模型Prompt 里只说“相关表结构”还不够你还需要给模型设定几条明确的约束。以我自己的经验下面这些约束几乎是必须的只允许生成 SELECT 查询禁止 INSERT、UPDATE、DELETE、DROP 等任何写操作。必须使用用户指定的 SQL 方言比如 MySQL 或 SQL Server。如果表或字段不存在不要编造如实返回错误。遇到模糊的问题可以先提问澄清而不是硬写。然后就是 few-shot 示例也就是给模型一到两个“问题 → SQL”的样例。示例的作用不仅是让模型学会格式更重要的是让它理解你那套 Schema 里的一些“约定俗成”的写法。比如你们数据库里日期字段虽然叫created_at但业务上习惯用它代表“下单时间”一个示例就能让模型明白这个映射关系。我通常会给两到三个示例覆盖“单表筛选”和“多表 JOIN 聚合”两种场景。不需要多多了反而容易让模型被带偏。3.3 让模型输出 JSON而不是裸 SQL这是我从多次实践中总结出的一个很实用的技巧不要让模型直接输出 SQL 字符串而是让它输出一个 JSON 对象。比如这样{ sql: SELECT city, SUM(amount) AS total_amount FROM orders JOIN users ON orders.user_id users.user_id WHERE created_at 2025-01-01 AND created_at 2025-02-01 GROUP BY city, explanation: 筛选2025年1月的订单按城市分组计算销售额 }这样做有三个好处。第一程序解析方便json.loads()一下就拿到 SQL不用处理 Markdown 代码块、各种引号转义的问题。第二强制模型“先想后写”它要先思考这个查询的语义再组织 SQL效果通常比直接输出 SQL 更好。第三后续如果想做“解释一下这条查询”的产品功能explanation字段可以直接用。需要提醒的是有些模型不擅长严格输出 JSON可能返回带注释的文本。这时候可以在 Prompt 里加一句“只输出 JSON不要输出其他任何内容”一般能解决。4. 从零跑通闭环代码实操4.1 准备一个本地数据库为了演示我用 SQLite 起一个极简数据库。SQLite 的好处是零配置、单文件、不需要装服务用来做本地测试再合适不过。建两张表和几条测试数据import sqlite3 conn sqlite3.connect(demo.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS users ( user_id INTEGER PRIMARY KEY, name TEXT, gender TEXT, city TEXT ) ) cursor.execute( CREATE TABLE IF NOT EXISTS orders ( order_id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, created_at TEXT ) ) cursor.executemany( INSERT INTO users VALUES (?, ?, ?, ?), [ (1, 张三, 男, 北京), (2, 李四, 女, 上海), (3, 王五, 女, 北京), (4, 赵六, 男, 广州), ], ) cursor.executemany( INSERT INTO orders VALUES (?, ?, ?, ?), [ (101, 1, 100.0, 2025-01-10 12:00:00), (102, 2, 200.0, 2025-01-15 13:00:00), (103, 3, 150.0, 2025-01-20 14:00:00), (104, 1, 50.0, 2025-02-01 14:30:00), (105, 4, 300.0, 2025-02-05 15:00:00), ], ) conn.commit()真实场景里这一步通常会连 MySQL 或者 PostgreSQL但原理完全一样。你可以把sqlite3的连接方式换成pymysql或者psycopg2Schema 信息通过查系统表拿就行。4.2 核心代码一个最小的 Text-to-SQL 闭环下边这段代码是我实际在用的最小实现我把每一步都注释清楚了。它做的事就是拼 Schema 和用户问题 → 调模型拿 SQL 和解释 → 在 SQLite 里执行 → 报错就反馈给模型重试。import json import sqlite3 import requests # 这里以兼容 OpenAI 格式的 API 为例 API_URL https://your-api-endpoint/v1/chat/completions API_KEY your-api-key MODEL_NAME your-model-name # 1. 准备好 Schema 描述 SCHEMA_DESCRIPTION 数据库共有两张表 CREATE TABLE users ( user_id INT PRIMARY KEY, name VARCHAR(50), gender VARCHAR(10), city VARCHAR(50) ); CREATE TABLE orders ( order_id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), created_at DATETIME ); 字段说明 - users.user_id: 用户ID - users.name: 用户名 - users.gender: 性别 - users.city: 用户所在城市 - orders.order_id: 订单ID - orders.user_id: 下单用户ID关联 users.user_id - orders.amount: 订单金额 - orders.created_at: 下单时间 # 2. 拼接 Prompt 并调用模型 def generate_sql(user_question, error_feedbackNone): system_prompt ( 你是一名资深SQL工程师。请根据给定的数据库结构将用户的中文问题转换为SQL查询语句。\n 约束条件\n 1. 只允许生成 SELECT 查询禁止生成 INSERT、UPDATE、DELETE、DROP、ALTER 等语句。\n 2. 数据库为 SQLite 语法。\n 3. 如果问题涉及的字段或表不存在请直接说明不要编造。\n 4. 输出必须是一个 JSON 对象包含 sql 和 explanation 两个字段。\n 5. 只输出 JSON不要输出 Markdown 代码块或其他内容。\n ) user_prompt f数据库结构如下\n{SCHEMA_DESCRIPTION}\n\n用户问题{user_question} if error_feedback: user_prompt f\n\n你之前生成的 SQL 执行报错了报错信息如下\n{error_feedback}\n请根据报错信息修正 SQL。 resp requests.post( API_URL, headers{ Authorization: fBearer {API_KEY}, Content-Type: application/json, }, json{ model: MODEL_NAME, messages: [ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], temperature: 0.2, }, timeout60, ) result resp.json() content result[choices][0][message][content] try: parsed json.loads(content) return parsed.get(sql), parsed.get(explanation) except json.JSONDecodeError: # 有些模型偶尔会输出多余文字这里做一次简单的兜底 start content.find({) end content.rfind(}) 1 if start ! -1 and end start: parsed json.loads(content[start:end]) return parsed.get(sql), parsed.get(explanation) return None, 模型输出无法解析 # 3. 在数据库中执行 SQL def execute_sql(sql): conn sqlite3.connect(demo.db) cursor conn.cursor() try: cursor.execute(sql) columns [desc[0] for desc in cursor.description] rows cursor.fetchall() return columns, rows, None except Exception as e: return None, None, str(e) finally: conn.close() # 4. 组装最小闭环生成 - 执行 - 报错反馈重试 def text_to_sql_loop(user_question, max_retries2): sql, explanation generate_sql(user_question) for attempt in range(max_retries 1): if not sql: return {error: 模型未生成有效SQL, explanation: explanation} print(f[第{attempt 1}次尝试] SQL: {sql}) columns, rows, err execute_sql(sql) if err is None: return {sql: sql, columns: columns, rows: rows, explanation: explanation} print(f[执行报错] {err}) sql, explanation generate_sql(user_question, error_feedbackerr) return {error: 多次重试后仍无法生成可执行的SQL, last_error: err} if __name__ __main__: question 2025年1月每个城市的订单总金额 result text_to_sql_loop(question) print(json.dumps(result, ensure_asciiFalse, indent2))这段代码的核心思想不复杂但“执行-报错-重试”这个循环价值非常大。实际跑下来第一次生成就成功执行的比例可能在百分之六七十加上重试机制以后能到百分之九十以上。这个提升不是靠调 Prompt 调出来的而是靠“让模型看到真实报错再改”这个朴素的策略。4.3 实际效果演示拿上面的代码跑一遍用户问题“2025年1月每个城市的订单总金额”我得到的输出长这样{ sql: SELECT users.city, SUM(orders.amount) AS total_amount FROM orders JOIN users ON orders.user_id users.user_id WHERE orders.created_at 2025-01-01 AND orders.created_at 2025-02-01 GROUP BY users.city, columns: [city, total_amount], rows: [ [上海, 200.0], [北京, 250.0] ], explanation: 统计2025年1月各城市的订单总金额通过订单表和用户表关联按城市分组汇总金额。 }北京 250.0 来自用户张三的 100 和王五的 150上海 200.0 是李四的 200数据完全对得上。这里我想多说一句很多人以为让大模型写 SQL难点在于“生成”其实“校验”同样关键。没有执行校验的 Text-to-SQL就像没编译过的代码直接上线你敢信它它不一定对得起你。加上了执行和反馈整体的可靠性才有保障。5. 常见故障与排查经验5.1 幻觉问题模型编造不存在的表和字段这是我在实际使用里遇到最多的问题没有之一。模型看着你的用户问题再看看 Schema有时候会“灵机一动”写出一个看起来像那么回事、但实际上完全不存在的东西。举个例子用户问“查一下每个品类的库存”Schema 里根本没有category表也没有stock字段模型却可能写SELECT category, stock FROM products而products表压根不存在。应对手段有两个。第一是在 Prompt 里反复强调“必须严格基于给定 Schema禁止使用不在列表中的表和字段”。第二是在执行层做强校验可以用正则或者 AST 解析把 SQL 里用到的表名和字段名提取出来跟 Schema 里的合法列表比对一遍不合法就拦截。像sqlglot这个 Python 库就可以做 SQL 的解析和校验比正则靠谱得多。5.2 语法正确但结果不对有些 SQL 能跑通但结果和业务预期不一致。比如“2025年1月的订单”模型写成了WHERE created_at 2025-01-01 AND created_at 2025-01-31这句本身没错但没想清楚边界——如果订单时间精确到秒2025-01-31 23:59:59之后的同一秒内订单就漏掉了。正确写法应该是 2025-02-01。这种问题靠执行校验发现不了因为 SQL 能跑结果也不是空的就是差了一点点。解决的思路是给模型提供更准确的业务口径说明比如在 Schema 描述里写明“所有时间字段均为 DATETIME 类型查询某月数据时请使用 月初 AND 下月初的区间写法”。这种“业务知识注入”对准确率的提升非常明显。5.3 安全风险不容忽视Text-to-SQL 有一个天然的安全隐患如果你的系统允许模型生成任意 SQL 并执行那么它也可能生成DELETE FROM users或者DROP TABLE orders这种灾难性语句。即使模型本身没有恶意一次 Prompt 注入攻击就能让系统干出离谱的事。我强烈建议有三道防线第一Prompt 层硬约束只允许 SELECT第二执行层用最小权限账号连接数据库这个账号只有SELECT权限连INSERT、UPDATE、DELETE都没有第三在应用层做 SQL 解析检查首个关键字是否为SELECT用正则或者 SQL 解析库拦掉其他任何语句。这三道防线少一道我都觉得不踏实。5.4 速度和成本控制生成一条 SQL 通常需要几百到上千个 token调用一次 API 可能花几毛钱到几块钱不等具体取决于模型定价。如果每来一个查询都走“生成-执行-报错-再生成”的循环一次对话可能产生三四次调用成本会翻倍。我的做法是加了一层简单缓存。把“用户问题 Schema 的哈希”作为 keySQL 作为 value 存进 Redis 或数据库命中就直接返回省掉一次模型调用。同时把重试次数限制在两次以内。实测下来缓存命中率能有百分之二三十长期看节省的费用相当可观。6. 往后可以怎么进阶6.1 Schema 太大怎么办前面提到表特别多的时候把全部 Schema 塞进 Prompt 不现实。一个比较通用的解法是把“Schema 字段注释 示例值”提前做成向量用户问题来了以后先做向量检索只找出最相关的三四张表拼进 Prompt。这一步有点像 RAG检索增强生成的思路效果好不好取决于你对字段注释写得细不细。注释写得越清楚检索和生成的效果都越好。6.2 微调一个专属 SQL 模型如果通用模型在你的业务上始终差那么一点可以考虑微调。像 Qwen2.5-Coder 这样的开源模型在代码能力上已经相当能打用几百条“问题-SQL”样本做一次 LoRA 微调是不少团队验证过的路径。但微调不是银弹。你需要准备高质量的标注数据、一台带 GPU 的机器而且迭代周期比 Prompt 工程长得多。我建议先把 Prompt、Schema 注入、错误重试这些“软功夫”做到位如果效果还差再考虑微调。很多时候你缺的不是模型能力而是工程细节。6.3 多轮对话与 Agent 化自然语言查数的真实需求很少是“一句话就结束”的。用户可能会追问“那上个月呢”“只看北京的数据”“按周再拆一下”。这时候你需要把整个对话历史都传给模型让它根据上文生成新的 SQL。这个方向再往前延伸就是 Agent 化——把数据库连接、Schema 获取、SQL 执行、结果分析都封装成工具让大模型自己决定调用哪些工具。业界现在有不少开源框架在做类似的事感兴趣可以往这个方向研究。但也要提醒一句自由度越高出错的可能性越大落地的时候务必保持审慎。最后分享一点个人心得整个闭环跑通之后我最大的感受是Text-to-SQL 的价值不在于“替代谁”而在于把一类高频、重复、有明确规则的劳动自动化。它做不了复杂的业务分析但能帮你解决那些“不看数据不知道看了数据吓一跳”的日常问题。如果你也想尝试我的建议是别急着上多么复杂的架构先按这篇文章的思路把最小闭环跑起来让业务方拿真实需求来“打”再根据反馈一点一点把细节磨好。另外提醒一句模型输出的 SQL 一定要有人工抽检的机制尤其是刚开始上线的时候别让一条错误的 SQL 带偏了业务决策。语言模型会犯错但只要你把校验、重试、权限防护这些工程细节做到位它就会成为你手上非常省力的工具。