高级 📋 7 个步骤 第 464 / 464 篇

把 Text2SQL 接上真实数据库:只读账号加 SQL 白名单加结果校验的安全闭环

站内已有教程讲清怎么让模型生成 SQL。但生成出来的 SQL 一旦接上真库直接执行,风险立刻放大:注入、越权读敏感表、结果过大打爆内存。本教程在生成 SQL 之上补上生产闭环:只读账号、SELECT 白名单、结果封顶与脱敏。

2026.09.22· 19 分钟阅读· 约 1579 字· 🐍 Python / 🗄️ SQLite / PostgreSQL

前面两篇站内教程已经讲清怎么让模型生成 SQL。但生成出来的 SQL 一旦接上真库直接执行,风险立刻放大:注入、越权读敏感表、结果过大把内存打爆。本教程在「生成 SQL」之上补上生产闭环:只读账号、SQL 白名单、结果封顶与脱敏,让数据库 Agent 能安全落地。

💡 演示用 SQLite(零依赖),PostgreSQL 只需换驱动与连接串。重点是那几道安全闸,和具体数据库无关。

Step 1:准备一个「只有读权限」的账号

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,且只针对白名单表

2 在提示词里钉死边界

系统提示明确只允许查询指定表,禁止任何写操作与系统表。这一步是「软约束」:靠模型听话,不是硬保险,所以必须配合第三步的代码层白名单校验,两层一起才稳妥。提示里把允许的范围写得越具体(具体到表名、列名),模型越不容易跑偏。

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()
💡 把白名单表名写进提示词时,最好再附上几行建表语句或字段说明,模型生成的 SQL 会更准,也更容易命中白名单,减少后续被正则拦下的概率。

Step 3:执行前用白名单兜底校验 SQL

3 模型的话不能照单全收

再严格的提示也可能被绕过(比如用注释、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,给结果封顶

4 别让一次查询把内存撑爆

用户问「所有订单」,模型很可能漏写 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
💡 LIMIT 封顶值按表大小定:小表 200 行够用,大表可能还要更低。宁可少返回让模型再追问,也别一次把内存拉爆。

Step 5:结果脱敏,敏感字段打码

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 工具,端到端跑一句

6 把前面所有闸接进一个工具函数

把「生成 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:上线前再核对三件事

7 安全是组合拳,少一环都不行
上线核对清单:
1. 账号是否只读、是否只连授权表(账号层)
2. SQL 是否过白名单校验、是否强制 LIMIT(代码层)
3. 结果是否脱敏、是否记审计日志(数据层)

绝对不要用字符串拼接把模型的 SQL 直接丢给 execute,更不要给 Agent 一个能写库的账号。哪怕只是「内部工具」,一旦接上真实业务数据,越权与泄露的代价都很高。

← 返回教程中心