从零实现Text-to-SQL最小闭环:用大模型把中文问句变成可执行SQL
2026/9/14 4:17:28 网站建设 项目流程

在数据开发这个圈子里摸爬滚打久了,你会发现一个特别现实的问题:SQL 的编写门槛,其实和数据思维的门槛是两回事。很多人业务逻辑想得清清楚楚,一坐到数据库客户端前就卡壳,一个 join 绕半小时,窗口函数更是能躲就躲。另一边,天天写 SQL 的老手也在烦,大量取数需求其实就是模板叠条件,重复劳动占了大半时间。Text-to-SQL这个方向,就是想把"自然语言直接转成可执行 SQL"这条路走通,让大模型来承担从想法到语法的翻译工作。这篇文章我不聊那些复杂的论文和榜单,就带你把一个最小闭环从零跑通:输入一句中文问句,输出一条可执行的 SQL,并且让 SQL 真正在数据库里跑出结果。整个过程不依赖任何重型框架,代码量控制在你能在一小时内读完的水平,适合刚接触大模型应用开发、想快速验证 Text-to-SQL 可行性的朋友。

1. 内容整体设计与思路拆解

1.1 先搞清楚 Text-to-SQL 到底在解决什么问题

我见过太多团队对 Text-to-SQL 抱有不切实际的期待,觉得它是个"万能取数机器人"——丢一句话进去,什么复杂报表都能给你算出来。实际上,当前大模型的能力边界远没到那个程度,但它在特定场景下的提效又是实打实的。要理解 Text-to-SQL,可以先把它拆成两个层次看:

第一层叫**"语义对齐"**。比如业务方问"上个月华东区销售额 Top10 的商品是哪几个",这句话里包含时间条件(上个月)、区域条件(华东区)、排序需求(Top10)、聚合粒度(商品维度)。一个不懂业务的模型很容易把"上个月"翻译成"last month"就直接拼进 SQL,但实际表里存的可能是 date 字段,也可能是 paid_at 时间戳,还可能需要排除退款订单。这层能力考验的是模型对业务口径的理解。

第二层叫**"语法生成"**。把理解到的语义转换成符合 SQL 语法、能在目标数据库上执行的语句。这个看似简单,实则坑很多:MySQL、PostgreSQL、SQL Server 的语法差异,分页怎么写,日期函数叫什么名字,字符串拼接用什么符号,这些细节模型很容易记混。

如果把这两层能力拆开,你会发现传统开发模式里,这两件事都是人在做。业务方把需求提给数据开发,数据开发先理解需求(语义对齐),再写查询(语法生成),中间还要反复确认口径。Text-to-SQL 想替代的,主要就是那些"需求明确、口径清晰、逻辑不复杂"的取数场景,把人的精力从重复劳动里解放出来。

1.2 为什么选大模型方案而不是传统规则解析

在聊技术选型之前,先说个很多人踩过的坑:最早做 Text-to-SQL 的人,用的根本不是大模型。业界有过一段基于规则和模板匹配的时期,比如把"xx 的 xx"这类句式映射成 select ... from ... where ...,或者用句法分析树去提取查询意图。这类方案在固定问答对里表现尚可,一旦句子结构稍微自由一点,比如"帮我看看哪些用户既买了 A 又买了 B 但没买过 C",规则就彻底崩了。

大模型方案的核心优势在于泛化能力。你不需要预先枚举所有可能的问法,模型基于海量预训练数据里的 SQL 知识,能够理解灵活的、口语化的表达。而且现在大模型对代码的理解能力明显强于对纯文本的理解能力,SQL 作为一种结构化语言,恰恰是模型比较擅长的输出形式。

当然,选大模型方案也要接受它的代价:推理有延迟、输出有幻觉、token 要花钱(如果用 API)。但这些代价在"最小闭环"验证阶段完全可控,我自己做技术预研时,通常先跑通链路,再回来优化延迟和成本。这个思路你后续做任何大模型应用都适用——先证明能做,再证明做得好。

2. 核心细节解析与实操要点

2.1 最小闭环的架构拆解:四件套缺一不可

一个能真正跑起来的 Text-to-SQL 闭环,绝不仅仅是"把问题丢给大模型"这么简单。根据我的实践经验,核心链路至少包含四个部分:

  • 数据库表结构信息(Schema):模型必须知道有哪些表、每张表有哪些字段、字段含义、数据类型,以及表之间的关系。没有 Schema 的提示词就像让一个新员工不带任何资料去查数,全靠猜。
  • 大模型推理服务:负责把自然语言转成 SQL。这个环节可以是云端 API,也可以是本地部署的模型。
  • SQL 校验与执行层:生成完 SQL 不能直接执行,得做安全检查,确认没有破坏性操作后,再去数据库里查询。
  • 结果封装与展示:把查询结果结构化输出,方便上层调用或展示给用户。

你可能觉得这四部分里"大模型推理"才是核心,但实际跑下来会发现,Schema 管理和结果校验往往才是决定成败的关键。我见过不少团队在模型上反复调参,效果始终不理想,最后发现是 Schema 设计一塌糊涂——字段名是拼音缩写,没有注释,表关系也没告知模型,再强的模型也白搭。

2.2 技术选型:云端 API 还是本地模型

在这个最小闭环里,选哪家大模型,主要看你手里的资源。这里我直接说结论:个人学习、小团队验证,优先选云端 API;有数据合规要求或需要离线运行,再考虑本地模型。

云端 API 的优势是省事。你不需要买显卡,不需要处理推理服务的高并发,一行请求就能拿到结果。目前主流的几家大模型 API 都支持对话补全接口,把 SQL 生成的请求塞进 system prompt 和 user prompt 里即可。缺点在于数据要出内网,部分公司会有合规风险,另外调用量大了费用也不容忽视。

本地模型则是另一条路线。用 Ollama 这类工具拉一个开源模型下来,比如 Qwen 系列或者 Llama 系列,然后在本地起一个兼容 OpenAI 格式的服务。这么做的好处是数据不出内网,调试也方便;坏处是效果——尤其是 SQL 生成这种需要强指令跟随能力的任务——和头部云端模型还有差距,显存不够的话推理速度也比较着急。

如果你的机器配置允许,我建议第一步直接用云端 API 跑通流程,等确认这个方案真的适合你的场景,再考虑迁移到本地。这样不会因为模型效果差而误判整个方向的可行性。

2.3 Prompt 设计:让大模型当好"SQL 翻译官"

在最小闭环里,Prompt 设计是整个链路中最关键、也最容易被轻视的一环。很多人写 Prompt 就一句话:"把这句话转成 SQL",然后抱怨模型输出一堆乱七八糟的东西。实际上,一个好的 Text-to-SQL Prompt 至少应该包含以下几类信息:

  • 角色设定:告诉模型"你是资深 SQL 工程师",这会显著影响输出风格。别小看这句话,实验下来同样的模型、同样的输入,角色设定能稳定提升几个百分点的准确率。
  • 数据库方言:明确告知目标数据库类型。MySQL 和 SQL Server 的很多函数写法不同,不限定方言,模型就随机选一个,结果经常跑不通。
  • 完整 Schema 描述:用 CREATE TABLE 语句的形式把表结构贴给模型,这是最直观的方式。表和表之间的关系,要在注释里写清楚。
  • 输出格式要求:明确要求只输出 SQL 语句,不要有解释性文字,方便下游解析。
  • 几个 Few-shot 示例:给两三个"问句 + 正确 SQL"的示范,帮模型理解你的表结构下哪些说法对应哪些写法。这是效果提升最明显的手段。

这些要素怎么组织,我在下一节直接给你一个可复制的 Prompt 模板。

3. 实操过程与核心环节实现

3.1 先搭一个能跑的最小环境

为了避免把时间花在环境安装上,我用 Python 加 Flask 来做这个闭环的骨架,数据库用 SQLite,大模型调用走 OpenAI 兼容接口。SQLite 的好处是零配置,文件即数据库,适合你本地快速验证;等逻辑跑通了,把连接串换成 MySQL 或者 PostgreSQL 就是改几行配置的事。

先安装依赖:

pip install flask openai

注意,这里用的openai库不一定要调 OpenAI 官方的服务,因为现在很多国产模型和本地推理服务都兼容 OpenAI 的接口协议,你只要把base_url指到对应的服务地址,就能一套代码处处用。这个操作在业内叫"API 兼容层",是降低切换成本的关键设计。

假设本地有一个 SQLite 数据库文件demo.db,里面有一张销售订单表,结构如下:

CREATE TABLE orders ( id INTEGER PRIMARY KEY, user_id INTEGER, product_name TEXT, amount REAL, region TEXT, order_time TEXT );

这张表里有用户 ID、商品名、金额、区域、下单时间,足够演示条件查询、聚合、排序、分组这些最常见的场景。

3.2 核心代码实现:三段式结构

整个闭环的代码逻辑就三步:组装 Prompt、请求模型、执行 SQL。我把代码拆成三个函数来写,方便你理解每一步的输入输出。

第一步,组装 Prompt。这里我会把 Schema 直接写在函数里,实际项目中你最好维护一个独立的 schema 文件,方便模型升级时统一修改。

def build_prompt(user_query: str, schema_sql: str) -> str: few_shot_examples = """ 示例1: 用户问题:上个月订单总金额是多少? SQL:SELECT SUM(amount) FROM orders WHERE order_time >= date('now','start of month','-1 month') AND order_time < date('now','start of month'); 示例2: 用户问题:按区域统计每个区域的订单数量,按数量降序排列。 SQL:SELECT region, COUNT(*) AS cnt FROM orders GROUP BY region ORDER BY cnt DESC; """ prompt = f""" 你是一名资深 SQL 工程师,擅长把用户的中文问题转换为 SQL 查询语句。 数据库类型:SQLite 表结构信息如下: {schema_sql} 注意:仅输出 SQL 语句,不要输出任何解释。不要使用 Markdown 代码块包裹 SQL。 {few_shot_examples} 用户问题:{user_query} SQL: """ return prompt

这里有个细节值得注意:我在 Few-shot 示例里有意识地和目标 Schema 保持一致。有人喜欢用网上的通用示例,效果往往很差,因为模型会参考示例里去猜字段名,对不上的时候就硬编,反而误导。

第二步,请求大模型。如果你用的是兼容 OpenAI 协议的本地服务,代码和官方接口几乎一样:

from openai import OpenAI client = OpenAI( base_url="http://localhost:11434/v1", # 假设本地用 Ollama 起服务 api_key="EMPTY" # 本地服务通常不校验 key ) def generate_sql(user_query: str, schema_sql: str) -> str: prompt = build_prompt(user_query, schema_sql) response = client.chat.completions.create( model="qwen2.5-coder:7b", messages=[ {"role": "system", "content": "你只负责生成 SQL,不讨论其他话题。"}, {"role": "user", "content": prompt} ], temperature=0.1, # 生成 SQL 时温度尽量低,减少随机性 max_tokens=500 ) sql = response.choices[0].message.content.strip() return sql.lstrip("```sql").rstrip("```").strip()

temperature参数我特意设为 0.1,这是 SQL 生成场景比较合适的值。温度太高模型会自由发挥,写出一些语法没错但不符合业务意图的 SQL;温度太低又容易死板,碰到复杂问题绕弯子。0.1 到 0.3 之间是我实验下来比较稳的区间。

第三步,执行 SQL。这一步必须处理模型输出不合法的情况,最简单的方式是捕获异常并重试或者报错:

import sqlite3 def execute_sql(sql: str, db_path: str) -> list: conn = sqlite3.connect(db_path) cursor = conn.cursor() try: # 只允许执行 SELECT 查询,防止模型生成 INSERT/UPDATE/DELETE 造成数据破坏 if not sql.strip().upper().startswith("SELECT"): raise ValueError("仅支持 SELECT 查询") cursor.execute(sql) columns = [desc[0] for desc in cursor.description] rows = cursor.fetchall() return {"columns": columns, "rows": rows} finally: conn.close()

3.3 用 Flask 包一层 API,形成完整闭环

光有函数还不够,我们把它包成一个简单的 HTTP 接口,这样业务方可以方便地调用,也能顺便测试一下从自然语言到查询结果的完整链路:

from flask import Flask, request, jsonify app = Flask(__name__) SCHEMA_SQL = """ CREATE TABLE orders ( id INTEGER PRIMARY KEY, user_id INTEGER, product_name TEXT, amount REAL, region TEXT, order_time TEXT ); """ @app.route("/ask", methods=["POST"]) def ask(): data = request.get_json() user_query = data.get("query", "") sql = generate_sql(user_query, SCHEMA_SQL) try: result = execute_sql(sql, "demo.db") return jsonify({"sql": sql, "data": result}) except Exception as e: return jsonify({"sql": sql, "error": str(e)}), 400 if __name__ == "__main__": app.run(port=8000)

跑起来之后,用 curl 测试一下:

curl -X POST http://localhost:8000/ask \ -H "Content-Type: application/json" \ -d '{"query": "华东区销量最高的三个商品是什么?"}'

如果一切顺利,你会拿到类似这样的响应:

{ "sql": "SELECT product_name, SUM(amount) AS total_amount FROM orders WHERE region = '华东区' GROUP BY product_name ORDER BY total_amount DESC LIMIT 3;", "data": { "columns": ["product_name", "total_amount"], "rows": [["坚果礼盒", 12800.0], ["保温杯", 9600.0]] } }

到这一步,"输入中文,输出 SQL,拿到结果"的闭环就算真正跑通了。整个过程你只需要一个 Python 脚本加一个大模型接口,没有复杂的编排引擎,也没有微调和训练环节——这就是最小闭环该有的样子。

4. 常见问题与排查技巧实录

4.1 Schema 信息不准导致 SQL 幻觉

这是 Text-to-SQL 最典型的翻车现场。模型一本正经地生成了字段名,但你只要去数据库里执行,立刻报"no such column: product_name"。原因很简单:模型在提示词里没看到完整可靠的字段列表,就自己凭"想象"补了几个字段进来,这种现象业内有个更通俗的说法叫幻觉(Hallucination)

排查思路很直接:把整个 prompt 打印出来,看看 Schema 是不是真的传进去了、字段名有没有被截断。我自己调试的时候,习惯先固定 prompt 文本,用不同的问句反复测,确认 Schema 部分的格式稳定后,才去怀疑模型的问题。

要根治这个问题,有一个技巧:在 prompt 里强调"只能使用表结构中存在的字段,不要臆造字段名",同时在用户问题涉及字段时,最好把字段的中文注释也列出来。比如amount -- 订单金额,这样模型就不容易把"金额"映射成price或者money

4.2 模型输出 Markdown 代码块导致执行失败

很多模型在微调时见过大量代码输出,习惯性地把生成内容用 Markdown 代码块包起来。如果你直接拿去执行,SQLite 会报语法错误,因为解析器不认识 ```sql 这个符号。

这个问题我建议在代码层面做兼容处理,不要指望模型改掉这个习惯。最简单的方式是正则清理:

import re def clean_sql_text(sql: str) -> str: # 去除开头的 ```sql 或 ``` 标记 sql = re.sub(r'^```(?:sql)?\s*', '', sql.strip()) sql = re.sub(r'\s*```$', '', sql) return sql.strip()

我在generate_sql函数里已经做了类似的lstriprstrip处理,如果你用的模型比较"传统"(喜欢加各种前后缀),这个方法能让你的闭环更稳定。一句话总结:永远不要在信任大模型输出这件事上偷懒,所有输出都要经过清洗再进入下游。

4.3 自然语言中的时间表达和数据库字段不匹配

业务方问"上个月"、"最近一周"、"今年 Q1",模型翻译出来的时间范围和你的数据存储格式经常对不上。比如你的order_time存的是字符串 "2024-01-15",模型却生成DATE_SUB(NOW(), INTERVAL 1 MONTH),这在 MySQL 里没问题,SQLite 里直接就报错。

我的处理方式是把常见的时间口径直接在 prompt 里告诉模型。比如在 Schema 后面加一句:

时间字段说明:order_time 是字符串类型,格式为 YYYY-MM-DD,可以使用 SQLite 的 date() 函数进行日期计算。

写清楚之后,模型生成的时间条件基本就规规矩矩了。这个"场景说明书"的思路比在 Few-shot 里堆例子更省 token,效果也立竿见影。

4.4 安全问题:生成的 SQL 必须限制为只读

前文代码里已经加了一行if not sql.strip().upper().startswith("SELECT")的判断,这只是一个最粗粒度的安全防线。很多团队实际落地时会做更强的限制:单独创建一个只读账号给 Text-to-SQL 服务用,数据库层面就封掉 INSERT、UPDATE、DELETE 权限,双层保险。

我见过有人在测试时图省事,直接用管理员账号让模型生成的 SQL 跑在业务库上,结果某次模型抽风生成了DROP TABLE orders;,好在提前开了事务回滚,不然哭都来不及。这类工具生成的内容,一定要把风险等级按"不可信输入"对待,只要执行条件允许,就给它最小权限。

4.5 复杂查询准确率低,别急着调模型

很多朋友第一次跑通以后,会拿一些复杂的多表 Join 查询来测,发现准确率一下子就垮了,于是开始怀疑模型能力。这里我想说个可能不太中听的观点:Text-to-SQL 的准确率瓶颈,很多时候不在模型,而在于问题本身的复杂度和你提供的信息充分度。

单表单条件查询,现在主流模型的准确率已经相当高,逼近甚至超过人类平均水平。但一旦涉及三表以上关联、子查询嵌套、窗口函数,模型就容易迷路。这不是模型笨,而是提示词里能容纳的 schema 说明有限,模型"看不到"完整的表关系链路,自然只能瞎猜。

应对策略有两个方向。一是业务上收窄范围:只对特定领域的简单查询开放 Text-to-SQL,自然语言入口后面挂一个引导层,先让用户选择题干要素(表、时间范围、维度),再把这几个要素拼进提示词。二是工程上给模型"答题卡":提前把常见的查询模板和对应的 SQL 骨架准备好,模型只需要填充条件字段,而不是从零写整条 SQL。后者虽然听上去不够"智能",但在生产环境里落地性和稳定性都很好。

5. 进阶优化:从"能跑"到"好用"

5.1 反馈纠错机制:让模型自己修 SQL

一个很实用的升级思路是:当执行出错时,把数据库的错误信息回传给模型,让它自己尝试修正。这个做法在业内叫"自我纠错"(self-correct),通用大模型应用里效果差异很大,但在 Text-to-SQL 这个任务上,由于错误信息通常非常结构化,比如 "near 'LIMIT': syntax error",模型往往能根据这个反馈有效地定位问题,改出正确的 SQL。

改法很简单,在generate_sql之后加一层循环:

MAX_RETRY = 2 def generate_with_retry(user_query: str, schema_sql: str, db_path: str): sql = generate_sql(user_query, schema_sql) for _ in range(MAX_RETRY): try: result = execute_sql(sql, db_path) return sql, None, result except Exception as e: error_msg = str(e) # 把错误信息拼进提示词,让模型重新生成 fix_prompt = f"你之前生成的 SQL 执行出错,错误信息:{error_msg}\n请修正后重新输出。原问题:{user_query}" messages = [ {"role": "system", "content": "你只负责生成 SQL。"}, {"role": "user", "content": build_prompt(user_query, schema_sql)}, {"role": "assistant", "content": sql}, {"role": "user", "content": fix_prompt} ] response = client.chat.completions.create(...) # 重新请求 sql = response.choices[0].message.content.strip() return sql, "retry_failed", None

实际跑下来,这个机制能挽回不少"就差一步"的失败场景,特别是字段名大小写、少个引号这类低级错误。但它也不是万能的,如果模型第一次就理解错了业务语义,错误信息又只是语法层面的,它很可能把 SQL 改对了语法但结果还是不对,这需要结合后面的手段来弥补。

5.2 上下文压缩与分库分表场景

当你数据库里不止两三张表时,把所有表的结构都塞进 prompt 是不现实的。两个原因:token 成本顶不住,模型注意力也会被无关表分散,导致准确率下降。这时候就要引入Schema 选择这一步——只把和用户问题相关的表结构放进 prompt。

简单做法是维护一个关键词表,比如用户问题里出现"订单""销售""成交",就把 orders 相关表加进重,出现"用户""会员""客户",就把 users 相关表加进来。比如现在有两张表:users 表和 orders 表,当用户查询只涉及用户信息(比如"用户总人数"),就没必要把 orders 的字段也传给模型。这个筛选逻辑用小小的 if-else 就能实现,跑到后面再接个向量检索,就是比较完整的方案了。有条件的话,可以用 embedding 模型做语义检索,把自然语言和表描述向量化,然后再筛选出 Top K 相关的表。这是生产级系统里的常见做法,但在最小闭环阶段,先别急着上向量库,关键词规则反而更容易调试。

5.3 用 query checker 兜底:结果层面的约束

除了语法层面,还要防一种情况:SQL 语法没问题,执行也不报错,但查出来的结果明显不合理。比如表里根本没有"华东区"这个区域值,模型却生成了WHERE region = '华东区',结果返回 0 行。这种语义层面的错误比语法错误更难发现。

一个成本很低的兜底方案是给模型提供字段的枚举值或典型值。在 Schema 描述里给注释:

region -- 区域,可选值:华东区、华南区、华北区、西南区

模型看到这行注释,生成条件的时候就会自己匹配合法值,明显降低语义出错的概率。

如果字段的枚举值太多,注释里写不下,可以在执行 SQL 前加一个"条件预校验":从用户输入里提取可能的条件词,和字段枚举值比对。这个逻辑可以做得简单也可以做得很深,但我的建议是:最小闭环阶段,手动加注释是最划算的投入。

6. 性能、成本与其他避坑建议

6.1 缓存机制

Text-to-SQL 场景下,用户的问法往往高度重复。比如业务方每天都会问"昨天的销售额",只是日期在变。如果每次请求都去调用大模型,既费钱又慢,完全可以做一层缓存。最简单的做法是用一个字典,key 是用户问题的哈希值,value 是生成的 SQL。更聪明的做法是把问句中的时间词替换成占位符,让"昨天的销售额"和"前天的销售额"命中同一条缓存,再在缓存 SQL 里替换具体时间。这么做能省下大比例的大模型调用成本,强烈建议你闭环跑通后就加上。

6.2 延迟与超时控制

大模型推理速度再快,也要一两秒甚至更久才能返回 SQL。如果业务方在页面上等待,体验肯定不好。我的做法是接口设计成异步模式:提交问题后立刻返回一个任务 ID,后台去调大模型和数据库,完成后再推送通知。最小闭环阶段不想搞得太复杂,至少也要在接口层设置合理的超时时间,避免客户端长时间挂着等待导致连接池耗尽。

6.3 评估集:衡量效果的唯一依据

很多人做完 Text-to-SQL,评估效果全靠"我随手测了几条感觉还行"。这在大模型应用开发里是很大的隐患。因为模型是概率输出,同一个问题这次可能对、下次可能错,没有评估集的开发过程就像闭着眼睛开车。

建议建立一个小规模的测试集,二三十条问题即可,覆盖单表查询、条件筛选、聚合分组、排序分页这几类典型场景。然后用脚本批量跑,比较生成的 SQL 和预期 SQL 是否一致。这里有个细节:字符串完全一致没什么意义,最好直接执行两条 SQL 对比结果集是否一致,因为同一个查询往往有不同写法,比如 join 和子查询都可能得出一样的数据。对比结果集的方式,能有效避免"SQL 写得和标准答案不同但是对的"这类误判。

6.4 大模型选型要与任务匹配

最后聊一句模型选型。Text-to-SQL 不是模型越大越好,也不是越强越好,而是在"效果、延迟、成本"三者之间找平衡。我实测下来,同级别的中文模型在 SQL 生成任务上的表现差异很明显,像 Qwen 系列、DeepSeek 系列、以及一些专门做过 SQL 指令微调的模型,普遍比通用对话模型更适合这个任务。有些 7B 级别的代码模型,效果甚至能超过未经过任务优化的更大参数模型。选型的时候,别只看发布会吹的通用能力,拿你的测试集实测一轮,让数据说话。

结尾

把这个最小闭环跑通之后,你已经拥有了一个可以继续往上加功能的骨架。我在实际折腾这个项目的过程中,最大的感受是:Text-to-SQL 真正难的地方从来不是调用大模型,而是你愿不愿意把业务知识结构化地喂给模型——Schema 写清楚、口径写明白、边界写具体,模型的准确率自然就上来了。很多人抱怨大模型"不够聪明",其实多数时候是我们在偷懒。最后再分享一个小技巧:调试 Prompt 的时候,把每轮请求的完整输入输出都打印到日志里。你会发现,模型翻车的原因,90% 都能从日志里直接定位,这比盲目换模型有效得多。

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

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

立即咨询