自然语言转 SQL 的麻烦,通常不是「写错一个列名」,而是一次幻觉就能扫全表、改数据,或把连接和 I/O 打满。本文用 Node.js 和 PostgreSQL 搭一条查询代理:只读事务、语句类型白名单、表允许清单、EXPLAIN 成本上限,再加上结果截断与语句超时。目标很明确——智能体只能在预算内读到它该读的数据,而不是拿到数据库的方向盘。
为什么不能把连接直接交给模型
把「会写 SQL 的智能体」接到业务库上,失败模式比聊天演示里看到的要脏得多。模型可能把 UPDATE 写成 SELECT 的近亲,也可能在本该过滤的地方漏掉 WHERE;更常见的是生成 SELECT * 去打一张宽表、历史表或日志表。语句本身合法,业务上却不可接受。
只禁写还不够。只读查询照样能拖垮库:缺失条件的连接、意外的笛卡尔积、对大表的顺序扫描,都会在执行器里变成真实的 CPU、磁盘和锁等待。智能体还有重试习惯,同一条坏 SQL 被连跑几次,比人肉误操作更伤。
所以正确的边界不是「让模型小心点」,而是:模型只负责提出问题,真正碰数据库的只有你自己的代理。 代理决定语句能不能进执行器,以及最多准许它花多少估算成本和返回多少行。
门禁要叠几层,而不是赌解析器
单一检查很容易被绕过。注释里藏关键字、字符串里夹语句、用 CTE 包装写入,都是老套路。更稳妥的做法是把控制拆开,让每一层只做自己擅长的事:
- 账号层:独立的只读角色,只有目标表的
SELECT,没有写入、没有函数类超级权限。 - 语句层:整段文本只允许单条
SELECT(含WITH查询),拒绝多语句和明显的写入/会话命令。 - 对象层:
FROM/JOIN出现的表必须在允许清单里;默认拒绝SELECT *。 - 计划层:先
EXPLAIN,用优化器给出的Total Cost做上限,超了就拒绝执行。 - 运行层:外层强制
LIMIT,事务内设置statement_timeout,连接池也要小。
文本层的白名单可以用保守规则先做,但它不是解析器,替代不了只读账号。正则看不懂引号嵌套,也处理不好所有方言。只读角色和超时是最后的物理刹车;白名单和 EXPLAIN 则用来把明显离谱的语句挡在执行之前,并给模型一条可理解的错误,让它改写而不是死磕。
PostgreSQL 的代价是优化器里的无量纲估算,不是毫秒。同一套 random_page_cost、统计信息和数据量下,它才有比较意义。阈值要按你自己的库标定:先对一批可接受查询看 Total Cost 的量级,再把上限设在「明显异常」的一侧,而不是抄一个通用数字。
用 Node.js 实现一条可运行的查询代理
下面这份代码只依赖官方驱动 pg,用环境变量连接 PostgreSQL。先安装:
npm init -y
npm install pg
完整文件 query-proxy.js:
const { Pool } = require('pg');
const MAX_COST = Number(process.env.AGENT_MAX_COST || 20000);
const MAX_ROWS = Number(process.env.AGENT_MAX_ROWS || 100);
const STATEMENT_TIMEOUT_MS = Number(process.env.AGENT_TIMEOUT_MS || 2000);
const ALLOWED_TABLES = new Set(
(process.env.AGENT_TABLES || 'orders,customers,products')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
);
const pool = new Pool({
host: process.env.PGHOST || '127.0.0.1',
port: Number(process.env.PGPORT) || 5432,
user: process.env.PGUSER || 'agent_readonly',
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE || 'shop',
max: 5,
idleTimeoutMillis: 10000,
connectionTimeoutMillis: 3000,
});
const FORBIDDEN = /\b(insert|update|delete|merge|drop|alter|truncate|create|grant|revoke|copy|call|do|execute|prepare|deallocate|listen|notify|load|lock|vacuum|cluster|reindex|refresh|comment|set|reset|show|begin|commit|rollback|savepoint|release|into|returning|security)\b/i;
function stripComments(sql) {
return sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
}
function assertSingleSelect(sql) {
const text = stripComments(sql).trim();
if (!text) throw new Error('SQL 为空');
if (/;/.test(text.replace(/;+\s*$/, ''))) {
throw new Error('只允许单条语句');
}
const body = text.replace(/;+\s*$/, '');
if (!/^(select|with)\b/i.test(body)) {
throw new Error('只允许 SELECT / WITH 查询');
}
if (FORBIDDEN.test(body)) {
throw new Error('语句包含不允许的关键字');
}
if (/\bfor\s+(update|share|no\s+key\s+update|key\s+share)\b/i.test(body)) {
throw new Error('不允许行锁子句');
}
if (/\bselect\s+\*\b/i.test(body)) {
throw new Error('不允许 SELECT *,请显式列出列');
}
return body;
}
function extractTables(sql) {
const names = [];
const re = /\b(?:from|join)\s+([a-zA-Z_][\w.]*)/gi;
let m;
while ((m = re.exec(sql)) !== null) {
names.push(m[1].replace(/"/g, '').split('.').pop().toLowerCase());
}
return names;
}
function assertTablesAllowed(sql) {
const tables = extractTables(sql);
if (tables.length === 0) {
throw new Error('无法识别 FROM/JOIN 表名');
}
for (const t of tables) {
if (!ALLOWED_TABLES.has(t)) {
throw new Error(`表不在允许清单中: ${t}`);
}
}
}
function wrapWithLimit(sql, limit) {
const n = Number.parseInt(String(limit), 10);
if (!Number.isFinite(n) || n < 1 || n > 1000) {
throw new Error('LIMIT 配置不合法');
}
return `SELECT * FROM (${sql}) AS _agent_q LIMIT ${n}`;
}
function readTotalCost(explainRow) {
let plan = explainRow['QUERY PLAN'];
if (typeof plan === 'string') plan = JSON.parse(plan);
const root = Array.isArray(plan) ? plan[0] : plan;
const cost = root && root.Plan && root.Plan['Total Cost'];
if (typeof cost !== 'number') {
throw new Error('EXPLAIN 结果中没有 Total Cost');
}
return cost;
}
async function execute(sql) {
const body = assertSingleSelect(sql);
assertTablesAllowed(body);
const wrapped = wrapWithLimit(body, MAX_ROWS);
const client = await pool.connect();
try {
await client.query('BEGIN READ ONLY');
await client.query(`SET LOCAL statement_timeout = ${STATEMENT_TIMEOUT_MS}`);
const explained = await client.query(`EXPLAIN (FORMAT JSON) ${wrapped}`);
const cost = readTotalCost(explained.rows[0]);
if (cost > MAX_COST) {
await client.query('ROLLBACK');
return {
ok: false,
error: `估算成本 ${cost} 超过上限 ${MAX_COST},请增加过滤条件或缩小范围`,
};
}
const result = await client.query(wrapped);
await client.query('COMMIT');
return {
ok: true,
cost,
rowCount: result.rowCount,
rows: result.rows,
};
} catch (err) {
try {
await client.query('ROLLBACK');
} catch (_) {
/* 连接可能已断开 */
}
return { ok: false, error: err.message };
} finally {
client.release();
}
}
async function main() {
const sql = process.argv.slice(2).join(' ') ||
'SELECT id, customer_id, amount FROM orders ORDER BY id DESC';
const out = await execute(sql);
console.log(JSON.stringify(out, null, 2));
await pool.end();
}
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
module.exports = { execute, pool };
库侧至少要有一个不能写的角色。用超级用户执行类似语句(表名按你的库改):
CREATE ROLE agent_readonly LOGIN PASSWORD 'replace-me';
GRANT CONNECT ON DATABASE shop TO agent_readonly;
GRANT USAGE ON SCHEMA public TO agent_readonly;
GRANT SELECT ON TABLE orders, customers, products TO agent_readonly;
本地可这样跑:
export PGPASSWORD='replace-me'
export AGENT_TABLES='orders,customers,products'
node query-proxy.js "SELECT id, amount FROM orders WHERE amount > 100"
语句白名单在拦什么
assertSingleSelect 做的是失败则拒绝,而不是试图「修」模型的 SQL。去掉块注释和行注释后,只接受以 SELECT 或 WITH 开头的一条语句;分号出现在中间直接拒绝,避免 SELECT 1; DROP TABLE ... 这类拼接。关键字表覆盖常见 DDL/DML 和会话命令,并单独禁止 SELECT INTO、FOR UPDATE 这类会建表或加锁的读法。
这套规则会误伤一些合法写法(例如列名碰巧叫 grant),这是刻意的。代理面向智能体,白名单窄一点,比漏掉一条写入更合适。真正的写入防护仍然靠 BEGIN READ ONLY 和只读角色:即便文本检查漏了,执行阶段也会失败。
表清单与 SELECT *
FROM / JOIN 后的标识符用正则抽出最后一段作为表名,再和 ALLOWED_TABLES 比对。它认不出子查询别名以外的所有写法,也处理不好动态 SQL。够用的前提是:允许清单很小,且模型被要求只查这几张表。
SELECT * 默认禁止,原因很具体:宽表、大字段、以及后续在应用里把整行塞回上下文,都会把「一次查询」变成一次意外的数据泄露和 token 膨胀。要模型写出列名,错误信息也可以反过来当提示。
EXPLAIN 门禁怎么用
代理执行的是包了 LIMIT 之后的语句,EXPLAIN 也针对同一条。这样成本反映的是即将跑的计划,而不是模型原文。PostgreSQL 在 EXPLAIN (FORMAT JSON) 的 Plan.Total Cost 里给出整树估算;pg 对 json 类型会解析成对象,代码里对字符串做了兼容。
超阈值时只返回错误,不执行。把估算成本回传给智能体是有用的:它比「查询失败」更容易被改写成加 WHERE、收窄时间范围或改走有索引的列。不要在这里自动重写 SQL,代理一改语义,责任就说不清了。
截断、超时和连接池
外层 LIMIT 由你控制,模型自己写 LIMIT 100000 也出不去。SET LOCAL statement_timeout 只作用于当前事务,避免把超时留在连接上污染池子。Pool 的 max 保持很小,防止智能体并发把会话打满。这些都是 PostgreSQL 和 pg 里长期存在的机制,不依赖额外框架。
接到智能体时只暴露这一层
在工具调用里,模型侧只应看到「一段 SQL 字符串」和「结构化的成功/失败」。连接串、角色名、允许清单的完整拷贝,都不进提示词。伪代码如下,真正的 SDK 按你用的模型服务对接即可:
const { execute } = require('./query-proxy');
async function run_readonly_sql({ sql }) {
const result = await execute(sql);
if (!result.ok) {
return { ok: false, message: result.error };
}
return {
ok: true,
cost: result.cost,
rowCount: result.rowCount,
rows: result.rows,
};
}
给模型的失败信息应当可执行:「表不在允许清单」比「permission denied」更容易让它换表;「估算成本超过上限」比语句超时更容易让它加条件。不要把服务器内部的连接错误原文全塞回去,以免泄露主机、角色或其它库名。
还有两件容易漏的事。第一,代理返回的行会进入后续上下文,MAX_ROWS 不仅是数据库保护,也是上下文保护。第二,标定 MAX_COST 时用只读副本或非高峰时间,先对若干条人工 SQL 跑 EXPLAIN (FORMAT JSON),看正常查询的成本落在哪一段,再把门禁放在异常值一侧。统计信息过期时成本会漂,这是优化器的已知行为,需要 ANALYZE 维护,而不是把阈值设到形同虚设。
总结
智能体写 SQL 的能力,不能等同于执行 SQL 的权限。一条实用的查询代理并不神秘:只读账号把写入从物理上关掉,白名单把语句形状收窄,表清单把可见对象锁死,EXPLAIN 的 Total Cost 挡住明显离谱的计划,外层 LIMIT 和 statement_timeout 限制实际伤害。本文的实现只用 pg 和 PostgreSQL 已有的事务、超时与 JSON 计划输出,可以直接跑,再按自己的表和成本分布改环境变量。
它仍然有边界。正则不是语法树,成本不是耗时,允许清单也要随表结构人工维护。把这几层叠在一起,目的不是宣称「模型不会再写错」,而是让写错的代价停留在一次被拒绝的工具调用里,而不是一次全表扫描或一次误更新。先把执行权收回来,再谈把自然语言转 SQL 做得更聪明。
评论