2026/10/27 8:29:22

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 fLIMIT {n} class SQLServerDialect(Dialect): def quote_identifier(self, name): return f[{name}] def limit_clause(self, n): return fOFFSET 0 ROWS FETCH NEXT {n} ROWS ONLY参数绑定这块我坚持用占位符绝不把用户输入直接拼进 SQL 文本。工作流里的:tenant、:region这些命名参数在代码生成阶段统一转成方言对应的占位符MySQL 用?PostgreSQL 用$1值通过参数数组传入。这既是安全要求也让 SQL 文本本身可以缓存复用。提示参数化不仅防注入还能让编译产物缓存命中率大幅提升。因为 SQL 文本里没有具体值同一类查询永远编译出同一段文本。4. 规划阶段LLM 到底该做什么、不该做什么4.1 规划任务的拆解规划阶段我把它拆成四个子任务每个子任务用一次独立的模型调用而不是让模型一口气全做完。意图识别判断用户是想查询、聚合、还是做对比分析。实体抽取从问题里抽出时间范围、维度、指标、过滤条件。模板召回在模板库里找最匹配的工作流模板。参数填充把抽取到的实体填进模板参数或者自由生成工作流。拆开的好处是每一步都可以单独评估、单独优化提示词。一口气全做完的方案出错时你根本不知道是哪一步错了。4.2 提示词里必须写死的约束规划阶段的提示词有几条硬约束我踩过坑之后加进去的。只允许使用元数据里存在的表和字段并且把相关表的元数据作为上下文注入。输出必须是合法的工作流 JSON符合给定 Schema。不确定的过滤条件不要编宁可留空让下游追问。时间范围必须显式给出不允许模型自己假设上个月是哪个月。最后一条特别重要。模型对上个月的理解依赖当前时间如果提示词里不给基准时间它会用训练数据里的时间结果就是查了个寂寞。4.3 规划失败时的降级策略规划不是每次都能成功。我的降级策略分三级模板召回失败就退到自由生成自由生成 Schema 校验失败就带着报错重试重试还失败就返回一个澄清问题给用户比如您说的华东区是指 region_name 还是 province_name这个降级链路保证了系统永远不会返回一条错误的 SQL最差情况是返回一个追问。在 To B 场景里追问比错误答案的体验好太多。5. 落地时踩过的坑与实测经验5.1 元数据同步的时效问题编译器依赖元数据但元数据是会变的——加字段、改类型、建索引。我一开始用定时全量同步结果出现过表已经加了字段但编译器还不知道的情况导致合法查询被误判为幻觉。后来改成事件驱动DDL 变更时发消息编译器增量更新元数据缓存。同时保留一个兜底的全量同步防止消息丢失。这个改动之后元数据不一致导致的误报基本消失了。5.2 工作流粒度的取舍工作流拆得太细步骤多模型容易漏拆得太粗一个步骤里塞太多语义编译器难处理。我的经验是每个步骤对应一个 SQL 子句级别filter对应WHEREaggregate对应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 和我预期不一样时特别好用也能帮新人快速理解编译流程。