news 2026/10/27 8:28:33

LLM规划+编译器生成:两阶段确定性Text-to-SQL架构实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 之后,大致走这么几步:

  1. 解析:把 DSL(YAML/JSON)读进来,构建成抽象语法树(AST)。这一步主要是格式校验,确保 DSL 符合 schema。
  2. 语义检查:对照数据库元数据,验证所有引用的表、字段、别名是否存在,类型是否匹配。比如sum(o.amount)里的amount必须是数值类型,between操作符的两个值必须和字段类型兼容。
  3. 逻辑优化:做一些不改变语义的重写,比如把过滤条件下推到 JOIN 之前、合并冗余的排序、消除无用的 SELECT 字段。这一步是可选的,但做了之后生成的 SQL 质量会明显提升。
  4. 代码生成:遍历 AST,按照目标数据库的方言规则,生成最终的 SQL 字符串。这里要处理不同数据库的差异——比如 MySQL 用反引号、PostgreSQL 用双引号、日期函数各不相同。
  5. 参数化:把 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_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] = None

Pydantic 会自动做类型校验和必填字段检查。解析失败时,它会给出详细的错误信息,这些信息可以直接反馈给 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 层面支持“增量修改”的语义,编译器也要能处理“基于已有工作流做局部更新”的场景。目前我还在摸索阶段,等有成熟方案了再单独写一篇。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 8:27:30

云原生与AI时代网络安全人才技能转型

1. 从挖洞专家到六边形战士:2025网络安全人才需求剧变十年前,网络安全从业者的核心竞争力是漏洞挖掘能力。谁能找到0day,谁就是行业翘楚。但最近两年,我面试了上百名安全工程师,发现一个明显趋势:单纯会挖洞…

作者头像 李华
网站建设 2026/10/27 8:27:24

KITE框架下Upcycle与YOCO协同实现LLM动态容量调度

1. 项目概述:当大模型扩容不再只是“堆卡”,而是一场架构级的范式迁移最近在KITE这个面向工业级AI系统构建的开源框架里,我反复调试Upcycle和YOCO两个模块时突然意识到:我们正在经历LLM工程实践里一次静默却深刻的转向——扩容这件…

作者头像 李华
网站建设 2026/10/27 8:26:19

AI时代测试工程师的转型与核心价值重构

1. 当AI宣称"编程已解决"时测试工程师的真实处境Claude Code负责人那句"编程已解决"的宣言,在技术社区掀起的波澜不亚于当年AlphaGo击败李世石。作为每天要和代码缺陷打交道的测试工程师,我们确实需要冷静思考:这究竟是职…

作者头像 李华
网站建设 2026/10/27 8:26:17

微内核与事件驱动架构:核心原理与工程实践对比

1. 架构设计模式的核心分野微内核架构和事件驱动架构作为两种主流的系统设计范式,在软件工程领域已经发展沉淀超过二十年。我最早接触这两种架构是在2008年参与电信级计费系统重构时,当时团队就架构选型进行了长达三个月的技术论证。从实践角度看&#x…

作者头像 李华
网站建设 2026/10/27 8:26:09

WMSST-MCNN-GRU混合模型在5G网络故障诊断中的应用

1. 项目背景与核心价值网络故障诊断一直是工业界和学术界关注的重点课题。随着5G、物联网等技术的普及,网络拓扑结构日益复杂,传统基于规则或统计的故障诊断方法已难以满足实时性和准确性的要求。我们团队提出的这套WMSST-MCNN-GRU混合模型,正…

作者头像 李华
网站建设 2026/10/27 8:25:25

E语言常量与变量核心区别及优化实践

1. E语言常量与变量本质区别解析E语言作为一门面向中文开发者的编程语言,其常量与变量的设计理念与其他主流语言既有共性又有特性。在实际开发中,很多初学者容易混淆这两者的本质区别,导致代码出现难以排查的隐患。本文将从底层实现、使用场景…

作者头像 李华