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_valuemin:有数据时取最小值,无数据时等于start_valuemax:有数据时取最大值,无数据时等于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_VALUELAST_VALUE两个窗口函数,再配上PARTITION BYORDER BYROWS BETWEEN,十几个关键词堆在一起,读的人得先脑补一遍执行顺序。SQLazy只有一行:summarize t argmin v max as first_val t argmax v max as last_val group window_startargminargmax直译就是“取最小时间对应的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还可以编译出MySQLPostgreSQL等数据库的SQL语句。如果还是用大模型,又不知道会折腾出什么妖蛾子。

这才是真正可用的Text2SQL。不是让大模型一步到位写出目标SQL,而是让它理清思路、你来编排命令、SQLazy负责编译出可靠的SQL。整个链条里,大模型只管“要做什么”,SQLazy确保“做出来的是什么”,最终交付的是一个语法正确、逻辑可控、可直接部署到数据库的SQL脚本。把SQL生成权从大模型手里收回来,还给确定性的命令组合和编译引擎,这条路才算走通了。

结语

指望大模型直接吐出生产级 SQL,就像指望一个没有代码补全和编译器的程序员用记事本写操作系统——也许能折腾出一些东西,但每一步都可能埋着意想不到的陷阱。现实中的数据分析场景,时间、空值、跨行引用几乎无处不在,正是大模型最容易失足的地带。我们不能因为它能写诗,就理所当然地认为它也一定能写好 SQL

我第一次得到的,是一个语法不合规、连执行都通不过的残次品。

第二次我说再给你一次机会,它用满屏的 NULL 告诉我不好意思,还是不行

第三次它终于跑出了数字,却在递归逻辑里悄悄拐了弯。

下次再有人告诉你 AI SQL 就行了,不妨把你最复杂的一个时间窗口需求丢给它,让它跑一遍给你看看。等你看完那三轮错误的完整记录,你就会明白——与其指望大模型写 SQL,不如指望它帮你理清思路,然后把真正的代码交给 SQLazy