接手任何一个数据平台项目,需求池里总会躺着一条几乎一模一样的诉求:“能不能用自然语言直接查数据,别再让我拖字段了。”这条需求背后是 NL2SQL 这个老命题,而 ZeroNL2SQL 想解决的,是它最要命的那一段——零样本。所谓零样本,指的是一套生成链路在面对一个从没见过、没标注过、甚至表名全是拼音缩写的库时,不经过任何微调,直接把自然语言问题翻译成能跑通的 SQL。它不训练模型,只做三件事:把数据库“翻译”成模型看得懂的结构、把可能相关的表和列圈出来、让模型写完再自己验证一遍。
这篇东西写给两类人:一类是正在评估“要不要给业务侧开一个自然语言取数入口”的技术负责人,另一类是已经动手写了几版提示词、发现准确率上不去、不知道瓶颈在哪的一线开发。我会把整条链路拆成四个工位,把每一处的取舍理由、参数口径、代码骨架和踩坑记录都摊开讲。全文不涉及任何具体商业产品,只讲通用做法和我自己在模拟环境里反复验证出来的经验值。
1. 零样本 NL2SQL 解决的到底是哪一等问题
1.1 有监督路线在真实业务里卡在哪
传统 NL2SQL 的做法很直白:拿一个跨领域的标注集,或者自己攒几千条“问题-SQL”对,微调一个编码器-解码器模型,在固定的一批库上把执行准确率刷到能看的水平。这条路在学术评测里跑得很好,一到真实业务就撞墙,而且撞的是同一堵墙。
第一堵墙是库在变。业务侧每个月新增几张宽表、改几个字段名、下线几张历史表,模型学到的是“某个库里的某张表长什么样”,一旦 schema 漂移,原来记住的对应关系全部失效。你不可能每次建表都重训一次模型。
第二堵墙是标注拿不到。要覆盖一个中等规模的数据仓库,认真标一千条以上才有统计意义,而标注的人必须同时懂业务口径和 SQL 写法,这种人通常是最忙的那几个。更麻烦的是标完还要评审,因为很多“看起来对”的 SQL 其实口径是错的,比如该用订单创建时间却用了支付时间。
第三堵墙是长尾问题没法覆盖。真实提问里大量是“上个月华东区复购率环比掉了几个点”这种带时间、带维度、带比较的复合问法,标注集里凑不齐足够的同类样本,模型只能靠猜。
我见过几个团队在这三堵墙前面反复横跳,最后的产出是一个只覆盖了十几张常用表的“半自动取数助手”,业务侧用两次就回退到手工写 SQL 了。
1.2 零样本把“适配成本”从训练侧挪到了提示侧
零样本路线的核心判断是:通用大模型已经会写 SQL 了,它缺的不是能力,是上下文。你给它一份准确的建表语句、几条同一数据库风格的示例、一句明确的方言约束,它写出来的 SQL 在简单单表查询上就已经可用;难的是多表关联、口径歧义和值匹配,而这些恰好可以用工程手段一层层补。
这个判断带来一个非常重要的成本结构变化:有监督路线的边际成本在“每上一个新库就要重训/重标”,零样本路线的边际成本在“每上一个新库就要调提示词模板、补几条示例、验证 join 路径”。前者是人力密集且不可复用的,后者是可以沉淀成配置和工具的。当你手上有几十个业务库要接的时候,这个差别是决定性的。
但我不打算把它讲成银弹。零样本在单表、聚合、简单过滤这类问题上表现相当稳,在多表 join 和复杂嵌套上会明显掉一截,在“口径本身就有歧义”的问题上干脆不该让它单独决策。所以我的实践口径是:零样本负责把 60%~80% 的常规问法自动化,剩下的走“生成候选 + 人工确认”的半自动模式,而不是追求全自动。
1.3 什么场景该上、什么场景别硬上
先说适合上的。库结构相对规整、有主外键或至少有明确的关联字段、字段命名有注释可读、查询以统计聚合为主、业务方能接受“先看一眼 SQL 再执行”——这类场景零样本投入产出比很高。我做过一个模拟项目,二十几张表的订单域模型,配好模板之后常规问法的可执行率能到七成以上,剩下的多半是口径歧义,不是语法问题。
再说别硬上的。第一种是超宽表加几百个字段、命名全靠缩写且没有注释,模型连“这列是什么意思”都判断不了,再好的提示词也救不回来,该做的是先补元数据。第二种是强口径、强合规的财务口径查询,错一个字段就是错账,这种场景必须有人工复核环节,不能把生成结果直接对外。第三种是高频固定问法,这种反而不要用大模型,写死几个参数化查询模板更快更稳。
把这三类分清楚,比闷头调提示词重要得多。
2. ZeroNL2SQL 的模块拆解:一条链路四个工位
2.1 工位一:schema 采集与序列化
整条链路的第一件事,是把数据库的结构变成模型能读的文本。听起来最没技术含量,实际上是最容易埋雷的地方。采集的内容不只是表名和列名,至少还要包括:列的数据类型、主键、外键关系、列注释、枚举列的取值样例、以及每个表的大致行数量级。
为什么要带行数量级?因为模型在决定用不用某张表时,会依赖“这张表是不是主表”这种直觉。一张百万行的明细表和一张几十行的字典表,在 join 里的角色完全不同,把行数写进去能显著减少“拿字典表当事实表”这类错误。
为什么要带枚举取值样例?因为值匹配是零样本里最隐蔽的失败点。用户问“已完成的订单”,库里 status 字段存的是'FINISHED'还是'3'还是'已完成',模型猜不出来。把每个低基数列的 top 5 取值塞进 schema 描述里,这一条能把值匹配错误砍掉一大半。
序列化的格式选择后面单独讲,这里只强调一个原则:序列化的目标是让模型快速定位到“该用哪张表、哪几列”,而不是把整个库完整抄一遍。信息密度比完整性重要。
2.2 工位二:schema linking 把候选范围收窄
真实业务库动辄几百张表,全量塞进上下文既不经济也会稀释模型注意力。schema linking 这一步就是做粗筛:根据问题文本,从全库中选出 3~8 张最可能的表,以及每张表里最可能的若干列。
我一般用三层叠加,成本从低到高:
- 字面匹配层:把问题分词,与表名、列名、列注释做子串和编辑距离匹配。注意表名常带下划线和前缀,
t_order_detail要先按_切开再匹配,否则 “order” 永远匹配不上。 - 向量召回层:把每张表的“表名 + 列名 + 注释”拼成一段描述做向量化,问题也向量化,取余弦相似度 top K。这一层能救回字面不重合但语义相近的情况,比如用户说“成交额”,列名叫
gmv。 - 邻接扩展层:把命中表通过外键关系扩展一跳邻居。这一步非常关键,因为 join 路径上的中间表往往不直接出现在问题里,但少了它 SQL 就关联不起来。
三层跑完,候选集控制在 10 张表以内,列控制在 60 列以内。这个规模既能覆盖绝大多数查询,又能把上下文控制在合理区间。
注意:邻接扩展一定要设上限。星型模型里一张事实表可能连着二十张维表,不加限制地扩一跳,候选集会瞬间膨胀回全库规模,前面做的收窄全部白费。
2.3 工位三:生成与多轮自修正
生成这一步的形态取决于你要不要自一致性。最简版是单次调用,温度调低,拿到 SQL 直接执行。进阶版是并行采样多条,按执行结果投票,再对失败的那条做错误回灌重试。
我的默认配置是先采样 3~5 条,按执行结果聚类投票;如果全部执行失败,挑报错信息最“有信息量”的那条做回灌,最多两轮。为什么是两轮?因为第一轮回灌通常能修掉语法错和列名错,第二轮回灌能修掉一部分 join 条件缺失,到第三轮模型开始“为了不报错而乱改”,把原本语义正确的部分也改坏,实测反而掉点。
回灌的提示词要把三样东西一起给:原始问题、上一条失败的 SQL、数据库返回的原始错误信息。错误信息不要自己翻译成中文再喂,原样给,模型对no such column: order_id这类字符串的敏感度比你以为的高。
2.4 工位四:执行校验与安全闸门
这一步是工程底线,不能省。任何模型生成的 SQL 在真正跑之前,必须过几道闸:
- 语句类型白名单:只允许
SELECT(含WITH开头的 CTE),用词法分析而不是正则去判断,因为注释和字符串里可能出现关键字。 - 只读账号:数据库层面就用只读权限连,不依赖应用层校验。
- 强制 LIMIT:对没有聚合的明细查询自动包一层
LIMIT 200,防止误查全表把库拖垮。 - 超时与资源限制:语句级超时 30 秒,超时直接掐断并记为失败样本。
- 结果规模校验:返回行数为 0 时不要直接判成功,这是个需要单独处理的信号。
最后这条很多人忽略。空结果在评测里很尴尬:它可能是正确答案(确实没有数据),也可能是过滤条件写错了(比如时间范围用了未来日期)。我的做法是把空结果标成“低置信”,触发一次重生成,两次都空才接受。
3. 提示词与序列化格式的细节打磨
3.1 三段式提示词的结构与顺序
提示词的组织顺序对结果影响比想象中大。我固定用三段:约束段、上下文段、任务段。
约束段放角色和硬性规则,比如“你是一个只会写只读查询的分析师,只输出 SQL,不要解释,方言是 SQLite,禁止使用ILIKE、FULL OUTER JOIN”。方言这一条必须显式写,因为大模型默认倾向于输出接近标准 SQL 或 PostgreSQL 风格的写法,而很多轻量库不支持这些语法。
上下文段放 schema 描述和少样本示例。这里有个顺序技巧:把用户问题放在整个提示词的最后,紧挨着生成位置。上下文越长,放在末尾的信息被模型利用得越充分,这是我在长 schema 场景下反复验证过的现象。
任务段只写一句明确的输出格式要求,比如“只输出一个 SQL 代码块,不要包含任何解释文字”。不要写“请仔细思考”这类模糊指令,它对结构化输出的帮助有限,反而容易让模型多输出一段分析把格式搞乱。
3.2 DDL、JSON、Markdown 三种序列化格式对比
序列化格式没有标准答案,但不同格式的取舍很明确,我做了一轮横向对比:
| 格式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 建表语句(DDL) | 模型最熟悉,含类型和约束,零学习成本 | 注释和枚举取值不好塞,token 密度低 | 库结构规整、外键完整 |
| 结构化 JSON | 字段可控,便于程序生成和裁剪 | token 消耗最高,大库容易爆上下文 | 需要精确控制每列信息 |
| Markdown 表格 | 信息密度高,人也好读,便于排查 | 类型信息表达弱,长表容易换行错乱 | 列数少、以列注释为主 |
我最终选的是混合式:每张表用一行 Markdown 标题写表名和行数量级,紧跟一个小代码块写 DDL(只含列名、类型、主外键),再用一行列出低基数列的取值样例。这个组合在 token 效率和信息完整度之间平衡得最好。
顺手给个实际写法:
-- 表: order_main (约 120 万行) 说明: 订单主表 CREATE TABLE order_main ( order_id TEXT PRIMARY KEY, -- 订单号 user_id TEXT, -- 下单用户 status TEXT, -- 状态, 取值: CREATED/PAID/FINISHED/CANCELED channel_id TEXT, -- 渠道, 关联 channel_dim.id created_at TEXT, -- 创建时间, 格式 YYYY-MM-DD HH:MM:SS paid_amount REAL -- 实付金额, 单位元 );注意这种写法把注释、取值样例、关联关系都压缩进了 DDL 里,模型读起来和读真实建表语句的体验一致,不需要额外的格式理解成本。
3.3 少样本示例怎么选才不“带偏”
零样本不等于零示例。给几条示例能显著稳定输出格式,但示例选错了会带来很讨厌的副作用:模型会模仿示例的表名和列名。
我踩过这个坑。早期为了省事,示例用的是同一个库里的真实问题,结果模型在生成新查询时,把示例里出现的表名硬套到不相干的问题上,出现了大量“表存在但语义完全无关”的幻觉。后来改成示例一律来自另一个库,而且显式在示例前标注“以下示例来自另一个数据库,仅示范写法,不要复用其中的表名”,这个问题才消失。
示例的数量我固定在三到五条,并且刻意覆盖三种形态:一条单表聚合、一条两表 join、一条带子查询或窗口的。三种形态各一条,比五条都是单表聚合有用得多。另外示例的复杂度要略高于目标问题的平均水平,给模型一个“上限暗示”,太简单的示例会让它倾向于用最保守的写法。
3.4 自一致性采样:温度、条数与投票口径
自一致性这块,参数选不对就是纯烧钱。先讲投票口径:按执行结果投票,不要按 SQL 字符串投票。同一个问题,WHERE status = 'PAID'和WHERE status IN ('PAID')字符串完全不同,结果却一样;而COUNT(*)和COUNT(DISTINCT user_id)字符串很像,结果差得远。
具体做法是把每条候选 SQL 都执行一遍,把结果集做归一化(列顺序排序、浮点数按容差取整、NULL 统一表示),然后用结果集的哈希做聚类,取最大簇。如果出现并列,优先选执行成功且非空的那条。
温度和条数的经验值:采样 5 条时温度取 0.5~0.7 比较合适,太低会导致五条几乎一样、投票失去意义,太高会导致全是语法错误。3 条的话温度可以降到 0.3。条数继续往上加,收益衰减很快,5 条到 9 条在我这里的提升不到两个百分点,但成本翻倍。
还有个便宜做法推荐试试:不用并行采样,而是温度 0 生成一条,再让模型自己“审查这条 SQL 有没有漏掉过滤条件或 join 条件”。这种自审模式成本只有采样的五分之一,能修掉一部分漏条件的问题,但对语法错无效,适合作为采样策略的廉价补充。
4. 从零跑通:一条可复现的实现路径
4.1 环境与数据准备
我用的验证环境很轻:一个文件型数据库,灌进模拟的订单域数据,二十几张表,包含事实表和维表,故意留了几处命名不规范的地方(比如有的表用user_id有的用uid),用来模拟真实情况。
准备工作分三步。第一步灌数据并生成元数据快照,把表名、列名、类型、主外键、行数、低基数列取值全部导出成一份字典。第二步准备问题集,我一般从三个来源凑:业务同事平时在群里问的那些问题、从历史 SQL 反推出来的问题、以及刻意构造的边界问题(带否定、带时间范围、带多条件组合)。第三步把问题集按涉及的库分组,确保评测时用的库和示例库不重叠。
这一步有个容易被跳过的细节:问题集要标注“参考答案的判定方式”。有些问题有唯一答案,可以精确比对;有些问题是开放式的(比如“最近哪天订单最多”),只要结果集内容一致就算对,不要求写法一致。这两种要用不同的判定逻辑,混在一起算指标会失真。
4.2 schema 抽取与 token 预算计算
抽取这块写起来很短:
import sqlite3 def dump_schema(conn, sample_limit=5): cur = conn.cursor() cur.execute("SELECT name FROM sqlite_master WHERE type='table'") tables = [r[0] for r in cur.fetchall()] meta = {} for t in tables: cur.execute(f"PRAGMA table_info('{t}')") cols = [{"name": c[1], "type": c[2], "pk": bool(c[5])} for c in cur.fetchall()] cur.execute(f"SELECT COUNT(*) FROM '{t}'") rows = cur.fetchone()[0] meta[t] = {"columns": cols, "rows": rows} return meta真正的活在于token 预算。粗算一下:一列写成col_name TYPE, -- 注释大约 10~15 个 token,加上表头和分隔,一张 12 列的表大概 180 个 token。二十张表就是 3600 个 token,再叠加五条示例(每条 150 token)和提示词模板,输入总量在 4500 token 上下,任何主流模型都装得下。
但换成 200 张表、每表 30 列的大库,全量序列化就是 200 × 30 × 12 ≈ 7.2 万 token,直接超限。所以必须做 schema linking 收窄,或者用两阶段策略:先让模型从精简版表清单里选出候选表(只看表名和一句话描述),再对候选表展开完整 DDL。两阶段多一次调用,但 token 总量反而更低。
提示:做预算时留 30% 余量。schema 里的注释长度不可控,有些库的列注释能写一整句话,实际 token 会比估算高不少。
4.3 生成-执行-回灌的主循环
主循环的结构是“生成 → 执行 → 判定 → 回灌或接受”。核心逻辑不长,但要处理的分支不少:
def run_pipeline(question, schema_text, examples, llm, conn, max_retry=2): prompt = build_prompt(question, schema_text, examples) sql = extract_sql(llm(prompt, temperature=0.6)) for attempt in range(max_retry + 1): if not is_select_only(sql): return {"ok": False, "reason": "non_select", "sql": sql} try: rows = execute_with_timeout(conn, sql, timeout=30, limit=200) except Exception as e: if attempt == max_retry: return {"ok": False, "reason": "exec_error", "error": str(e), "sql": sql} sql = fix_sql(llm, question, schema_text, sql, str(e)) continue if len(rows) == 0 and attempt < max_retry: sql = fix_sql(llm, question, schema_text, sql, "查询返回空结果,请检查过滤条件与关联条件") continue return {"ok": True, "sql": sql, "rows": rows}几个细节值得单独说。execute_with_timeout一定要真的能做到超时中断,不能只是包一层 try,否则一条笛卡尔积就能把进程挂死。is_select_only要用词法解析,把注释和字符串常量剔掉再判断,正则很容易被/* select */ delete ...这种写法绕过。
回灌函数的提示词要控制住长度,不要把整份 schema 再塞一遍,只塞候选表的 DDL 加错误信息就够了,否则上下文里信息太多,模型容易改错地方。
4.4 评测口径:执行准确率怎么算才不骗自己
执行准确率(比结果集而不是比 SQL 字符串)是这类系统的标准指标,但计算细节里全是坑,我列几个必须处理的:
- 列顺序:两个 SQL 返回同样的列但顺序不同,应该判对。比较前把列按列名排序。
- 行顺序:除非问题里明确要求排序,否则行顺序不参与比较,用集合比较。
- 浮点数:任何涉及除法或平均值的查询都会遇到浮点误差,必须设容差,我一般用相对误差 1e-4 或绝对误差 0.01 取严。
- NULL 与空串:库里 NULL 和空字符串经常混用,比较时是否等价要提前定规则,不能一会儿当一样一会儿当不一样。
- 重复行:如果模型多写了
DISTINCT或少写了,结果集行数会不同。是否算错取决于业务口径,建议单独标记成“近似正确”,不要混进准确率。
我习惯把结果分成四档:完全正确、结果近似(差异在容差内)、可执行但结果错、不可执行。四档分开统计,你才能看出瓶颈在语法层还是在语义层。只看一个总的准确率数字,是没法指导优化的。
5. 常见故障与排查实录
5.1 典型问题速查表
下面这张表是我在实际调试里频率最高的几类问题,基本覆盖了八成故障:
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 报错“no such column” | schema 序列化时列名被截断,或模型自行改写列名 | 检查 DDL 是否完整;在约束段强调“列名必须原样使用” |
| 报错“no such table” | 少样本示例里的表名被复用 | 示例改用另一个库,并显式声明不要复用示例表名 |
| 语法错(函数不存在) | 方言不匹配,模型按标准 SQL 写 | 约束段显式声明方言,并在后处理做语法适配 |
| 能跑但结果全空 | 过滤条件的值没匹配上,status 用了中文而库里是英文枚举 | schema 里补低基数列取值样例 |
| 能跑但数字明显偏小 | 多表 join 漏了条件,或者聚合前没去重导致重复计数 | 检查 join 路径是否完整;对事实表聚合建议显式加 DISTINCT |
| 时间范围全错 | 模型用了训练截止日期当“今天” | 把当前日期注入提示词,并说明时区 |
| 结果行数爆炸 | 漏了关键过滤条件,或 join 产生了笛卡尔积 | 强制 LIMIT;检查外键关系是否在 schema 里写清 |
| 同一问题两次结果不同 | 温度过高且没做自一致性 | 降低温度或加投票机制 |
5.2 几个反直觉的踩坑经验
第一个坑是**“加更多示例反而变差”**。我一开始直觉认为示例越多越好,从 3 条加到 10 条,结果准确率掉了。排查发现原因是上下文变长之后,模型对“问题本身”的注意力被稀释了,而且示例之间的风格差异开始互相干扰。后来固定在 3~5 条,并且严格保证示例风格一致,效果反而最好。
第二个坑是**“注释不是越多越好”**。有些列注释是从别的系统同步过来的,写了一大段业务规则说明,里面还夹杂着“详见 XX 文档”。这种注释塞进 schema 不只是浪费 token,还会误导模型去理解那些它根本用不上的信息。我的处理是把注释截断到 30 字以内,并人工过一遍,把“详见”“参考”这类无效内容剔掉。
第三个坑是**“join 路径要靠猜”**。有些库压根没有外键约束,两张表靠命名约定关联。这种情况下模型完全靠猜,猜错的概率很高。解决办法是在 schema 里显式补一段“关联关系说明”,把order_main.channel_id → channel_dim.id这种约定写进去。这段说明可以手工维护,也可以从历史 SQL 里挖掘高频 join 对自动生成,后者在有一定历史查询量的库上效果很好。
第四个坑是**“评测集和示例库撞库”**。零样本的前提是模型没见过这个库,但如果你的少样本示例恰好和评测库是同一个,那测出来的数字是虚高的,上线之后会大幅回落。检查方法很简单:把示例库的名字列出来,和评测库做交集,必须为空。
第五个坑,也是最容易忽略的:别把“能执行”当成“对”。很多 SQL 语法完全正确、能跑出结果,但业务口径是错的。这类错误在自动评测里查不出来,只能靠人工抽检。我的做法是每周从线上日志里随机抽五十条,人工看一遍 SQL 逻辑,把口径错的挑出来反哺到提示词或元数据里。这一步很枯燥,但它是唯一能持续提升真实可用性的手段。
最后分享一个我觉得最划算的优化:给高频问法建一个语义缓存。把问题向量化,和 schema 版本号一起做 key,命中相似度阈值的直接返回上次的 SQL。业务侧的真实提问重复率比想象中高,尤其是日报周报类的固定问法,缓存命中率能到三成以上,省下来的调用成本足够覆盖缓存维护的开销。唯一要注意的是 schema 一变就必须让缓存失效,否则会返回引用已删字段的旧 SQL。