尧图精选

LLM规划+编译器生成:让Text-to-SQL告别幻觉的工程实践

🕒 发布时间:2026/10/2 7:23:21 📁 来源:尧图网络
1. 当大模型写SQL开始胡说八道我们该怎么治它用大模型生成SQL这件事做过的人大概都有类似体验模型给出的语句语法看着没问题字段名也像模像样但一跑就报错——表名拼错了、JOIN条件漏了、聚合函数用在了不该用的地方。更让人头疼的是同一个问题问两次它给你两个不同版本的SQL你根本不知道哪个是对的。这不是模型不够聪明而是我们把一个需要确定性的任务交给了一个本质上概率性的系统。我最初接触这个方向是因为手头有一个数据查询平台用户用自然语言描述需求后台调LLM生成SQL去查数仓。上线第一周就炸了生成的SQL有30%左右跑不通剩下70%里还有一部分虽然能跑但逻辑是错的比如该去重的没去重、该过滤的时间范围没过滤。用户投诉不断我们只能加人工审核但这就失去了自动化的意义。后来我意识到问题的根源在于我们把LLM当成了一个SQL翻译器而它实际上更适合做一个规划器。这两者的区别很关键翻译器要求输入输出严格对应一个词对一個词规划器只需要给出高层意图和步骤具体执行交给确定性的系统去完成。打个比方LLM像是一个项目经理你告诉他帮我查一下上个月华东区销售额前十的产品他不需要自己写代码只需要拆解出时间范围是上个月、区域是华东、按销售额排序取前十这几个关键约束然后交给一个懂SQL的工程师去写。这个工程师就是编译器。这就是本文要聊的核心思路以工作流为契约让LLM负责规划让编译器负责生成确定性的SQL。关键词里的LLM、SQL、编译器、工作流、DSL这五个词串起来就是一条完整的技术链路。适合谁看如果你正在做Text-to-SQL、数据问答、BI自助分析这类产品或者你单纯好奇怎么把LLM的不确定性关进笼子里这篇内容应该能给你一些可以直接抄作业的思路。2. 为什么LLM直接写SQL这条路走不通2.1 概率系统的本质缺陷同一个问题两个答案LLM的底层是概率分布给定相同的输入它每次采样出来的输出可能不同。这在聊天场景里是优点显得灵活自然但在SQL生成场景里就是灾难。我做过一个测试同一个问题查询2024年1月每个城市的订单总数连续问十次得到了七种不同的SQL写法有的用COUNT(*)有的用COUNT(order_id)有的把时间条件写在WHERE里有的写在JOIN的ON里有的用BETWEEN有的用 AND 。这些写法在语义上可能等价但一旦涉及NULL值处理、索引命中、执行计划结果就可能不一样。更严重的是有些差异不是风格问题而是逻辑错误。比如COUNT(*)和COUNT(column)在column有NULL值时结果不同模型并不知道你的业务里这个字段是否允许为空。它只是在猜你想要什么。2.2 幻觉的三种典型表现在实际项目中我总结LLM生成SQL的幻觉主要有三类第一类是** schema 幻觉**。模型会编造不存在的表名或字段名。比如你的数仓里表叫dwd_order_detail它可能写成order_details或者dwd_orders。这种错误最容易被发现因为一跑就报table not found。第二类是语义幻觉。表名字段名都对但逻辑是错的。比如你问复购率它可能算成了重复购买用户数除以总用户数而你的业务定义是重复购买订单数除以总订单数。这种错误最危险因为SQL能跑通结果看起来也合理但就是不对。第三类是结构幻觉。涉及多表JOIN时模型可能漏掉关联条件导致笛卡尔积或者把本该用子查询的场景写成了JOIN导致数据膨胀。我见过最离谱的一个案例模型生成了一个四表JOIN其中两个表之间没有任何关联条件结果返回了几百万行数据直接把查询拖垮。2.3 确定性需求的本质可复现、可审计、可优化数据查询这个场景对确定性的要求其实非常高。可复现意味着同样的业务问题今天跑和明天跑应该得到相同的SQL结构否则没法做版本对比和回归测试。可审计意味着每一条SQL都能追溯到它是怎么被生成的哪个环节做了什么决策出了问题能定位。可优化意味着生成的SQL要能稳定地命中索引、走正确的执行计划而不是每次都要DBA手动调。这三条要求LLM直接生成SQL一条都满足不了。所以我们需要换一个思路把生成SQL这个动作拆开LLM只做它擅长的事——理解意图、拆解步骤、处理模糊性确定性的部分交给编译器——按照预定义的规则和模板把规划结果翻译成标准SQL。3. 工作流契约给LLM画一张施工图纸3.1 什么是工作流契约工作流契约这个词听起来有点抽象说白了就是一份双方约定好的接口规范。LLM作为规划方输出必须符合这个规范编译器作为执行方只接受符合规范的输入。这份契约定义了有哪些步骤类型、每个步骤需要哪些参数、步骤之间的依赖关系怎么表达、最终输出是什么格式。我习惯用一个JSON Schema来定义这份契约。比如一个最简单的查询工作流可能包含这几个步骤类型select指定要查询的字段from指定数据源where指定过滤条件group_by指定分组维度order_by指定排序limit指定返回条数每个步骤类型有自己的参数规范。比如where步骤需要field、operator、value三个参数operator只能是预定义的几个值、、、in、like等。LLM的输出必须严格符合这个Schema否则编译器直接拒绝。3.2 契约设计的三个原则设计这份契约的时候我踩过不少坑总结下来有三个原则原则一粒度要适中。太粗了LLM的自由度太大还是可能生成不确定的东西太细了LLM的负担太重相当于让它直接写SQL了。我的经验是把SQL的每个子句作为一个步骤类型但子句内部的细节由编译器填充。比如LLM只需要说我要按城市分组不需要指定GROUP BY city还是GROUP BY city_name编译器会根据元数据自动映射到正确的字段名。原则二枚举值要封闭。所有可选的值必须是预定义的枚举不能让LLM自由发挥。比如操作符只能是、!、、、、、in、not in、like、is null、is not null这几种不能让它写BETWEEN或者REGEXP除非你明确支持。这样编译器的处理逻辑就是有限的、可穷举的。原则三依赖关系要显式。步骤之间的依赖必须用明确的方式表达不能靠隐式约定。比如order_by依赖select里定义的字段别名这个依赖关系要在契约里写清楚编译器才能做校验。我一般用一个depends_on字段来标记每个步骤可以引用前面步骤的ID。3.3 一个真实的工作流契约示例下面是我在一个项目里实际使用的契约片段用JSON Schema描述{ type: object, properties: { steps: { type: array, items: { type: object, properties: { id: { type: string }, type: { enum: [select, from, where, group_by, order_by, limit] }, params: { type: object }, depends_on: { type: array, items: { type: string } } }, required: [id, type, params] } } } }LLM的输出必须符合这个结构。比如对于查询上个月华东区销售额前十的产品这个问题LLM应该输出类似这样的规划{ steps: [ { id: s1, type: from, params: { table: sales_fact } }, { id: s2, type: where, params: { field: region, operator: , value: 华东 }, depends_on: [s1] }, { id: s3, type: where, params: { field: sale_date, operator: , value: 2024-01-01 }, depends_on: [s1] }, { id: s4, type: where, params: { field: sale_date, operator: , value: 2024-02-01 }, depends_on: [s1] }, { id: s5, type: group_by, params: { fields: [product_name] }, depends_on: [s1] }, { id: s6, type: select, params: { fields: [product_name, SUM(sales_amount) as total_sales] }, depends_on: [s5] }, { id: s7, type: order_by, params: { field: total_sales, direction: desc }, depends_on: [s6] }, { id: s8, type: limit, params: { count: 10 }, depends_on: [s7] } ] }注意这里LLM没有写任何SQL关键字它只是在描述要做什么。表名sales_fact、字段名region、sale_date这些要么来自元数据注入要么来自LLM对元数据的理解。编译器拿到这个规划后会按照预定义的模板生成最终的SQL。4. 编译器层把规划翻译成铁打的SQL4.1 编译器的核心职责编译器在这个架构里扮演的是确定性执行者的角色。它的输入是LLM输出的工作流规划输出是标准的SQL语句。它的核心职责有四条第一校验。检查规划是否符合契约Schema步骤类型是否合法参数是否完整依赖关系是否有环。任何一条不满足直接拒绝并返回错误信息。第二解析。把规划中的逻辑名称比如华东、上个月解析成物理名称比如region_code HD、sale_date 2024-01-01。这一步需要依赖元数据服务包括字段映射表、枚举值字典、时间表达式解析器等。第三优化。对规划做等价变换比如把多个where步骤合并成一个、把limit下推到子查询、消除冗余的group_by。这一步是可选的但做了之后生成的SQL质量会明显提升。第四生成。按照预定义的模板把规划渲染成SQL字符串。模板可以用字符串拼接也可以用模板引擎比如Jinja2我倾向于用模板引擎因为可读性和可维护性更好。4.2 元数据服务编译器的字典编译器要能把华东翻译成region_code HD前提是它知道这个映射关系。这就是元数据服务的作用。元数据服务通常包含这几类信息表元数据表名、字段名、字段类型、主键、外键、分区信息字段映射业务名称到物理名称的映射比如销售额对应sales_amount枚举字典业务枚举值到物理值的映射比如华东对应HD时间表达式自然语言时间到具体日期的解析规则比如上个月对应[2024-01-01, 2024-02-01)指标定义业务指标的计算口径比如复购率的计算公式这些元数据可以存在数据库里也可以放在配置文件里。我的经验是表元数据和字段映射适合放数据库因为会频繁变更枚举字典和时间表达式适合放配置文件因为相对稳定。4.3 SQL模板的设计技巧编译器生成SQL的方式我见过三种纯字符串拼接、AST构建、模板渲染。纯字符串拼接最灵活但最容易出bugAST构建最严谨但开发成本高模板渲染是折中方案也是我推荐的方式。模板渲染的核心是设计好模板。一个好的SQL模板应该满足结构清晰、占位符明确、条件分支可控。比如一个查询模板可能长这样SELECT {{ select_clause }} FROM {{ from_clause }} {% if where_clause %}WHERE {{ where_clause }}{% endif %} {% if group_by_clause %}GROUP BY {{ group_by_clause }}{% endif %} {% if order_by_clause %}ORDER BY {{ order_by_clause }}{% endif %} {% if limit_clause %}LIMIT {{ limit_clause }}{% endif %}每个子句的生成逻辑单独封装成函数比如generate_where_clause(steps)接收所有where类型的步骤返回一个字符串。这样职责清晰测试也方便。4.4 处理不确定的边界情况即使有了契约LLM的输出还是可能有一些边界情况需要处理。我遇到过几种典型场景场景一字段名模糊。用户说查一下销售额但数仓里有sales_amount、gmv、revenue三个字段。LLM可能随便选一个也可能输出一个模糊的引用。我的做法是在契约里加一个resolve步骤类型LLM可以标记这个字段需要消歧编译器收到后返回候选列表让上游系统决定用哪个。场景二时间范围不明确。用户说最近LLM可能输出recent这样的值。编译器需要有一个时间解析器把最近解析成最近7天或最近30天具体取决于业务约定。这个约定要写在元数据里不能靠猜。场景三多表关联路径不唯一。用户要查订单和用户的信息但订单表和用户表之间可能有多条关联路径直接关联、通过中间表关联。LLM可能选了一条但未必是最优的。我的做法是在元数据里预定义常用关联路径编译器优先选择预定义的路径如果没有再让LLM决定。5. 从规划到SQL的完整链路拆解5.1 第一步意图理解与槽位填充用户输入的自然语言问题首先要经过意图理解。这一步可以用LLM做也可以用传统的NLU模型。我倾向于用LLM因为对复杂句式的处理能力更强。意图理解的目标是识别出查询类型是聚合查询、明细查询还是对比查询、涉及的实体表、字段、指标、约束条件时间、地区、状态等。这一步的输出是一个结构化的意图对象比如{ intent: aggregate_query, entities: { table: sales_fact, dimensions: [product_name], measures: [SUM(sales_amount)] }, constraints: [ { field: region, operator: , value: 华东 }, { field: sale_date, operator: between, value: [2024-01-01, 2024-01-31] } ] }这个对象是后续规划的基础。注意这里的between操作符在契约里可能被拆成两个where步骤这是规划阶段要做的事。5.2 第二步工作流规划生成有了意图对象接下来让LLM生成工作流规划。这一步的Prompt设计很关键。我的Prompt模板大致是这样的你是一个SQL规划助手。根据用户意图生成一个符合以下Schema的工作流规划。注意你只需要描述做什么不需要写SQL。所有字段名必须来自提供的元数据。所有枚举值必须来自提供的字典。然后附上Schema定义和元数据摘要。LLM输出的就是前面示例里那种JSON结构。这里有一个经验元数据摘要不要给太多。我一开始把整个数仓的元数据都塞进Prompt结果LLM反而更容易选错字段。后来改成只给相关的表和字段准确率明显提升。具体做法是先用一个轻量级的检索模型比如基于embedding的相似度匹配召回候选表和字段再把这些候选喂给LLM。5.3 第三步规划校验与修正LLM输出的规划不能直接信任必须经过校验。校验分两层语法校验用JSON Schema验证结构是否正确必填字段是否缺失枚举值是否合法。这一层是纯机械的用现成的JSON Schema验证库就能做。语义校验检查字段是否存在于元数据、表是否可访问、操作符是否适用于该字段类型比如不能对字符串字段用、依赖关系是否成环。这一层需要查元数据服务。校验不通过怎么办我的做法是把错误信息反馈给LLM让它重新生成。比如字段sales不存在可用的字段有sales_amount、sales_count请重新生成。通常一到两轮就能修正。如果三轮还不行就降级到人工处理或返回默认SQL。5.4 第四步编译生成与执行校验通过后编译器开始工作。它按照步骤类型逐个处理最终拼装成完整的SQL。这里有一个细节步骤的执行顺序不一定等于数组顺序。比如where步骤可能在from之前定义但生成SQL时WHERE子句必须在FROM之后。所以编译器需要先按依赖关系做拓扑排序再按SQL子句的顺序重新排列。生成的SQL还要经过一层安全校验主要是防止SQL注入。虽然我们的架构里LLM不直接写SQL但用户输入的值比如华东最终会拼接到SQL里所以必须做参数化处理或转义。我的做法是所有值都用占位符执行时用参数绑定不直接拼字符串。6. 实测中那些教科书不会写的坑6.1 坑一LLM的过度规划LLM有时候会想太多。比如你问查一下昨天的订单数它可能生成一个包含select、from、where、group_by、order_by、limit六个步骤的规划而实际上只需要select count(*) from orders where date yesterday。多出来的group_by和order_by不仅没必要还可能改变语义。我的应对策略是在Prompt里加一条约束只生成必要的步骤不要添加用户没有要求的操作。同时在编译器里加一个优化规则如果group_by的字段和select的字段完全一致且没有聚合函数就自动去掉group_by。6.2 坑二时间表达式的方言问题上个月、最近一周、本季度这些时间表达式不同业务方的理解可能不一样。比如上个月是指自然月还是最近30天本季度是从季度第一天开始还是从今天往前推90天我踩过的坑是LLM按自然月理解但业务方期望的是最近30天结果数据对不上。解决办法是把时间表达式的解析规则显式定义在元数据里并且在Prompt里告诉LLM遇到时间表达式时使用time_expr类型不要自己解析成具体日期。编译器收到time_expr后查元数据里的规则来解析。这样规则统一不会出现歧义。6.3 坑三多轮对话中的上下文污染如果是多轮对话场景用户可能先问查一下华东区的销售再问那华南区呢。第二轮问题里没有明确说销售但LLM需要从上下文里继承。我遇到的问题是LLM有时候会把上一轮的where条件也带进来导致查华南区的时候还带着华东区的过滤条件。解决思路是在工作流规划里加一个context字段明确标记哪些条件是从上下文继承的哪些是当前轮新增的。编译器在处理时只合并标记为inherit的条件当前轮的条件覆盖同名字段。这个机制需要LLM在生成规划时显式标注所以Prompt里要写清楚规则。6.4 坑四编译器的过度优化编译器做优化是好事但过度优化可能改变语义。我写过一个优化规则把WHERE a 1 AND a 2简化为WHERE false因为一个字段不可能同时等于两个值。这个规则本身没错但如果a是浮点数或者有特殊语义就可能出问题。后来我把优化规则分成两类安全优化如合并相同字段的多个条件和激进优化如常量折叠后者默认关闭需要显式开启。7. 这套架构适合什么场景不适合什么场景7.1 适合的场景这套架构最适合查询模式相对固定、但表达方式多样的场景。比如BI自助分析、数据问答机器人、报表自动化。这些场景的特点是底层数据模型稳定查询类型有限主要是聚合和明细查询但用户的问法千变万化。用LLM做规划可以覆盖各种问法用编译器做生成可以保证SQL质量。另一个适合的场景是需要审计和复现的场景。比如金融、医疗行业每一条查询都要留痕出了问题要能追溯到是谁、在什么时候、基于什么规则生成的。这套架构天然支持审计因为规划是结构化的编译过程是确定性的每一步都有日志。7.2 不适合的场景复杂分析查询不太适合。比如涉及窗口函数、CTE、多层嵌套子查询的SQL工作流契约很难覆盖所有情况。强行覆盖会导致契约变得极其复杂LLM的规划准确率也会下降。这种场景我建议还是让LLM直接生成SQL但加上人工审核。实时性要求极高的场景也不太适合。这套架构比直接生成SQL多了一个规划步骤和编译步骤延迟会增加。如果查询本身很简单比如单表点查直接生成可能更快。我的经验是当查询涉及三个以上表或者有聚合操作时这套架构的收益才明显。7.3 一个决策参考表场景特征推荐方案理由单表简单查询LLM直接生成规划开销大于收益多表聚合查询工作流编译器确定性收益明显复杂分析查询LLM直接生成人工审核契约难以覆盖高频重复查询缓存编译器规划一次复用多次需要审计追溯工作流编译器天然支持审计8. 关于DSL选型的一些个人看法8.1 为什么不用通用DSL有人可能会问为什么不直接用现成的DSL比如Apache Calcite的RelNode或者Substrait我的看法是通用DSL的表达能力太强反而不好约束LLM。Calcite的RelNode可以表达任意复杂的关系代数LLM很容易生成一些合法但奇怪的表达式。而自定义的轻量级DSL虽然表达能力有限但胜在可控。这就像给小孩一把剪刀和一把瑞士军刀的区别。剪刀只能剪纸但你知道他不会伤到自己瑞士军刀什么都能干但也什么都能干坏。在LLM这个小孩还不够成熟的时候我倾向于用剪刀。8.2 自定义DSL的设计要点如果你决定自定义DSL有几个要点需要注意保持扁平。尽量不要嵌套结构每个步骤都是平级的用depends_on表达依赖。嵌套结构会让LLM的生成难度指数级上升。用业务语言。步骤类型和参数名尽量用业务语言不要用SQL术语。比如用filter而不是where用group而不是group_by。这样LLM更容易理解也更容易和业务方沟通。版本化。DSL一旦上线就会有存量数据。任何变更都要考虑兼容性。我的做法是在DSL里加一个version字段编译器根据版本号选择不同的解析逻辑。8.3 和Dify、Coze这类工作流平台的对比最近工作流平台很火Dify、Coze这些工具让非技术人员也能搭建AI工作流。有人可能会想直接用这些平台不就行了我的看法是这些平台适合做编排但不适合做编译。它们的工作流节点是黑盒你很难控制每个节点的输出格式和校验逻辑。而我们的场景需要精确控制LLM的输出结构需要自定义校验和编译逻辑这些在通用平台上很难实现。不过这些平台的思路可以借鉴。比如Dify的DSL文件格式其实就是一种工作流契约。它的节点类型、连线规则、变量传递机制都可以作为设计自定义DSL的参考。我甚至想过把我们的DSL导出成Dify兼容的格式这样可以在Dify里做可视化编排然后导出给编译器执行。这个方向后续可以探索。9. 一些实操建议和踩坑记录9.1 Prompt里一定要写不要做什么LLM的指令遵循能力有个特点你告诉它要做什么它可能做过头你告诉它不要做什么它反而记得牢。所以Prompt里除了正面指令一定要加负面约束。比如不要生成用户没有要求的步骤不要自己解析时间表达式不要使用元数据里没有的字段不要生成嵌套的子查询这些负面约束能挡掉大部分低级错误。9.2 校验失败时的重试策略LLM生成规划失败是常态关键是重试策略。我的做法是第一次失败把错误信息原样返回让LLM重新生成第二次失败把错误信息加上请仔细检查字段名是否在元数据中的提示第三次失败降级到规则引擎用预定义的模板匹配三次都失败返回无法理解您的问题请换个说法这个策略的核心是逐级降级保证任何情况下都有输出不会卡死。9.3 监控和回归测试上线之后一定要做监控。我关注的指标有规划生成成功率、编译成功率、SQL执行成功率、用户满意度点赞/点踩。其中编译成功率是最关键的它直接反映了契约设计和LLM规划的质量。回归测试也很重要。我维护了一个测试集包含100个典型问题和对应的期望SQL。每次修改Prompt或契约都跑一遍测试集看通过率有没有下降。这个习惯帮我避免了好几次改了一个地方坏了十个地方的事故。9.4 一个容易被忽略的细节字段别名编译器生成SQL时字段别名很容易出问题。比如SUM(sales_amount) as total_sales如果LLM在order_by里引用了total_sales编译器需要知道这个别名是在select里定义的。我的做法是在编译时维护一个别名表select步骤生成别名后注册到表里后续步骤引用时先查表。如果查不到就报错。这个细节看起来小但不处理的话生成的SQL会报unknown column错误。我一开始就栽在这上面排查了半天才发现是别名没注册。10. 最后聊几句个人体会这套架构我前后迭代了三个版本从最初的LLM直接生成SQL到现在的规划编译中间踩的坑比这篇文章里写的多得多。最大的体会是不要试图让LLM做它不擅长的事。LLM擅长理解模糊的、开放的、需要常识的问题不擅长做精确的、确定的、需要严格逻辑的操作。把这两类任务分开让LLM做前者让传统程序做后者系统才能稳定。另一个体会是契约的设计比LLM的选型更重要。我试过GPT-4、Claude、还有几个开源模型在规划任务上的表现差异其实没有想象中那么大。但契约设计得好不好直接决定了整个系统的上限。一个好的契约能让弱模型也生成可用的规划一个差的契约强模型也救不回来。如果你正在做类似的事情我的建议是先从最简单的场景开始把契约设计好把编译器写扎实再逐步扩展场景。不要一上来就追求覆盖所有查询类型那样只会让契约变得臃肿LLM的准确率反而下降。小步快跑持续迭代这套架构的收益会随着场景的积累越来越明显。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →