☰
LLM规划+编译器生成:确定性SQL生成的工程实践
2026/10/7 12:05:32 网站建设 项目流程

之前在做内部数据平台的查询服务时,我接到了一个看起来很简单的需求:让业务人员用自然语言问“上个月华东区销售额前10的客户有哪些”,系统直接给出SQL查询结果。当时团队里所有人都觉得这事交给大模型就行,LLM生成SQL已经是成熟能力了。但真正落到生产环境后,我们踩了一连串的坑:大模型偶尔会把日期边界算错、join条件写漏、聚合逻辑和业务口径对不上,甚至同一个问题问两遍,生成的SQL结构都不一样。最要命的是,这些错误不是一眼能看出来的,等到报表数据错了几天才发现。

这让我彻底想明白一件事:LLM负责“想清楚要查什么”,但不应该负责“写出最终能跑的SQL”。前者是规划问题,后者是工程问题。规划可以交给大模型,但生成必须交给确定性的编译器。于是就有了这个项目——以工作流为契约的确定性SQL生成器。核心思路一句话就能说清楚:LLM不直接输出SQL,而是输出一张结构化的查询工作流图,再由一个编译器把这张图翻译成可执行、可校验、可回滚的SQL。整个过程把自然语言到SQL的不确定路径,拆成了“LLM规划+编译器生成”两段,前半段允许模糊,后半段必须精确。

这套思路适合谁?如果你正在做类似ChatBI、数据分析助手、报表自动生成这类产品,或者在维护一堆口径复杂、动辄几十行SQL的数据报表,见不得模型瞎编,那这篇文章应该能给你一个可以落地的工程范式。我会把整个项目的架构、DSL设计、编译器实现、确定性保障措施以及实际踩过的坑都摊开讲一遍。

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

先说说我们最开始直接用LLM生成SQL时踩到的问题,只有把痛点摊开,你才能理解后面这套“规划+编译”架构为什么是必要的。

1.1 模型的不确定性是天然属性,不是bug

技术圈常说大模型是“概率型计算器”,这句话放在SQL生成场景特别扎心。我们用同一个prompt、同样的 temperature=0,连问GPT-4和Claude三次,生成的SQL十个里有七八个结构不一致。有的把LEFT JOIN换成了INNER JOIN,有的把筛选条件挪到了子查询里,有的干脆给了一个语义完全等价但写法完全不同的写法。单看每个答案都对,但放到生产环境就是灾难——你没法对SQL做回归测试,因为SQL本身不稳定。

可能有朋友会说,可以固定prompt、固定模型版本、关闭temperature。但我的实测结论是:这只能降低波动概率,无法根除。模型版本一升级,SQL风格可能全变;prompt里多了一个标点符号,可能就改变了表的选择逻辑。LLM的迭代特性决定了,只要它参与最终SQL的生成,不确定性就会从模型层传导到数据层。

1.2 业务口径、权限边界和性能约束难以在free-form SQL中体现

更现实的问题是,自然语言描述的“上个月”在不同的业务场景里有完全不同的含义。管理层说的“上个月”是自然月,财务说的“上个月”是会计周期,运营说的“上个月”可能是截至上周日的滚动30天。你可以把这些口径写进prompt,但prompt是给模型看的“建议”,不是给系统执行的“约束”,模型稍有理解偏差,口径就错了。

权限问题更麻烦。我们的数据表有列级权限,某个部门的用户不能看成本列,模型生成的SELECT *会直接泄露敏感字段。如果对最终SQL做后置改写,改写逻辑本身又是一套复杂的编译器逻辑,为什么不一开始就把权限下推到生成层呢?

1.3 无法测试、无法审计、无法回滚

这是压垮我的最后一根稻草。当我们试图给每个自然语言问题建立“黄金SQL”回归集时,发现LLM直出的SQL根本没法做自动化断言——因为模型每次生成的SQL都可能不同,你今天断言了这条SQL,明天它生成另一条。当用户拿着一条错误SQL来投诉时,你甚至说不清楚这条SQL是模型理解错了,还是生成策略错了。

所以我们需要一个分界线:LLM只输出意图层面的规划,SQL由代码生成。代码意味着确定性,意味着可以写单测、可以做快照测试、可以审计。

2. 总架构:LLM规划层与编译器生成层之间放一张工作流契约

这个项目的关键决策,就是在这两层之间强行插入了一个“契约层”——一个结构化的查询工作流。LLM的输出不再是一段SQL,而是一棵有向无环图,节点是操作,边是数据依赖。编译器拿到这张图之后,才开始做表名解析、字段映射、JOIN规划、SQL拼接和参数绑定。

2.1 工作流其实就是“用户意图的规范化表达”

我常跟团队打一个比方:LLM就像一个项目经理,他听完需求后,画了一张业务流程草图,上面写着“先筛选华东区,再按客户分组,然后算销售额,最后排序取前10”。而编译器是施工队,施工队只认图纸,项目经理嘴里的“大概”“差不多”“你看着办”一律不认。图纸就是工作流,图纸的语法就是工作流DSL。

我们定义的工作流大概是这样的:

{ "workflow": { "version": 1.0, "source": { "type": "table", "name": "orders", "alias": "o" }, "steps": [ { "id": "s1", "op": "filter", "field": "order_date", "operator": "between", "params": { "start": "2024-08-01", "end": "2024-08-31" } }, { "id": "s2", "op": "join", "type": "left", "source": { "type": "table", "name": "customers", "alias": "c" }, "conditions": [ { "left": "o.customer_id", "right": "c.id", "operator": "=" } ] }, { "id": "s3", "op": "group_by", "fields": ["c.name"] }, { "id": "s4", "op": "aggregate", "name": "total_sales", "func": "sum", "field": "o.amount" }, { "id": "s5", "op": "sort", "field": "total_sales", "direction": "desc", "limit": 10 } ] } }

你可能会说,这不就是把SQL拆成了JSON吗?没错,但差别在于:JSON是结构化数据,可以被schema校验、被静态分析、被形式化验证,而自然语言字符串不能。LLM生成JSON的错误率远低于生成SQL的错误率,因为JSON的结构约束缩小了输出空间,并且我们可以用JSON Schema对LLM的输出做一次硬校验,不合格就重规划或直接报错。

2.2 为什么不用函数调用或自然语言指令当契约

我们也试过让LLM直接调用filter(table, field, operator, value)这类函数,这就是业界常说的function calling方案。后来发现一个问题:函数调用只描述了“这一步做什么”,没有描述“这一步在整个流程中的位置”。一个复杂查询通常是多步骤的组合,函数调用是一维序列,无法表达多个数据源在逻辑上的汇聚、拆分和依赖关系。比如先对A表做汇总,再和B表关联,然后对关联结果做条件过滤——这种结构用函数调用扁平化表达很容易丢失步骤间的依赖顺序,最后还是得靠LLM靠“记忆”维护状态,这不就又把不确定性引回来了吗?

而工作流图不一样,它天然支持有向依赖,节点之间只通过数据流沟通,后续节点不关心前面节点的内部实现,只关心它输出了什么字段。编译器的优化器也能在有向无环图上做等价变换,这在字符串SQL上很难安全地做。

2.3 规划层专注于“意图识别”,生成层专注“物理实现”

这个分工还有一个隐含的好处:两层的迭代互不干扰。业务口径变了,比如“销售额”从含税改为不含税,只需要改编译器里aggregate节点的物理实现或字段映射规则,LLM的prompt完全不用动。如果LLM能力升级了,比如可以理解更模糊的口语表达,只需要调整规划层的prompt或模型,编译器的逻辑完全不用动。这个解耦在团队协作上的价值,是实打实的。

3. 工作流DSL的设计约束:宁可减少操作类型,也要确保语义唯一

工作流DSL是这个项目的灵魂,它的设计质量直接决定编译器能优化到什么程度、LLM能在多大范围内自由发挥。我们设计DSL时定了几条铁律,这里分享出来。

3.1 操作类型必须是“封闭集合”,不提供自定义表达式

第一条铁律:DSL里所有的op只能从枚举集合里取,比如filter、join、group_by、aggregate、sort、limit、project、distinct、union。不允许出现raw_sql节点,也不允许在参数里写任意表达式字符串。

这么做的原因很朴素:raw_sql节点等于把不确定性从后门又放回来了。只要编译器看到一个无法解析的字符串,它就无法校验、无法优化、无法做权限裁剪。我们初期为了让系统能处理一些复杂case,加过expression字段,结果这个字段成了LLM幻觉的重灾区,模型经常生成离谱的自定义表达式。后来一刀切删掉,所有计算逻辑必须拆成原子的aggregate或filter节点,系统复杂度不降反升——因为编译器可以统一处理所有节点了。

3.2 字段引用必须显式化,禁止星号通配

第二个约束是:任何涉及字段引用的地方,必须显式写明表和字段,比如o.amount、c.name,编译器不提供SELECT *展开,不允许推断用户“可能想要所有列”。

权限控制可以在这个环节自然落地:如果用户无权限访问某个字段,编译器在字段解析阶段就直接报错,而不是生成完SQL后再做脱敏。字段级别的血缘关系也变得可追踪了,每个输出列都能倒推到原始表的字段。

3.3 DSLL须自带版本号,破坏性变更必须走迁移

工作流JSON是我们的“接口契约”,契约一定会演化。我们给DSL定了版本号(上面示例里的version: 1.0),模型输出的工作流必须带上当前支持的版本号。如果以后DSL从1.0升到1.1,编译器会兼容处理1.0的节点,但1.1新增的节点类型不会再被1.0的schema接受。在升级过程中,我们只保证向后兼容,不保证向前兼容——旧版本的工作流可以继续编译,但新版本的工作流不会被旧编译器接受。这条规则避免了LLM偶尔输出“未来语法”导致的历史数据回溯问题。

3.4 DSL校验要“前置”,在进入编译器之前先卡一道Schema校验

所有LLM输出的工作流JSON,第一道关卡是JSON Schema校验。我们在规划层和编译器之间加了一个validator,节点类型、必填字段、枚举值、数据类型全都做严格校验。校验不过的,直接返回错误给上层,LLM会拿到错误信息做一次自我修正(re-plan),修正次数上限2次,超了就兜底走“模板检索”或直接报错给用户。

这道前置校验的价值,是在进入复杂的语义分析之前,先把语法层面75%左右的异常拦下来,极大减轻编译器的负担。

4. 编译器实现的核心拆解:从工作流图到SQL生成的四个阶段

接下来进入这个项目的核心区——编译器。我们内部管它叫wsqlc(workflow to SQL compiler),是一个纯Python实现的库,没有蹭LLM的任何能力,它的所有输入输出都是确定性的。

4.1 阶段一:解析与规范化 — 构建带字段血缘的中间表示

编译器拿到经过Schema校验的工作流JSON后,先做解析,构建中间表示(IR)。我们没有直接用JSON节点干活,而是把每个节点转换成内部定义的Step对象,并构建一张图。

@dataclass class Step: step_id: str op: str params: dict input_schema: Schema = None output_schema: Schema = None class WorkflowIR: def __init__(self, workflow_dict: dict): self.version = workflow_dict["version"] self.source = workflow_dict["source"] self.steps: list[Step] = [Step(**s) for s in workflow_dict["steps"]] # 这里会构建每一步的输入输出字段血缘图

这张血缘图是编译器的宝贝。每一步节点都能回答一个问题:你输出的每个字段是从哪个原始表的哪个字段来的?有了血缘图,后续的权限裁剪、列裁剪、表达式复用优化就都有下刀的地方了。

4.2 阶段二:语义校验 — 表存在性、字段存在性、类型匹配、权限检查

解析之后是语义校验。这一步做了四件事,全部失败即抛异常,并携带英文错误码返回给上层,方便LLM下一轮据此修正:

  1. 列表存在性与表别名冲突检测
  2. 字段存在性检测——比如o.amount中的amount是否存在于orders表
  3. 操作数类型匹配——比如filter里order_date是日期字段,就不允许传"abc"这类非法日期字符串
  4. 字段权限检测——把解析出的所有原始字段和用户权限矩阵比对,无权限列一律禁止进入IR

很多人在这一步会偷懒:把权限检查放到SQL生成之后用字符串正则去搜。我的建议是千万别这么做,字符串级别的权限检查有太多绕过方式(同义别名、子查询嵌套等),只有在IR字段级别的权限检查才能打穿所有语法糖。

4.3 阶段三:逻辑优化与等价变换 — 谓词下推、JOIN重组、列裁剪

IR构建完成后,编译器会跑几轮等价的逻辑优化,大多数优化规则是经典数据库优化器的简化版:

  • 谓词下推:把一个filter节点尽可能往数据源靠近。比如先和另一个表做了LEFT JOIN,然后在JOIN结果上过滤,如果过滤条件只涉及左表字段,就把它下推到JOIN之前,减少JOIN参与的数据量。
  • 列裁剪:如果后续步骤只用到了orders表的3个字段,编译器就自动把SELECT限定到这3个字段,避免SELECT *带出多余列到内存。
  • JOIN条件重组:把等值连接条件整理成ON子句,非等值条件整理成WHERE。

这份优化其实不复杂,几百行代码就能做完,但带来的SQL执行性能提升通常很明显。我们有一个真实案例:原始工作流生成的SQL跑一次要35秒,经过谓词下推和JOIN重组之后,降到1.8秒,数据量没变,纯粹是执行计划变好了。

4.4 阶段四:代码生成与参数绑定 — 生成带占位符的SQL和独立参数数组

最后一个阶段是把优化后的IR翻译成SQL字符串。这里有一个关键设计:最终的SQL必须使用参数占位符,不拼接任何用户输入字面量。

# 输出示例 sql = """ SELECT c.name, SUM(o.amount) AS total_sales FROM orders o LEFT JOIN customers c ON o.customer_id = c.id WHERE o.order_date >= %s AND o.order_date <= %s GROUP BY c.name ORDER BY total_sales DESC LIMIT %s """ params = ["2024-08-01", "2024-08-31", 10]

这么做有两个原因。第一是安全性:LLM规划层输出的过滤值、分页参数等都属于外部输入,如果直接拼进SQL,等于把注入漏洞拱手相让。LLM有可能会在值里输出恶意构造的字符串(它不是故意的,但在红队测试里模型确实可能生成类似' OR 1=1 --的字符串),用参数绑定后这类值只会被当作字符串字面量处理,不会参与SQL语法解析。第二是缓存友好:同样的SQL结构配合不同参数值时,数据库端的prepared statement缓存可以直接复用执行计划,这对高并发查询场景特别重要。

另外还有一个内部共识:不在编译器里做方言适配。如果有一天要从MySQL迁移到PostgreSQL,不会通过SQL字符串里加无数个if dialect == "postgres"来做,而是把方言相关的差异下沉到一组“方言适配器”,SQL生成器只生成语法树,方言适配器再渲染成具体SQL。当前我们这个项目只支持PostgreSQL,这套扩展点已经预留了,但还没完整实现。

5. 确定性保障措施:让输出从“可能对”变成“必然对”

聊完编译器的实现,回到这个项目标题里最核心的词:确定性。LLM规划层不可能100%稳定,编译器再确定,如果前端规划错了,产物也会错。所以确定性不是靠祈祷,而是靠一整套工程措施兜底。

5.1 规划层用固定模型版本 + 冻结温度参数 + 结构化输出

在规划层,我们固定了模型版本,不再跟随模型厂商自动升级。每次版本切换是主动的行为,需要重新跑一遍回归测试集才能上线。温度参数统一设成0,关闭多轮采样。同时用模型的structured output功能,直接约束输出为工作流JSON,不用先输出Markdown再解析。这些都是老生常谈,但真正做到位的团队其实不多。

5.2 黄金回归集与快照测试:每次编译必须产出一模一样的SQL

我们做了一个黄金回归集,目前有800多条“自然语言问句-黄金工作流-黄金SQL”三件套。CI流水线里会跑两个测试维度:

  • SQL快照测试:同一份工作流输入,断言多次编译后的SQL字符串完全一致。这条测试在防编译器逻辑回归,确保我们的优化规则没有引入非确定性行为。
  • 语义等价测试:对于允许等价变换的优化,断言新的SQL与黄金SQL执行结果一致(数据集固定)。这条测试在防“优化器改错语义”。

只要有一个测试用例失败,CI就挂,不允许合并代码。这套测试体系运行半年后,我们基本再没遇到过线上SQL结果飘忽不定的事故。

5.3 架构级的兜底策略:规则模板、护栏校验、人工回溯

LLM规划层就算再配置,也会出现无法理解复杂问句的情况。我们的兜底策略分为三级:

第一级是精度优先的模板匹配。对高频的查询模式(比如“按XX分组汇总”、“TopN排行”),我们会预先用规则模板直接生成工作流,完全不依赖LLM。统计下来,线上约45%的查询走了模板通道,这部分是100%确定性的。

第二级是护栏校验。所有LLM规划出的工作流进入编译器之前,要过一遍语义一致性检查。比如用户问了“华北区”,工作流里却filter了“华东区”,这类错误编译器能检测到,但通常不会触发——我们训练规划层在输出工作流的同时输出一段“用户问题意图摘要”,然后有专门的校验器比对摘要和问题是否一致。

第三级是人工回溯。一旦线上SQL报错率或数据质量反馈指标异常,我们用一张workflow_run_log表记录了每次请求的原始问题、LLM输出工作流、编译后SQL、参数值、执行结果和报错信息,可以精确回溯到任何一条SQL的完整生命周期。

下面这张表总结了我们生产环境里常用的三类兜底手段的触发条件和效果:

兜底层级触发条件处理方式期望效果
模板匹配问句命中高频模式直接生成工作流,不调用LLM高确定性、低延迟
LLM规划未命中模板,Schema校验通过模型生成工作流JSON,编译器翻译覆盖长尾语义
校验失败重规划Schema校验或语义校验失败带着错误信息让模型重规划,最多2次降低规划阶段错误率
人工兜底重规划仍失败或规则禁止自动生成报错并走人工处理流程保证平台底线安全

5.4 可观测性建设:每次编译都留痕,每次执行都可溯源

如果没有完备的可观测性,确定性就是一句空话。我们把每条工作流的编译过程拆成plan_id、workflow_id、compile_version、sql_hash几个关键ID,写入日志和数据库。每个SQL执行计划变更、参数列表、执行耗时、返回行数都按request_id关联存好。遇到用户投诉数据不对,只需要按用户ID和时间段拉出request_id,就能一键关联到当时的完整执行链路,定位是规划层问题还是数据源问题。

6. 生产环境的实测效果与踩坑记录

方案说了一堆,最后聊聊实际落地效果和几个特别值得注意的坑。我们的系统上线6个月之后,线上自然语言查询的SQL正确率(以业务人员确认查询结果满足需求为准)稳定在93%左右,剩下7%的错误主要发生在规划层——模型对复杂多表查询或嵌套业务口径理解不对。但即便遇到规划层理解错误,错误模式也更多是“查的结果范围理解错了”,而不是“SQL语法不可执行”或“权限泄漏”,后两者基本被编译器彻底拦死了。

6.1 坑一:LLM输出的JSON偶尔会带回车符和语法噪音

我们最开始规划层用的是普通文本输出,要求模型“输出JSON”。结果模型经常在JSON前后加解释文字,或者在里面夹 ````json` 标记。解析器先要做清洗,清洗失败率直接导致整条链路失败。后来切换到 structured output(强制JSON schema)才彻底解决。我的建议是,如果你用的是不支持结构化输出的模型,宁可忍受一点推理延迟,也要自己写一个JSON提取层,并在schema校验失败时做一次重规划,不要直接信任输出。

6.2 坑二:谓词下推和NULL语义之间有隐藏冲突

这是一个让我印象深刻的bug。我们的优化器做谓词下推时,把WHERE o.amount > 1000下推到LEFT JOIN之前,看起来没问题——只拿销售额大于1000的订单去参与连接,结果JOIN后还是会过滤掉没有订单的客户,但业务期望是“左表全保留,订单金额小于等于1000的客户也要显示”。这就是经典的“谓词下推改变LEFT JOIN语义”的坑。当时我们花了整整一周定位,最后加了一条规则:但凡JOIN类型是LEFT/RIGHT/FULL OUTER,优化器禁止将只针对一侧表的过滤条件下推到JOIN对侧。如果你也要写类似的优化器,这条一定提前写进规则列表。

6.3 坑三:地图映射表和数据库schema变化之间的联动

当业务表加了列、改了列名,工作流DSL里的字段引用会解析失败。我们一开始靠编译器报错给LLM让模型自己猜,但模型在多次re-plan时会把代码越改越偏。后来我们加了schema快照缓存:编译器在字段解析阶段查的是“数据库当前schema”,而不是“用户提问时刻的schema历史”,一旦schema变更导致历史workflow解析失败,我们直接返回“数据口径变版”的提示,引导业务人员重新表述问题,而不是让LLM去猜新列名。这个决策让错误提示的稳定性大增。

6.4 坑四:参数绑定的分页参数必须显式INT,不能当字符串传

这是个很小的细节,但线上出过事故:LIMIT参数被LLM规划层的params传成了字符串"10",在PostgreSQL的参数绑定里,LIMIT %s传字符串会报错。编译器在参数绑定阶段加了对每个占位符的类型检查,如果声明的类型和实际参数类型不匹配立即报错。这个检查也顺带拦截了用户问“用英语单词表示排名截断”这种奇怪输入。

7. 结尾:确定性是一种选择,而不是特征

最后说一点个人体会。前几年大家都在讨论“大模型会不会取代程序员”,我自己做完这个项目之后,观点反而更明确了:在一个数据链路上,LLM适合做开放式的理解、拆解和规划,但不适合做封闭式的精确输出。SQL生成恰好是一个“理解开放、生成封闭”的场景。理解用户可以天马行空,但最终生成的SQL必须精确、可靠、可审计。所以不要试图让LLM直接变成那个“写出最终SQL的机器”,而是让它变成“画出施工图的规划师”,再请一位从不出错的编译器来做施工。这个选择比任何prompt工程技巧都管用。

如果你也在做类似的项目,我的建议是先别急着上“高阶优化规则”或“复杂DSL”,第一步先做两件事:一是把你最常用的几十个查询模式做成模板工作流,让行为可预测;二是把编译器能覆盖的最小有效节点集先跑通,再逐步扩展。确定性系统的建成不是一蹴而就的,它是在每次选择和每次测试中一点点焊起来的。

另外,如果你后续想把这套模式推广到别的领域(比如生成图表配置、生成仪表盘等等),完全可以把工作流契约这个思路复制过去。只要是“自然语言理解 + 精确配置输出”的组合,用“LLM规划 + 编译器生成”这个范式大概率都比让LLM直接输出最终配置稳定得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询