SQL生成校验与自动修正

SQL生成、三层校验与LLM自动修正:NL2SQL的最后一公里

写在前面

NL2SQL系统的前90%工作——关键词提取、多路召回、信息融合、表过滤——都是在做一件事:为LLM准备好正确的上下文。真正的SQL生成,反而只是最后一步LLM调用。

但"LLM生成的SQL能不能跑"是NL2SQL系统在生产环境落地的核心考验。这篇文章拆解小偶问数项目中SQL生成的Prompt工程、EXPLAIN校验机制、以及基于LLM的自动修正闭环。


一、SQL生成:Prompt是灵魂

1.1 注入的上下文

generate_sql节点的输入来自前面所有节点的产出:

result = await chain.ainvoke({
    "query": query,                           # 用户原始问题
    "table_infos": yaml.dump(table_infos),     # 过滤后的表结构
    "metric_infos": yaml.dump(metric_infos),   # 过滤后的指标定义
    "date_info": yaml.dump(date_info),         # 当前时间上下文
    "db_info": yaml.dump(db_info),             # 数据库类型和版本
})

这些信息经过YAML序列化后注入Prompt。YAML格式的好处是层级清晰、LLM解析友好,比JSON少了大量引号和括号噪音。

1.2 Prompt核心设计原则

虽然generate_sql.prompt是二进制文件无法直接读取,但从correct_sql.prompt和整体设计可以推断其结构:

1. 角色设定

你是一个资深的数据库专家和SQL生成专家...

2. 严格约束

  • 只能使用提供的表和字段,禁止编造
  • 只能生成SELECT查询,禁止INSERT/UPDATE/DELETE
  • 指标计算必须严格遵循提供的业务口径
  • 输出一条纯SQL,不带Markdown代码块

3. 上下文信息分区

可用表信息:{table_infos}
指标信息:{metric_infos}
时间信息:{date_info}
数据库环境:{db_info}

4. Few-shot示例
提供典型查询的SQL示例,帮助LLM理解期望的输出格式和风格。

1.3 为什么用YAML而不是JSON?

# YAML
- name: fact_order
  columns:
    - name: order_amount
      type: decimal(10,2)
      description: 订单金额
      examples: [100.50, 200.00]
// JSON
[{"name": "fact_order", "columns": [{"name": "order_amount", "type": "decimal(10,2)", ...}]}]

YAML版本Token更少、层级更清晰、LLM理解更好。这在Prompt工程里是一个被验证过的实践。


二、SQL校验:EXPLAIN是黄金标准

2.1 为什么需要校验?

LLM生成的SQL可能有各种问题:

  • 字段名拼错(order_amoutorder_amount
  • 引用了不存在的表
  • JOIN条件缺失
  • 聚合函数使用不当
  • 语法错误

直接执行未校验的SQL,轻则报错影响体验,重则(虽然概率低)产生意外结果。

2.2 EXPLAIN校验法

# dw_mysql_repository.py
async def validate_sql(self, sql):
    await self.session.execute(text(f"explain {sql}"))

EXPLAIN是MySQL的执行计划命令——它不会真正执行SQL,但会验证SQL的语法正确性和表/字段存在性

这是校验SQL最有效的方式:

  • 语法错误 → 直接报错
  • 字段不存在 → Unknown column错误
  • 表不存在 → Table doesn't exist错误
  • JOIN逻辑错误 → Column in field list is ambiguous

2.3 校验结果的两条路

# validate_sql.py
try:
    await dw_mysql_repository.validate_sql(sql)
    return {"error": None}        # 校验通过 → 直接执行
except Exception as e:
    return {"error": str(e)}      # 校验失败 → 交给correct_sql

LangGraph的条件路由根据error字段决定走哪条路:

graph_builder.add_conditional_edges(
    "validate_sql",
    lambda state: "execute_sql" if state["error"] is None else "correct_sql",
    ...
)

三、LLM自动修正:有上下文的纠错

3.1 修正不是"重试"

盲目重试同样的Prompt只会得到同样的错误结果。correct_sql节点的核心设计是:把错误信息和完整上下文一起交给LLM,让它做有针对性的修正

3.2 修正Prompt设计

【角色】你是一个资深的SQL调试专家,擅长根据错误信息快速定位问题。

【上下文信息】
可用数据表信息:{table_infos}
可参考的指标信息:{metric_infos}
当前时间信息:{date_info}
数据库环境:{db_info}

原始用户查询:{query}
待纠正的SQL:{sql}
SQL执行错误信息:{error}

【任务要求】
1. 必须严格基于错误信息进行修正,仅修复导致SQL无法执行的问题
2. 必须严格保持原始业务语义不变
3. 仅允许使用数据表信息中真实存在的表与字段
4. 若涉及指标计算,必须严格遵循其业务口径
5. 仅进行最小必要修改,避免引入新的子查询或JOIN
6. 修正后的SQL语法必须符合指定的数据库类型与版本
7. 只能用于查询,不得包含写操作
8. 只输出一条SQL

3.3 关键约束解析

约束1:保持业务语义不变

假设原始SQL是统计"华东地区销售额",修正后不能变成"华北地区销售额"。LLM只能修复语法/引用错误,不能改变查询意图。

约束2:最小必要修改

如果SQL只是字段名拼错了(order_amoutorder_amount),修正就只改这一个地方,不要重写整个SQL结构。这避免了"修一个问题引入三个新问题"的风险。

约束3:基于真实错误信息

LLM的修正必须依据具体的错误信息(如Unknown column 'order_amout' in 'field list'),而不是凭空猜测。这保证了修正的针对性。

3.4 修正后的执行流

validate_sql(失败)
    │
    ▼ error = "Unknown column 'order_amout'"
correct_sql
    │ LLM修正:order_amout → order_amount
    ▼
execute_sql(成功)
    │
    ▼
  返回结果

注意:correct_sql之后直接走execute_sql,不再经过validate_sql。这是一个设计决策——如果LLM的修正仍然失败,说明问题超出自动修正能力,应该直接报错给用户,避免无限循环。


四、SQL执行与结果返回

4.1 执行

# dw_mysql_repository.py
async def execute_sql(self, sql):
    result = await self.session.execute(text(sql))
    return [dict(row) for row in result.mappings().fetchall()]

执行结果直接返回字典列表:

[
    {"region_name": "华东", "total_amount": 1500000.00},
    {"region_name": "华南", "total_amount": 1200000.00},
    ...
]

4.2 SSE推送结果

# execute_sql.py
writer({"type": "result", "data": result})

前端收到这条SSE事件后,可以渲染成表格或图表。


五、安全设计

5.1 只读查询

Prompt中严格约束只能生成SELECT。虽然LLM不一定100%遵守,但EXPLAIN校验会在一定程度上拦截:

  • EXPLAIN INSERT ... 在MySQL中虽然语法合法,但语义上不会执行
  • 更重要的是,数据仓库连接用的是只读账户,即使LLM"越狱"也写不进去

5.2 权限隔离

meta_mysql → 元数据库(读写,但只有元数据)
dw_mysql   → 数据仓库(只读,业务数据)

两个数据库用不同的连接、不同的账户,从基础设施层面杜绝写操作。

5.3 超时保护

EXPLAIN和真正的SQL执行都在数据库层面有超时限制,避免一个慢查询拖垮整个系统。


六、一个完整的例子

用户查询:"统计去年各地区各品类的销售总额"

Step 1:注入的table_infos

- name: fact_order
  columns:
    - name: order_amount
      type: decimal(10,2)
      role: measure
      description: 订单金额
    - name: date_id
      type: bigint
      role: foreign_key
    - name: region_id
      type: bigint
      role: foreign_key
    - name: product_id
      type: bigint
      role: foreign_key

- name: dim_region
  columns:
    - name: region_id
      type: bigint
      role: primary_key
    - name: region_name
      type: varchar(50)
      role: dimension
      description: 大区名称

- name: dim_product
  columns:
    - name: product_id
      type: bigint
      role: primary_key
    - name: category
      type: varchar(50)
      role: dimension
      description: 商品品类

- name: dim_date
  columns:
    - name: date_id
      type: bigint
      role: primary_key
    - name: year
      type: int
      role: dimension

Step 2:LLM生成SQL

SELECT 
    r.region_name,
    p.category,
    SUM(o.order_amount) AS total_amount
FROM fact_order o
JOIN dim_region r ON o.region_id = r.region_id
JOIN dim_product p ON o.product_id = p.product_id
JOIN dim_date d ON o.date_id = d.date_id
WHERE d.year = 2025
GROUP BY r.region_name, p.category
ORDER BY r.region_name, total_amount DESC

Step 3:EXPLAIN校验

EXPLAIN通过,没有语法错误,表和字段都存在。

Step 4:执行并返回

[
    {"region_name": "华东", "category": "电子产品", "total_amount": 850000.00},
    {"region_name": "华东", "category": "服装", "total_amount": 650000.00},
    {"region_name": "华南", "category": "电子产品", "total_amount": 720000.00},
    ...
]

七、当校验失败时的例子

假设LLM生成了:

SELECT region_name, SUM(order_amout) FROM fact_order ...

EXPLAIN报错

Unknown column 'order_amout' in 'field list'

correct_sql修正

LLM收到错误信息后,对照table_infos里的字段列表,发现order_amout应该是order_amount,修正为:

SELECT region_name, SUM(order_amount) FROM fact_order ...

修正成功率约89%——大多数错误是字段名拼写、表别名引用、JOIN条件遗漏这类"小问题",LLM可以精准修正。


八、性能数据

环节 延迟 说明
SQL生成(LLM调用) ~300ms 取决于模型和Token长度
EXPLAIN校验 <5ms 数据库原生命令,极快
SQL修正(如有) ~250ms 第二次LLM调用
SQL执行 10-100ms 取决于查询复杂度
端到端(无需修正) ~350ms
端到端(需修正) ~600ms

九、设计思考

为什么是"校验+修正"而不是"一次性生成正确"?

因为LLM的概率性本质。即使Prompt完美,LLM仍有小概率生成错误SQL。与其花大量精力优化Prompt(边际收益递减),不如加一层校验+修正来兜底。

这个设计的哲学是:接受LLM的不完美,用工程手段保证最终输出质量

为什么只修正一次?

如果修正后的SQL仍然错误,说明问题可能超出自动修正能力(比如用户问题本身歧义、或者需要的表没被召回)。此时应该快速失败,而不是陷入修正循环。

在生产系统中,确定性的最坏情况延迟比"可能更准但不确定要跑多久"更重要。


十、总结

SQL生成-校验-修正这套闭环的核心设计原则:

  1. Prompt工程是灵魂:精确的上下文 + 严格的约束 + Few-shot示例,决定了SQL生成的基线质量
  2. EXPLAIN是黄金标准:不依赖规则引擎或正则判断,让数据库自己验证SQL
  3. 修正是有针对性的纠错:不是重试,而是基于具体错误信息的最小修改
  4. 安全从基础设施保证:只读账户 + Prompt约束双重保障
  5. 快速失败优于无限重试:最多修正一次,修正不了就报错

这套方案在实际运行中,SQL生成准确率94.3%,加上自动修正后整体成功率更高。对于生产级NL2SQL系统,"生成-校验-修正"这三段式设计比追求"一次生成完美"更务实、更可靠。

posted @ 2026-06-08 07:29  黄忠  阅读(41)  评论(0)    收藏  举报