前面两篇站内教程已经讲清怎么让模型生成 SQL。但生成出来的 SQL 一旦接上真库直接执行,风险立刻放大:注入、越权读敏感表、结果过大把内存打爆。本教程在「生成 SQL」之上补上生产闭环:只读账号、SQL 白名单、结果封顶与脱敏,让数据库 Agent 能安全落地。
Step 1:准备一个「只有读权限」的账号
不要用业务主库账号连 Agent。建一个只授 SELECT 的只读角色,连一个副本或只读实例。即使账号泄露,攻击者也只能读、不能改,把爆炸半径压到最小。
-- PostgreSQL 示例:只读角色
CREATE ROLE agent_ro LOGIN PASSWORD '强密码';
GRANT CONNECT ON DATABASE shop TO agent_ro;
GRANT USAGE ON SCHEMA public TO agent_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_ro;
-- 敏感表(用户、支付)不授权,Agent 根本看不到
账号层的「最小权限」是最后一道墙。就算后面所有代码闸都漏了,只读账号也写不进数据、读不到未授权表。这一步不能省。
Step 2:只让模型生成 SELECT,且只针对白名单表
系统提示明确只允许查询指定表,禁止任何写操作与系统表。这一步是「软约束」:靠模型听话,不是硬保险,所以必须配合第三步的代码层白名单校验,两层一起才稳妥。提示里把允许的范围写得越具体(具体到表名、列名),模型越不容易跑偏。
SYSTEM = '''你是一个只读查询助手。
只允许生成 SELECT 语句,查询范围仅限表:orders、products、users(仅 id 与 city 两列)。
禁止 INSERT/UPDATE/DELETE/DROP,禁止查询 information_schema、payment、password 相关列。
只输出一条 SQL,不要解释。'''
def ask_sql(question):
resp = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "system", "content": SYSTEM}, {"role": "user", "content": question}])
return resp.choices[0].message.content.strip()
Step 3:执行前用白名单兜底校验 SQL
再严格的提示也可能被绕过(比如用注释、Union 偷渡)。执行前用正则做硬校验:必须 SELECT 开头、只允许白名单表、禁止多语句与危险关键字。
import re
ALLOWED_TABLES = {"orders", "products", "users"}
FORBID = re.compile(r"\b(insert|update|delete|drop|alter|union|information_schema|password)\b", re.I)
def safe_sql(sql):
s = sql.lower().replace(";", " ").strip()
if not s.startswith("select"):
raise ValueError("只允许 SELECT")
if FORBID.search(s):
raise ValueError("命中禁词或禁表")
tables = set(re.findall(r"from\s+([a-z_]+)", s)) | set(re.findall(r"join\s+([a-z_]+)", s))
if not tables <= ALLOWED_TABLES:
raise ValueError(f"超出白名单表:{tables - ALLOWED_TABLES}")
return sql
白名单校验是「纵深防御」的一环,不是装饰。提示词越权防护加代码硬校验,两层都过才执行。正则校验无法覆盖所有 SQL 注入手法,所以账号层的只读权限仍是终防。
Step 4:强制 LIMIT,给结果封顶
用户问「所有订单」,模型很可能漏写 LIMIT,或者写出没有上限的聚合查询。执行前统一补一个上限,结果行数再做二次封顶。这既保护内存,也避免一次返回几十万行把下游渲染或模型上下文直接撑爆。封顶值视表大小而定,宁可少返回让模型追问,也不要贪多。
def with_limit(sql, cap=200):
if "limit" not in sql.lower():
sql = sql.rstrip(";") + f" LIMIT {cap}"
return sql
def run_query(sql):
sql = safe_sql(sql)
sql = with_limit(sql)
cur = conn.execute(sql)
rows = cur.fetchall()
if len(rows) > 200:
rows = rows[:200] # 二次封顶,保护内存
return rows
Step 5:结果脱敏,敏感字段打码
users 表即便只暴露 id 与 city,也可能和别处的公开信息组合出可识别个体。对手机号、邮箱、身份证这类字段,统一打码后再返回给模型或用户。脱敏要放在「返回给模型之前」:模型做聚合统计并不需要真实号码,打码后的数据足够它回答「哪个城市下单最多」这类问题。
def mask(value):
s = str(value)
if "@" in s: # 邮箱
return s[0] + "***@" + s.split("@")[-1]
if s.isdigit() and len(s) >= 7: # 手机号
return s[:3] + "****" + s[-2:]
return s
def mask_row(row):
return tuple(mask(v) for v in row)
Step 6:串成 Agent 工具,端到端跑一句
把「生成 SQL 到 校验 到 加 LIMIT 到 执行 到 脱敏 到 封顶」收成一个工具,Agent 就能用自然语言查库。
def db_tool(question):
try:
sql = ask_sql(question)
sql = safe_sql(sql)
sql = with_limit(sql)
rows = run_query(sql)
return {"sql": sql, "rows": [mask_row(r) for r in rows], "count": len(rows)}
except Exception as e:
return {"error": str(e), "rows": []}
print(db_tool("上个月销售额 top 5 的商品"))
生产里这一步还要补「审计日志」:记下来谁、在什么时间、查了什么 SQL、返回多少行。出问题时审计日志比任何护栏都管用。
Step 7:上线前再核对三件事
上线核对清单:
1. 账号是否只读、是否只连授权表(账号层)
2. SQL 是否过白名单校验、是否强制 LIMIT(代码层)
3. 结果是否脱敏、是否记审计日志(数据层)
绝对不要用字符串拼接把模型的 SQL 直接丢给 execute,更不要给 Agent 一个能写库的账号。哪怕只是「内部工具」,一旦接上真实业务数据,越权与泄露的代价都很高。