AI 编程这么火,写个几十行的 SQL 不在话下吧?嗯,那你想多了
上周,我试着让某知名大模型帮我写一段 Oracle 的时间窗口补全 SQL。
数据结构很简单:CREATE TABLE test (
t DATE,
v NUMBER
)
需求不算复杂:
针对包含t(时间)、v(数值)字段的数据表,生成连续完整的每分钟时间窗口,补齐缺失的分钟区间,并为每个窗口计算四项统计指标:
start_value为窗口基准起始值,首个窗口取当前窗口第一条记录的v,其余窗口沿用前一个窗口的end_value;
end_value取当前窗口最后一条记录的v,窗口无数据时与start_value保持一致;
min为本窗口内v的最小值,无数据时等于start_value;
max为本窗口内v的最大值,无数据时等于start_value。
最终输出每条窗口记录,包含窗口起始时间start、窗口结束时间end及四项统计指标。
我甚至还给它定了一套极其细致的“规则”:所有逻辑必须拆成平级 CTE,每个 CTE 只能做一件事,禁止子查询,禁止合并操作。这么做本来是为了让 SQL 每一步都清晰可读,也方便后期维护。
结果,接下来发生的事,彻底浇灭了我对“大模型直接写生产 SQL”的最后一丝幻想。
语法翻车
第一次执行,语法层面就翻了车。
大模型首先生成了一组 17 步的 CTE 定义,然后我把它们拼成了完整 SQL。这一步我没给它任何示例,纯粹让它按自己的理解输出。打开 DBeaver 一跑,直接报错,连执行都没通过。
报错的原因出在第 16 步的递归 CTE:它在一个普通 CTE 内部又嵌套了 WITH ... SELECT ... FROM filled,把递归定义写成了内嵌语句,而不是标准的平级递归 CTE 声明。Oracle 根本不吃这套语法,它要求递归 CTE 必须在最外层 WITH 子句中完整定义,包括列别名列表、锚点部分和递归部分。大模型显然记住了“递归查询要用 WITH”,却搞混了它的语法位置——这一个错误直接导致整个 SQL 失效,连第一步都没跑下去。
也就是说,在严格遵守我给的“单步单功能”铁则的前提下,大模型第一次产出的是一个语法不合规的残次品。一个连跑都跑不起来的 SQL,别说正确性了,连被讨论的资格都没有。
空值结果
第二次生成:我说“再给你一次机会”,它用全空值回应我。
修复了语法问题之后,我决定再给它一次机会,重新生成了整段 JSON 和 SQL。这次它倒是把递归 CTE 的定义改成了标准形式,列别名也补上了,整个查询顺利通过语法检查,成功执行。可当我看到结果集的那一刻,心凉了半截——整整五行记录,四项统计指标全部是 NULL:
start |
end |
START_VALUE |
END_VALUE |
min |
max |
2026-04-01 10:10:00.000 |
2026-04-01 10:11:00.000 |
NULL |
NULL |
NULL |
NULL |
2026-04-01 10:11:00.000 |
2026-04-01 10:12:00.000 |
NULL |
NULL |
NULL |
NULL |
2026-04-01 10:12:00.000 |
2026-04-01 10:13:00.000 |
NULL |
NULL |
NULL |
NULL |
2026-04-01 10:13:00.000 |
2026-04-01 10:14:00.000 |
NULL |
NULL |
NULL |
NULL |
2026-04-01 10:14:00.000 |
2026-04-01 10:15:00.000 |
NULL |
NULL |
NULL |
NULL |
明明原始表里是有数据的,每个窗口都有对应的 v 值,但统计指标全部为 NULL。
定位问题后发现,这次坑在窗口边界上。大模型生成的窗口序列,用的是原始数据的最小和最大时间直接参与时间运算(min_t + (rn - 1) / 1440),而没有先把这两个边界截断到整分钟。而另一边,它对原始数据做分组时,使用的却是 TRUNC(t, 'MI')——也就是已经截到整分钟的窗口标识。结果就是一个用秒级边界、一个用分钟级边界,两边根本对不上齐,所有的 LEFT JOIN 自然匹配了个寂寞,NULL 值也就全面占领了结果集。
更讽刺的是,它的自然语言描述里,相邻两步分别是“计算每个分钟窗口的开始时间和结束时间”和“为每条记录计算所属的分钟窗口开始时间”,光看这两句话,你完全看不出它在时间截断这个细节上偷了懒。直到你在数据库里亲眼看到满屏的 NULL,才会意识到那个小小的 TRUNC 才是整个查询的命门。
逻辑错误
第三次生成:能跑了,但逻辑偷偷拐了弯。
修复边界问题之后,再次执行,NULL 消失了,数值也填上了,看起来一切正常。但当我拿着预期结果逐行比对时,立刻发现不对劲:
start |
end |
START_VALUE |
END_VALUE |
min |
max |
2026-04-01 10:10:00.000 |
2026-04-01 10:11:00.000 |
3 |
3 |
3 |
3 |
2026-04-01 10:11:00.000 |
2026-04-01 10:12:00.000 |
4 |
4 |
4 |
4 |
2026-04-01 10:12:00.000 |
2026-04-01 10:13:00.000 |
4 |
4 |
4 |
4 |
2026-04-01 10:13:00.000 |
2026-04-01 10:14:00.000 |
5 |
8 |
5 |
9 |
2026-04-01 10:14:00.000 |
2026-04-01 10:15:00.000 |
2 |
2 |
2 |
2 |
按照需求,第二行的 START_VALUE 应该等于第一行的 END_VALUE(即 3),但这版结果中,第二行的 START_VALUE 却是 4——它直接取了当前窗口第一条记录的数值。原因在于递归 CTE 的取值逻辑:
COALESCE(t16.first_val, t17.end_val) AS start_val
大模型的理解是:如果当前窗口有数据,就用当前窗口的第一条记录值;如果当前窗口没有数据,才用上一行的 end_value。这看起来似乎“更合理”,但它完全背离了需求里“其余窗口沿用前一个窗口的 end_value”的明确定义。这个错误之所以特别危险,是因为它跑出来的数字也是合理的、非空的、甚至看起来像是对的——如果不拿着事先准备好的正确答案逐行比对,你几乎百分之百会被它骗过去。
直到我手写了一条递归逻辑,把非首行的 start_value 强制设为上一行的 end_value,才终于拿到了正确的结果。
至此,为了一个十几分钟就能手写完成的简单需求,我与大模型进行了六轮对话、三次生成、两次人工纠错,才勉强把它拉到正确的轨道上。
为什么大模型写 SQL 这么容易翻车?
我们直接让AI自己总结原因——它干这事倒是非常到位:问题的根源,埋在 Text2SQL 技术的基因里。
上下文越长,一致性越脆弱
17 个平级 CTE,每个 CTE 里不同来源的别名、JOIN 的键、递归的引用,构成了一张复杂的依赖网。大模型需要在极其有限的注意力窗口内,始终记住哪个字段来自哪个步骤,并确保跨步骤引用毫无错乱。不幸的是,只要任何一个别名手误或者数据类型不匹配,整个查询就会沉默地吐出错误结果——就像我第二次遇到的全空值。
能跑通 ≠ 逻辑对
这是最致命的一点。Text2SQL 天生缺乏可执行测试环境,模型只能拼凑出“语法正确”的 SQL,却无法验证其语义。像 COALESCE(t16.first_val, t17.end_val) 这种写法,在它看来只是“合理使用了一个合并空值的函数”,但在业务逻辑里,它完全扭曲了“沿用上一行末尾值”的规则。如果我没有事先知道正确结果,第三次那个看起来既正常又流畅的查询,就很可能被当做最终交付物,隐患就此埋下。
自然语言解释会骗人
更可怕的是,如果你让大模型为每一步生成自然语言描述,它会忠实地根据它自己写的错误 SQL,编造出一串合情合理的解释。比如对于那个错误逻辑,它会说:“本步骤取当前窗口的首条记录值作为 start_value,若该值为空则使用前一窗口的 end_value 填充”。你看,连解释都无懈可击。对于不懂 SQL 的决策者来说,这条解释就是“可信”的证明,而真正的逻辑黑洞却藏在了字典般的描述之下。
调试成本远超手写
发现错误、让大模型重写、再验证、再纠错——这个循环中,人的角色从一个“SQL 编写者”变成了“SQL 审查员”。审查一段由机器生成的、冗长且高度拆分的 SQL,脑力负担其实比亲自写更大。至少我自己写的时候,能清楚地知道每一步为什么要这样做;而阅读大模型的产物,却需要逆向推导它的“思路”,然后找出它在哪里悄悄拐了弯。
出路
让大模型管思路,用SQLazy写命令,最后编译。
三次失败之后,我换了一种思路。既然大模型直接生成SQL不可靠,那能不能让它只负责梳理业务逻辑,把真正写SQL的活儿交给专业工具?
我重新向大模型提出了同一个需求,但这次不要求它生成SQL,只让它用自然语言描述解决步骤。
第一步,找到数据覆盖的时间范围,确定数据最早和最晚出现的时间,作为后续生成时间窗口的边界。
第二步,生成连续的每分钟时间窗口,根据时间范围生成从开始到结束的所有连续分钟窗口,即使某些分钟没有任何数据,也保留对应窗口。
第三步,将原始数据归入对应的分钟窗口,把每条数据按照发生时间放入所属的分钟窗口。
第四步,统计每个窗口的数据,计算每个分钟窗口中的第一条记录的值、最后一条记录的值、最小值、最大值,对于没有数据的窗口先保留为空。
第五步,补齐没有数据的窗口,对于没有任何数据的分钟窗口,沿用前一个窗口的结束值,使时间序列保持连续。
第六步,计算每个窗口的最终统计指标。start_value:第一个窗口取第一条记录,其余窗口取前一个窗口的结束值。end_value:有数据时取最后一条记录,无数据时等于start_value。min:有数据时取最小值,无数据时等于start_value。max:有数据时取最大值,无数据时等于start_value。
第七步,输出最终结果,包含每个分钟窗口的开始时间、结束时间以及四项统计指标。
它给出了清晰的七步思路。然后我对着这七步思路,在SQLazy里用下面这些命令把整个查询搭了出来:
Name |
Anchor |
Statement |
t1 |
test |
compute datetime(t precise minute) as t_m |
t2 |
summarize t_m min as min_window t_m max as max_window |
|
t3 |
list from t2.min_window to t2.max_window step 1 minute as window_start |
|
t4 |
join window_start with t1 t_m take t,v |
|
t5 |
sort t |
|
t6 |
summarize t argmin v max as first_val t argmax v max as last_val v min as min_val v max as max_val group window_start |
|
t7 |
compute if (last_val isnull then last_val[-1] else last_val) as filled_last_val |
|
t8 |
compute if (# = 1 then ifn(first_val, filled_last_val) else filled_last_val[-1]) as start_value |
|
derive window_start as 'start' (window_start elapse 1 minute) as 'end' start_value nvl(last_val, filled_last_val) as end_value nvl(min_val, start_value) as 'min' nvl(max_val, start_value) as 'max' |
大模型的七步思路是粗粒度的业务蓝图,SQLazy的命令则把这些蓝图拆解成精准、可执行的原子操作,每个业务环节通过几条命令的组合就能完整落地。比如“找到时间范围”这个业务环节,大模型一句话就概括了,实际落地的SQLazy里需要t1把时间截断到整分钟、t2再取截断后时间的起止边界两条命令,每个命令只做一件事,语法边界极其清晰,不存在模糊地带。再比如“统计每个窗口的数据”这一环,t5负责排序、t6负责分组聚合,两个动作分得干干净净,不会像大模型生成SQL那样把排序和窗口函数搅在一起埋下隐患。这种“蓝图加执行”的分工,既保留了大模型在需求理解上的灵活性,又通过命令层的严格语法约束避免了生成式SQL的随意性。
SQLazy的语法本身就是为“人看懂”设计的。同样是取每个窗口的第一条和最后一条记录值,SQL里要写FIRST_VALUE和LAST_VALUE两个窗口函数,再配上PARTITION BY、ORDER BY和ROWS BETWEEN,十几个关键词堆在一起,读的人得先脑补一遍执行顺序。SQLazy只有一行:summarize t argmin v max as first_val t argmax v max as last_val group window_start。argmin和argmax直译就是“取最小时间对应的v”和“取最大时间对应的v”,和你在脑子里想事情的方式一模一样。跨行引用也是,SQL里要么LAG加空值判断,要么递归CTE,绕来绕去。SQLazy里就是last_val[-1],取上一行,像Excel公式一样直接。sort就是排序,compute就是算新列,summarize就是分组汇总,每一步命令写的都是你要什么,而不是数据库要怎么执行。对着大模型的思路往下写命令,几乎是把中文翻译成英文短语,不需要在脑子里做任何SQL语法的二次转换。
然后呢,任务就结束了。SQLazy 可以把这些命令编译成指定数据库的 SQL。前面大模型折腾了三轮都写不对的 Oracle CTE SQL,SQLazy 编译出来是这样的:
WITH t1 AS (
SELECT
t,
v,
TRUNC(t, 'MI') AS t_m
FROM
t_1742353802112
),
t2 AS (
SELECT
MIN(t_m) AS min_window,
MAX(t_m) AS max_window
FROM
t1
),
TEMP_TABLE__2(window_start) AS (
SELECT
(SELECT min_window FROM t2) AS window_start
FROM
DUAL
UNION ALL
SELECT
window_start + NUMTODSINTERVAL(1, 'MINUTE')
FROM
TEMP_TABLE__2
WHERE
window_start < (SELECT max_window FROM t2)
),
t6 AS (
SELECT
window_start,
MAX(v) KEEP (
DENSE_RANK FIRST
ORDER BY t
) AS first_val,
MAX(v) KEEP (
DENSE_RANK LAST
ORDER BY t
) AS last_val,
MIN(v) AS min_val,
MAX(v) AS max_val
FROM (
SELECT
TEMP_TABLE__2.window_start,
t1.t,
t1.v
FROM
TEMP_TABLE__2
LEFT JOIN t1
ON TEMP_TABLE__2.window_start = t1.t_m
WHERE
TEMP_TABLE__2.window_start >= TO_TIMESTAMP('2026-04-01 10:10:00', 'YYYY-MM-DD HH24:MI:SS')
AND TEMP_TABLE__2.window_start <= TO_TIMESTAMP('2026-04-01 10:14:00', 'YYYY-MM-DD HH24:MI:SS')
) t_3
GROUP BY
window_start
),
t7 AS (
SELECT
window_start,
first_val,
last_val,
min_val,
max_val,
CASE
WHEN last_val IS NULL
THEN LAG(last_val) OVER (ORDER BY window_start)
ELSE last_val
END AS filled_last_val
FROM
t6
),
t8 AS (
SELECT
window_start,
first_val,
last_val,
min_val,
max_val,
filled_last_val,
CASE
WHEN ROW_NUMBER() OVER (ORDER BY window_start) = 1
THEN COALESCE(first_val, filled_last_val)
ELSE LAG(filled_last_val) OVER (ORDER BY window_start)
END AS start_value
FROM
t7
)
SELECT
window_start AS "start",
window_start + NUMTODSINTERVAL(1, 'MINUTE') AS "end",
start_value,
COALESCE(
NULLIF(CAST(last_val AS VARCHAR2(4000)), ''),
NULLIF(CAST(filled_last_val AS VARCHAR2(4000)), '')
) AS end_value,
COALESCE(
NULLIF(CAST(min_val AS VARCHAR2(4000)), ''),
NULLIF(CAST(start_value AS VARCHAR2(4000)), '')
) AS "min",
COALESCE(
NULLIF(CAST(max_val AS VARCHAR2(4000)), ''),
NULLIF(CAST(start_value AS VARCHAR2(4000)), '')
) AS "max"
FROM
t8;
这段代码每一条都是确定性的模板展开,语法一定正确,不会出现时间边界忘了截断、跨行引用逻辑偷偷拐弯这些大模型反复犯的错误。它生成的SQL可以直接拿到数据库里执行,和在SQLazy里调试的结果完全一致。
改一下目标数据库类型,SQLazy还可以编译出MySQL、PostgreSQL等数据库的SQL语句。如果还是用大模型,又不知道会折腾出什么妖蛾子。
这才是真正可用的Text2SQL路径。不是让大模型一步到位写出目标SQL,而是让它理清思路、你来编排命令、SQLazy负责编译出可靠的SQL。整个链条里,大模型只管“要做什么”,SQLazy确保“做出来的是什么”,最终交付的是一个语法正确、逻辑可控、可直接部署到数据库的SQL脚本。把SQL生成权从大模型手里收回来,还给确定性的命令组合和编译引擎,这条路才算走通了。
结语
指望大模型直接吐出生产级 SQL,就像指望一个没有代码补全和编译器的程序员用记事本写操作系统——也许能折腾出一些东西,但每一步都可能埋着意想不到的陷阱。现实中的数据分析场景,时间、空值、跨行引用几乎无处不在,正是大模型最容易失足的地带。我们不能因为它能写诗,就理所当然地认为它也一定能写好 SQL。
我第一次得到的,是一个语法不合规、连执行都通不过的残次品。
第二次我说“再给你一次机会”,它用满屏的 NULL 告诉我“不好意思,还是不行”。
第三次它终于跑出了数字,却在递归逻辑里悄悄拐了弯。
下次再有人告诉你“用 AI 写 SQL 就行了”,不妨把你最复杂的一个时间窗口需求丢给它,让它跑一遍给你看看。等你看完那三轮错误的完整记录,你就会明白——与其指望大模型写 SQL,不如指望它帮你理清思路,然后把真正的代码交给 SQLazy。
