ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM规划+编译器生成:确定性SQL生成器架构设计与落地

LLM规划+编译器生成:确定性SQL生成器架构设计与落地

SQL 生成这件事,很多人第一反应是"让大模型直接写一条不就行了"。我一开始也这么干过,结果在生产环境里被现实教育了好几轮:同一个问题问两次,模型给出的 SQL 结构不一样;字段名偶尔幻觉出一个数据库里根本不存在的列;更麻烦的是,当业务方要求"这条 SQL 必须走某个索引""必须带上某个租户过滤条件"时,纯靠提示词根本约束不住。后来我把思路换了一下——让 LLM 只负责它真正擅长的事,也就是理解意图和做规划,把"从规划到 SQL 文本"这一段交给一个确定性的编译器来做。这篇文章就把这套"LLM 规划、编译器生成、以工作流为契约"的确定性 SQL 生成器的设计思路和落地细节完整拆一遍。

1. 为什么纯 LLM 直出 SQL 在生产环境站不住

1.1 三个反复出现的翻车场景

先说清楚问题,不然没法理解后面为什么要引入编译器。我在实际项目里遇到的翻车基本集中在三类。

第一类是结构漂移。用户问"查一下上个月华东区销售额前 10 的商品",第一次模型给的是SELECT ... ORDER BY amount DESC LIMIT 10,第二次可能变成先GROUP BY再套一层子查询再LIMIT。两条 SQL 语义等价,但执行计划可能完全不同,缓存命中率直接归零,DBA 那边也会来问你为什么同样的查询有五种写法。

第二类是幻觉字段与幻觉表。模型很擅长"编"出一个听起来合理的列名,比如total_sales_amount,但你的表里实际叫gmv。这种错误在开发阶段容易发现,一旦进了自动化链路,就是运行时报错。

第三类是约束不可控。企业里几乎每张表都有租户隔离字段、软删除标记、数据权限范围。你希望每条生成的 SQL 都自动带上tenant_id = ? AND is_deleted = 0,但靠提示词写"请务必加上租户条件",模型十次里有两次会忘。这种不确定性在 To B 场景里是致命的。

1.2 确定性到底指什么

我这里说的"确定性",不是指整个系统没有随机性,而是指给定同一份规划(Plan),编译出来的 SQL 文本逐字节一致。这一点非常关键,因为它带来三个直接好处:可缓存、可审计、可回归测试。

可缓存意味着同样的规划可以直接命中 SQL 模板缓存,不用重复编译;可审计意味着你能把"用户问题 → 规划 → SQL"这条链路完整存下来,出问题能回溯;可回归测试意味着你可以为编译器写单元测试,输入一个 Plan,断言输出等于某条固定 SQL,这在纯 LLM 直出的方案里几乎做不到。

提示:把"确定性"的边界划在编译器这一层,而不是整个系统。LLM 规划阶段允许有随机性,但一旦规划定稿,后面必须是纯函数式的转换。

1.3 分工的本质:理解归理解,生成归生成

我后来总结出一句话:LLM 负责"要什么",编译器负责"怎么写"。理解自然语言意图、消歧、补全隐含条件,这些是 LLM 的强项,也是传统规则引擎做不好的地方;而把结构化的意图翻译成符合方言、符合规范、带齐约束的 SQL 文本,这是编译器的强项,LLM 反而容易出错。

这个分工一旦确立,整个系统的架构就清晰了:中间需要一个稳定的契约,也就是"工作流"和它承载的 DSL。下一节就讲这个契约怎么设计。

2. 工作流作为契约:把"意图"固化成可编译的中间表示

2.1 为什么选工作流而不是直接输出 AST

有人会问,为什么不直接让 LLM 输出一棵抽象语法树(AST)?我试过,问题在于 AST 太底层了。让模型直接吐 JSON 形式的 AST,节点一多就容易漏字段、括号不匹配,而且模型对 AST 的结构理解并不稳定。

工作流(Workflow)是更合适的中间层。它描述的是"先做什么、再做什么、按什么条件分支"这样的步骤序列,每个步骤是一个语义明确的算子,比如Scan、Filter、Join、Aggregate、Sort、Limit。这个抽象层级刚好卡在"人能看懂"和"机器能编译"之间。

我用的 DSL 大概长这样,是一个步骤数组:

{ "workflow": "sales_ranking", "steps": [ { "op": "scan", "table": "order_fact", "alias": "o" }, { "op": "filter", "expr": { "and": [ { "eq": ["o.tenant_id", ":tenant"] }, { "eq": ["o.is_deleted", 0] }, { "between": ["o.pay_time", ":month_start", ":month_end"] } ]}}, { "op": "join", "type": "inner", "table": "region_dim", "alias": "r", "on": { "eq": ["o.region_id", "r.id"] } }, { "op": "filter", "expr": { "eq": ["r.region_name", ":region"] } }, { "op": "aggregate", "groupBy": ["o.item_id"], "aggs": [{ "fn": "sum", "field": "o.amount", "as": "gmv" }] }, { "op": "sort", "by": [{ "field": "gmv", "dir": "desc" }] }, { "op": "limit", "n": 10 } ] }

注意这里没有任何 SQL 方言的痕迹,没有SELECT、没有反引号、没有LIMIT和TOP的区别。方言相关的处理全部推迟到编译阶段,这是让同一份规划能适配 MySQL、PostgreSQL、SQL Server 的关键。

2.2 契约的稳定性靠什么保证

工作流作为契约,最大的风险是"模型今天生成这个字段,明天生成那个字段"。我的做法是给 DSL 定义一个严格的 Schema,用 JSON Schema 校验,规划阶段输出的东西必须先过校验才能进编译器。校验不过就退回让模型重规划,最多重试两到三次。

Schema 里对每个算子都规定了必填字段和可选字段。比如filter的expr必须是一个合法的表达式树,join必须有on条件。这样即使模型偶尔抽风,也会在校验层被拦住,而不是把脏数据带到 SQL 生成阶段。

注意:Schema 校验的错误信息要回传给模型,让它知道错在哪。我实测下来,把校验器的报错原文塞回对话,模型第二次修正的成功率能到九成以上。

2.3 工作流模板的复用价值

工作流还有一个隐藏价值:模板化。企业里大量查询其实是同一类模式,比如"按维度分组取 TopN""按时间窗口做同比环比"。这些模式可以沉淀成工作流模板,LLM 的任务从"从零生成"变成"选模板 + 填参数",难度大幅下降,稳定性大幅上升。

我维护了一个模板库,每个模板有名字、描述、参数列表。规划阶段模型先做模板召回,命中就直接填参,没命中才走自由生成。这个策略让线上的一次规划成功率从七成左右提到了九成五以上。

3. 编译器内部:从工作流到 SQL 文本的确定性转换

3.1 编译流水线的四个阶段

编译器不是一步到位的,我把它拆成四个阶段,每个阶段职责单一,方便测试和排错。

阶段输入输出核心职责
语义分析工作流 DSL带类型标注的逻辑计划校验表/字段存在性、类型推导
逻辑优化逻辑计划优化后逻辑计划谓词下推、投影裁剪、常量折叠
物理规划逻辑计划物理计划选择 Join 算法、决定方言特性
代码生成物理计划SQL 文本拼接方言 SQL、参数绑定

这个分层借鉴了数据库查询优化器的经典结构,好处是每一层都能单独测。语义分析层保证"引用的表和字段真实存在",逻辑优化层保证"生成的 SQL 尽量高效",物理规划层处理"方言差异",代码生成层只做字符串拼接。

3.2 语义分析:把幻觉挡在编译之前

语义分析阶段要做的最重要一件事,是拿元数据去核对工作流里引用的每一个表和字段。我维护了一份元数据缓存,包含所有表的列名、类型、是否可空、是否有索引。

当工作流里出现o.total_sales_amount而元数据里只有o.gmv时,语义分析直接报错,并且可以给出"你是不是想用 gmv"的候选建议。这一步把 LLM 的幻觉彻底挡在了 SQL 生成之前,比事后在数据库报错要友好得多。

类型推导也很关键。比如between操作符要求两端类型一致,如果一边是日期字符串一边是时间戳,语义分析阶段就要做隐式转换或者报错,而不是让数据库在运行时去猜。

3.3 逻辑优化:让生成的 SQL 不只是"能跑"

很多人以为编译器只要生成正确 SQL 就行,但实际生产里"能跑"和"跑得快"是两回事。逻辑优化层做几件性价比很高的事。

谓词下推:把过滤条件尽量推到扫描阶段,减少中间结果集。比如上面那个工作流,tenant_id和is_deleted的过滤应该直接进scan的WHERE,而不是等 Join 完再过滤。

投影裁剪:只SELECT真正用到的列。工作流里用到的字段是明确的,编译器可以精确算出需要哪些列,避免SELECT *。

常量折叠:如果某个过滤条件是1 = 1这种恒真,直接消掉;1 = 0恒假,整个查询可以短路成空结果。

这些优化在纯 LLM 方案里基本靠运气,模型有时候会做有时候不会。放到编译器里,就是确定性的、每次都执行的规则。

3.4 代码生成:方言适配与参数绑定

代码生成层是唯一关心方言的地方。我抽象了一个Dialect接口,不同数据库实现各自的quoteIdentifier、limitClause、dateFunction等方法。

class Dialect: def quote_identifier(self, name): ... def limit_clause(self, n): ... def date_trunc(self, unit, field): ... class MySQLDialect(Dialect): def quote_identifier(self, name): return f"`{name}`" def limit_clause(self, n): return f"LIMIT {n}" class SQLServerDialect(Dialect): def quote_identifier(self, name): return f"[{name}]" def limit_clause(self, n): return f"OFFSET 0 ROWS FETCH NEXT {n} ROWS ONLY"

参数绑定这块我坚持用占位符,绝不把用户输入直接拼进 SQL 文本。工作流里的:tenant、:region这些命名参数,在代码生成阶段统一转成方言对应的占位符(MySQL 用?,PostgreSQL 用$1),值通过参数数组传入。这既是安全要求,也让 SQL 文本本身可以缓存复用。

提示:参数化不仅防注入,还能让编译产物缓存命中率大幅提升。因为 SQL 文本里没有具体值,同一类查询永远编译出同一段文本。

4. 规划阶段:LLM 到底该做什么、不该做什么

4.1 规划任务的拆解

规划阶段我把它拆成四个子任务,每个子任务用一次独立的模型调用,而不是让模型一口气全做完。

  1. 意图识别:判断用户是想查询、聚合、还是做对比分析。
  2. 实体抽取:从问题里抽出时间范围、维度、指标、过滤条件。
  3. 模板召回:在模板库里找最匹配的工作流模板。
  4. 参数填充:把抽取到的实体填进模板参数,或者自由生成工作流。

拆开的好处是每一步都可以单独评估、单独优化提示词。一口气全做完的方案,出错时你根本不知道是哪一步错了。

4.2 提示词里必须写死的约束

规划阶段的提示词有几条硬约束,我踩过坑之后加进去的。

  • 只允许使用元数据里存在的表和字段,并且把相关表的元数据作为上下文注入。
  • 输出必须是合法的工作流 JSON,符合给定 Schema。
  • 不确定的过滤条件不要编,宁可留空让下游追问。
  • 时间范围必须显式给出,不允许模型自己假设"上个月"是哪个月。

最后一条特别重要。模型对"上个月"的理解依赖当前时间,如果提示词里不给基准时间,它会用训练数据里的时间,结果就是查了个寂寞。

4.3 规划失败时的降级策略

规划不是每次都能成功。我的降级策略分三级:模板召回失败就退到自由生成;自由生成 Schema 校验失败就带着报错重试;重试还失败就返回一个澄清问题给用户,比如"您说的华东区是指 region_name 还是 province_name?"

这个降级链路保证了系统永远不会返回一条错误的 SQL,最差情况是返回一个追问。在 To B 场景里,追问比错误答案的体验好太多。

5. 落地时踩过的坑与实测经验

5.1 元数据同步的时效问题

编译器依赖元数据,但元数据是会变的——加字段、改类型、建索引。我一开始用定时全量同步,结果出现过"表已经加了字段但编译器还不知道"的情况,导致合法查询被误判为幻觉。

后来改成事件驱动:DDL 变更时发消息,编译器增量更新元数据缓存。同时保留一个兜底的全量同步,防止消息丢失。这个改动之后,元数据不一致导致的误报基本消失了。

5.2 工作流粒度的取舍

工作流拆得太细,步骤多,模型容易漏;拆得太粗,一个步骤里塞太多语义,编译器难处理。我的经验是每个步骤对应一个 SQL 子句级别,filter对应WHERE,aggregate对应GROUP BY+ 聚合函数,sort对应ORDER BY。这个粒度下,模型生成的工作流步骤数通常在 5 到 10 之间,既不会太碎也不会太粗。

5.3 缓存键怎么设计

编译产物要缓存,缓存键的设计很讲究。我一开始用工作流 JSON 的哈希做键,但发现参数值不同会导致哈希不同,缓存命中率很低。后来改成用工作流结构(去掉参数值)的哈希做键,参数值单独存,命中率一下子上来了。

缓存键方案命中率问题
工作流全量哈希低参数值变化导致键变化
工作流结构哈希高需要额外存参数值
模板名 + 参数名集合最高仅适用于模板化查询

5.4 回归测试怎么建

编译器最大的优势是可测试。我建了一个回归测试集,每条用例是一个工作流加期望的 SQL 文本。每次改编译器代码,跑一遍测试集,确保没有回归。这个测试集现在有几百条,覆盖了各种算子组合和方言。

注意:回归测试的期望 SQL 不要手写,而是用"人工审核过的编译器输出"作为基线。手写容易和编译器实际输出有格式差异,导致测试假失败。

6. 这套架构适合什么样的团队

6.1 不适合的场景

先说清楚不适合的。如果你的查询场景非常开放,用户想问什么就问什么,涉及几十张表的任意组合,那工作流模板的覆盖率会很低,规划阶段的负担会很重。这种情况下,纯 LLM 直出加人工审核可能更划算。

另外,如果团队没有维护元数据的能力,语义分析层就形同虚设,整套架构的价值会打折扣。

6.2 最适合的场景

最适合的是查询模式相对固定、但组合灵活的场景。比如 BI 报表、运营后台的即席查询、数据 API 网关。这些场景里,查询的"形状"是有限的,但过滤条件、维度组合是多样的,正好是工作流模板加参数填充的用武之地。

我目前落地的项目就是数据 API 网关,用户用自然语言描述查询需求,系统生成 SQL 并执行。上线后 SQL 生成准确率稳定在九成五以上,剩下的主要是需要澄清的模糊需求。

6.3 团队需要具备的能力

落地这套架构,团队需要三方面能力:一是懂数据库查询优化,能设计好逻辑优化规则;二是懂编译器或解释器开发,能维护好分层结构;三是懂 LLM 应用,能写好规划阶段的提示词和降级策略。三者缺一不可,但不需要每个人都精通,有分工就行。

我自己是从数据库和后端背景转过来的,LLM 那部分边做边学。实际感受是,编译器这部分的基本功反而更关键,因为它决定了系统的下限,而 LLM 部分决定的是上限。

最后分享一个我在调试编译器时常用的小技巧:给编译器加一个"解释模式",输入工作流,输出不只是 SQL,还有每一步做了什么优化、为什么这么生成。这个模式在排查"为什么生成的 SQL 和我预期不一样"时特别好用,也能帮新人快速理解编译流程。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进