
看到这个项目标题熟悉数据平台建设的朋友应该能瞬间get到我的兴奋点——LLM写SQL这件事demo阶段确实惊艳可一旦往生产环境一放就原形毕露。这篇博文要聊的是我实际落地过的一套“以工作流为契约的确定性SQL生成器”LLM只负责把自然语言翻译成结构化的工作流编译器负责把工作流变成最终SQL。整套方案要解决的核心问题是如何让NL2SQL从“演示能用”变成“生产可信”适合正在做Text-to-SQL落地、想给数据分析平台加AI查询入口或者被“模型生成SQL不稳定”折磨得想拍桌子的同学参考。先说结论把“规划”和“生成”彻底拆开是这套方案和普通prompt工程最本质的区别。下面我会从设计动机、工作流契约定义、编译器实现细节、实操流水线、常见坑位五个维度展开尽量把每一步为什么这么做讲透。1. 为什么需要确定性SQL生成器LLM直出SQL的四个深坑先别急着聊方案我栽过的跟头比诸位只多不少。在切换到“LLM规划编译器生成”之前我用过很长一段时间的“直接让大模型输出SQL”方案就是把表结构、业务规则全部塞进prompt让它一次生成完整语句。演示时给业务方看确实爽但进入联调和生产后四个问题接踵而至。1.1 随机性同一句话三次查询三种SQL大模型是概率模型同一个问题多跑几次生成的SQL大概率不一样。虽然大部分情况下结果集是相同的但字段别名、子查询结构、JOIN顺序、甚至括号的嵌套方式都可能变化。这在开发环境里问题不大一旦上线就是灾难。我遇到的最典型场景是应用层解析查询结果。业务方要求在结果集里固定返回“客户编号、客户名称、销售额、排名”四个字段模型第一次生成的是SELECT c.id AS customer_id, c.name AS customer_name ...第二次变成了SELECT c.id, c.name ...字段名对不上应用层代码直接崩。更麻烦的是同一个问题在A/B测试两个prompt版本下生成的SQL风格完全不同你根本没法判断是模型能力问题还是prompt问题。随机性还有一个隐蔽伤害它让“改一个prompt导致全量回归”变成了不可能任务。一次迭代后你无法用同一批测试用例对比前后效果因为你甚至复现不了上一次的输出。这在数据仓库、财务报表这类对稳定性要求极高的场景里等于宣判了方案死刑。1.2 幻觉字段模型在“编”你的表结构这是直出SQL方案里最让人头疼的问题。模型在训练语料里见过海量的通用SQL遇到不熟悉的字段名时它会基于“语义相似度”脑补一个看起来合理的名字。比如业务表里明明是order_amount它可能输出amount表里是created_at它可能脑补成create_time。单表查询时幻觉还容易被发现多表JOIN时才是重灾区。业务系统的关联字段经常是id、code、no这种不带表名的短命名模型在构建JOIN条件时会“编造”一个自认为正确的关联关系比如a.id b.user_id但真实关系可能是a.customer_id b.id。执行时要么直接报字段不存在的错误要么更可怕——字段存在但语义完全错了查出一堆无意义数据还不报错。我见过有团队为了一个幻觉字段排查了整整两天最后发现是模型把payment_time自动替换成了pay_time两个字段都真实存在值却完全不同。这种问题靠加prompt约束很难根治因为模型不知道你的库表里到底有哪些字段。1.3 业务约束丢失能查出数据但不符合规则数据分析平台的查询往往带有强业务约束。比如“只能查自己负责的事业部订单”、“只能统计已支付订单”、“金额必须大于0”这类约束在业务方眼里是“不言自明”的但在模型眼里只是prompt里的一句话。直出SQL方案下模型经常为了“简化”查询而把约束漏掉。我见过最离谱的一次用户问“深圳区域的订单量”生成的SQL里完全没带region 深圳的过滤条件反而因为训练语料的偏差自作主张加了一个order_status completed。数据对不上业务方来投诉你和模型都说不清楚是谁的锅。你可能会说“那我多强调几遍约束嘛”但每加一条约束prompt就变得更长模型出错的概率反而上升。更关键的是业务约束往往会随着组织架构调整而变化你不可能每次都去改线上prompt。1.4 测试与审计无法做确定性回归最后这个坑是压死骆驼的最后一根稻草。数据库相关功能尤其是财务、报表、风控场景线上问题必须可复现、变更必须可回放。直出SQL的方案即使你把每次查询日志都记录下来也只能看到“当时模型生成了什么”无法在代码变更后拿同一个输入重新跑一遍回归——因为模型输出是概率性的重跑出来的SQL和线上那次完全不一样。这就意味着你没法写单元测试、没法做CI/CD的自动化校验、出了问题没法精准定位是哪次prompt改动引入的。在讲究审计合规的行业里这几乎是不可接受的。我认识的不少团队在这个阶段直接放弃了NL2SQL回到传统的“下拉框筛选器”交互相式。所以我的结论很明确LLM的长处是“理解意图”和“把模糊的自然语言转化成有序的语义结构”而不是“逐字写出精确的SQL文本”。与其让模型做一件它天生不擅长的事不如把SQL生成这部分交给确定性代码。这也就是后面要讲的“LLM规划编译器生成”的由来。2. 核心设计以工作流为契约把LLM关进“规划层”被四个深坑教育过之后我开始重新设计整个查询生成链路。核心思路一句话LLM只输出结构化的工作流描述SQL文本完全由编译器生成。这里的“工作流”指的不是业务流程引擎里的那种工作流而是一棵结构化的查询语义树。2.1 工作流契约到底是什么所谓契约就是在LLM和编译器之间定义一份“双方都严格遵守的中间表达格式”。LLM的职责是把一句自然语言翻译成这份中间表达编译器的职责是把这份中间表达翻译成可执行的SQL。两边各司其职互不越界。这份中间表达我把它命名为“查询工作流”Query Workflow。它是一份严格的JSON包含查询语义所需要的全部信息。用一个贯穿全文的例子来解释用户说“查询上个月销售额最高的前10个客户及其总金额”。在直出SQL方案下模型得自己完成所有决策知道要关联orders和customers表知道要按客户分组知道销售额要SUM知道要过滤支付状态为成功知道要按金额降序排列并LIMIT 10。且不说生成结果的语法风险光是“上个月”这个相对时间的处理模型就经常翻车。在工作流契约方案下LLM输出的是一份结构清晰的JSON类似下面这样{ workflow_version: 1, from: { table: orders }, joins: [ { type: inner, table: customers, on: { left_field: orders.customer_id, right_field: customers.id } } ], select_fields: [ { field: customers.id, alias: customer_id }, { field: customers.name, alias: customer_name }, { field: { func: SUM, args: [orders.amount] }, alias: total_amount } ], where: { type: and, conditions: [ { field: orders.status, operator: , value: success }, { field: orders.created_at, operator: in_range, value: last_month } ] }, group_by: [customers.id, customers.name], order_by: [{ field: total_amount, direction: desc }], limit: 10 }这份JSON就是LLM和编译器之间的“契约”。LLM不需要关心SQL语法细节比如JOIN写在WHERE前面还是后面、标识符要不要加反引号编译器也不需要理解自然语言语义它只需要严格按照契约逐条生成SQL片段。2.2 最小原子节点集合覆盖90%查询语义设计契约时我没有搞一套特别复杂的语言而是只定义了SQL中最高频的八个节点类型。分别是from来源表、joins关联关系、select_fields目标字段、where过滤条件、group_by分组、having分组后过滤、order_by排序、limit行数限制。这个集合看起来朴素但组合表达能力非常强。日常分析查询里90%以上逃不出这八类节点。复杂一点的需求比如“近30天每个品类的销量环比变化”其实也就是在where里加时间范围、在select_fields里用聚合函数、再按品类分组而已。另外我把“子查询”也纳入了契约体系做法是把一个子查询先编译成一个独立的、完整的工作流再放到from节点里作为子表达式。这样一来即使遇到“先算各渠道总销售额再找超过平均值10%的渠道”这种嵌套场景也能通过工作流的递归组合搞定而不需要让LLM直接输出一段嵌套SQL。每个节点都不是松散的“自由文本”而是有严格类型约束的结构体。比如operator字段只有、!、、、、、in、not_in、like、is_null、in_range这些枚举值可取select_fields里的field要么是字符串形式的“表名.字段名”要么是带聚合函数的表达式对象。这种约束保证了后续编译器的输入一定是“安全且可解析”的。2.3 为什么是JSON工作流而不是AST或SQL片段有些朋友看到这里可能会问为什么不直接让LLM输出AST抽象语法树或者干脆输出几段SQL片段然后拼接直接输出AST的问题在于AST的递归结构嵌套太深括号层级复杂大模型很难稳定输出平衡且完整的树形结构。哪怕一个小括号配对错误整个JSON就废了而且AST节点类型非常多让模型去记忆“这是什么节点、那是什么节点”本身就是一个巨大的负担。工作流JSON则不同它是SQL的一种“扁平化约束投影”每个节点的语义贴近日常语言模型容易理解也容易生成。直接输出SQL片段的问题则在于片段之间的边界不可控拼接处极容易产生语法错误更不要说字段引用是否正确了。如果你用“正则匹配替换”来做拼接那基本等于给自己埋雷如果你用完整SQL模板来套那LLM的灵活性又被磨灭了。工作流JSON在“表达力”和“可控性”之间取得了平衡——它有足够的结构来表达复杂的查询语义又不会复杂到让模型难以稳定输出。从工程角度看JSON还有一个优势天然兼容现成的数据校验体系。你可以用JSON Schema、Pydantic这类成熟工具对LLM的输出做严格校验不符合结构的一律拒绝重试。这种“先结构化再翻译”的思路把“模型可能犯错”这件事限制在了一个可以捕获、可以重试的边界内。2.4 契约校验让错误在编译前暴露契约的定义只是第一步真正让系统变可靠的是配套的校验机制。我在编译器之前增加了一个独立校验层对LLM输出的工作流JSON做两层检查。第一层是结构校验用JSON Schema检查字段是否齐全、类型是否正确、枚举值是否合法。比如workflow_version必须是数字joins必须是数组limit必须是正整数。这一层能拦截掉大量“模型输出乱写”的垃圾结果。第二层是语义校验这才是契约的精华。校验器会检查“select_fields中的字段是否真的存在于from或join指定的表里”“同名字段是否明确声明了表名”“有group_by时select字段是否要么是分组键、要么是聚合函数”“order_by的字段是否真的出现在select或group_by中”。这些规则在直出SQL方案下只能靠数据库运行时报错才能发现而现在全部前置到了编译之前定位问题的时间从“小时级”降到“秒级”。我坚持一个原则所有错误都在编译前暴露绝不把不合法的工作流交给数据库执行。因为一旦到数据库层报错错误信息往往是“unknown column”这种模糊描述而契约校验层可以给出“字段 amount 不存在于表 orders是否想用 order_amount”这类精确提示。这种体验差异上线后业务方的感受是完全不同的。3. 编译器生成细节从工作流到确定性SQL契约层解决了“LLM输出不可控”的问题接下来核心就是编译器如何把工作流变成SQL。这一层是纯确定性代码不掺杂任何模型调用原则上必须做到“同样的工作流输入永远产生完全相同的SQL文本”。3.1 按SQL语法顺序组装片段SQL的语法是有固定顺序的编译器的工作本质上就是一个“按顺序填充片段”的过程。我实现的workflow_to_sql函数主流程就是按SELECT - FROM - JOIN - WHERE - GROUP BY - HAVING - ORDER BY - LIMIT的顺序调用每个节点的序列化函数然后把片段拼接起来。比如select_fields节点序列化时会遍历字段列表对每个字段生成对应的列表达式。字符串形式的字段直接写customers.name聚合表达式则先解析函数名和参数再生成SUM(orders.amount) AS total_amount。where节点的序列化稍微复杂一点需要递归处理and/or条件和每个具体条件但核心逻辑依然很简单——根据操作符枚举映射到对应的SQL比较表达式。让我把上面的JSON例子手动“编译”一遍结果应该长这样SELECT customers.id AS customer_id, customers.name AS customer_name, SUM(orders.amount) AS total_amount FROM orders INNER JOIN customers ON orders.customer_id customers.id WHERE orders.status success AND orders.created_at 2025-03-01 00:00:00 AND orders.created_at 2025-04-01 00:00:00 GROUP BY customers.id, customers.name ORDER BY total_amount DESC LIMIT 10注意最后渲染出来的SQL里last_month被展开成了具体的时间范围。这一步非常关键也是确定性生成的核心体现稍后在3.3里细讲。3.2 字段归属校验与稳定别名多表JOIN场景下字段归属是最容易出问题的地方。两张表里都可能有id、status、created_at这些通用字段工作流里如果只写一个裸字段名编译器根本无法确定它属于哪张表。我通过两个机制解决这个问题。第一个机制是“稳定别名规则”编译器在拿到from和joins信息后会自动为每张表生成固定的别名比如orders记为ocustomers记为c整个编译过程全部使用这些别名。LLM完全不需要操心别名前缀它只需要在工作流里用“表名.字段名”的完整形式引用字段即可。第二个机制是“字段归属校验”。编译器内置了一个schema元数据映射记录每张表的字段列表、字段类型、主外键关系。在序列化每个字段之前编译器会先校验这个字段存在于哪个表如果字段只在from主表里找到那就用主表别名如果主表没有、joins里某张表有那就用对应表的别名如果两张表都有编译器直接抛错提示“字段id存在于 orders 和 customers 两张表中工作流需明确指定来源表名”。这个机制看似简单但直接把“模型幻觉”这条路上最大的口子堵死了。模型输出一个orders.pay_time编译器一查schema映射发现orders表根本没有pay_time字段立即报错并返回候选字段名。系统在编译期就知道字段不存在压根不会让这条SQL进入数据库执行。3.3 相对时间与运行参数的确定性展开这是整个方案里我最有心得的一个点。直出SQL方案里“上个月”“最近7天”“今年”这类相对时间模型很随意地写成CURRENT_TIMESTAMP、NOW()、DATE_SUB(CURDATE(), INTERVAL 7 DAY)导致同一句查询在不同时间执行结果集完全不同。这对报表和对账来说是致命的。工作流契约方案里我把时间表达式抽象成in_range这种条件节点value字段可以填last_month、last_7_days、this_year这类枚举值。编译器在执行时通过一个“时间基准点”参数把相对表达式展开成绝对的起止时间范围默认情况下基准点就是当前时间。比如在3.1的例子中“上个月”被展开成了[2025-03-01 00:00:00, 2025-04-01 00:00:00)。如果系统配置了默认基准点为2025-04-15那编译器每次展开得到的时间范围都是一样的。这样做的好处太明显了第一SQL完全可复现同样被缓存的查询结果可以直接命中第二测试时可以传入固定的mock时间让回归测试用例的时间断言变得确定第三审计时能清楚知道这次查询到底查了哪个时间段而不是“动态计算出来的时间段”。3.4 函数白名单与多方言适配编译器里还维护了一个“函数白名单”。select_fields中出现的聚合函数、表达式函数只允许在名单范围内使用比如SUM、COUNT、AVG、MAX、MIN、DATE_FORMAT、DATE_TRUNC、ROUND这些常用函数。模型在工作流里写了白名单之外的函数编译器直接拒绝编译。你可能觉得这限制了模型的灵活性但实际落地时白名单带来的收益远大于损失。第一它防止模型调用数据库里没有的函数或危险函数比如某些数据库的非标准扩展第二它统一了SQL的计算口径避免同一个需求有人在select里用DATE_FORMAT、有人用DATE_TRUNC造成统计结果对不上第三白名单本身就是一种性能保护防止模型生成SELECT * FROM orders WHERE id IN (SELECT ... FROM ...)这种高危全表扫描式写法。方言适配是编译器的最后一层。我用一个轻量配置管理不同数据库的语法差异MySQL用反引号包裹标识符、PostgreSQL用双引号分页语法MySQL写LIMIT 10PostgreSQL支持LIMIT 10 OFFSET 0函数签名也要做差异映射。所有方言细节收口在编译器的渲染层业务代码、工作流契约、LLM prompt都完全不用感知这些差异。换了数据库只要替换方言配置整套系统照跑不误。4. 项目实操整条流水线落地与效果对比理论讲完进入实操环节。这一章说说我落地这套系统时的架构分层、Prompt设计、编译器实现参考和效果评估。4.1 系统模块划分与处理流程整套系统拆成五个模块按顺序调用Schema Resolver元数据裁剪器接收用户自然语言和完整库表元数据先粗筛出与本次查询相关的表、字段、关系生成一个缩减后的schema子集。这一步是为了减少prompt长度降低模型出错的概率毕竟把整库几十张表全塞给LLM它大概率会迷失。Planner规划器基于缩减后的schema子集调用LLM生成工作流JSON。这是整个系统里唯一使用大模型的地方。Validator校验器对Planner输出的JSON做结构校验和语义校验不合法就返回错误信息并要求重试合法则进入下一步。Compiler编译器把校验通过的工作流JSON编译成目标数据库方言的SQL文本。Executor执行器负责执行SQL、处理超时、熔断、审计日志等。这里有个小决策说一下我一开始也纠结要不要直接用现成的查询构建器比如SQLAlchemy Core、jOOQ作为编译器底座因为它们已经在AST和方言适配层面做了大量成熟工作。实际上完全可行——你完全可以把“编译器”理解为“从工作流到查询构建器AST的映射器”。我最终选择了自研一个轻量编译器主要原因是想完全控制错误信息格式比如字段归属冲突时给出候选字段提示以及不想引入过重的ORM依赖。如果你是团队落地我更推荐直接复用现成查询构建器省去造轮子的成本。4.2 规划层Prompt与输出配置要点Planner的Prompt设计和传统“让模型直接写SQL”的Prompt有两处关键差异。第一处是输出格式约束。我在构建Prompt时不会让模型自由发挥文本而是要求它只能输出一份严格符合工作流JSON Schema的结果并且通过两个手段保证这一点。一是把输出格式控制交给模型API的结构化输出能力比如function calling或JSON mode让模型从机制上只能产出JSON二是如果工具不支持结构化输出就在Prompt里写清楚“只输出JSON不要包含任何解释性文字”并在代码侧用JSON解析器兜底解析失败就重试。第二处是few-shot例子设计。我给Planner准备了六到八个示例覆盖单表查询、多表JOIN、分组聚合、时间范围过滤、排序分页、子查询这六类高频场景。每个示例都包含用户问题、关联schema子集、期望工作流JSON三段内容。实践下来few-shot例子比在Prompt里反复重申“不要漏字段”“不要编字段”有效得多因为模型是通过模仿示例来学会输出模式的。模型参数方面我把temperature强制设为0关闭采样随机性让模型在JSON生成上尽可能稳定。还要注意给Planner限定输出token上限防止它生成超长垃圾JSON浪费响应时间。这里分享一个个人经验Schema Resolver的裁剪质量直接影响Planner的准确率。早期我直接拿全量schema给模型效果很差后来我专门训练了一个小的“表候选选择器”——可以理解为一个轻量分类或排序任务每次只把最相关的5到8张表、每张表的必要字段和外键关系传给Planner效果肉眼可见地提升。本质上这是把“全库表结构理解”从规划任务中剥离出去让Planner聚焦在“意图到语义结构”的翻译上。4.3 编译器核心实现参考Python片段下面放一段简化版的编译器核心实现算是给大家一个可参考的骨架。完整代码还包含方言层、错误处理、日志这里只保留主干逻辑。def workflow_to_sql(workflow: dict, dialect: str mysql) - str: # 1. 解析基础节点 from_table workflow[from][table] from_alias _alias_for(from_table) joins workflow.get(joins, []) select_fields workflow.get(select_fields, [*]) where workflow.get(where) group_by workflow.get(group_by, []) having workflow.get(having) order_by workflow.get(order_by, []) limit workflow.get(limit) fragments [] fragments.append(SELECT) fragments.append(_serialize_select_fields(select_fields, dialect)) fragments.append(FROM) fragments.append(f{_quote_identifier(from_table, dialect)} AS {from_alias}) # 2. JOIN 节点 for join in joins: join_table join[table] join_alias _alias_for(join_table) on_left join[on][left_field] on_right join[on][right_field] fragments.append(f{join[type].upper()} JOIN {_quote_identifier(join_table, dialect)} AS {join_alias}) fragments.append(fON {_resolve_field(on_left)} {_resolve_field(on_right)}) # 3. WHERE 节点 if where: fragments.append(WHERE) fragments.append(_serialize_condition(where, dialect)) # 4. GROUP BY / HAVING if group_by: fragments.append(GROUP BY) fragments.append(, .join(_resolve_field(f) for f in group_by)) if having: fragments.append(HAVING) fragments.append(_serialize_condition(having, dialect)) # 5. ORDER BY / LIMIT if order_by: fragments.append(ORDER BY) fragments.append(, .join( f{_resolve_field(item[field])} {item[direction].upper()} for item in order_by )) if limit: fragments.append(LIMIT) fragments.append(str(limit)) return \n.join(fragments)这个函数本身并不难真正的复杂度在_resolve_field和_serialize_condition这两个辅助函数里。_resolve_field负责做字段归属校验它接收“表名.字段名”格式的字符串先查schema映射确认字段存在再确认是主表还是JOIN表的字段最后决定使用哪个别名。_serialize_condition则要递归处理and/or嵌套条件和时间范围的展开同时处理字符串值的引号转义防止拼接SQL时出问题。我在实际项目中还给编译器加了一个“解析器回归检查”。SQL文本生成后先不直接发给数据库而是用SQLGlot这样的解析器对文本做一次语法级别校验能通过解析器检查后基本不会再有语法错误。这一步对线上稳定性来说是个性价比非常高的保险丝。4.4 回归测试与效果评估没有效果数据的架构方案都是耍流氓。我搭建这套系统时建了一个两百条真实业务问题的测试集覆盖英语和中文、简单和复杂、单表和跨表各类查询。每条测试用例包含三部分用户自然语言、期望的工作流JSON、期望的SQL文本。回归流程是这样的每次修改Planner的Prompt、调整编译器逻辑或更新schema元数据后整个测试集全量跑一遍。Planner层统计“工作流生成且通过校验”的比例编译器层统计“编译成功”的比例最后人工或自动比对SQL语义一致性。如果某次改动导致某类用例通过率下降测试集能立刻暴露是哪几个用例出了问题。从我个人的测试记录来看效果提升非常明显。直出SQL方案在两百条用例上语义完全正确的比例大概在65%到70%之间而且样本内外的波动很大切换成工作流契约方案后工作流生成并通过校验的比例在85%到90%左右编译器编译成功率接近100%最终SQL语义正确率能稳定到80%以上。更重要的变化是“确定性”同一个测试用例无论跑多少次编译出来的SQL文本都是完全一致的回归测试有了真正的意义。需要说明的是这个数字是我个人测试集的记录不同业务、不同模型肯定会有差异但趋势是一致的——把生成交给编译器后整体正确率下限被拉高了系统不再“忽好忽坏”。5. 实际落地中的坑与排查技巧最后这章我把落地过程中踩过的坑、排查思路和值得分享的技巧整理成一份问题速查表。每一条都是真金白银换来的经验。5.1 LLM输出不合规JSON怎么办即使用了function calling模型偶尔还是可能输出不合法JSON特别是上下文较长、输出token较多的时候。我的兜底策略是三层第一层解析JSON失败进入第二层第二层是“修复重试”把错误信息和前一次输出一起送回模型告诉它“你的输出不符合JSON结构请修正后重新输出”通常这一层就能解决80%的解析问题第三层是“降级处理”如果重试两次仍失败就直接放弃本次查询并给用户返回“无法理解该查询请换个说法”绝对不让脏数据流向下游。5.2 多表Join时字段归属报错怎么解这是编译器报错最频繁的一类。两张表都有amount、status、created_at工作流里写了裸字段名编译器就会报“字段归属不明确”。排查思路上先看Schema Resolver给的schema子集里有没有包含表之间的外键关系提示如果提示不足模型确实难以判断字段归属此时应该增强的是元数据描述而不是继续调prompt。我在schema子集里给每个字段额外附加了一句中文业务含义比如“orders.amount订单支付金额单位元”。模型看到这个上下文后输出精确字段名的概率大幅提升。编译器报错时我也故意把错误信息写成了“字段 amount 存在于 orders 和 refunds 两张表请在字段名前补充表名”引导模型下次注意。5.3 业务约束怎么强制注入而不依赖模型自觉前文提到模型容易漏业务约束在编译器方案里这个问题可以彻底根治。我的做法是把业务约束放在编译器的“策略层”统一注入而不是放在LLM prompt里。比如“普通用户只能查询本事业部数据”这条约束编译器在编译任何工作流时都会无条件在WHERE条件中加入department_id 当前用户部门。这个步骤发生在编译后期不经过LLM不存在“模型忘记了”的可能。类似地“金额大于0”“排除测试订单”“只允许查询最近365天数据”这类硬约束全部在策略层注入。这个设计让安全合规检查变得非常简单——只需要审计策略层代码不需要去审计每一句SQL。业务方改一条规则全系统所有查询立即生效也不用担心prompt版本混乱。5.4 性能与安全兜底措施最后说说兜底。确定性生成并不意味着绝对安全我把防范重心放在四个方面第一强制LIMIT。即使用户没指定行数编译器也会自动加上一个系统配置的最大行数限制防止全表扫描拖垮数据库。第二禁止多语句。编译器只生成单条SELECT语句从机制上杜绝了拼接多条SQL导致的风险。第三执行超时熔断。执行器设置查询超时阈值超过就取消并返回“查询太复杂请缩小范围”。第四审计日志。每次查询的工作流JSON和最终SQL一起落盘出了问题可以直接回溯到具体的规划过程和编译结果。最后分享一点个人体会整套系统从设计到落地我最大的感受是所谓“确定性SQL生成器”真正约束的不是大模型而是你自己的架构决策。你把哪些能力交给模型、哪些能力收进代码决定了系统是“demo级”还是“生产级”。我至今保留着切换方案之前的一份旧代码每次想贪图方便直接让模型输出完整SQL时都会翻出来看看当时的四个深坑。这套以工作流为契约的方案并不难实现但它逼着你把每个环节的责任边界划清楚——LLM负责理解意图编译器负责精确翻译策略层负责规则兜底。三者各司其职时NL2SQL才真正从“有问有答”变成了“可信赖的基础设施”。如果后续你要扩展可以沿着两个方向走一是把工作流契约的可视化编辑器做出来让业务方通过拖拽配置生成查询流程再让编译器自动转SQL那系统的价值会再上一个台阶二是把审计跟踪和血缘关系完整接进来让每一步规划都能溯源。这套底座已经把最难的“确定性”问题解决了剩下的事情就顺手多了。