AI 参数化查询:让模型生成的 SQL 自动绑定参数防注入
大模型写 SQL 又快又准,但直接把 AI 的输出拼到数据库里执行,等于把家门钥匙挂在门口。今天聊聊怎么给 AI 生成的 SQL 套上一层"安检"。
一、外卖订单里的 SQL 注入启示录
先讲个真实场景。上周同事用 GPT 写了个查询接口,输入用户 ID 返回订单列表。前端传过来 userId=1001,模型生成:
SELECT * FROM orders WHERE user_id = 1001
一切正常,直到有人传了 userId=1001 OR 1=1 —- 模型老老实实拼了进去,瞬间全表数据全暴露了。
这就是 AI 生成 SQL 最典型的坑:模型只管语法正确,不管安全边界。传统后端开发里,参数化查询是常识,但 AI 生成的代码往往绕过这一层,直接把用户输入和 SQL 模板混合输出。这就像你雇了一个超聪明的厨师,你告诉他"来一碗面",结果他用什么料你完全没控制 —- 万一有人递进去的不是葱花而是刀片呢?
问题的本质在于,AI 看到的是一个"完整的字符串任务",它收到的是 请写 SQL 查询用户 1001 OR 1=1 的订单,它无法区分"1001"是数据,而"OR 1=1"是恶意的代码片段。要解决这个问题,不能指望模型自己变聪明 —- 得在它输出之后、数据库执行之前,加一层参数化转换。
二、参数化转换流水线的核心设计
我们的思路很简单:不要把 AI 的输出当最终 SQL,把它当"带占位符的模板"。整个流水线分三步走:
graph LR
A[用户自然语言请求] –> B[AI 生成带占位符的 SQL 模板]
B –> C[参数提取器: 分离 SQL 骨架与参数值]
C –> D[参数化执行器: 用连接器安全绑定]
D –> E[(数据库)]
style C fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#bbf,stroke:#333,stroke-width:2px
第一步,让模型输出带命名占位符的 SQL,而不是直接把值拼进去。这是 Prompt 工程的核心:
# 系统提示词设计:要求模型使用命名参数
SYSTEM_PROMPT = """
你是一个 SQL 生成助手。所有生成的 SQL 必须使用命名参数占位符格式,
冒号开头,例如 :user_id。不要将用户提供的具体值直接写入 SQL 语句。
示例:
输入:查询用户 1001 的订单
正确输出:SELECT * FROM orders WHERE user_id = :user_id
错误输出:SELECT * FROM orders WHERE user_id = 1001
"""
第二步,从模型输出中提取参数占位符和对应的值,构造安全的参数绑定:
import re
from typing import Dict, Tuple
def extract_params(sql_template: str, param_values: Dict[str, object]) -> Tuple[str, Dict[str, object]]:
"""
从 AI 生成的 SQL 模板中提取参数并准备安全绑定
设计意图:模型的输出是"骨架+提示",真正的参数值由调用方按规则注入,
而不是从用户原始输入中直接提取,这样切断了注入路径。
"""
# 匹配 :param_name 格式的占位符
placeholders = re.findall(r':(\\w+)', sql_template)
# 只提取在模板中出现、且在参数映射中有对应值的占位符
bound_params = {}
for ph in placeholders:
if ph in param_values:
bound_params[ph] = param_values[ph]
else:
# 占位符缺失对应值,属于异常情况,需要告警
raise ValueError(f"参数占位符 :{ph} 缺少对应的值映射")
return sql_template, bound_params
第三步,用数据库驱动自带的参数化接口执行,这是真正的安全底线:
import pymysql
from pymysql.cursors import DictCursor
def safe_execute(connection, sql_template: str, params: Dict[str, object]):
"""
使用 PyMySQL 的参数化查询接口安全执行 SQL
设计意图:底层驱动在协议层面分离了 SQL 结构与参数值,
即使参数中包含恶意字符串,也不会被解释为 SQL 语法的一部分。
这是防御 SQL 注入的最后一道也是最可靠的一道防线。
"""
with connection.cursor(DictCursor) as cursor:
# PyMySQL 使用 %(name)s 格式的命名参数
# 将 :param 占位符转换为 %(param)s 格式
py_sql = re.sub(r':(\\w+)', r'%(\\1)s', sql_template)
cursor.execute(py_sql, params)
return cursor.fetchall()
三、三重防护的协同工作原理
上面这三步不是简单的功能堆砌,而是层层递进的纵深防线:
Prompt 层防护:在生成阶段就引导模型输出安全格式。这层最弱,因为模型可能不听话,但它让后续处理有了统一的输入格式。
提取校验层防护:在模板处理阶段检查参数完整性。如果模型偷偷把值拼进了 SQL 而不是用占位符,这层就能抓到异常。比如检测到 SQL 中没有任何占位符,说明模型可能绕过了 Prompt 约束。
驱动层防护:在数据库通信阶段,驱动协议层面将 SQL 骨架和参数值分离传输。这是机制层面的保障 —- 即使前两层全失败了,这层也能兜底。
sequenceDiagram
participant User as 用户请求
participant LLM as 大模型
participant Filter as 参数提取层
participant DB as 数据库
User->>LLM: "查询用户 1001 OR 1=1 的订单"
LLM->>Filter: SELECT * FROM orders WHERE user_id = :user_id
Note over Filter: 提取占位符 user_id<br/>匹配参数值 "1001 OR 1=1"
Filter->>DB: SELECT * FROM orders WHERE user_id = %(user_id)s<br/>params = {"user_id": "1001 OR 1=1"}
Note over DB: 参数化绑定<br/>"OR 1=1" 被当作字符串值<br/>不会产生注入效果
DB->>Filter: 返回空结果(无匹配用户)
四、生产中要避开的三个坑
第一个坑:占位符格式不统一。有的数据库用 ?(SQLite),有的用 %s(MySQL),有的用 $1(PostgreSQL)。跨数据库时千万别在 Prompt 里写死一种格式。解法是 Prompt 里用统一的 :param 格式,在连接器适配层再做转换。比如用 SQLAlchemy 的 text() + bindparams() 自动适配。
第二个坑:动态表名和列名无法参数化。参数化绑定只能绑定值,不能绑定标识符。如果业务需要 AI 生成动态字段名(比如 SELECT :column FROM orders),请记住 —- 表名和列名必须走白名单校验:
# 白名单校验:只允许预定义的合法标识符
ALLOWED_COLUMNS = {"order_id", "user_id", "amount", "status", "created_at"}
def validate_identifier(column_name: str) -> str:
"""只允许白名单中的列名,其他一律拒绝"""
if column_name not in ALLOWED_COLUMNS:
raise ValueError(f"非法的列名: {column_name}")
return column_name
第三个坑:过度信任模型的一致性。同一套 Prompt,GPT-4 输出 :user_id,换 Claude 可能输出 ${user_id},换国产模型可能直接忽略指令。实际部署时,必须在提取层做格式兼容和兜底处理:
def normalize_placeholders(sql: str) -> str:
"""统一各种占位符格式为 :param 格式"""
# 兼容 ${param} 格式
sql = re.sub(r'\\$\\{(\\w+)\\}', r':\\1', sql)
# 兼容 {{param}} 格式(部分模型习惯)
sql = re.sub(r'\\{\\{(\\w+)\\}\\}', r':\\1', sql)
return sql
五、总结
AI 生成 SQL 不是原罪,把 AI 输出当成品直接执行才是。参数化查询这套机制在传统后端领域已经很成熟了,把它嫁接到 AI 生成 SQL 的流水线里,本质上就是让模型的输出从"完整的可执行语句"变成"模板骨架+参数映射",把安全的掌控权交还给数据库驱动。
落地建议:不管用哪家模型、哪种数据库,都按 Prompt 约束 → 占位符提取 → 驱动绑定三步走。尤其是第三步,一个 cursor.execute(sql, params) 就能拦住 99% 的注入攻击。别让模型自由发挥 —- 给它划定安全边界,比让它自己"学会谨慎"靠谱一万倍。



