14 | 验证并修正 LLM 生成的 SQL
每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。
这是一篇系列文,请按顺序阅读。
本文目标
上一篇已经让大模型根据 sql_context 生成了候选 SQL:
sql_context → generate_sql → sql
但是“模型生成了 SQL”不等于“SQL 可以安全执行”。候选 SQL 仍可能存在:
- SQL 语法错误。
- 一次生成多条语句。
- 生成
DELETE、UPDATE等非只读语句。 - 编造上下文中不存在的表和字段。
- 使用错误的 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_sql。corrected_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 无法连接。
- 用户名或密码错误。
- 数据库不存在。
- 连接中途断开。
- 当前账号没有
EXPLAIN或SELECT权限。
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}
核心要求包括:
- 逐项解决
validation_errors。 - 只做最小必要修改。
- 保持指标、维度、值和时间范围不变。
- 仍然受表字段和 relationships 白名单约束。
- 只输出一条只读 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。
科普白名单和黑名单
黑名单列举“不允许什么”,例如禁止 DELETE、DROP 和 SLEEP。它的问题
是很难列完所有危险写法。
白名单定义“只允许什么”,例如:
- 只允许查询节点。
- 只允许当前
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 时,仍需要加入只读事务、超时、最大行数、结果序列化和
取消请求等执行阶段保护。验证通过不是放开执行限制的理由。