
接手任何一个数据平台项目,需求池里总会躺着一条几乎一模一样的诉求:“能不能用自然语言直接查数据,别再让我拖字段了。”这条需求背后是 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_limit5): cur conn.cursor() cur.execute(SELECT name FROM sqlite_master WHERE typetable) tables [r[0] for r in cur.fetchall()] meta {} for t in tables: cur.execute(fPRAGMA table_info({t})) cols [{name: c[1], type: c[2], pk: bool(c[5])} for c in cur.fetchall()] cur.execute(fSELECT 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_retry2): prompt build_prompt(question, schema_text, examples) sql extract_sql(llm(prompt, temperature0.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, timeout30, limit200) 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。