1. 为什么要把 SQL 生成这件事拆成“规划”和“编译”两段
第一次听到“LLM 规划,编译器生成”这个组合的时候,我脑子里蹦出来的画面是建筑工地:大模型像个经验丰富但偶尔犯迷糊的工头,站在高处指方向——“这面墙要承重、那扇窗朝南、厨房和客厅之间要打通”;而编译器则是拿着图纸和卷尺的老师傅,一砖一瓦都按规范来,绝不自由发挥。这个分工的核心逻辑就一句话:让擅长模糊推理的去做模糊推理,让擅长精确执行的去做精确执行。
做过 Text-to-SQL 的人都知道,直接让大模型吐 SQL 有多不靠谱。你问它“查一下上个月华东区销售额超过十万的客户名单”,它可能给你生成一个语法完全正确、但 JOIN 条件写反、时间范围用错、甚至把HAVING和WHERE混用的语句。更麻烦的是,这种错误往往不是“一眼假”,而是跑出来结果看着像那么回事,实际数据全错。在真实业务里,这种“静默错误”比直接报错危险十倍。
所以这套方案的价值主张很明确:把 SQL 生成从“一次性文本生成”变成“两阶段确定性构造”。第一阶段,LLM 只负责理解意图,输出一份结构化的“工作流契约”——也就是用 DSL 描述的查询计划,里面写清楚要查哪些表、按什么条件过滤、怎么聚合、怎么排序。第二阶段,一个专门的编译器读取这份契约,按照预定义的语法规则和数据库元数据,生成最终可执行的 SQL。整个过程里,LLM 碰不到 SQL 语法本身,它只跟 DSL 打交道。
这套东西适合谁?如果你正在做 BI 工具、数据问答机器人、低代码平台里的查询构建器,或者任何需要让非技术用户用自然语言查数据库的场景,这套思路值得仔细看看。它不要求你从头训练模型,也不需要你精通编译器理论,核心工作量在于 DSL 设计和编译器实现——这两块都是可控的工程问题。
2. 整体架构拆解:工作流契约到底长什么样
2.1 三层结构:意图层、契约层、执行层
整套系统我习惯把它切成三层来看。最上面是意图层,用户输入自然语言,LLM 在这里工作。中间是契约层,也就是 DSL 工作流,它是整个系统的“合同”,规定了 LLM 能表达什么、不能表达什么。最下面是执行层,编译器把契约翻译成目标数据库的 SQL 方言,交给数据库执行。
这三层之间的边界非常关键。意图层和契约层之间是“宽松”的——LLM 可以用自然语言理解用户的各种说法,但输出必须严格符合 DSL schema。契约层和执行层之间是“死板”的——编译器只认 DSL,任何 DSL 里没定义的表达方式,编译器直接拒绝,不会自作主张。这种“一头松一头紧”的设计,恰好把 LLM 的灵活性和编译器的确定性结合起来了。
我见过不少团队试图让 LLM 直接输出 JSON 格式的查询条件,然后自己写个函数拼 SQL。这其实就是这套方案的简化版,但问题在于:当查询逻辑变复杂——比如多层嵌套聚合、窗口函数、CTE——手写拼接函数会迅速变成一团乱麻。而一个正式的编译器可以用语法分析、语义检查、优化重写这些成熟手段来管理复杂度。
2.2 DSL 设计:给 LLM 划定的“安全活动区”
DSL 的设计是整个项目里最需要动脑子的部分。它不能太简单,否则表达不了复杂查询;也不能太复杂,否则 LLM 学不会、用不对。我的经验是,DSL 的抽象层级应该刚好比 SQL 高一层——把 SQL 里那些容易出错的细节(比如 JOIN 条件、括号嵌套、方言差异)藏起来,但保留查询的逻辑结构。
一个典型的 DSL 工作流大概长这样(用 YAML 表示,实际可以用任何格式):
workflow: name: "华东区高价值客户查询" source: table: "orders" alias: "o" joins: - table: "customers" alias: "c" on: left: "o.customer_id" right: "c.id" filters: - field: "o.region" op: "eq" value: "east_china" - field: "o.order_date" op: "between" value: ["2024-04-01", "2024-04-30"] group_by: - field: "c.id" - field: "c.name" having: - field: "sum(o.amount)" op: "gt" value: 100000 select: - field: "c.id" - field: "c.name" - field: "sum(o.amount)" alias: "total_amount" order_by: - field: "total_amount" direction: "desc"这份 DSL 里没有任何 SQL 关键字,全是结构化的字段和操作符。LLM 要做的就是从用户那句话里提取出这些结构。你可以把它理解成“查询的骨架”,编译器负责给骨架填上肌肉——也就是具体的 SQL 语法。
注意:DSL 里一定要显式声明
source和joins,不要让 LLM 自由发挥 JOIN 关系。我踩过的坑是,LLM 有时候会“脑补”表之间的关联字段,生成一个看起来合理但实际不存在的 JOIN 条件。解决办法是在 DSL schema 里强制要求 JOIN 的on条件必须引用已声明的表别名和字段,编译器在语义检查阶段会验证这些引用是否存在于数据库元数据中。
2.3 编译器的工作流程:从 DSL 到 SQL 的确定性映射
编译器拿到 DSL 之后,大致走这么几步:
- 解析:把 DSL(YAML/JSON)读进来,构建成抽象语法树(AST)。这一步主要是格式校验,确保 DSL 符合 schema。
- 语义检查:对照数据库元数据,验证所有引用的表、字段、别名是否存在,类型是否匹配。比如
sum(o.amount)里的amount必须是数值类型,between操作符的两个值必须和字段类型兼容。 - 逻辑优化:做一些不改变语义的重写,比如把过滤条件下推到 JOIN 之前、合并冗余的排序、消除无用的 SELECT 字段。这一步是可选的,但做了之后生成的 SQL 质量会明显提升。
- 代码生成:遍历 AST,按照目标数据库的方言规则,生成最终的 SQL 字符串。这里要处理不同数据库的差异——比如 MySQL 用反引号、PostgreSQL 用双引号、日期函数各不相同。
- 参数化:把 DSL 里的字面量值替换成占位符,生成参数化查询,避免 SQL 注入风险。
整个流程里,LLM 只在第一步之前参与——它负责把自然语言变成 DSL。一旦 DSL 确定,后面全是确定性代码,同样的 DSL 永远生成同样的 SQL。这就是“确定性”三个字的含义。
3. 核心细节解析:LLM 怎么把话说对,编译器怎么把活干好
3.1 让 LLM 稳定输出 DSL 的四个关键手段
LLM 输出 DSL 的稳定性,直接决定整个系统能不能用。我试过不少方法,最后沉淀下来四个比较有效的手段。
第一,用 JSON Schema 或 Pydantic 模型做约束。不要只给 LLM 一段文字描述“请输出 YAML”,而是给它一个严格的 schema 定义。如果你用的是支持结构化输出的 API,直接开启 JSON mode 或 function calling,让模型在解码阶段就受约束。实测下来,这能把格式错误率从 30% 降到 5% 以下。
第二,提供少样本示例(few-shot)。在 prompt 里放 3 到 5 个“自然语言 → DSL”的完整示例,覆盖常见的查询模式:简单过滤、多表 JOIN、聚合分组、排序分页。示例要精选,不要堆量。我一般会放一个简单查询、一个带 JOIN 的、一个带聚合的、一个带时间范围的,基本能覆盖 80% 的场景。
第三,把数据库元数据注入 prompt。LLM 不知道你的表结构,你必须告诉它。但不要把所有表的 DDL 都塞进去——那样 prompt 会爆炸。我的做法是先用一个轻量级的检索步骤,根据用户问题找出相关的表和字段,只把这些元数据注入 prompt。元数据里要包含表名、字段名、字段类型、字段注释,以及表之间的外键关系。
第四,加一层“自检”步骤。LLM 输出 DSL 之后,不要直接交给编译器,而是让 LLM 自己检查一遍:“你生成的 DSL 里引用的表和字段,是否都在给定的元数据里?JOIN 条件是否引用了正确的别名?”这个自检步骤可以用同一个模型,也可以用更小的模型。实测能再降低一半左右的语义错误。
3.2 编译器的语义检查:把错误拦在生成 SQL 之前
语义检查是编译器的核心价值所在。没有这一步,编译器就只是个“字符串拼接器”。语义检查主要做这几件事:
- 表和字段存在性检查:DSL 里引用的每个表名、字段名,都必须在数据库元数据里存在。别名也要检查,确保 JOIN 条件里引用的别名是已声明的。
- 类型兼容性检查:操作符和字段类型要匹配。比如
like操作符只能用于字符串类型,sum只能用于数值类型,日期字段不能和字符串直接比较。 - 聚合逻辑检查:如果查询里用了聚合函数(sum、count、avg 等),那么 SELECT 里非聚合的字段必须出现在 GROUP BY 里。这是 SQL 的标准要求,但 LLM 经常忽略。编译器在这里强制检查,能避免大量运行时错误。
- 操作符合法性检查:DSL 里定义的操作符是有限集合,编译器只认这些。如果 LLM 输出了一个未定义的操作符,直接报错,而不是尝试猜测。
这些检查全部基于元数据,不需要连数据库,速度很快。我一般会把所有检查结果收集起来,一次性返回给上层,而不是遇到第一个错误就中断。这样 LLM 可以根据完整的错误列表做修正,减少来回次数。
3.3 代码生成阶段的方言适配
不同数据库的 SQL 方言差异,是 Text-to-SQL 系统绕不开的坑。编译器在这一层要做适配,而不是让 LLM 去学各种方言。我的做法是定义一个“中间 SQL 表示”,它接近标准 SQL 但又不绑定具体方言,然后在代码生成阶段做方言转换。
常见的方言差异包括:
| 特性 | MySQL | PostgreSQL | SQL Server |
|---|---|---|---|
| 标识符引用 | 反引号 | 双引号 | 方括号 |
| 字符串拼接 | CONCAT() | || | + |
| 分页 | LIMIT/OFFSET | LIMIT/OFFSET | OFFSET/FETCH |
| 日期函数 | DATE_FORMAT | TO_CHAR | FORMAT |
| 布尔值 | 0/1 | TRUE/FALSE | 0/1 |
编译器里维护一个方言配置表,代码生成时根据目标数据库选择对应的规则。这样新增一种数据库支持,只需要加一份配置,不用改核心逻辑。
实操心得:不要试图让编译器生成“完美优化”的 SQL。编译器的首要目标是正确性和确定性,优化是次要的。我见过有团队在编译器里塞了大量优化规则,结果引入了一堆边界 bug,反而得不偿失。优化应该放在数据库层面——建好索引、写好统计信息,让数据库自己去做执行计划优化。
4. 实操过程:从零搭一个可用的 SQL 生成器
4.1 环境准备与技术选型
这套系统的技术栈可以很轻。核心组件就三个:一个 LLM 调用层、一个 DSL 解析器、一个 SQL 代码生成器。语言方面,Python 是最顺手的,生态里有 Pydantic 做 schema 校验、有 Jinja2 做模板渲染、有 sqlparse 做 SQL 格式化。如果你追求性能,Go 或 Rust 也可以,但开发效率会低一些。
LLM 的选择上,我建议用支持结构化输出的模型。如果预算有限,可以用小模型做 DSL 生成,大模型只在自检和纠错环节介入。实测下来,7B 级别的模型在 few-shot 加持下,生成 DSL 的准确率能到 85% 左右,加上自检环节能到 92% 以上。
数据库元数据的获取,不同数据库有不同的方式。MySQL 可以查information_schema,PostgreSQL 可以查pg_catalog,SQL Server 有sys.tables和sys.columns。我一般会写一个元数据抽取脚本,把表结构导出成 JSON 格式,缓存起来,定期刷新。
4.2 DSL 解析器的实现要点
DSL 解析器不需要手写词法分析和语法分析——直接用 YAML 或 JSON 的解析库就行。关键是解析之后的 AST 构建和校验。
我用 Pydantic 定义 DSL 的 schema,每个节点都是一个模型类。比如:
from pydantic import BaseModel, Field from typing import List, Optional, Union class Filter(BaseModel): field: str op: str value: Union[str, int, float, List] class Join(BaseModel): table: str alias: str on: dict class Workflow(BaseModel): name: str source: dict joins: List[Join] = [] filters: List[Filter] = [] group_by: List[str] = [] having: List[Filter] = [] select: List[dict] order_by: List[dict] = [] limit: Optional[int] = NonePydantic 会自动做类型校验和必填字段检查。解析失败时,它会给出详细的错误信息,这些信息可以直接反馈给 LLM 做修正。
AST 构建完之后,进入语义检查阶段。这一步需要元数据的配合。我会把元数据加载成一个字典结构,key 是表名,value 是字段列表和类型信息。然后遍历 AST,检查每个字段引用。
4.3 SQL 代码生成的具体实现
代码生成我用的是“访问者模式”——每个 AST 节点对应一个生成方法,递归遍历。这样结构清晰,也方便扩展。
生成 SELECT 子句的时候,要注意处理别名。DSL 里的select列表可能包含聚合函数,比如sum(o.amount),生成 SQL 时要写成SUM(o.amount) AS total_amount。如果 DSL 里没给别名,编译器要自动生成一个,比如expr_1、expr_2。
生成 JOIN 子句的时候,要按 DSL 里声明的顺序生成。如果 DSL 里没有显式声明 JOIN 类型,默认用 INNER JOIN。我建议在 DSL 里支持join_type字段,允许指定 LEFT JOIN、RIGHT JOIN 等。
生成 WHERE 子句的时候,要把 filters 列表里的条件用 AND 连接起来。如果某个 filter 的 op 是in,value 是列表,要生成IN (...)的形式。如果 op 是between,value 是两个元素的列表,要生成BETWEEN ... AND ...。
参数化处理是最后一步。我会把 DSL 里的字面量值替换成?或%s(取决于数据库驱动),然后把值收集到一个参数列表里。这样生成的 SQL 是参数化的,既安全又利于数据库缓存执行计划。
def generate_sql(workflow: Workflow, dialect: str) -> tuple: params = [] sql_parts = [] # SELECT select_items = [] for item in workflow.select: expr = item['field'] alias = item.get('alias') if alias: select_items.append(f"{expr} AS {alias}") else: select_items.append(expr) sql_parts.append("SELECT " + ", ".join(select_items)) # FROM sql_parts.append(f"FROM {workflow.source['table']} {workflow.source['alias']}") # JOIN for join in workflow.joins: on_cond = f"{join.on['left']} = {join.on['right']}" sql_parts.append(f"INNER JOIN {join.table} {join.alias} ON {on_cond}") # WHERE if workflow.filters: conditions = [] for f in workflow.filters: conditions.append(f"{f.field} {op_to_sql(f.op)} ?") params.append(f.value) sql_parts.append("WHERE " + " AND ".join(conditions)) # GROUP BY, HAVING, ORDER BY, LIMIT 类似处理 return " ".join(sql_parts), params这段代码是简化版,实际实现里还要处理方言差异、括号嵌套、操作符映射等细节。但核心思路就是这样:遍历 AST,拼接 SQL 片段,收集参数。
4.4 端到端测试与效果验证
搭好之后,一定要做端到端测试。我一般会准备一个测试集,包含 50 到 100 个自然语言查询,覆盖各种查询模式。每个查询都有对应的“标准 SQL”,用来对比生成结果。
测试的时候不要只看“生成的 SQL 能不能跑”,还要看“跑出来的结果对不对”。有些 SQL 语法正确但语义错误,比如 JOIN 条件写反了,结果集可能为空或者重复。我一般会准备一个小型测试数据库,填充一些已知数据,然后对比生成 SQL 和标准 SQL 的查询结果。
准确率的计算要分两个维度:语法准确率(生成的 SQL 能否执行)和语义准确率(执行结果是否与标准 SQL 一致)。前者一般能到 95% 以上,后者取决于 DSL 的表达能力和 LLM 的理解能力,通常在 80% 到 90% 之间。
5. 常见问题与排查技巧实录
5.1 LLM 输出格式错误的排查
最常见的问题是 LLM 输出的 DSL 不符合 schema。表现可能是字段名拼错、缺少必填字段、类型不对。排查思路是:先把 LLM 的原始输出打印出来,看看它到底生成了什么。如果格式错误频繁出现,检查你的 prompt 里 schema 描述是否清晰,few-shot 示例是否覆盖了当前场景。
我遇到过一个坑:LLM 把group_by写成了groupby,因为它在训练数据里见过这种写法。解决办法是在 schema 里加一个别名映射,或者用 Pydantic 的alias功能做兼容。但更好的做法是让 LLM 严格按 schema 来,不要给它“猜”的空间。
5.2 语义错误的典型模式
语义错误比格式错误更隐蔽。我总结了几种典型模式:
- JOIN 条件错误:LLM 把两个表的关联字段搞混了。比如订单表和客户表应该用
customer_id关联,它却用了id。解决办法是在元数据里显式声明外键关系,并在 prompt 里强调“JOIN 条件必须使用已声明外键”。 - 聚合逻辑错误:SELECT 里有非聚合字段但没出现在 GROUP BY 里。编译器要强制检查这一点。
- 时间范围错误:用户说“上个月”,LLM 算错了日期范围。解决办法是在 DSL 里支持相对时间表达,比如
last_month,由编译器在运行时解析成具体日期。 - 操作符误用:把
=用在多值场景,应该用in。编译器可以在语义检查阶段发现这种问题。
5.3 性能瓶颈与优化方向
这套系统的性能瓶颈通常在 LLM 调用上,而不是编译器。编译器处理一个 DSL 通常只要几毫秒,而 LLM 调用可能要几百毫秒到几秒。优化方向有几个:
- 缓存:相同的自然语言查询,缓存 DSL 结果。可以用语义相似度做模糊匹配,提高缓存命中率。
- 并行:如果一次请求需要多个查询,可以并行调用 LLM。
- 小模型:简单查询用小模型,复杂查询才用大模型。可以训练一个分类器来判断查询复杂度。
- 流式输出:如果 LLM 支持流式输出,可以边生成 DSL 边解析,减少等待时间。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| DSL 解析失败 | 格式不符合 schema | 打印原始输出,对比 schema | 加强 prompt 约束,增加 few-shot |
| SQL 执行报错 | 字段名或表名错误 | 检查元数据是否最新 | 刷新元数据缓存,加语义检查 |
| 查询结果为空 | JOIN 条件错误 | 对比标准 SQL 的 JOIN 条件 | 在元数据里声明外键,强制使用 |
| 结果重复 | 一对多 JOIN 未去重 | 检查 JOIN 关系和 SELECT 字段 | 加 DISTINCT 或调整 JOIN 逻辑 |
| 聚合结果错误 | GROUP BY 缺失字段 | 检查 SELECT 和 GROUP BY 是否匹配 | 编译器强制检查聚合逻辑 |
| 时间范围错误 | 相对时间解析错误 | 检查 DSL 里的时间表达 | 用编译器解析相对时间,不让 LLM 算 |
最后分享一个我踩过的坑:不要试图让 LLM 生成“优化过的 SQL”。有一次我让 LLM 在 DSL 里加索引提示,结果它生成了一堆无效的 hint,反而拖慢了查询。后来我把优化完全交给数据库,编译器只负责生成正确、清晰的 SQL,性能问题通过建索引和调优数据库来解决。这个分工清晰之后,系统稳定多了。
5.5 扩展方向:从单表查询到复杂分析
这套架构的扩展性很好。如果要支持更复杂的查询,比如窗口函数、CTE、子查询,只需要在 DSL 里增加对应的节点类型,在编译器里增加对应的生成逻辑。LLM 那边不用改,因为它只跟 DSL 打交道。
我最近在尝试把 DSL 扩展到支持“查询模板”——预定义一些常用的查询模式,LLM 只需要填充参数。比如“同比增长分析”模板,LLM 只需要指定对比的两个时间段和度量字段,编译器自动生成带窗口函数的 SQL。这样既提高了复杂查询的准确率,又降低了 LLM 的负担。
另一个方向是“多轮对话”。用户可能先问“华东区销售额”,再问“那华南区呢”。系统需要维护一个对话上下文,把上一轮的 DSL 作为基础,只修改变化的部分。这需要在 DSL 层面支持“增量修改”的语义,编译器也要能处理“基于已有工作流做局部更新”的场景。目前我还在摸索阶段,等有成熟方案了再单独写一篇。