LLM,听我说 - 焦虑型开发者变成安全型 SQL 验证篇

第一部 · 自由交给 LLM,准确交给系统

李
李承宰 · NHN AD
平台服务实验室 · 2026 年 10 月 · 约 6 分钟

写在前面

大家好,我是想要掌控 LLM 的焦虑型开发者李承宰。这篇文章记录了我如何以确定性的方式控制 LLM 编写的 SQL。我打算分两篇来写:作为第 1 篇的本文讲 LLM 写完 SQL 之后到执行为止的过程,第 2 篇讲如何让 LLM 把 SQL 写对。

我在开发一款供代理商营销人员使用的广告管理服务,其中我负责的是通过聊天查询投放效果的功能。营销人员过去要下载效果数据,再用 Excel 公式汇总,我希望这件事只需一个问题就能完成。

这怎么就不行呢?

一开始 SQL 是由代码生成的。也就是我在上一篇文章中介绍的 SQL 构建器:AI 从句子中提取维度、指标、期间和筛选条件,构建器再用这些值按子句拼装 SQL。同样的输入总会得到同样的 SQL,因此是确定性的,也很容易测试。

但它太费工夫了。每出现一个新需求,就要大改参数和拼装规则。更重要的是,用户的问题无穷无尽,而构建器只能回答事先写好的分支。一旦遇到 “在比上周转化减少的关键词中,把预算提高 15% 后,按广告费从高到低显示 10 个” 这样的问题,每次都得新写一个分支。

于是我改成了让 LLM 直接写 SQL。

以前 · 代码拼装 SQL自然语言问题AI 提取参数维度·指标·期间·筛选构建器按子句拼装仅限预设分支SQL分支之外的问题会被挡住现在 · LLM 编写 SQL自然语言问题LLM 编写 SQL问题原样处理代码检查规则 · EXPLAIN · 审批执行自由度给 LLM,准确度交给代码
从代码按固定分支拼装 SQL,变成 LLM 编写、代码检查

自由度要高,但要确定性、准确率 99%,还要顺便给我结果文件

然而需求没有变。这些是广告主看的数字,所以 SQL 必须是确定性的,准确率也必须在 99% 以上。可是 LLM 对同一个问题每次都会写出不同的 SQL。它并不是同样输入就给出同样输出的函数,这理所当然,而且也无法保证它总能写出正确的 SQL。

最难的是语法正确但 SQL 与我们的意图不同的时候,以及 SQL 正确但 LLM 的回答不准确的时候。比如不先分别汇总效果和转化,而是逐行直接连接,广告费行就会按转化类型的数量被复制,但查询照常运行,表格也填得像模像样。

效果广告系列广告费A10,000转化广告系列转化类型A购买A购物车A注册逐行JOIN连接结果广告系列转化类型广告费A购买10,000A购物车10,000A注册10,000SUM(广告费)30,000实际为 10,000 · 3 倍
语法正确、查询也能跑,但广告费按转化类型的数量膨胀

多个广告主共用一张表,只要账户条件错一处就会混入别人的数据,还出现过把 “最近 7 天” 算成 6 天的日期错误。

缺少账户条件时广告主广告费A10,000B20,000C5,000A10,000问的是 A 的广告费35,000 · 混入他人数据“最近 7 天”24日25日26日27日28日29日30日31日今天 · 无数据正确 · 截至昨天的 7 天LLM · 6 天开始从今天算,结束拉回到昨天,少了一天
账户条件错一处、日期差一天,也不会报错

回答也出过错。即使 SQL 运行正确,模型在转述结果时也会出错:把只有 30 行的合计说成账户总计,或把 35、114、81 这样参差不齐的值笼统说成 “每月 100 次左右”。

让同一个模型再审查一遍,也只是重复同样的错误。于是我决定,解读问题和编写 SQL 尽量交给 LLM,而从这条 SQL 写好到执行为止,用代码严格管控。

LLM自由度代码确定性解读问题判断类型选择示例黄金集 BM25编写 SQL仅占位符账户·期间服务器填入检查sqlglotEXPLAIN审批卡片用户确认执行汇总值撰写回答引用服务器值交给 LLM 解读无穷无尽的问题、编写 SQL、用文字解释结果交给代码 挑选示例、填入值、检查、获得确认、执行、计数
解读和编写交给 LLM,挑选、填充、检查和计数交给代码

管控 SQL

本节只讲 LLM 写完 SQL 之后到执行为止的过程。写 SQL 之前的事情放到第 2 篇再讲。

LLM 不会把账户 ID 和期间直接写进 SQL,只用 :id、:start_date、:end_date 留出位置。这些标记不是 SQL 语法,而是我们系统内部的约定,表示“这里由服务器填入”。ID 用用户在界面上选择、由后端通过会话传来的账户填入;期间则由 LLM 传入 period_days=7 之类的参数,服务器计算日期后填入。

用户提问“显示最近 7 天各广告系列的广告费” LLM 编写的 SQLSELECT camp_no, SUM(spend)FROM ad_spendWHERE id = :id -- 原样写这个标记,而不是数字 AND event_day BETWEEN :start_date AND :end_date:id ← 会话账户用户在界面上选择的账户 · 由后端传入:start_date · :end_date ← 服务器计算LLM 传入的 period_days=7 → 20261024~20261030实际执行的 SQLSELECT camp_no, SUM(spend)FROM ad_spendWHERE id = '1234' AND event_day BETWEEN '20261024' AND '20261030'
LLM 只标记位置,值由服务器填入

写好的 SQL 会用 sqlglot 解析,并在语法树上检查。写入查询、缺少分区条件的 SQL、读取与会话不同 ID 的 SQL 都会被拒绝。我最在意的是 JOIN。不先分别汇总效果和转化就逐行连接,会被视为 grain 不匹配的 JOIN 而拒绝。LLM 也经常使用 CTE,所以这项检查会对每个 CTE 单独进行。

用户提问“显示最近 7 天各广告系列的广告费和转化数” LLM 编写的 SQL 示例WITH s AS ( SELECT camp_no, spend FROM ad_spend WHERE id = '1234' AND event_day BETWEEN '20261024' AND '20261030'),c AS ( SELECT camp_no, action_kind, action_cnt FROM ad_actions WHERE event_day BETWEEN '20261024' AND '20261030')SELECT s.camp_no, SUM(s.spend) AS spend, SUM(c.action_cnt) AS convFROM s JOIN c ON s.camp_no = c.camp_noGROUP BY s.camp_no解析语法树检查SELECTCTE sad_spend✓ 有 ID · 期间条件CTE cad_actions✗ 无 ID 条件JOINs ⋈ c✗ 广告费按转化类型GROUP BYcamp_no✓ 没有问题数量复制 → 膨胀拒绝 · 理由发给 LLM根据理由重写LLM 修正后的 CTE cc AS (SELECT camp_no, SUM(action_cnt) AS action_cnt FROM ad_actions WHERE id = '1234' … GROUP BY camp_no) -- 每个广告系列 1 行✓ 重新检查通过 → 进入执行按 CTE 分别检查 ID · 期间条件,JOIN 则检查两侧 grain 是否一致。被拒绝时理由发给 LLM,LLM 修正后的 SQL 会再次接受检查示例数据 · ID 1234 · 10/24~10/30ad_spendcamp_nospendA10,000B5,000ad_actionscamp_noaction_kindaction_cntA购买3A购物车5A注册2B购买1错误 SQL · 直接 JOIN cJOIN 结果 · 广告费按类型数量复制camp_nospendaction_kindaction_cntA10,000购买3A10,000购物车5A10,000注册2B5,000购买1A 广告费 30,000 ✗ 转化 10B 广告费 5,000 转化 1A 的 10,000 按转化类型数量被加了三次修正后 SQL · 先按广告系列汇总 cJOIN 结果 · 每个广告系列 1 行camp_nospendaction_cntA10,00010B5,0001A 广告费 10,000 ✓ 转化 10B 广告费 5,000 ✓ 转化 1两条 SQL 都能无错误运行
在语法树上判定 CTE 和 JOIN;在同样的数据上运行时,错误的那条会让广告费膨胀

按类型查看广告系列 A 的转化时也一样。给每个类型行都附上广告费,同一笔广告费就会重复出现,一旦在结果文件或 Excel 里求和就会膨胀。所以检查器会拒绝这类 SQL,并返回“把类型展开成列”的理由。修正后的 SQL 让每个广告系列保持一行,把购买、购物车、注册分别放到各自的列里。

用户提问“按类型显示最近 7 天各广告系列的广告费和转化” LLM 编写的 SQL 示例 · 类型展开成行SELECT s.camp_no, s.spend, c.action_kind, c.action_cntFROM s JOIN ( SELECT camp_no, action_kind, SUM(action_cnt) AS action_cnt FROM ad_actions WHERE id = '1234' AND … GROUP BY camp_no, action_kind -- 广告系列 × 类型 = 多行) c ON s.camp_no = c.camp_no -- s.spend 附在每个类型行上修正后 SQL · 类型展开成列SELECT s.camp_no, s.spend, c.purchase, c.cart, c.signupFROM s JOIN ( SELECT camp_no, SUM(CASE WHEN action_kind = '购买' THEN action_cnt ELSE 0 END) AS purchase, SUM(CASE WHEN action_kind = '购物车' THEN action_cnt ELSE 0 END) AS cart, SUM(CASE WHEN action_kind = '注册' THEN action_cnt ELSE 0 END) AS signup FROM ad_actions WHERE id = '1234' AND … GROUP BY camp_no) c ON s.camp_no = c.camp_no示例数据 · ID 1234 · 10/24~10/30ad_spendcamp_nospendA10,000B5,000ad_actionscamp_noaction_kindaction_cntA购买3A购物车5A注册2B购买1错误 SQL 结果camp_nospendaction_kindaction_cntA10,000购买3A10,000购物车5A10,000注册2B5,000购买1广告费合计 35,000 ✗ 实际 15,000广告费在每个类型行上重复,合计膨胀 → 拒绝修正后 SQL 结果 · 类型展开成列camp_nospend购买购物车注册A10,000352B5,000100广告费合计 15,000 ✓每个广告系列 1 行,每种类型一列
按类型查看时,把类型展开成列而不是行

通过规则检查的 SQL 会再用 Athena 的 EXPLAIN 确认一次。它不读取数据、只生成执行计划,因此没有费用或时间上的执行开销,还能提前发现不存在的列、类型不匹配的比较等引擎错误。

调用 SQL 执行工具LLM · 仅占位符填入 ID · 期间ID 会话 · 计算期间sqlglot 规则语法树检查EXPLAIN扫描 0 字节进入执行仅限全部通过的 SQL返回拒绝理由 · 引擎错误信息 → LLM 修正后再次调用 · 最多 3 次
SQL 写好之后的检查。被拒绝时会带着理由回到 LLM

啊,想了想,回答里能不能再加点洞察?

还有人提出希望回答里带上一些简单的洞察。为了让 LLM 能给出丰富的回答,服务器会生成包住那条 SQL 的汇总查询,一并计算行数、合计、分布以及前后几行再交给它。LLM 以这些值为依据,指出哪里大、哪里空。

汇总查询按照用 sqlglot 读出的输出列类型,依规则生成。可加总指标给出合计和分布,比率给出分子之和 ÷ 分母之和,名称和 ID 给出去重计数。行与行之间的比较判断只使用同事开发的计算工具给出的值,回答中的数字是否存在于结果中,则用正则表达式比对并记录。

LLM 所写 SQL 的输出列 · 用 sqlglot 解析camp_nospendclicksimprctr列类型判定 · 名称词典 → 名称末尾标记(_ctr 等)→ 输出表达式(SUM · COUNT)名称 · IDcamp_no· 去重计数· 空值数· 前后几行的标签可加总指标spend · clicks · impr· SUM 合计· 分布 p25 · p50 · p75 · p90· 值为 0 的行数· 前 · 后 5 行比率ctr· 在 SQL 副本上追加分子 · 分母列· 用 SUM(clicks) ÷ SUM(impr) 重算· 不取各行比率的平均按规则拼装服务器生成的汇总查询 · 与主查询一起执行SELECT COUNT(*) AS 行数, SUM(spend) AS 广告费合计, SUM(clicks) * 1.0 / NULLIF(SUM(impr), 0) AS 整体CTR, approx_percentile(spend, 0.5) AS 广告费中位数, max_by(camp_no || '=' || spend, spend, 5) AS 前5, min_by(camp_no || '=' || spend, spend, 5) AS 后5FROM ( <LLM 写的 SQL 原样> ) AS r行数 · 合计 · 统计 · 前 · 后几行与结果一起交给 LLM → 回答中原样引用
按规则判定列类型生成汇总查询,再把结果交给 LLM
执行前 · 审批卡片查询确认期间2026-10-24 ~ 2026-10-30列广告系列 · 广告费 · 转化数范围所选广告账户预览 · 广告系列 A 10,000 · 10 …批准修改取消批准后执行后 · 结果信号0 行无符合条件的数据负数不可能的值大量 NULL值为空不做猜测,告知依据不足回答示例“这段期间没有符合条件的数据,缺乏判断依据。”批准Athena 执行 SQL结果 CSV 文件CSV + LLM 回答
只执行人工批准的 SQL,结果以 CSV 和回答的形式发出

写在最后

最近我去了优雅兄弟主办的技术大会 WOOWACON。会上有很多基于 LLM 的服务,其中我和一位讲翻译功能的讲者聊天时,听到了 “LLM 是翻译系统的翻译器” 这句话,深有同感。

我也是这么想的。LLM 把用户的请求翻译成我们系统的语言。只要告诉它系统的规划意图和目的、谁在用、能做什么,LLM 就会据此解读请求并作答。从某种意义上说,也可以把它称作编译服务的编译器。

第 2 篇会讲如何让这个翻译器把 SQL 写对。

感谢阅读。

参考