上个月我接了一个内部数据问答的需求,业务方抱着一叠报表跑过来问:能不能让系统直接回答“上季度华东区退货率最高的三级类目是什么”。我当时第一反应是这需求不大,写个 SQL 就行了。但当我把问题翻译成 SQL 时发现,业务那边的提问每分钟都在变,今天问退货率,明天问客单价,后天问库存周转天数。靠人工写 SQL 根本不现实。这就是我为什么开始认真研究大模型 Text-to-SQL 的原因:让非技术用户直接用自然语言查数据库,模型自动生成 SQL 并执行返回结果。
当时我翻了不少资料,也踩了不少坑。坦白说,Text-to-SQL 这个方向看着简单,实际落地时会遇到一堆“模型认识表结构、但选错列名”这类糟心事。这篇文章就当作一次项目复盘,把从整体设计、核心难点、提示词模板、执行闭环到评估调优的完整过程梳理一遍。如果你正准备在内部数据分析平台上接入自然语言查询,这篇文章应该能帮你省下大量试错时间。
1. 项目定位与价值拆解:为什么值得做 Text-to-SQL
1.1 业务痛点与需求本质
先聊聊这个项目的定位。很多团队的数据基础设施已经做得不错,底层有数仓、有统一口径的宽表,但业务方想拿一个数字,还是得提工单、找数据同学、排队等结果。整个过程少则半天,多则两三天。业务人员真正想要的不是一个 BI 报表工具,而是“我问一句话,你给我一个可验证的数字”。
Text-to-SQL 解决的就是这个链路:自然语言问题 -> 大模型理解意图 -> 生成 SQL -> 执行查询 -> 返回结果。它把“写 SQL”这个门槛从业务人员身上拿掉,让数据团队从重复的取数需求中解放出来。这个价值在数据量越大、表结构越复杂的公司里越明显。
我最初以为这只是套一层提示词的事,后来发现远没那么简单。一个看似简单的“退货率”,在不同业务团队口中可能是“退货订单占比”“退货件数占比”或“退货金额占比”。如果模型没有上下文解释,生成的 SQL 结果是错的,那就是事故。所以这个项目从一开始就不只是“写提示词”,而是“定义好问题边界 + 引导模型理解业务口径 + 工程上做校验闭环”。
1.2 这条链路的核心组成
Text-to-SQL 的整体架构可以拆成四个环节:输入理解、Schema 建模、SQL 生成、结果验证。
- 输入理解:把用户的自然语言问题转换成对表、字段、过滤条件和聚合逻辑的抽取。
- Schema 建模:把数据库表结构、字段注释、枚举值、表间关系整理成模型能读取的形式。
- SQL 生成:基于解析后的意图和 schema,让大模型写出可执行的 SQL。
- 结果验证:执行 SQL,发现报错或结果不理想时进行二次修复,最终把数据返回给用户。
这四个环节环环相扣。Schema 建模如果做得潦草,后续生成质量一定上不去;执行验证如果不做,模型编造列名的问题就会直接漏到线上。
1.3 适合落地与不适合落地的场景
不是所有场景都适合上 Text-to-SQL。我梳理了一下,目前比较成熟的应用场景包括内部数据问答助手、运营后台的报表即席查询、客服工单的知识库定位辅助,以及运维场景下的日志检索。这些场景的共同点是问题相对固定、表结构相对可控、存在明确的正确性判断标准。
不适合的场景也很明显:完全开放式的自然语言问答、对 SQL 性能要求极其苛刻的超复杂查询、底层数据质量本身很差、口径混乱不堪的业务。如果你发现业务团队连“订单金额”是含税还是不含税都说不清楚,先别急着做自然语言转 SQL,先把数据治理做完再说。这是一条很重要的前置判断。
2. 核心难点与方案选型:从“看懂人话”到“写出 SQL”
2.1 绕不开的 Schema 理解问题
大模型生成 SQL 和生成代码最大的不同,在于 SQL 是强 schema 约束的。如果说错一个 Python 变量名,程序可能只是报 NameError,但 SQL 里如果写错列名,数据库会立刻执行失败;更麻烦的是,如果模型用了数据库里不存在的别名但凑巧和某个字段模糊匹配,结果可能是错的。
实际测试中,模型“编造列名”的情况非常常见。比如表里字段叫created_at,模型会写成create_time;表里字段叫sale_amount,模型会写成amount。这种幻觉单靠提示词“你要严格使用表结构中的列名”很难根治,因为模型根本没有把你的 schema 完整记进去。
我采用的方案是“Schema 预处理 + 显式约束”:在建表 DDL 之外,给每个字段补一行业务注释,比如order_status VARCHAR(16) -- 订单状态,paid/pending/closed。同时把表名、字段名、枚举值全部整理成一个紧凑的 schema 文本,塞进提示词。这一步看起来简单,但对准确率的影响是决定性的。
2.2 多表关联与业务歧义
第二个难点是多表 join 的选择。一个中等规模的电商库往往有用户表、商品表、订单表、订单明细表、退货表。用户问“查一下上季度卖得最好的商品”,模型需要自己判断:是查订单表里的total_amount,还是通过订单明细表聚合quantity * sale_amount?两张表算出来的“卖得好”口径可能完全不同。
还有一种更隐蔽的歧义:两个表里都有order_id字段,模型如果只根据字段名做 join,很可能把关联条件写错。例如orders和order_items都含order_id,但returns表里关联的是order_id还是returned_order_id?这种细节如果没有在 schema 注释里写清楚,模型大概率会猜。
我最后总结的方法是:给表关系画一张文本版的关系图,明确写出主外键。比如order_items.order_id -> orders.order_id、returns.order_id -> orders.order_id。把这些关系写进提示词后,join 条件选错的概率明显下降。表关系越复杂,这个注释的价值越大。
2.3 聚合逻辑与时间窗口的“坑”
Text-to-SQL 难的不只是 join,还有聚合和时间计算。业务问“最近 7 天销售额环比变化”时,模型需要生成两个时间窗口的子查询,再做除法。模型往往能写出一个看起来合理但实际边界定义错误的 SQL。比如“最近 7 天”到底包不包括今天,是用date('now','-7 day')还是datetime('now','start of day','-6 day'),每个团队都有自己的口径。
各类数据库方言也加剧了这个问题。MySQL 的日期函数是DATE_SUB,SQLite 是date('now','-7 day'),PostgreSQL 又是now() - interval '7 day'。如果项目同时对接多种数据源,提示词里最好显式声明“当前数据库类型为 SQLite,只使用 SQLite 支持的函数”,否则模型很容易写出别的方言的语法。
2.4 技术路线选型:生成式模型是当前的最优解
我对比过几种技术路线:早期基于模板和规则匹配的方式,可解释性强,但覆盖率太低,用户换一种问法就失效;基于语义解析器和序列到序列模型的方案,效果平庸,泛化能力也不够。大模型生成式的路线,基本成为当前事实上的最优解。
不过纯生成式方案也有明显短板:不可控、会产生幻觉、对 schema 的依赖很大。所以我的最终选型是“大模型生成 + 规则校验 + 执行反馈”的混合架构。让模型先基于 prompt 生成 SQL,再用一个轻量的规则引擎做基本检查,比如是否存在未定义的列名、是否包含非 SELECT 语句、字段是否符合白名单。通过了再执行,执行报错就把错误信息喂回给模型让它重写,最多重试两次。
这套架构的价值在于“让模型做擅长的事,让规则做确定的事”。模型负责理解语义和生成 SQL,规则和校验负责兜底。不追求一步到位,而是接受一次不完美,通过反馈闭环迭代到正确。
3. 实操落地:一套可复现的 Text-to-SQL Demo
3.1 准备示例数据库与表结构
为了把细节讲清楚,我用 SQLite 搭了一个简化的电商数据库。表结构如下,六张表基本覆盖了常见 join 和聚合场景。实际项目中的表结构会比这个复杂得多,但核心思路一致。
CREATE TABLE users ( user_id INTEGER PRIMARY KEY, user_name TEXT, reg_time TEXT ); CREATE TABLE categories ( category_id INTEGER PRIMARY KEY, category_name TEXT ); CREATE TABLE products ( product_id INTEGER PRIMARY KEY, product_name TEXT, category_id INTEGER, price REAL ); CREATE TABLE orders ( order_id INTEGER PRIMARY KEY, user_id INTEGER, order_date TEXT, status TEXT, total_amount REAL ); CREATE TABLE order_items ( item_id INTEGER PRIMARY KEY, order_id INTEGER, product_id INTEGER, quantity INTEGER, sale_amount REAL ); CREATE TABLE returns ( return_id INTEGER PRIMARY KEY, order_id INTEGER, product_id INTEGER, return_reason TEXT, return_date TEXT );建表之后,我给每个字段都准备了注释:order_items.sale_amount -- 该商品行实际成交金额(不含运费),returns.order_id -- 被退货的原始订单ID。这些注释不是给数据库看的,是给模型看的,属于 prompt 的一部分。
3.2 提示词模板的写法
Text-to-SQL 的提示词不需要花哨,但结构必须清晰。我用的模板包含四个部分:角色定义、表结构、业务规则、输入输出格式要求。
你是数据库查询助手。请结合下面的表结构,把用户问题转换成可执行的 SQLite SQL。 要求: 1. 只能使用表结构中出现的表和字段名。 2. 不要使用 SELECT *,只查询必要字段。 3. 聚合查询必须考虑 group by 的正确性。 4. 只输出 JSON,格式为 {"sql": "sql 语句"},不要输出任何解释。 表结构如下: orders.order_id INTEGER -- 订单ID,主键 orders.user_id INTEGER -- 下单用户ID,关联 users.user_id orders.order_date TEXT -- 下单日期,格式 YYYY-MM-DD HH:MM:SS orders.status TEXT -- 订单状态,paid 已支付 / pending 待支付 / closed 已关闭 orders.total_amount REAL -- 订单实付总金额 order_items.item_id INTEGER -- 明细ID,主键 order_items.order_id INTEGER -- 订单ID,关联 orders.order_id order_items.product_id INTEGER -- 商品ID,关联 products.product_id order_items.quantity INTEGER -- 购买数量 order_items.sale_amount REAL -- 该商品行实际成交金额 users.user_id INTEGER -- 用户ID,主键 users.user_name TEXT -- 用户名 users.reg_time TEXT -- 注册时间,格式 YYYY-MM-DD HH:MM:SS categories.category_id INTEGER -- 类目ID categories.category_name TEXT -- 类目名称 products.product_id INTEGER -- 商品ID,主键 products.product_name TEXT -- 商品名称 products.category_id INTEGER -- 所属类目ID,关联 categories.category_id products.price REAL -- 商品销售单价 returns.return_id INTEGER -- 退货记录ID,主键 returns.order_id INTEGER -- 被退货的原始订单ID,关联 orders.order_id returns.product_id INTEGER -- 退货商品ID,关联 products.product_id returns.return_reason TEXT -- 退货原因 returns.return_date TEXT -- 退货日期,格式 YYYY-MM-DD HH:MM:SS 表间关系: order_items.order_id -> orders.order_id order_items.product_id -> products.product_id orders.user_id -> users.user_id products.category_id -> categories.category_id returns.order_id -> orders.order_id returns.product_id -> products.product_id 业务规则: - “退货率”默认按退货订单数 / 已支付订单数计算。 - 金额字段如果未声明是否含运费,一律按不含运费处理。 用户问题:{question}这套模板的核心不是把 DDL 原样丢进去,而是把字段含义、主外键关系、业务口径写清楚。很多模型本身就能看懂建表语句,但它们不理解业务口径。你要做的就是提前把口径约束写死。
3.3 用结构化输出约束模型
大模型输出经常带解释文字,比如“根据表结构,我生成了如下 SQL:...”。如果直接跑,解析必炸。所以我在 prompt 里强制要求只输出 JSON,并在代码层面做一层兜底解析。
import requests import json import sqlite3 API_URL = "https://your-endpoint/v1/chat/completions" API_KEY = "your-api-key" MODEL = "your-model" def call_model(prompt): resp = requests.post( API_URL, headers={"Authorization": f"Bearer {API_KEY}"}, json={ "model": MODEL, "messages": [ {"role": "system", "content": "你是数据库查询助手,只输出JSON。"}, {"role": "user", "content": prompt} ], "temperature": 0, }, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] def extract_sql(raw): try: data = json.loads(raw) return data["sql"] except Exception: start = raw.find("{") end = raw.rfind("}") + 1 return json.loads(raw[start:end])["sql"]在temperature上我固定用 0,因为 Text-to-SQL 任务需要确定性输出,不需要模型发挥创造力。温度调得越高,同一个问题两次生成的 SQL 越可能不一样,这对生产环境是致命的。
3.4 SQL 执行与二次修复闭环
生成 SQL 之后必须执行。如果执行报错,把错误信息回喂给模型,让它根据报错修改 SQL。这是提升最终成功率最有效的一招。
def run_sql(db_path, sql): conn = sqlite3.connect(db_path) try: cur = conn.cursor() cur.execute(sql) cols = [d[0] for d in cur.description] rows = cur.fetchall() return cols, rows except Exception as e: raise e finally: conn.close() def repair_sql(question, old_sql, error, schema_text): prompt = f""" 你生成的 SQL 执行报错,请根据错误信息修复。 用户问题:{question} 之前的 SQL:{old_sql} 报错信息:{error} 表结构:{schema_text} 只输出 JSON,格式为 {"sql": "修复后的SQL"}。 """ return call_model(prompt)重试逻辑我控制在两次以内。实测下来,第一次报错大多是列名拼写、字符串未加引号、函数名写错这类低级问题,修复成功率很高。超过两次还失败的,往往是模型对问题理解本身有误,重复修复只会越改越乱,不如直接返回“无法理解问题,请重新表述”。
3.5 实战演示:一次完整的查询链路
我拿一个真实测试过的问题来演示:“上季度退货率最高的三个类目分别是什么”。
第一步,模型根据 schema 和业务规则生成 SQL:
SELECT c.category_name, COUNT(DISTINCT r.order_id) AS return_order_cnt, COUNT(DISTINCT o.order_id) AS paid_order_cnt, COUNT(DISTINCT r.order_id) * 1.0 / COUNT(DISTINCT o.order_id) AS return_rate FROM orders o JOIN order_items oi ON o.order_id = oi.order_id JOIN products p ON oi.product_id = p.product_id JOIN categories c ON p.category_id = c.category_id LEFT JOIN returns r ON o.order_id = r.order_id WHERE o.status = 'paid' AND o.order_date >= date('now', '-3 months') GROUP BY c.category_id, c.category_name ORDER BY return_rate DESC LIMIT 3;第二步,在 SQLite 中执行,返回结果类似:
| category_name | return_order_cnt | paid_order_cnt | return_rate |
|---|---|---|---|
| 家居清洁 | 42 | 210 | 0.2000 |
| 餐厨用品 | 33 | 185 | 0.1784 |
| 收纳整理 | 29 | 165 | 0.1758 |
第三步,把结果转成自然语言回复:“上季度退货率最高的类目是家居清洁(20%),其次是餐厨用品(17.84%),第三是收纳整理(17.58%)。”到这里,一次典型的 Text-to-SQL 调用就算跑通了。这个流程看起来简单,真正上线要面对的情况比这个复杂得多。
4. 评测方法与效果调优:拿数据说话
4.1 不要只看“生成得对不对”
很多人评测 Text-to-SQL 只看一眼模型生成的 SQL 对不对,这是一个很大的误区。当前业界比较通用的指标是执行准确率(Execution Accuracy,EX),也就是把生成的 SQL 跑出来,结果和标准答案 SQL 跑出来的结果逐行对比,一致才算对。
除了 EX,我还会看组件匹配(EM,Exact Match),对比模型生成的 SQL 和标准 SQL 的表名、join 条件、过滤条件是否一致。EM 更严格,但有些 SQL 写法不同、结果相同,EM 低并不代表系统不可用。另有 Valid Efficiency Score(VES),会在结果正确的基础上进一步比较执行效率,适合对查询性能有要求的场景。
指标选取建议:初期只看 EX 和有效率,先保证业务能拿到正确结果;等准确率稳定后,再看组件匹配和执行效率。千万别一上来就追求 EM 满分,容易把方案带偏。
4.2 自建评测集的方法
评测集是提升准确率的根基。我建评测集的方法是按“难度分层”人工构造问题,大概分四层:
- 单表过滤:查询某条件下的记录,比如“查一下 3 月注册的用户数量”。
- 多表 join:跨 2 到 3 张表关联查询,比如“每个类目的商品数量”。
- 聚合计算:含 group by、having、子查询,比如“销量超过 1000 的分类有哪些”。
- 复杂业务问法:含环比、占比、时间窗口、TopN,比如“最近 7 天各渠道的订单金额环比”。
每个问题都人工编写标准 SQL,并标注对应的正确结果。评测时把问题集合跑一遍,算整体 EX。这个集合不用太大,我一开始只有 120 条,已经能发现大部分问题。关键是问题难度要覆盖真实用户问法,不要只挑简单的测。
4.3 失败案例复盘
评测过程中我发现失败案例高度集中在几类,这里列一份比较有代表性的统计:
| 错误类型 | 占比(经验值) | 典型表现 | 处理思路 |
|---|---|---|---|
| 列名幻觉 | 30% | 把sale_amount写成amount | 强化 schema 注释 + 枚举值约束 |
| join 条件错误 | 20% | 用到返回表却 join 错了键 | 明确写出主外键关系 |
| 聚合逻辑错误 | 15% | group by 字段缺失、having 位置不对 | 增加 standard SQL few-shot 示例 |
| 日期/时间函数错误 | 15% | 用错方言函数、时间边界不对 | 指定数据库方言 + 时间口径 |
| 无效/多余条件 | 10% | 自作主张加limit 10 | 在 prompt 中约束不要加未提及的 limit |
| 其他 | 10% | 输出格式解析失败 | 做 JSON 兜底解析 |
看到这个分布就知道,最值得投入的是“把 schema 描述清楚”,而不是疯狂调 prompt 说“你要认真生成 SQL”。模型不是不认真,是它真的不知道sale_amount才是正确字段。
4.4 提升准确率的工程技巧
效果调优阶段性排序大概是:schema 治理 > 示例质量 > 输出约束 > 执行修复 > 模型选择。我先把每个字段的业务含义写成一句人话,再在 prompt 中给出 2 到 3 个标准示例,覆盖 join 和聚合场景。示例的质量比数量重要,一个和用户问题相近的高质量示例,比十个无关示例强得多。
如果 schema 太长导致提示词放不下,可以先用关键词检索召回最相关的表,再把候选表的 schema 放入 prompt。这个“先召回、后生成”的思路在表数量超过 30 张时几乎是必选项。我之前面对 50 多张表时不分青红皂白全部塞进去,模型几乎失效,后来改成先召回 3 到 5 张相关表,准确率直接回升。
还要注意一点:把数据库连接限制为只读,SQL 只允许 SELECT。防止模型生成DELETE或UPDATE造成事故。这是一条红线,无论模型多聪明都不能让它直接操作生产库。
5. 常见问题排查与避坑经验
5.1 模型反复“编造”列名怎么办
如果模型总是把created_at写成create_time,先别急着改 prompt。检查你的 schema 描述里是否真的把每个列名都写出来了。很多表字段命名不规律,模型很难从原始 DDL 推断出正确名称。
另一个有效做法是给模型提供“字段名 -> 业务名”的映射清单。比如在 schema 末尾加一段:“created_at表示创建时间,用户提问时说‘创建时间’时,一律使用created_at。”这个映射能大幅降低列名幻觉。再配合执行报错后的自动修复,基本能兜住大多数情况。
如果还不行,再考虑切换更强的模型。实测发现不同模型在 schema 理解能力上差距明显,那些在开放问答上差距不大的模型,到了 Text-to-SQL 上可能一个天上一个地下。建议把“列名幻觉率”作为模型选型的一个重要指标。
5.2 多表 join 关联键选错
模型选错关联键的根本原因,是它不知道表之间的主外键关系。只要你在 schema 里把所有外键关系写明,这个问题会大幅减少。还有一个细节:如果两表需要通过复合键关联(比如order_id加product_id才能唯一对应),一定要在关系描述里写清楚,否则模型只会选一个键。
我自己踩过一个坑:returns表记录退货时,order_id指向原始订单,但无order_items关联,导致计算退货率时 join 出的行带“同一订单多个商品”。后来我在注释里补了一句“本表按 return_id 去重,一个退货记录对应一个商品行”,模型才不再写错。这类业务语义必须由人工定义,模型不会自己悟出来。
5.3 schema 太长塞不进提示词
表一多,schema 全量塞入提示词会占大量 token,还可能冲淡重点。两条路:一是配置 token 上限更大的模型,二是做 schema 裁剪。
schema 裁剪的思路是:先把用户问题用模型做一次“信息抽取”,提取涉及的表名、字段名、过滤条件,然后用这些关键词去匹配表结构候选。比如用户问了“季度退货率”,就召回包含return、order、category的表。这本质上是给 Text-to-SQL 加了一层轻量检索,效果非常实用。
5.4 SQL 执行报错后的自动修复
执行报错不要直接放弃。报错信息是模型最好的反馈信号,让它根据报错修改 SQL,成功率很高。但重试次数要控制,我建议最多两次。如果两次修复仍失败,说明模型对问题理解有偏差,继续修只会让 SQL 变得很怪。
修好之后写日志也很重要。我一直坚持记录“原问题、生成 SQL、报错信息、修复 SQL、修复结果”。积累几百条之后,你会非常清楚模型的薄弱点在哪里,也能持续优化评测集。没有日志,调优就是盲人摸象。
5.5 数据权限与口径校验
Text-to-SQL 上线前一定要做权限隔离。用户提问‘所有订单金额’时,如果系统带出别的团队或敏感字段,那是合规事故。方案上可以给每个用户或角色维护一张可见字段白名单,在 SQL 生成后用规则引擎校验涉及的字段是否越权。
口径校验同样不能省。我遇到过一个典型问题:模型默认total_amount是订单总额,但业务里这个字段有时包含运费、有时不含。后来我们在 prompt 中用“如果用户未明确说明,默认按不含运费口径”来兜底,并且在返回结果时附带一句“本数据按不含运费口径计算”。这样业务方至少能发现口径是否偏了,而不至于直接把错误数字拿去汇报。
6. 个人实操中的几点体会
做完这个项目之后,我对“大模型技术落地”这件事有了更具体的认识。Text-to-SQL 真正困难的地方不在 prompt 本身,而在于你把数据库的语义信息梳理得多清楚。字段注释、表关系、业务口径、枚举值,这些基础工作做好了,哪怕模型不是最优的,整体效果也能很稳。反过来,schema 一团乱麻,换再强的模型也白搭。
再分享一个细节:不要迷信“一次性生成正确 SQL”。我在实际使用中几乎默认了“执行 -> 报错 -> 修复”这条路径的存在。第一次能写出可执行 SQL 当然好,但更重要的是一套完整的反馈闭环。这个思路放到很多大模型应用里都成立,大模型不是不能用,而是要设计好“出错后怎么拉回来”的机制。
最后再给一个扩展方向:你可以在生成 SQL 之后,让模型用一句自然语言解释这个 SQL 的逻辑,比如“我统计的是已支付订单中,每个类目有多少个不同的订单产生了退货,按退货订单占比排序”。这个解释能让业务方或数据团队快速发现口径问题,避免一个错误数字被默默用下去。这一步成本很低,但带来的信任提升非常明显。