15 | 安全执行 SQL 并返回查询结果

15 | 安全执行 SQL 并返回查询结果

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

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

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

本文目标

前面的节点已经完成:

text 复制代码
用户问题
  → 召回字段、指标和维度值
  → 过滤候选
  → 组装 SQL 上下文
  → 生成 SQL
  → 验证并有限修正 SQL

最后还差一步:执行已经通过验证的 SQL,并把数据库结果转换成前端可以通过 SSE 接收的 JSON 数据。

但是执行节点不能只是:

python 复制代码
await session.execute(sql)

即使 SQL 已经通过 AST 和 EXPLAIN 校验,仍然可能:

  • 返回几十万行,把内存和 SSE 响应撑大。
  • 扫描大量数据,长时间占用数据库连接。
  • 返回 Decimal、日期或二进制数据,导致 json.dumps() 失败。
  • 出现同名输出列,转换成字典时互相覆盖。
  • 因数据库状态变化,在真正执行时失败。
  • 因未来图结构调整,错误地绕过验证节点。

本文实现的受控执行流程:

text 复制代码
validate_sql
     │
     │ sql_validated = true
     ↓
run_sql 再次检查验证标记
     ↓
SQLGlot 添加 max_rows + 1 的内部 LIMIT
     ↓
SET TRANSACTION READ ONLY
     ↓
在应用超时内执行查询
     ↓
最多读取 max_rows + 1 行
     ↓
回滚只读事务
     ↓
序列化 Decimal / 日期 / bytes / 长文本
     ↓
通过 SSE 返回结果、截断信息和耗时

先看输入和输出

1. run_sql 的输入

执行节点从 LangGraph State 读取:

python 复制代码
{
    "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_validated": True,
    "validation_errors": [],
    "error": None,
}

其中真正决定是否可以进入执行节点的是:

python 复制代码
sql_validated is True and error is None

不能只判断 error is None,因为工作流初始 State 也可能暂时没有错误,但此时 SQL 还没有经过验证。

2. 查询成功时的 State 输出

python 复制代码
{
    "execution_result": {
        "columns": ["sales_amount"],
        "rows": [
            {"sales_amount": "279159.50"}
        ],
        "row_count": 1,
        "truncated": False,
        "truncated_cell_count": 0,
        "elapsed_ms": 36,
    },
    "error": None,
}

字段含义:

  • columns:按数据库返回顺序排列的列名。
  • rows:转换成 JSON 兼容类型的结果行。
  • row_count:实际返回给前端的行数,不是数据库结果总行数。
  • truncated:是否因系统行数上限丢弃了更多结果行。
  • truncated_cell_count:有多少个超长单元格被截断。
  • elapsed_ms:从开始执行到完成序列化前的近似毫秒数。

金额使用字符串返回是有意设计:

json 复制代码
{"sales_amount": "279159.50"}

如果把 MySQL DECIMAL 强制转成 JavaScript 浮点数,可能损失金额精度。

3. 查询成功时的 SSE 输出

json 复制代码
{
  "type": "result",
  "columns": ["sales_amount"],
  "data": [
    {"sales_amount": "279159.50"}
  ],
  "row_count": 1,
  "truncated": false,
  "truncated_cell_count": 0,
  "elapsed_ms": 36
}

继续保留 data 作为结果行,是为了让现有前端消费方式不需要改变;更完整的 元数据放在同一个事件的其他字段中。

4. 查询失败时的输出

真正执行时如果 MySQL 连接断开、表结构刚好发生变化或查询超时,会发送:

json 复制代码
{
  "type": "execution_error",
  "message": "MySQL 执行失败:..."
}

State 中记录:

python 复制代码
{
    "execution_result": {},
    "error": "MySQL 执行失败:...",
}

执行错误不会再次自动调用 LLM。因为 SQL 在 EXPLAIN 后又失败,可能是连接、 权限、并发变更或运行期资源问题,并不一定能靠改写 SQL 解决。

5. 没有验证标记时的输出

即使未来有人直接调用 run_sql 节点,只要缺少:

python 复制代码
{"sql_validated": True}

节点就会返回:

text 复制代码
SQL 尚未通过验证,拒绝执行

这是图路由之外的第二次防御性检查。


第一部分:项目实现

1. 设计执行配置

conf/app_config.yaml 中增加:

yaml 复制代码
sql_execution:
  max_rows: 1000
  timeout_seconds: 30
  max_cell_chars: 10000

分别限制三个不同维度:

text 复制代码
max_rows          最多向前端返回多少行
timeout_seconds   应用最多等待查询多少秒
max_cell_chars    单个文本或二进制值最多返回多少字符

对应的 Pydantic Settings:

python 复制代码
class SQLExecutionSettings(StrictSettings):
    max_rows: int = Field(default=1000, gt=0, le=10000)
    timeout_seconds: float = Field(default=30, gt=0, le=300)
    max_cell_chars: int = Field(default=10000, gt=0, le=1000000)

配置值自身也有边界,避免误把 max_rows 设置成几百万,或者把超时设置成 无限大。

2. 为什么 Repository 和 Service 要分开

执行功能分为:

text 复制代码
SQLExecutionRepository
  → 只负责 MySQL 连接、只读事务、内部 LIMIT、读取原始行、回滚

SQLExecutionService
  → 只负责应用超时、结果类型转换、列名去重、单元格截断、耗时

这样单元测试不需要真实连接 MySQL,就能独立验证超时和各种 Python/MySQL 类型的序列化。

3. 定义 Repository 原始结果

python 复制代码
@dataclass(frozen=True)
class RawSQLExecutionResult:
    columns: list[str]
    rows: list[tuple[Any, ...]]
    truncated: bool

Repository 暂时保留 Decimaldatetimebytes 等数据库原始值,不在数据 访问层决定 API 表现形式。

4. 使用 AST 添加内部 LIMIT

只在 Python 中调用:

python 复制代码
result.fetchmany(max_rows + 1)

并不能保证数据库少返回数据。一些驱动会先把整个结果集读取到客户端,再由 Python 截取前几行。

所以 Repository 在发送 SQL 前,使用 SQLGlot 给根查询添加 LIMIT:

python 复制代码
return expression.limit(fetch_limit, copy=True).sql(dialect="mysql")

默认 max_rows = 1000,实际查询上限是:

text 复制代码
1000 + 1 = 1001

多取的一行不返回给前端,只用来判断:

python 复制代码
truncated = len(fetched_rows) > max_rows

5. 不扩大用户原本的 LIMIT

如果模型已经生成:

sql 复制代码
SELECT region_id
FROM dim_region
LIMIT 10;

系统不能为了判断截断而把它改成 1001,否则会改变用户要求。Repository 会 检查原 LIMIT:

python 复制代码
if original_limit <= fetch_limit:
    保留原 LIMIT
else:
    改成 fetch_limit

示例:

text 复制代码
无 LIMIT       → LIMIT 1001
LIMIT 10       → LIMIT 10
LIMIT 100000   → LIMIT 1001

CTE 和 UNION 的 LIMIT 也加在整个根查询上,而不是错误地加到内部某张表。

6. 执行前再次解析 SQL

build_bounded_sql() 会再次确认:

  • 只有一条语句。
  • 根节点仍然是查询。
  • 可以按 MySQL 方言解析。

这不是替代上一篇的完整验证,而是执行层的防御性编程。执行 Repository 不应 假设所有调用者永远只会从当前 LangGraph 路径进入。

7. 开启 MySQL 只读事务

Repository 获取共享 Engine 中的一条连接:

python 复制代码
async with self.database.engine.connect() as connection:
    await connection.exec_driver_sql("SET TRANSACTION READ ONLY")
    result = await connection.exec_driver_sql(execution_sql)

只读保护形成多层结构:

text 复制代码
第 1 层:SQLValidationService 只允许 Query AST
第 2 层:run_sql 要求 sql_validated = true
第 3 层:MySQL SET TRANSACTION READ ONLY
第 4 层:生产数据库账号只授予 SELECT 权限

其中第 4 层仍然最重要。如果应用代码将来出现漏洞,数据库账号本身没有写权限 才能形成真正的权限边界。

8. 查询成功也回滚

查询完成后执行:

python 复制代码
if connection.in_transaction():
    await connection.rollback()

虽然普通 SELECT 没有需要提交的数据,但回滚可以明确结束只读事务、释放一致性 读快照和相关资源。不能因为"只有查询"就把事务一直留在连接池中。

回滚放在 finally 中,因此查询异常时也会执行。

9. 使用 exec_driver_sql

和 EXPLAIN Repository 一样,执行层使用:

python 复制代码
await connection.exec_driver_sql(execution_sql)

当前 SQL 是系统生成且已经完整验证的 SQL,不需要 SQLAlchemy 再解释命名绑定 参数。这样还可以避免时间字符串中的冒号被 text() 识别为 :parameter

这不代表普通业务代码应该拼接用户输入。只有经过本项目整条生成和验证链的 SQL 才能走这个接口。

10. 设置应用等待超时

Service 使用 Python 3.12 的异步超时上下文:

python 复制代码
async with asyncio.timeout(self.timeout_seconds):
    raw_result = await self.repository.execute(sql, self.max_rows)

超时后转换为可读错误:

text 复制代码
SQL 执行超过 30 秒,已取消等待

应用超时解决的是"当前请求不再无限等待"。它不能保证 MySQL 服务端在同一 毫秒立即停止所有工作。生产环境还应该配置 MySQL 的语句执行时间限制、查询 代理限制或资源组,并确认驱动取消时会关闭或废弃对应连接。

11. 转换 Decimal

MySQL 的 DECIMAL(18,2) 通常被 asyncmy/SQLAlchemy 转成 Python Decimal

错误做法:

python 复制代码
float(Decimal("9999999999999999.99"))

浮点数无法精确表示所有十进制金额。因此项目返回:

python 复制代码
format(value, "f")

最终 JSON 中是字符串:

json 复制代码
{"amount": "9999999999999999.99"}

前端显示金额时可以保留精度;需要计算时应使用十进制定点库,而不是直接使用 JavaScript Number

12. 转换日期和时间

以下 Python 类型调用 isoformat()

text 复制代码
datetime → 2026-07-31T10:30:00
date     → 2026-07-31
time     → 10:30:00

ISO 格式比本地化字符串稳定,前后端也更容易解析。数据库中没有时区信息的 DATETIME 不会凭空增加时区,业务时区仍应由元数据和应用约定明确。

13. 转换二进制值

bytesbytearraymemoryview 不能直接交给 JSON 编码器,因此先转换为:

text 复制代码
base64:YWJj...

base64: 前缀告诉消费方这不是普通文本。超过单元格长度上限时会截断并以 省略号结尾,此时它只是预览,不再是可完整解码的 Base64 数据。

14. 限制单个单元格长度

即使只有一行结果,一个超大的文本或 BLOB 字段也可能占用大量响应内存。

python 复制代码
if len(value) > max_cell_chars:
    return value[:max_cell_chars] + "...", True

所有被截断的单元格数量记录到:

text 复制代码
truncated_cell_count

行截断和单元格截断是两个不同概念:

text 复制代码
truncated = true              还有更多结果行没有返回
truncated_cell_count > 0      已返回行中有值只返回了预览

15. 处理重复列名

SQL 可能返回:

sql 复制代码
SELECT fo.region_id, dr.region_id
...

数据库结果列名都是 region_id。直接转换为字典会让后一个值覆盖前一个值。

Service 会生成稳定列名:

text 复制代码
region_id
region_id_2
region_id_3

更推荐生成 SQL 时主动使用清晰别名,但执行层仍要防御重复输出列。

16. 定义最终执行结果

python 复制代码
@dataclass(frozen=True)
class SQLExecutionResult:
    columns: list[str]
    rows: list[dict[str, Any]]
    row_count: int
    truncated: bool
    truncated_cell_count: int
    elapsed_ms: int

to_dict() 让节点可以同时把相同结构写进 State 和 SSE。

17. 实现 run_sql 节点

节点首先检查明确验证标记:

python 复制代码
if state.get("sql_validated") is not True:
    return execution_error

随后调用 Service:

python 复制代码
result = await service.execute(state.get("sql", ""))

成功时推送 result,失败时推送 execution_error。节点只捕获预期的 SQLExecutionError;编程错误不会被静默伪装成普通数据库错误。

18. 扩展 State 和 RuntimeContext

State 增加:

python 复制代码
sql_validated: bool
execution_result: dict[str, Any]

RuntimeContext 增加:

python 复制代码
sql_execution: SQLExecutionDependencies

执行结果属于单次工作流状态;数据库 Engine、Repository、Service 和安全配置 属于应用级依赖。

19. 在 lifespan 中注入

python 复制代码
sql_execution_dependencies = SQLExecutionDependencies(
    service=SQLExecutionService(
        repository=SQLExecutionRepository(dw_database),
        max_rows=app_config.sql_execution.max_rows,
        timeout_seconds=app_config.sql_execution.timeout_seconds,
        max_cell_chars=app_config.sql_execution.max_cell_chars,
    )
)

它复用已经创建的 dw_database Engine 和连接池。应用退出时仍由 dw_database.close() 统一释放,不会为每次查询新建 Engine。

20. 删除全部占位逻辑

之前 nodes.py 中还有 _placeholder_node()asyncio.sleep(1),用于让前端 流程图在节点尚未实现时显示进度。

现在从召回到执行的全部节点都已有真实逻辑,因此删除该占位函数和无用的 asyncio 导入。执行节点不会再返回演示用的男女销售额数据。

21. 单元测试

运行:

shell 复制代码
UV_CACHE_DIR=/tmp/n2sql-uv-cache uv run python -m unittest discover -s tests/unit -v

当前结果:

text 复制代码
Ran 49 tests
OK

执行层新增测试覆盖:

  • SQL 会先进入只读事务。
  • 查询会添加 max_rows + 1 的服务端 LIMIT。
  • 更小的原始 LIMIT 会被保留。
  • 更大的原始 LIMIT 会被限制。
  • CTE 根查询可以正确添加 LIMIT。
  • 多语句和非查询语句会被执行层再次拒绝。
  • 多取的一行只用于判断截断。
  • Decimal、日期、时间、二进制和无限浮点数可以序列化。
  • 重复列名不会覆盖。
  • 超长单元格会记录截断数量。
  • 慢 Repository 会触发应用超时。
  • 没有明确验证标记的 State 不会路由到执行。
  • run_sql 会拒绝缺少验证标记的直接调用。

22. 真实 MySQL 验证

使用本地 dw MySQL,在只读事务中执行:

sql 复制代码
SELECT COUNT(*) AS region_count
FROM dim_region;

真实返回:

json 复制代码
{
  "columns": ["region_count"],
  "rows": [{"region_count": 6}],
  "row_count": 1,
  "truncated": false,
  "truncated_cell_count": 0,
  "elapsed_ms": 160
}

这次测试只读取聚合数量,没有读取维度明细;确认了当前 MySQL 与 asyncmy 环境 支持 SET TRANSACTION READ ONLY、受限查询、结果读取和回滚流程。

elapsed_ms 会随机器、连接池和数据库状态变化,不能把示例中的 160 当作固定 性能指标。


第二部分:相关科普

科普数据库连接、Session 和事务

这三个概念经常混在一起:

text 复制代码
Engine / 连接池
  → 管理多条数据库连接,通常是进程级长生命周期对象

Connection
  → 连接池借出的一条实际连接

Session
  → SQLAlchemy 面向 ORM 和业务操作的工作单元

Transaction
  → 一组具有提交或回滚边界的数据库操作

本项目执行的是模型生成的原生查询 SQL,不需要 ORM 对象跟踪,所以直接使用 异步 Connection 更清晰。

科普只读事务和只读账号

只读事务是连接当前事务的运行模式;只读账号是数据库授权系统中的长期权限。

text 复制代码
只读事务:这一次事务不应该写
只读账号:这个身份从权限上就不能写

应用代码可以被修改或出现漏洞,因此不能只依赖 SET TRANSACTION READ ONLY。 生产环境应该给 NL2SQL 使用独立账号,只授予必要 Schema 的 SELECT 权限。

科普为什么成功查询也要回滚

事务不只是为了写入。长时间不结束的只读事务可能持有一致性读快照,影响清理 旧版本和连接复用。

查询结束后显式回滚表达的是:

text 复制代码
这次工作单元已经结束,不保留任何事务状态。

对于只读查询,回滚不会撤销已经发送给客户端的结果。

科普 LIMIT 和 fetchmany 的区别

LIMIT 在数据库服务端参与查询计划和结果生成:

sql 复制代码
SELECT ... LIMIT 1001;

fetchmany(1001) 是 Python 驱动从结果游标获取多少行。只使用后者时,驱动或 数据库可能已经生成、传输甚至缓存了更多行。

因此本项目同时使用服务端 LIMIT 和客户端 fetchmany。

科普为什么多取一行

如果上限是 1000,只取 1000 行时无法区分:

text 复制代码
数据库刚好只有 1000 行
数据库其实有 50000 行,只返回了前 1000 行

取 1001 行即可判断:

text 复制代码
读取 ≤ 1000 行 → 没有证据表明被系统截断
读取 1001 行   → 返回前 1000 行,truncated = true

这是一种常见的分页和结果上限判断方法。

科普 LIMIT 不能解决所有性能问题

下面的聚合即使最终只返回一行,也可能扫描整个大表:

sql 复制代码
SELECT SUM(order_amount)
FROM fact_order;

外层 LIMIT 1001 无法减少聚合前的扫描量。类似地,大 OFFSET、排序、JOIN 和 窗口函数仍可能消耗大量资源。

所以还需要:

  • 合理索引和分区。
  • 查询超时。
  • 数据库资源组或查询代理。
  • 只读副本。
  • 慢查询审计。

科普应用超时和数据库超时

应用超时表示客户端不再等待;数据库超时由数据库服务器控制实际语句运行。

text 复制代码
应用超时:保护 API 请求和连接占用时间
数据库超时:限制数据库服务端工作时间

二者应该同时配置。仅取消 Python 协程,不应被理解为服务端已经百分之百立即 停止执行。

科普 JSON 序列化

JSON 原生类型只有:

text 复制代码
null、boolean、number、string、array、object

Python 的 Decimaldatetimebytes 不属于 JSON 原生类型,需要先约定 表示方式。序列化不仅是"让代码不报错",还涉及精度、时区和二进制协议。

科普浮点数和 Decimal

浮点数采用二进制近似表示,适合科学计算,但某些十进制金额不能精确表示。 Decimal 按十进制规则保存精度,更适合财务数据。

通过 JSON 字符串返回 Decimal,可以避免中间层自动变成低精度浮点数。API 契约需要明确告诉前端哪些字段是十进制字符串。

科普 Base64

Base64 使用可打印 ASCII 字符表示二进制数据,体积通常比原始 bytes 大约多 三分之一。它适合在 JSON 中传输小型二进制内容,不适合通过 SSE 返回大型文件。

大型 BLOB 应返回对象存储地址或使用专门下载接口。

科普结果截断和分页

本文的 truncated 只告诉用户"还有更多行",不提供下一页游标。对于自然语言 分析,大多数结果应该是小型聚合或 TopN,1000 行通常已经过多。

如果产品确实需要浏览明细,应单独设计:

  • 基于稳定排序字段的游标分页。
  • 页面大小上限。
  • 下一页 Token。
  • 数据权限和脱敏。

不要让 LLM 自己随意修改 OFFSET 来实现产品级分页。

科普 SSE 和大结果集

SSE 适合单向推送进度、模型文本片段和中小型结果。它不是大型数据导出协议。

大结果集会带来:

  • JSON 编码内存。
  • 网络响应时间。
  • 浏览器解析和渲染压力。
  • 客户端断开后的资源清理问题。

大规模导出应该转成异步任务,生成 CSV/Parquet 文件后提供下载地址。

科普纵深防御

完整 NL2SQL 安全链现在是:

text 复制代码
最小元数据召回
  → Prompt 只读约束
  → SQL AST 白名单
  → 表字段和 JOIN 校验
  → MySQL EXPLAIN
  → 显式 sql_validated 标记
  → 只读事务
  → 只读数据库账号
  → 行数、单元格、超时限制
  → 审计与监控

其中任何单独一层都不是绝对安全保证。多层限制让某一层出现遗漏时,后面的层 仍有机会阻止写入、越权读取或资源耗尽。


本文小结

本文完成了最后一个 run_sql 节点:

  • 新增 SQLExecutionRepository,在 MySQL 只读事务中执行查询。
  • 使用 SQLGlot 添加服务端结果上限,并保留更小的用户 LIMIT。
  • 多取一行判断结果是否截断。
  • 查询结束始终回滚事务。
  • 新增应用级异步超时。
  • 将 Decimal、日期、时间和二进制转换为 JSON 兼容值。
  • 限制超长单元格并处理重复输出列名。
  • 通过 SSE 返回结果、行数、截断状态和耗时。
  • 使用 sql_validated 防止绕过验证节点。
  • 删除最后的演示占位逻辑。
  • 通过 49 个单元测试和真实 MySQL 只读查询验证。

至此,当前版本的 NL2SQL 主流程已经完整连通:

text 复制代码
自然语言问题
  → 关键词与召回
  → 候选合并和过滤
  → SQL 上下文
  → 生成 SQL
  → 验证和有限修正
  → 受控执行
  → SSE 查询结果

后续进入生产化阶段时,优先补充数据库独立只读账号、服务端查询超时、用户级 数据权限、敏感字段脱敏、审计日志和可观测性。

相关推荐
新芒15 小时前
海尔洗衣机智慧洗护:AI赋能洗烘护全面进化
人工智能
掘金酱15 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
颜酱15 小时前
14 | 验证并修正 LLM 生成的 SQL
人工智能·python
AI创界者15 小时前
AIGC进阶】Sulphur-2 视频生成大模型离线实战:文生视频/图生视频本地一键部署整合包解压即用与调优指南
人工智能·aigc·音视频
颜酱16 小时前
13 | 使用 LangChain 生成 SQL
人工智能·python·langchain
LDZKKJ16 小时前
OpenAI暂停GPT-6训练:AI行业从“竞速“到“刹车“的分水岭
人工智能·gpt·语言模型·chatgpt·transformer
四方云16 小时前
录音转文字完整技术原理(ASR自动语音识别)技术文档
人工智能·机器人·语音识别·外呼系统·销售成长·拓客
奥莱维16 小时前
酒店客房智能控制如何提升睡眠与入住体验
大数据·人工智能
实验如有神祝女士16 小时前
不同参数规模大模型在医学翻译场景的适配差异
论文阅读·人工智能·深度学习·学习·算法·语言模型·论文笔记