ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LLM写SQL不稳,不如让它只写规划:NL2SQL的确定性编译架构

LLM写SQL不稳,不如让它只写规划:NL2SQL的确定性编译架构 在生产环境里直接让大模型写 SQL是每个做 LLM 应用的人迟早要踩的坑。你可能已经试过几轮方案先在提示词里塞进全量表结构让模型直接输出查询语句结果每天都有几条 SQL 引用一个根本不属于任何表的列名接着把出过问题的语句整理成 few-shot 示例希望模型照着样子写没想到示例一多模型开始自我混淆同一个意图在相似表述下会落到不同模板上。我自己带了两个多月的 NL2SQL 项目之后最终把系统改成另一个结构LLM 只负责做规划把自然语言请求翻译成一份结构化的工作流真正拼 SQL 的任务全部交给一个确定性编译器。为什么要绕这么一圈因为业务系统对 SQL 正确性的要求是 100%而大模型输出的正确率再高也只是概率性的。与其赌这一次生成恰好正确不如把 LLM 的输出空间压缩到一个可验证的中间表示里把剩下的工作交给编译过程。这份中间表示就是工作流它是整个系统里最核心的契约LLM 承诺只输出工作流编译器承诺只消费工作流双方都不越界。这篇文章把我从架构选型到落地踩坑的整个过程完整写出来包括工作流 DSL 怎么设计、编译器怎么实现确定性、前后端节点怎么组织、回归测试怎么覆盖。适合正在做数据查询智能体、报表自动化和 NL2SQL 产品的同学参考也适合所有被“AI 生成的 SQL 不够稳”折磨过的团队。1. 为什么选择“LLM 规划 编译器生成”先解决谁来写 SQL 的问题1.1 直接让模型生成 SQL 的生产灾难在深入方案之前先说明一个事实直接让 LLM 生成 SQL 有四个很具体的坑。第一个是随机性。同样一句“查询最近30天每个城市的销售额”同一个模型同一个提示词连续调用十次至少会出现好几种不同的写法。有的把日期条件用 CURRENT_DATE有的用 NOW()有的写成硬编码日期有的把城市维度放在 GROUP BY有的在 SELECT 里嵌套子查询。写法的差异本身不是问题问题在于这些差异无法被有效排查。线上一个报表的指标突然和昨天不一样你很难判断是业务数据变化还是模型换了一种写法后触发了隐性 bug。第二个是幻觉。这是最伤人的一个。就算把完整的表结构、字段注释、枚举值都塞进提示词里模型还是会在某些边缘场景下编出一个不存在的列名。比如用户问“这么多订单里有多少是 VIP 客户贡献的”模型可能想当然地生成 vip_level 字段而实际表里根本没有这个字段。更麻烦的是这类错误不会稳定复现它在测试集上偶尔出现一到线上就变成生产事故。第三个是维护成本。当你开始依赖提示词或 few-shot 来纠偏时提示词会越来越臃肿。每一个新报表需求都意味着往提示词里添加示例而示例之间的边界会互相干扰。模型升级一次之前精心调过的示例可能全部失效你需要重新评估、重新调参周而复始。第四个是无法审计。直接生成 SQL 时你只有一条 SQL没有中间过程。这条 SQL 为什么是这种写法、它基于什么逻辑推理、它的字段血缘是什么全部不可见。这在面对业务方质疑“这个数怎么算的”时非常被动。这四个坑的本质是同一个把“语义理解”和“语法实现”交给了同一个概率系统。而“语义理解”恰恰是 LLM 的强项“语法实现”恰恰是编译器的绝对优势。让擅长的人干擅长的事是我后来确定这条路线时最核心的出发点。1.2 工作流作为契约把不确定性关在笼子里那么工作流作为契约究竟是什么意思我的定义是工作流是一份描述“用户查询意图”的中间表示它位于 LLM 和编译器之间格式固定、语义明确、可被程序校验。LLM 的责任只到“把自然语言转换成工作流”为止编译器则以这份工作流为输入确定性地产出 SQL。关键点在于“确定性地”。工作流的每种节点、每个参数都有唯一的 SQL 语义编译器对同一个节点组合永远生成同一条 SQL不存在随机因素。于是整个系统的概率性风险就被限制在“LLM 到工作流”这一段而这一段可以靠结构约束、校验和重试来兜底。用生活类比来说这相当于把原来交给模型自由发挥的“开放式作文题”变成了“请填一张标准表单”。用户说“我要看每个城市销售额 Top10”模型要做的不是当场写一篇作文而是在表单里填上查询对象是订单表分组字段是城市排名指标是销售额取前 10。表单有固定格子模型不能随意发挥后续的“成文工作”则由编译器完成。这个设计带来的第二个好处是审计和可测试性。工作流本身就是一份可读文本产品同学可以看测试同学可以基于它写用例。每次生成 SQL 都是先产出工作流再产编译日志任何一个指标出问题都能从工作流层面回溯逻辑是否正确。第三个好处是工程化的可维护性。如果某个导出需求偶尔需要新建一个字段你不需要动提示词只需要扩展编译器的新节点如果数据库换了方言你只需要替换编译器的方言后端LLM 规划层完全不动。工作流成为系统的稳定接口让演化成本大幅下降。2. 工作流 DSL 怎么设计把“意图”翻译成“可校验的中间表示”2.1 DSL 的节点类型、语义与字段指代规则工作流 DSL 是整个系统最先应该设计的东西它决定了后面所有环节的复杂度。我的建议是不要一开始就套用现成的查询语言比如 SQL 本身或 GraphQL而是先抽象出“业务查询场景需要的操作类型”做成一个小而严谨的 JSON DSL。以我们最常见的“销售分析”场景为例我设计的 DSL 包含这些核心节点节点类型语义必须参数可选参数source指定查询主表tablealiasfilter行级过滤条件field, operator, valuelogicgroup_by维度分组fields无measure指标计算field, func, alias无join关联其他表table, on_fieldstyperank分组内排序取前 Npartition_fields, order_fields, limit无select最终输出字段fields无这个设计参考了查询的常见逻辑骨架。source 表示从哪里查filter 表示先过滤哪些行group_by 表示按什么维度聚合measure 表示聚合度量rank 表示分组内的 Top-Nselect 表示最终展示哪些列。我没有把 order by 和 limit 做成独立节点而是并入 rank是为了强制表达“先排序、后截断”的语义避免 LLM 生成语义混乱的排序字段。字段指代规则是这个 DSL 能否发挥作用的生命线。LLM 绝不能直接在 DSL 里写原始列名因为原始列名往往是英文的用户在提问时使用的是中文或业务术语模型一旦自由映射就会产生拼写错误和幻觉。我的做法是给每个字段一个稳定的字段 ID同时维护字段 ID、物理列名、字段注释、数据类型、枚举值之间的关系。LLM 只被允许在字段 ID 列表里选择。比如“销售额”对应的字段 ID 是 field_order_amount模型永远不该写 order_amount更不该自己发明 orderamount。2.2 用结构化输出和枚举约束把 LLM 的生成关进格子里有了 DSL 定义后下一步是让 LLM 的输出严格符合 DSL。这里我不会依赖提示词里写“请严格按照 JSON 格式输出”这种软约束而是直接使用结构化输出能力把 DSL 的 JSON Schema 传进去让模型输出的 JSON 在结构层就受到约束。这里有一个容易被忽略的点结构化输出只能保证 JSON 结构正确不能保证字段值正确。也就是说模型可能老老实实输出一个合法的 filter 节点但 value 字段写了一个表里没有的枚举值或者把“最近30天”理解成“最近3个月”。所以规划层的提示词里我必须把所有可枚举的选择全部列出来并且明确标注“你只能从以下列表中取值”。以字段为例我会在提示词中给出一个精简后的 schema 视图{ field_order_amount: 订单金额Decimal(18,2)单位元, field_order_count: 订单数量Integer, field_customer_city: 客户所在城市String来自客户维度表, field_order_date: 订单日期Date格式 yyyy-MM-dd }然后明确写一句“所有 field 属性只能填写上面的 field_id不要写物理列名不要自己造新字段。”这句话我实测非常好用结合结构化输出之后幻觉字段出现的概率会从原来的百分之几十降到千分之一以下。另外有两个细节值得提。第一规划模型的温度参数要设为 0 或尽量接近 0虽然结构化输出本身会降低随机性但温度对 value 的取值仍有影响。第二DSL 的 JSON Schema 不应该一次性给全所有节点而是按场景分开。我常用的做法是根据用户的第一个问题先做一次轻量意图分类再分发到对应场景的规划提示词这样每次进入模型视野的节点和字段数量都在可控范围内。2.3 字段解析与语义归一化让用户口语映射到正确的字段 ID虽然我在提示词中提供了字段 ID 的枚举但用户不会按字段 ID 说话。用户说“销售额”“订单金额”“卖了多少钱”表达的是同一件事模型需要把它们映射到同一个 field_order_amount。这一步本质上是语义归一化它考验的是模型的语言理解能力也是 LLM 在整个系统里最有价值的一环。我的经验是字段注释质量比字段数量更重要。给模型一个几行字的字段说明让它理解“这个字段到底是什么业务口径”比给它列 50 个同义词更有效。比如 field_order_amount 的注释要写清楚“单笔订单的实际成交金额含优惠后金额不含退款”这样模型在面对“实际到账”和“纯销售额”时才有判断依据。归一化过程中也会出现多种写法命中同一个字段的情况比如“客户所在城市”和“收货城市”都可能被模型映射到同一个人。这不是问题只要字段 ID 正确SQL 生成就是确定性的。真正的问题在于歧义字段也就是多个字段在语义上高度相似。这种情况我只提醒一句宁可让编译器报“字段不明确”也不要在规划层强行猜测。把歧义反馈给用户让用户在下一次提问时补充限定条件比模型自作主张要安全得多。3. 编译器端的设计与生成原理确定性从哪来3.1 工作流到 SQL 的映射规则不是字符串拼接而是 AST 构建编译器收到工作流后的处理链路是解析 JSON → 构建内部 AST → 做语义校验 → 生成 SQL 语法树 → 输出目标方言 SQL。之所以强调 AST是因为任何文本拼接方案都会在组合爆炸的边界场景下失控而 AST 可以让每个节点独立映射再在树结构上做整体处理。具体映射规则大致如下。source 节点对应 FROM 语句及其主表别名filter 节点对应 WHERE 条件其中的日期区间操作 last_n_days 会被翻译成 CURRENT_DATE 加 INTERVAL 的表达式group_by 对应 GROUP BY 子句measure 对应 SELECT 中的聚合表达式并绑定别名rank 节点最复杂它会被编译成窗口函数 ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) 包裹的外层查询select 节点决定最终输出列编译器还会自动剔除分析型字段只保留最终需要展示的列。我这里给一个真实例子的中间产物。用户提出“查询最近30天各城市销售额 Top10 客户及其订单量”规划器生成的工作流大致是{ intent: sales_rank, source: {table: orders, alias: o}, steps: [ {node: join, table: customers, alias: c, on: [customer_id, id]}, {node: filter, field: field_order_date, operator: last_n_days, value: 30}, {node: group_by, fields: [field_customer_city, field_customer_name]}, {node: measure, field: field_order_amount, func: sum, alias: total_amount}, {node: measure, field: field_order_id, func: count, alias: order_count}, {node: rank, partition_fields: [field_customer_city], order_fields: [{field: total_amount, direction: desc}], limit: 10} ] }编译器最终产出的 SQL 是WITH base AS ( SELECT c.city AS field_customer_city, c.name AS field_customer_name, o.order_id, o.amount FROM orders o JOIN customers c ON o.customer_id c.id WHERE o.order_date CURRENT_DATE - INTERVAL 30 days ), aggregated AS ( SELECT field_customer_city, field_customer_name, SUM(amount) AS total_amount, COUNT(order_id) AS order_count FROM base GROUP BY field_customer_city, field_customer_name ), ranked AS ( SELECT field_customer_city, field_customer_name, total_amount, order_count, ROW_NUMBER() OVER (PARTITION BY field_customer_city ORDER BY total_amount DESC) AS rn FROM aggregated ) SELECT field_customer_city, field_customer_name, total_amount, order_count FROM ranked WHERE rn 10可以看到工作流里没有直接写任何复杂 SQL 语法比如窗口函数、CTE、INTERVAL而这些都是在编译期由 AST 层组装出来的。这样一来模型即使完全不知道窗口函数怎么写也能通过工作流表达出等价语义。3.2 确定性如何保证严格校验、节点剪枝与失败即报错编译器最重要的原则是宁可拒绝绝不猜测。任何一步出现以下情况都应该直接报编译错误字段 ID 不在 schema 里、操作符不在白名单里、聚合函数和字段类型不匹配、rank 的分区字段和 select 字段冲突。这些校验全部发生在执行之前把原本要等到数据库运行时报错的问题提前到编译期暴露。在这个原则基础上我加入了节点剪枝优化。所谓剪枝是指编译器根据 schema 字段的依赖关系删除冗余节点。比如 group_by 之后如果没有 measure编译器会报“缺失度量”如果 rank 节点前没有 measure 节点rank 的 order_fields 引用了聚合别名编译器会发现依赖缺失。剪枝的目标不是性能而是保证工作流逻辑闭环。除了“失败即报错”编译器还要保留完整的编译日志。我的编译日志包含四部分输入工作流原文、schema 校验结果、AST 的结构树、最终 SQL 和字段血缘。字段血缘特别关键它记录每个 select 字段是由哪些原始字段、经过哪些转换得到的业务方来质疑指标时我能直接给出一个可解释的回答。方言适配也被放在编译器的最后一段。编译器内部先按照语义模型生成中间 SQL 树输出时才根据目标数据库把 INTERVAL 语法、分页语法、函数名称做方言化处理。我当前支持 MySQL 和 PostgreSQL 两套方言切换目标库在配置里指定即可LLM 规划层完全不需要知道目标库是哪一种。3.3 编译产物与调试信息让 AI 生成的 SQL 也敢被审计很多团队不敢在报表类场景用 LLM 生成 SQL核心原因是不可审计。编译器方案天然缓解了这个问题。除了 SQL 本身编译器会同步输出一个编译报告包含工作流版本号、编译时间、模块 SHA 校验值、字段血缘以及中间每一段 AST 子树的节点内容。这份编译报告的价值有两个。一是回归测试时可以据此做语义等价对比不需要每次都比较 SQL 文本而是比较字段血缘和 AST 结构比较结果准确得多。二是出现问题时定位快。如果线上有一条 SQL 被业务方投诉我第一件事不是看 SQL而是看它的工作流确认规划层理解是否有偏差如果工作流是对的再看编译器的字段血缘确认映射是否有偏差。责任边界清晰排查效率高很多。4. 从需求到 SQL 的完整管线搭建4.1 整体链路与模块划分整个系统可以拆成五个模块入口模块负责接收自然语言请求并做意图粗分类规划模块负责把请求翻译成工作流 DSL校验模块负责对工作流做结构化校验编译模块负责把工作流编译成 SQL执行与回归模块负责实际查询和测试。模块之间以数据形式而非代码调用形式解耦链路上任何一环都可以单独替换。入口模块只做一件事判断请求属于哪个业务场景。比如“销售分析场景”和“库存分析场景”虽然底层可能共享同一批表但字段视图和节点模板差异很大。场景分类正确规划模块才能加载对应的 schema 子集这能显著减少模型的混乱概率。4.2 LLM 规划层的工程实现要点规划层的代码并不复杂复杂的是提示词里的信息密度。我整理了一份稳定使用的提示词结构角色定义、业务口径说明、字段 ID 白名单、节点定义与示例、负面指令、输出格式约束。每部分都有存在的意义角色定义让模型以“数据分析师”的身份处理请求业务口径说明避免模型用错误的口径理解指标字段 ID 白名单是约束力最强的部分节点定义和示例则教会模型把请求拆成节点序列。负面指令同样重要。我会明确告诉模型不要做什么不要推断字段 ID 之外的字段、不要合并两个无法合并的 filter 条件、不要为缺失参数填空值、不要输出额外解释。这些负面规则的目的是让模型在不确定时选择保持原样而不是自作聪明去补全。工程实现上我用的是结构化输出和 function calling 的组合方式。把 DSL JSON Schema 注册为函数的参数 Schema让模型的输出天然走 JSON 路径。校验和重试机制在规划层也必不可少如果校验模块发现工作流不合格会把具体错误信息拼进新的提示词再让模型修正一次。实测一次通过率在九成以上两次修正后基本能到达百分百。成本优化方面schema 子集要缓存规划提示词里的字段视图尽量用摘要而非全量字段描述。每个字段的注释控制在三到四行以内超过这个长度就精简或拆到“复杂口径说明”附录里避免 token 浪费。规划模型选一个性价比高的中端模型即可因为复杂 SQL 语法已经由编译器承担不需要把贵的模型浪费在模型已经擅长的结构化输出任务上。4.3 编译器的执行流程与示例走查编译器实现用了我习惯的三段式解析器、语义分析器、生成器。解析器把 JSON 工作流转换成内部结构体语义分析器拿着 schema 元数据逐节点校验并做依赖关系检查生成器负责把校验通过的 AST 转成 SQL 文本。这里用一个最小示例走一遍。用户问“昨天订单量是多少”规划层产出{ source: {table: orders}, steps: [ {node: filter, field: field_order_date, operator: equal_to_date, value: 2024-01-15}, {node: measure, field: field_order_id, func: count, alias: order_count} ] }编译器解析后语义分析器首先检查 field_order_date 是否存在、数据类型是否为 date、equal_to_date 操作符是否允许与 date 搭配然后检查 measure 节点中 count 函数是否对该字段类型合法最后检查没有 group_by 但只有 measure此时生成逻辑会自动为单值聚合结果补一个“无条件聚合”的语义结构。最终输出很简单SELECT COUNT(o.order_id) AS order_count FROM orders o WHERE o.order_date DATE 2024-01-15这看起来简单但要注意如果不经过语义分析器LLM 可能直接把 field_order_date 写成字符串字面量 2024-01-15在 PostgreSQL 里可能因为类型推断差异导致性能问题。编译器做的就是把类型和表达式层级理顺避免这类隐性问题。4.4 回归测试与评估集构建保住正确性长期不滑坡有了工作流这个契约回归测试终于变得容易。我的评估集是这样组织的每一个用例包含一段真实业务问题、一份人工核对过的目标工作流、以及对应的目标 SQL 语义树。每次改动规划提示词、升级模型或修改编译器逻辑都跑一遍评估集检查三件事规划结果与目标工作流是否语义等价、编译是否成功、编译出的 SQL 语义树与目标树是否等价。这里有一个值得推广的技巧语义等价比较不要用 SQL 文本字符串而是先把两段 SQL 各自解析成 AST再做规范化处理后比较。规范化包括去掉别名差异、忽略括号位置、统一函数大小写、合并常量表达式。这样比较的准确度远高于文本比较能避免“同一个查询因为一行写法不同被误判为不通过”的情况。我统计过自己数据集上的对比结果直接用 LLM 生成 SQL 再跑语法校验通过率大概在 80% 上下跑语义等价验证的通过率更低换成工作流加编译器之后编译期通过率在 99% 以上语义等价通过率稳定在 98% 左右。剩下的失败集中在表结构变化和极少数歧义业务口径上这类问题通过人工补充字段注释就能解决。5. 常见问题与排查技巧实录5.1 模型输出不合格工作流重试都不行怎么办我遇到最多的场景是模型在一个简单查询上反复把“最近30天”理解成“最近30单”。第一次重试后依然错原因通常是提示词里的 last_n_days 语义说明太少。我的解决办法是给这个操作符单独加一条注释“last_n_days 表示按日期字段回溯 N 个自然日value 必须是天数”。同时示例里补一条一模一样的“按最近30天过滤”的句子和对应工作流。经验是给操作符写清楚语义说明比反复强调“不要误解”有效得多。另一个常见情况是模型输出包含多余字段比如在 select 节点里把中间计算字段和最终展示字段混在一起。这类问题我直接用校验模块卡住报“select 字段中存在非叶子字段”然后走重试。重试时附带校验错误信息模型通常能意识到问题并修正。5.2 编译失败和字段解析失败怎么快速定位编译失败的日志里我固定打印三行失败节点、失败原因、涉及字段的血缘链。比如 filter 节点失败时会打印“节点 filter 第 2 步失败field_customer_region 不存在于 schema”。这个信息足够让规划层的重试调用直接拿去用也足够让研发人员看懂问题。字段解析失败我分为两类字段不存在和字段歧义。字段不存在通常是 schema 视图太窄某个业务用词没有对应字段注释字段歧义是多个字段语义重叠模型选了其中任意一个都算错误。我的处理是不存在时补 schema 注释并重试歧义时返回一个“请补充业务限定词”的提示给用户而不是进入重试死循环。5.3 性能与成本优化不能一上来就上贵模型这个系统最烧钱的点在于每次规划请求都要发送 schema 子集和节点定义token 占用不小。我做的第一层优化是 schema 摘要化把两百多个字段的描述压缩成“字段 ID 一句话口径”只在用户问题明显涉及复杂口径时才加载全量附录。第二层优化是把规划结果做缓存。同样的问题表述在短时间内通常会重复出现比如客户反复查同样的报表。我对工作流做规范化哈希命中缓存就直接跳过 LLM 调用。这个缓存命中率在实际使用中能到三成以上。第三层是异步化。规划模块和校验模块都做成可重入任务用户同时发多个请求时可以并行调用 LLM而 AST 编译和方言生成在本地并行执行。整体延迟从原来的 3.5 秒压到了 2 秒以内其中大部分时间还是花在模型响应上编译本身只要几十毫秒。5.4 边界情况权限安全、方言差异和超大查询权限安全一定要放在编译期而不是提示词层。行级权限、列级权限、敏感字段脱敏都应该在编译器拿到工作流之后以过滤器的方式注入最终的 SQL。比如普通客户不允许查看其他城市的数据编译器会在 WHERE 条件后面自动追加当前用户的 city 条件。这样权限逻辑不依赖模型是否遵守提示词确定性得到保障。方言差异我在前面提过放在编译器输出层处理。这里要强调字段类型的方言差异很容易被忽略比如 MySQL 的 DATETIME 和 PostgreSQL 的 TIMESTAMP在 date 条件上的写法完全不同。编译器内部统一使用语义类型输出层再按方言转换能省掉大量排错时间。超大查询是指 group_by 字段过多、filter 条件过多导致生成的 SQL 过于复杂。我的经验是不要让编译器一开始就追求生成最优雅的 SQL先保证结果正确再根据执行计划做必要的节点合并优化。复杂度超过一定阈值时在编译报告中给出提示让上层决定是否拆成两步查询。最后说一点我个人的体会。这个项目做到后期最花时间的其实不是编译器实现也不是 LLM 提示词调试而是 schema 字段口径的维护。业务侧的“销售额”今天可能是毛利明天就变成了净额如果字段注释没有同步更新编译器生成出的 SQL 再稳定也毫无意义。所以如果你要复刻这套架构请一定给自己的 schema 管理流程留出足够的重视度把字段口径当成产品去维护。工作流解决了“确定性”问题而口径管理才决定这个系统最终的价值。
返回列表