LLM规划+编译器生成:两阶段确定性Text-to-SQL架构实践 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 之后大致走这么几步解析把 DSLYAML/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 但又不绑定具体方言然后在代码生成阶段做方言转换。常见的方言差异包括特性MySQLPostgreSQLSQL Server标识符引用反引号双引号方括号字符串拼接CONCAT()||分页LIMIT/OFFSETLIMIT/OFFSETOFFSET/FETCH日期函数DATE_FORMATTO_CHARFORMAT布尔值0/1TRUE/FALSE0/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_schemaPostgreSQL 可以查pg_catalogSQL 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 是invalue 是列表要生成IN (...)的形式。如果 op 是betweenvalue 是两个元素的列表要生成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(fFROM {workflow.source[table]} {workflow.source[alias]}) # JOIN for join in workflow.joins: on_cond f{join.on[left]} {join.on[right]} sql_parts.append(fINNER 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-shotSQL 执行报错字段名或表名错误检查元数据是否最新刷新元数据缓存加语义检查查询结果为空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 层面支持“增量修改”的语义编译器也要能处理“基于已有工作流做局部更新”的场景。目前我还在摸索阶段等有成熟方案了再单独写一篇。