
当大模型遇上企业数据,安全问题远比“会不会写SQL”更重要。本文将分享一套生产级NL2SQL系统,重点攻克恶意SQL注入、幻觉表名和结果不可控三大顽疾,并基于腾讯云原生组件实现全链路闭环。
目前开源的NL2SQL项目(如Vanna、Chat2DB)大多聚焦于“如何把问题翻译成SQL”,却在安全拦截和自愈能力上严重缺失。当用户问“帮我删掉订单表”或“把薪资字段减半”时,标准RAG流程会老老实实生成DELETE或UPDATE语句。
在企业真实环境中,直接执行LLM生成的SQL是绝对的高危行为。本文将分享一套我们在腾讯云上落地的三层漏斗式拦截架构:
同时,我们引入执行结果驱动的自愈机制,当SQL报错或返回空值时,自动触发混元大模型进行反思修正。
为了兼顾数据安全(敏感数据不出VPC)和模型效果,我们采用以下纯国内云原生架构:
组件 | 腾讯云产品选型 | 核心职责 |
|---|---|---|
大模型 | 腾讯混元大模型(hunyuan-pro) | 自然语言转SQL、异常日志复盘 |
向量存储 | 腾讯云向量数据库(VectorDB) | 存储数据字典、历史正确SQL的Embedding |
元数据中心 | 腾讯云TDSQL-MySQL | 存储表结构、字段注释、枚举值列表 |
沙箱执行 | 腾讯云函数(SCF) | 隔离环境执行SQL,超时强制销毁 |
数据源 | 腾讯云CDW(云数仓) | 只读从库,物理隔离写权限 |
核心流程:用户提问 → 向量检索召回相关表结构(Few-shot)→ 混元生成SQL → 安全拦截器过滤 → 若通过则提交SCF执行 → 结果异常则触发自愈重写(最多2次)。
我们不依赖简单的正则黑名单(容易被绕过),而是使用sqlparse + sqlglot解析语法树,白名单放行(仅允许SELECT),并对WHERE条件中的子查询和UNION进行递归检查。
import sqlglot
from sqlglot import exp
class SQLSafetyChecker:
FORBIDDEN_TOKENS = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'TRUNCATE', 'GRANT', 'REVOKE']
@staticmethod
def validate(sql: str) -> tuple[bool, str]:
"""
返回 (是否安全, 拒绝原因)
"""
try:
parsed = sqlglot.parse_one(sql, dialect="mysql")
except Exception as e:
return False, f"SQL语法解析失败: {str(e)}"
# 1. 检查顶层操作类型
if not isinstance(parsed, exp.Select):
return False, f"仅支持SELECT查询,禁止执行{parsed.key}操作"
# 2. 深度遍历,禁止多表UPDATE或子查询中的修改操作(部分数据库支持with update)
for node in parsed.walk():
if isinstance(node, exp.Update) or isinstance(node, exp.Delete):
return False, f"检测到高危操作: {node.key}"
# 检查表名是否包含敏感库(如mysql系统库)
if isinstance(node, exp.Table):
if node.name.lower() in ['mysql', 'information_schema', 'performance_schema']:
return False, "禁止查询系统元数据库"
# 3. 限制返回行数,强制添加LIMIT防止全表扫描拖垮数据库
if not parsed.args.get('limit'):
# 如果SQL没有LIMIT,我们自动追加(但不修改原SQL,在后续执行层强制注入)
pass
return True, "安全校验通过"
# 测试用例
print(SQLSafetyChecker.validate("SELECT * FROM users")) # (True, '安全校验通过')
print(SQLSafetyChecker.validate("DELETE FROM users WHERE id=1")) # (False, '仅支持SELECT...')企业宽表动辄上百列,全量塞进Prompt不现实且浪费Token。我们利用腾讯云向量数据库存储每个字段的语义向量,检索时只召回Top-5关联字段。
初始化向量集合(离线任务):
import tcvectordb
from tcvectordb.model.enum import FieldType, IndexType, MetricType
from tencentcloud.common import credential
from sentence_transformers import SentenceTransformer # 可使用本地BGE模型
# 初始化客户端(内网地址)
client = tcvectordb.VectorDBClient(url="http://your-vip.vectordb.tencentcloudapi.com",
username="root", key="your-key", timeout=30)
# 创建Collection(如果不存在)
collection = client.create_collection(
database_name="nl2sql_meta",
collection_name="field_embeddings",
shard=1,
replicas=1,
description="数据字典向量索引",
index_spec=[
{"field_name": "id", "field_type": FieldType.String, "index_type": IndexType.PRIMARY_KEY},
{"field_name": "vector", "field_type": FieldType.Vector, "index_type": IndexType.HNSW,
"dimension": 768, "metric_type": MetricType.COSINE},
{"field_name": "field_name", "field_type": FieldType.String},
{"field_name": "table_name", "field_type": FieldType.String},
{"field_name": "comment", "field_type": FieldType.String}
]
)
# 入库示例(实际需遍历所有表的INFORMATION_SCHEMA)
def index_field(field_name, table_name, comment, embedding_vector):
doc = {
"id": f"{table_name}_{field_name}",
"vector": embedding_vector,
"field_name": field_name,
"table_name": table_name,
"comment": comment
}
collection.upsert(documents=[doc])运行时检索(作为Prompt上下文):
def retrieve_relevant_schema(question: str, top_k=5):
# 调用混元Embedding接口或本地BGE
# 这里假设已有 embed(text) 函数
q_vector = embed(question)
res = collection.search(
vectors=[q_vector],
limit=top_k,
output_fields=["field_name", "table_name", "comment"]
)
# 组装成简洁的DDL描述
schema_lines = []
for doc in res[0]:
schema_lines.append(f"表 {doc['table_name']} 字段 {doc['field_name']} 含义: {doc['comment']}")
return "\n".join(schema_lines)我们将检索到的Schema、历史Few-shot样例、当前问题组装成Prompt,调用腾讯混元hunyuan-pro。
from tencentcloud.common import credential
from tencentcloud.hunyuan.v20230901 import hunyuan_client, models
import json
def call_hunyuan(prompt: str) -> str:
cred = credential.Credential("SECRET_ID", "SECRET_KEY")
client = hunyuan_client.HunyuanClient(cred, "ap-guangzhou")
req = models.ChatCompletionsRequest()
req.Model = "hunyuan-pro"
req.Messages = [{"Role": "user", "Content": prompt}]
req.Temperature = 0.1 # 降低随机性保证SQL确定性
resp = client.ChatCompletions(req)
return json.loads(resp.to_json_string())["Choices"][0]["Message"]["Content"]自愈闭环逻辑:
def execute_with_self_healing(question, df_schema, max_retries=2):
# 构建初始Prompt
base_prompt = f"""
你是一个严谨的Hive/MySQL专家。根据表结构生成标准SQL。
要求:
1. 只生成SELECT语句。
2. 不要使用中文别名,不要包含注释。
3. 若涉及时间分区,必须带分区过滤条件(若字段存在)。
表结构信息:
{df_schema}
用户问题:{question}
SQL:
"""
sql = call_hunyuan(base_prompt)
for attempt in range(max_retries + 1):
# 1. 安全校验
is_safe, reason = SQLSafetyChecker.validate(sql)
if not is_safe:
# 让混元反思:这是高危SQL,重写
fix_prompt = f"之前生成的SQL被安全系统拦截,原因:{reason}。请重新生成完全不同的、只包含SELECT的查询。原问题:{question}\n新SQL:"
sql = call_hunyuan(fix_prompt)
continue
# 2. 注入强制LIMIT(若没有)
import re
if not re.search(r'\bLIMIT\s+\d+', sql, re.IGNORECASE):
sql = sql.rstrip(';') + " LIMIT 100;"
# 3. 执行(调用腾讯云CDW API或SCF,这里模拟执行)
exec_result = execute_in_sandbox(sql) # 返回 {status, data, error_msg}
if exec_result['status'] == 'success':
return {"sql": sql, "data": exec_result['data']}
else:
# 自愈:把错误信息喂给混元
error_prompt = f"""
之前生成的SQL执行报错:
SQL: {sql}
错误信息: {exec_result['error_msg']}
请根据错误修正SQL,重新输出修正后的完整SQL。
用户问题:{question}
修正后的SQL:
"""
sql = call_hunyuan(error_prompt)
return {"sql": sql, "error": "超过最大重试次数,执行失败"}在生产环境(50张宽表,总字段数2000+)压测结果:
DBUtils),内存建议配置 1024MB,超时时间设 15秒(防止复杂Join拖垮性能)。SQL、是否命中自愈、耗时写入腾讯云日志服务(CLS),便于后续微调Prompt。本文没有空谈“未来趋势”,而是聚焦于NL2SQL落地中最棘手、最容易被忽视的安全与容错环节。通过AST语法树强校验、向量检索轻量化上下文、以及执行反馈驱动的自愈循环,我们在保证绝对安全的前提下,将查询成功率稳定在85%以上。
下一步计划:针对复杂多表Join的Schema Link关系,利用腾讯云图数据库(TGraph)存储外键关系,辅助混元进行多路径推理。
欢迎在评论区探讨你对SQL安全校验的更好思路。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。