14 | 验证并修正 LLM 生成的 SQL

18 阅读17分钟

14 | 验证并修正 LLM 生成的 SQL

项目地址:github.com/frontzhm/n2…

每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。

这是一篇系列文,请按顺序阅读。

本文目标

上一篇已经让大模型根据 sql_context 生成了候选 SQL:

sql_context → generate_sql → sql

但是“模型生成了 SQL”不等于“SQL 可以安全执行”。候选 SQL 仍可能存在:

  • SQL 语法错误。
  • 一次生成多条语句。
  • 生成 DELETEUPDATE 等非只读语句。
  • 编造上下文中不存在的表和字段。
  • 使用错误的 JOIN 字段。
  • 使用其他数据库或 SELECT * 扩大数据范围。
  • 使用 MySQL 不存在的函数。
  • 使用文件、锁或耗时函数。

本文实现下面的验证和修正闭环:

候选 SQL
    ↓
SQLGlot 解析 MySQL AST
    ↓
单语句、只读性和危险节点检查
    ↓
表、数据库和字段白名单检查
    ↓
JOIN relationships 检查
    ↓
MySQL EXPLAIN
    ↓
  是否通过? ── 是 ──→ run_sql
    │
    否
    ↓
是否还有修正次数? ── 否 ──→ 安全结束,不执行
    │
    是
    ↓
校验错误 + 原 SQL + SQL 上下文
    ↓
LLM 最小修正
    ↓
重新进入 validate_sql

这里最重要的原则是:

修正后的 SQL 不能直接执行,必须从头重新通过全部确定性校验。


先看输入和输出

1. validate_sql 的输入

验证节点从 State 读取:

{
    "sql": """
        SELECT SUM(fo.order_amount) AS sales_amount
        FROM fact_order AS fo
        JOIN dim_region AS dr
          ON fo.region_id = dr.region_id
        WHERE dr.province = '北京市'
    """,
    "sql_context": {
        "tables": [
            {
                "id": "fact_order",
                "name": "fact_order",
                "columns": [
                    {"name": "order_amount", "type": "decimal(18,2)"},
                    {"name": "region_id", "type": "bigint"},
                ],
            },
            {
                "id": "dim_region",
                "name": "dim_region",
                "columns": [
                    {"name": "region_id", "type": "bigint"},
                    {"name": "province", "type": "varchar(64)"},
                ],
            },
        ],
        "relationships": [
            {
                "foreign_column_id": "fact_order.region_id",
                "primary_column_id": "dim_region.region_id",
                "condition": "fact_order.region_id = dim_region.region_id",
            }
        ],
        "database_info": {
            "dialect": "mysql",
            "database": "dw",
            "version": "8.4.0",
        },
    },
    # 第一次验证时 State 中没有该字段,按 0 处理。
    "correction_attempts": 0,
}

为什么验证仍然需要 sql_context?因为“语法正确”只能说明数据库认识这条 SQL,不能说明模型有权使用这些表字段,也不能说明 JOIN 符合当前问题召回到 的关系。

2. 验证通过时的输出

{
    "validation_errors": [],
    "can_correct": False,
    "error": None,
}

路由函数看到 error is None,才允许进入 run_sql

同时向 SSE 发送:

{
  "type": "validation",
  "valid": true,
  "errors": [],
  "correction_attempts": 0,
  "can_correct": false
}

3. 验证失败但可以修正时的输出

例如模型使用了不存在的字段:

{
    "validation_errors": [
        "字段白名单校验失败:Column 'sales' could not be resolved"
    ],
    "can_correct": True,
    "error": "字段白名单校验失败:Column 'sales' could not be resolved",
}

这时工作流进入 correct_sql,而不是 run_sql

4. correct_sql 的输入和输出

修正服务接收一个结构化对象:

{
    "sql_context": {
        # 与生成 SQL 时相同的精简业务上下文
    },
    "candidate_sql": "SELECT SUM(sales) FROM fact_order",
    "validation_errors": [
        "字段白名单校验失败:Column 'sales' could not be resolved"
    ],
    "correction_attempt": 1,
}

修正后写回 State:

{
    "corrected_sql_raw": "```sql\nSELECT SUM(order_amount) ...\n```",
    "sql": "SELECT SUM(order_amount) ...",
    "correction_attempts": 1,
}

随后图中的固定边会把它送回 validate_sqlcorrected_sql_raw 保留模型原始 输出,sql 保存去掉外围 Markdown 代码块后的候选 SQL。

5. 达到修正上限时的输出

默认最多修正两次:

sql_validation:
  max_correction_attempts: 2

第二次修正后如果仍然验证失败:

{
    "error": "仍然存在的校验错误",
    "correction_attempts": 2,
    "can_correct": False,
}

工作流直接进入 END,不会执行 SQL。失败结束是正常的安全结果,不应该为了 “一定返回数据”而绕开校验。


第一部分:项目实现

1. 安装 SQLGlot

uv add sqlglot

当前安装版本为:

sqlglot 30.14.0

SQLGlot 用于按 MySQL 方言把 SQL 解析成 AST。相比使用正则搜索 DELETE、 分号或表名,AST 能分辨关键字、字符串、注释、别名、CTE 和嵌套查询所在的 真实语法位置。

2. 为什么分为静态校验和 MySQL 校验

单一手段无法覆盖所有问题,因此验证器分成两部分:

静态校验(SQLGlot)                 数据库校验(MySQL)
─────────────────────────────────  ─────────────────────────
是否能解析                          实际 MySQL 是否接受语法
是否只有一条语句                    函数在真实环境是否存在
是否只读                            数据库版本是否支持该写法
表字段是否在 SQL 上下文中           优化器能否建立执行计划
JOIN 是否来自 relationships
是否使用危险结构

静态校验失败时不会连接 MySQL。只有前面的规则全部通过,才执行普通 EXPLAIN

3. 定义稳定的验证结果

app/application/sql_validation_service.py 中定义:

@dataclass(frozen=True)
class SQLValidationResult:
    is_valid: bool
    errors: list[str]

    @property
    def error_message(self) -> str | None:
        return "\n".join(self.errors) if self.errors else None

服务不通过抛异常表达“SQL 不合法”,而是返回结构化结果。因为候选 SQL 不合法 是工作流预期分支,不是系统故障。

以下情况才应该抛异常并终止工作流:

  • MySQL 无法连接。
  • 用户名或密码错误。
  • 数据库不存在。
  • 连接中途断开。
  • 当前账号没有 EXPLAINSELECT 权限。

4. 第一层:解析 MySQL AST 和限制单条语句

statements = [
    statement
    for statement in sqlglot.parse(text, read="mysql")
    if statement is not None
]

必须使用 parse() 而不是只使用 parse_one()parse() 会返回全部语句, 因此下面的输入能被识别为两条:

SELECT order_amount FROM fact_order;
DELETE FROM fact_order;

项目明确要求:

if len(statements) != 1:
    return invalid(...)

不能简单地按分号切割,因为字符串值本身可能包含分号,SQL 末尾也通常允许 一个分号。

5. 第二层:只允许只读 Query

首先要求根节点是 exp.Query,它可以覆盖:

  • SELECT ...
  • WITH ... SELECT ...
  • UNION 等查询表达式

然后继续查找禁止节点:

FORBIDDEN_NODES = (
    exp.Insert,
    exp.Update,
    exp.Delete,
    exp.Merge,
    exp.Create,
    exp.Drop,
    exp.Alter,
    exp.TruncateTable,
    exp.Command,
    exp.Execute,
    exp.Transaction,
    exp.Copy,
    exp.LoadData,
    exp.Into,
    exp.Lock,
    exp.SessionParameter,
    exp.PropertyEQ,
)

除了常见 DML 和 DDL,这里还禁止:

  • SELECT ... INTO OUTFILE 等文件输出。
  • SELECT ... FOR UPDATE 等锁操作。
  • 会话变量读取和赋值。
  • 命令、事务和数据加载节点。

根节点限制和内部节点检查需要同时存在,防止危险结构藏在 CTE 或子查询中。

6. 第三层:处理星号和危险函数

禁止 SELECT ,保留 COUNT()

假设 SQL 上下文只允许:

fact_order.order_amount
fact_order.region_id

如果放行:

SELECT * FROM fact_order;

MySQL 会返回真实表的全部字段,而不是 SQL 上下文中的两个字段。这会绕过字段 白名单,因此项目禁止投影通配符:

if any(
    star.find_ancestor(exp.Count) is None
    for star in expression.find_all(exp.Star)
):
    errors.append("禁止使用 SELECT * 或 table.*")

COUNT(*) 不返回整行字段,只做行数统计,所以继续允许。

禁止明显危险的 MySQL 函数

项目还维护了一个小型拒绝列表:

SLEEP              BENCHMARK
LOAD_FILE          GET_LOCK
RELEASE_LOCK       IS_FREE_LOCK
IS_USED_LOCK       MASTER_POS_WAIT

这些函数可能读取服务器文件、持有锁或故意占用连接。函数拒绝列表只是补充保护, 后续执行阶段仍应设置只读账号、超时和资源限制。

7. 第四层:建立上下文 Schema

验证器从 sql_context.tables 建立 SQLGlot Schema:

{
    "fact_order": {
        "order_amount": "decimal(18,2)",
        "region_id": "bigint",
    },
    "dim_region": {
        "region_id": "bigint",
        "province": "varchar(64)",
    },
}

这个 Schema 不是从整个数据库读取,而是来自当前问题过滤和补全后的最小上下文。 所以真实数据库里存在、但没有进入本次 sql_context 的字段仍然不能使用。

8. 第五层:验证真实业务表和数据库名

SQL 中的 exp.Table 不一定都是物理表。例如:

WITH region_total AS (
    SELECT region_id, SUM(order_amount) AS total
    FROM fact_order
    GROUP BY region_id
)
SELECT SUM(total) FROM region_total;

region_total 是 CTE,不应该拿它去和 MySQL 物理表白名单比较。因此项目使用 traverse_scope() 分析作用域,只检查 scope.sources 中真正的 exp.Table

同时检查数据库限定符:

SELECT order_amount FROM other_database.fact_order;

即使表名是允许的,只要数据库不是上下文中的 dw,仍然会拒绝。

9. 第六层:字段白名单和歧义字段检查

项目使用 SQLGlot 的 qualify()

qualified = qualify(
    expression.copy(),
    dialect="mysql",
    schema=schema,
    quote_identifiers=False,
    identify=False,
    validate_qualify_columns=True,
)

它会利用上下文 Schema:

  • 解析表别名。
  • 给可确定来源的字段补全所属表。
  • 验证字段是否存在。
  • 识别多表中的歧义字段。
  • 理解 CTE 输出别名和派生字段。

例如下面的 SQL 会失败,因为 password 不在本次上下文:

SELECT password FROM fact_order;

下面的 SQL 也会失败,因为两个表都有 region_id,但没有限定来源:

SELECT region_id
FROM fact_order
JOIN dim_region ON fact_order.region_id = dim_region.region_id;

10. 第七层:验证 JOIN relationships

只检查表字段存在还不够。下面的 JOIN 语法正确、字段也都存在,但业务关系是 错误的:

JOIN dim_region AS dr
  ON fo.order_amount = dr.region_id

验证器按每个 SQL 作用域建立“别名 → 业务表 ID”映射,然后从 ON 中提取 字段等式,并与 sql_context.relationships 比较:

允许:fact_order.region_id = dim_region.region_id
实际:fact_order.order_amount = dim_region.region_id
结果:拒绝

同时禁止:

  • CROSS JOIN
  • 缺少 ON 的 JOIN
  • 使用逗号连接多张表
  • 多张物理表存在但没有显式 JOIN

当前关系模型是一对字段的等值关系,适合本项目现有的主键/外键推断。以后如果 需要复合主键、范围连接或桥表附加条件,应把关系元数据升级为结构化条件,而 不是继续扩充字符串判断。

11. 第八层:使用真实 MySQL EXPLAIN

静态校验全部通过后,SQLValidationRepository 执行:

await connection.exec_driver_sql(f"EXPLAIN {statement}")

这里使用普通 EXPLAIN,不是 EXPLAIN ANALYZE

  • 普通 EXPLAIN 让 MySQL 解析和优化 SQL,返回预计执行计划。
  • EXPLAIN ANALYZE 会真实执行查询并统计运行信息,不适合验证阶段。

使用 exec_driver_sql() 而不是 session.execute(text(...)),是为了避免 SQL 字符串值中的冒号被 SQLAlchemy 当成命名绑定参数。

MySQL 可以补充 SQLGlot 不知道的真实环境问题,例如:

MySQL EXPLAIN 校验失败:
(1305, 'FUNCTION dw.UNKNOWN_FUNC does not exist')

这类错误会作为 validation_errors 交给修正节点。

12. 区分 SQL 错误和基础设施错误

不是所有 MySQL 异常都应该让 LLM 修 SQL。项目按错误码识别以下故障:

  • 2002、2003:无法连接。
  • 2006、2013、2055:连接断开。
  • 1044、1045:数据库或账号认证失败。
  • 1049:配置的数据库不存在。
  • 1142:没有 EXPLAIN/SELECT 权限。
  • 连接数和账号资源限制。

这些异常会直接向上抛出。让 LLM 根据“数据库连不上”改写 SQL 既浪费调用, 也不能解决问题。

13. 实现 SQLCorrectionService

修正服务仍使用 LCEL 流式链:

prompt = PromptTemplate.from_template(
    self.prompt_loader.load("correct_sql")
)
chain = prompt | self.chat_model | StrOutputParser()

但它和初次生成的目标不同:

generate_sql:根据业务上下文从零生成 SQL
correct_sql: 根据明确错误,对现有 SQL 做最小修改

所以修正 Prompt 必须同时看到:

  • 精简后的 SQL 上下文。
  • 当前候选 SQL。
  • 确定性校验错误列表。
  • 当前是第几次修正。

如果没有 SQL 或错误列表,服务直接拒绝调用模型,避免无依据重写。

14. 改写 correct_sql.prompt

Prompt 只使用一个结构化变量:

【修正上下文】
{correction_context}

核心要求包括:

  1. 逐项解决 validation_errors
  2. 只做最小必要修改。
  3. 保持指标、维度、值和时间范围不变。
  4. 仍然受表字段和 relationships 白名单约束。
  5. 只输出一条只读 SQL 纯文本。

Prompt 约束只是提高修正成功率。修正后的输出仍不可信,所以必须重新校验。

15. 实现 validate_sql 节点

节点调用验证服务后,计算是否还能修正:

correction_attempts = state.get("correction_attempts", 0)
can_correct = (
    not result.is_valid
    and correction_attempts < dependencies.max_correction_attempts
)

然后返回:

{
    "validation_errors": result.errors,
    "can_correct": can_correct,
    "error": result.error_message,
}

error 是现有条件路由使用的汇总字符串,validation_errors 是修正 Prompt 使用 的结构化列表,两者用途不同。

16. 实现 correct_sql 节点

修正节点先递增次数:

correction_attempt = state.get("correction_attempts", 0) + 1

流式生成时继续发送 SSE:

{
  "type": "llm_token",
  "step": "correct_sql",
  "content": "SELECT"
}

完整结果到达后发送:

{
  "type": "sql",
  "step": "correct_sql",
  "data": {
    "sql": "SELECT ...",
    "correction_attempt": 1
  }
}

节点只写回候选 SQL,不负责判断修正是否成功。

17. 修改 LangGraph 路由

验证后的路由现在有三个结果:

def route_after_validation(state: State) -> str:
    if state.get("error") is None:
        return "run_sql"
    return "correct_sql" if state.get("can_correct", False) else "end"

图中的边改为:

.add_edge("generate_sql", "validate_sql")
.add_conditional_edges(
    source="validate_sql",
    path=route_after_validation,
    path_map={
        "run_sql": "run_sql",
        "correct_sql": "correct_sql",
        "end": END,
    },
)
.add_edge("correct_sql", "validate_sql")

不能再使用旧逻辑:

correct_sql → run_sql

否则修正模型只要输出一条危险 SQL,就能绕过所有校验。

18. 注入依赖

FastAPI lifespan 中复用已有 ChatModel 和 dw_database

sql_validation_dependencies = SQLValidationDependencies(
    validation_service=SQLValidationService(
        repository=SQLValidationRepository(dw_database),
    ),
    correction_service=SQLCorrectionService(
        chat_model=chat_model_resources.model,
        prompt_loader=prompt_loader,
    ),
    max_correction_attempts=(
        app_config.sql_validation.max_correction_attempts
    ),
)

验证 Repository 复用数仓连接池,不为每次校验重复创建 Engine。修正服务复用 ChatModel 的异步 HTTP 连接池。

19. 测试结果

运行:

UV_CACHE_DIR=/tmp/n2sql-uv-cache uv run python -m unittest discover -s tests/unit -v

当前结果:

Ran 40 tests
OK

新增测试覆盖:

  • 合法只读 SQL 会进入 EXPLAIN。
  • 多语句和写操作不会访问数据库。
  • 上下文之外的表字段被拒绝。
  • 错误 JOIN 被拒绝。
  • SELECT * 被拒绝而 COUNT(*) 被允许。
  • 危险函数和会话变量赋值被拒绝。
  • CTE 派生字段可以通过校验。
  • MySQL 语句错误会进入修正链路。
  • MySQL 连接错误不会被当成 SQL 错误。
  • 修正服务可以流式输出并清理 Markdown 代码块。
  • 图只会把验证通过的 SQL 路由到执行节点。

20. 真实 MySQL 验证

本项目已经使用本地 dw MySQL 执行普通 EXPLAIN。

合法 SQL:

SELECT SUM(fo.order_amount) AS sales_amount
FROM fact_order AS fo
JOIN dim_region AS dr
  ON fo.region_id = dr.region_id
WHERE dr.province = '北京市';

结果:

is_valid: true
errors: []

再验证一个 SQLGlot 能解析、但 MySQL 没有对应函数的 SQL:

SELECT UNKNOWN_FUNC(order_amount)
FROM fact_order;

结果:

is_valid: false
MySQL EXPLAIN 校验失败:
(1305, 'FUNCTION dw.UNKNOWN_FUNC does not exist')

这说明静态验证和真实数据库验证分别发挥了作用。

真实 LLM 修正没有在本文环境中调用,因为修正请求会把表、字段、错误 SQL 和 业务问题发送到 LLM_BASE_URL。确认模型端点的数据策略后,可以通过完整 API 链路联调修正效果;本地单元测试不会发送这些信息。


第二部分:相关科普

科普 SQL 语法正确、业务正确和安全

这三个概念不是一回事:

语法正确:数据库可以解析 SQL
业务正确:查询真正回答了用户问题,指标和 JOIN 口径正确
执行安全:SQL 的权限、资源消耗和数据范围受控

EXPLAIN 通过只能证明 MySQL 能建立计划,不能证明销售额口径正确。 relationships 校验能确认 JOIN 使用了允许字段,也不能证明召回阶段选对了表。

所以 Text-to-SQL 必须是多阶段系统,而不是寻找一个“万能验证函数”。

科普 AST

AST 是 Abstract Syntax Tree,中文常叫抽象语法树。它把 SQL 文本转换成有 层级和类型的结构。

例如:

SELECT SUM(order_amount) FROM fact_order;

可以简化理解为:

Select
├── expressions
│   └── Sum
│       └── Column(order_amount)
└── From
    └── Table(fact_order)

程序可以判断某个节点是真正的表、字段还是函数,而不是只看字符串中有没有 某个单词。

科普为什么不用正则验证 SQL

正则适合简单文本格式,不适合完整 SQL 语法。例如:

SELECT 'DELETE FROM fact_order' AS example
FROM fact_order;

字符串中出现了 DELETE,但它只是普通值。反过来,危险语法也可能藏在 注释、CTE、嵌套语句或方言写法中。

正则还很难正确处理:

  • 引号和转义字符。
  • 子查询和括号。
  • CTE。
  • 表别名和字段别名。
  • 多条语句。
  • MySQL 特有语法。

因此正则可以做显示层清理,但不应该成为 SQL 安全验证器。

科普 SQL 方言

SQL 有共同标准,但 MySQL、PostgreSQL、SQLite、SQL Server 等数据库仍有 不同函数、日期语法、分页语法和标识符规则。

SQLGlot 解析时指定:

sqlglot.parse(sql, read="mysql")

MySQL EXPLAIN 又用真实数据库做第二次确认。当前项目验证器明确只支持 MySQL; 以后增加其他数据库时,不能只改一个字符串,还需要替换 Repository、危险 语法规则和实际方言测试。

科普作用域、别名和 CTE

SQL 作用域决定一个名字在什么范围内有效:

WITH total AS (
    SELECT region_id, SUM(order_amount) AS amount
    FROM fact_order
    GROUP BY region_id
)
SELECT amount FROM total;

外层的 total 是 CTE,amount 是 CTE 输出字段;它们不是 MySQL 中独立保存 的真实表字段。验证器必须先理解作用域,再检查底层真正访问的是 fact_order.order_amount

科普白名单和黑名单

黑名单列举“不允许什么”,例如禁止 DELETEDROPSLEEP。它的问题 是很难列完所有危险写法。

白名单定义“只允许什么”,例如:

  • 只允许查询节点。
  • 只允许当前 sql_context 中的表字段。
  • 只允许当前 relationships 中的 JOIN。
  • 只允许当前数据库。

安全设计通常以白名单为主,再用危险函数黑名单补充。

科普 EXPLAIN 和 EXPLAIN ANALYZE

普通 EXPLAIN SELECT ... 主要让数据库解析、优化并给出预计计划,通常不会 读取完整业务查询结果。

EXPLAIN ANALYZE SELECT ... 会真实执行查询,再提供实际耗时和行数。它适合 人工性能分析,不适合作为未信任 SQL 的验证方式。

即使普通 EXPLAIN 不运行完整查询,它仍需要数据库连接、权限和优化器资源, 也不能代替前置 AST 安全检查。

科普错误驱动修正

让模型直接“再写一次”SQL,可能得到完全不同的查询。错误驱动修正则把确定性 错误作为约束:

原 SQL
+ 字段 order_amount_total 不存在
+ 只能使用 fact_order.order_amount
→ 最小修改字段名

错误越明确,模型越容易只修改真正的问题。错误信息应该描述可操作事实,不应 只说“SQL 不对”。

科普有限重试

LLM 修正不是确定性过程,可能:

  • 没有修改错误。
  • 修好一个问题又引入另一个问题。
  • 在两个错误写法之间来回变化。
  • 模型服务持续返回同一内容。

没有上限的“修正 → 校验”会形成无限循环,持续占用模型费用和请求资源。因此 项目默认最多两次,并把次数放在 State 中,让整个工作流可观察。

科普纵深防御

当前验证器只是执行安全链中的一层。生产环境还应该:

  • 使用只读 MySQL 账号。
  • 只授权允许查询的 Schema。
  • 设置查询超时。
  • 限制最大返回行数和响应大小。
  • 限制并发和数据库资源。
  • 对 SQL、校验结果、耗时和调用者做审计。
  • 必要时在只读副本或隔离查询引擎执行。

即使 AST 校验代码存在遗漏,数据库权限仍应阻止写入;即使查询合法,超时和 行数限制仍应控制资源。这就是纵深防御。


本文小结

本文完成了:

  • 安装 SQLGlot,并按 MySQL 方言解析 SQL AST。
  • 校验单语句、只读节点、星号、危险函数和会话操作。
  • 使用 SQL 上下文构建表字段白名单。
  • 正确区分物理表、别名和 CTE。
  • 验证 JOIN 必须来自 relationships。
  • 使用真实 MySQL 普通 EXPLAIN 做最终语法和环境检查。
  • 区分可修正的 SQL 错误与不可修正的基础设施错误。
  • 新增流式 SQL 修正服务和专用 Prompt。
  • 修正后重新进入完整验证链。
  • 设置最大修正次数,失败后安全结束。
  • 通过 40 个单元测试和真实 MySQL EXPLAIN 验证。

下一篇实现 run_sql 时,仍需要加入只读事务、超时、最大行数、结果序列化和 取消请求等执行阶段保护。验证通过不是放开执行限制的理由。