Agent 工程体系 · 第 59/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

SQL Agent:Schema 上下文、只读约束、查询校验、成本和审计

SQL Agent 是能够把自然语言问题转换为 SQL、执行查询并解释结果的 Agent。它看起来像一个“自然语言转 SQL”功能,实际却是一个同时涉及语义理解、数据库权限、查询安全、资源治理、结果验证和审计追踪的系统。

如果只让模型生成 SQL,再把 SQL 直接交给数据库执行,系统至少存在五类风险:

  1. 模型理解错表、字段或业务口径;
  2. 生成 UPDATEDELETEINSERT、DDL 或多语句;
  3. 查询绕过租户、对象或行级权限;
  4. JOIN、排序、聚合或笛卡尔积导致数据库资源耗尽;
  5. 查询结果无法证明“为什么这样算”,审计时无法复原过程。

因此,SQL Agent 的核心不是“让模型会写 SQL”,而是建立一条可验证的执行链:

自然语言问题
    │
    ▼
身份与权限解析
    │
    ▼
按权限生成 Schema 上下文
    │
    ▼
模型生成查询计划和 SQL
    │
    ▼
SQL 解析、语义校验、权限校验
    │
    ▼
成本估计与执行策略判断
    │
    ▼
只读数据库会话执行
    │
    ▼
结果验证、引用元数据、回答用户
    │
    ▼
完整审计事件

Function Calling 只规定模型可以请求应用执行某个工具;真正的工具执行、参数校验、数据库权限和安全策略仍然必须由应用侧实现。OpenAI 对工具调用的描述也是一个多步循环:应用提供工具,模型产生工具调用,应用执行,再把工具结果返回给模型,模型随后生成最终回答或继续请求工具。(developers.openai.com)


一、先定义 SQL Agent 的执行边界

1. SQL Agent 不等于数据库管理员

SQL Agent 通常只应提供以下能力:

  • 查询已授权的数据;
  • 执行只读的 SELECT
  • 返回有限行数的结构化结果;
  • 生成聚合、排序和分页;
  • 对结果进行解释、制图或交给代码执行环境进一步分析。

它通常不应直接拥有:

  • 任意表的访问权限;
  • 任意 SQL 的执行权限;
  • 写入、删除和结构变更权限;
  • 读取数据库账号、密钥、个人敏感字段的权限;
  • 修改查询超时、内存、并发或资源组的权限。

数据库账号权限、Agent 工具权限和业务用户权限是三层不同的权限:

用户权限 ≠ Agent 工具权限 ≠ 数据库连接权限

例如,用户只能访问租户 tenant_a 的订单;Agent 服务账号即使技术上能够读取所有租户,应用也不能因为“数据库允许”就把全部数据交给模型。安全边界必须由数据库和应用共同 enforce,而不是依赖模型遵守提示词。

2. 一个可执行请求的形式化表示

可以把一次 SQL Agent 请求表示为:

R=(a,t,s,q,p,b)R = (a, t, s, q, p, b)

其中:

  • aa:Actor,发起请求的用户、服务或任务;
  • tt:Tenant,租户或数据域;
  • ss:Scope,当前会话允许的操作范围;
  • qq:自然语言问题;
  • pp:生成的查询计划;
  • bb:资源预算,例如最大执行时间、最大扫描量和最大返回行数。

最终允许执行的 SQL 不应只满足“语法正确”,而应满足:

Allow(SQL)=Syntax(SQL)ReadOnly(SQL)ObjectAuth(SQL)RowPolicy(SQL)Budget(SQL)Intent(SQL)\operatorname{Allow}(SQL) = \operatorname{Syntax}(SQL) \land \operatorname{ReadOnly}(SQL) \land \operatorname{ObjectAuth}(SQL) \land \operatorname{RowPolicy}(SQL) \land \operatorname{Budget}(SQL) \land \operatorname{Intent}(SQL)

这几个条件分别回答不同问题:

  • Syntax:数据库能否解析;
  • ReadOnly:是否只读;
  • ObjectAuth:涉及的表、视图和字段是否授权;
  • RowPolicy:是否带有租户、部门、用户等行级约束;
  • Budget:是否可能超出成本预算;
  • Intent:SQL 是否真的回答了用户问题,而不是“合法但答非所问”。

其中任意一个条件失败,都不应执行。


二、Schema 上下文:模型需要的不是表名列表

1. Schema 上下文是什么

Schema 上下文是提供给模型的、与当前请求相关的数据库结构和业务语义。它至少包括:

  • 数据库、Schema、表或视图名称;
  • 字段名称和类型;
  • 主键、外键以及可用连接关系;
  • 字段含义和单位;
  • 时间字段定义;
  • 枚举值或状态值;
  • 是否允许聚合、排序和过滤;
  • 脱敏规则;
  • 租户、部门和行级权限约束;
  • 业务指标的正式定义。

仅把以下内容交给模型是不够的:

orders(id, user_id, amount, status, created_at)
users(id, name, department)

因为模型仍然不知道:

  • amount 是含税金额还是不含税金额;
  • created_at 是下单时间还是支付时间;
  • status = 'completed' 是否代表已支付;
  • 一个用户是否可能属于多个租户;
  • 订单表是否已经通过视图过滤了取消订单;
  • 金额是否需要除以 100 才能得到元;
  • “销售额”是否应排除退款。

因此,生产系统中的 Schema 上下文应当是“结构 + 语义 + 权限 + 约束”的组合。

2. 表、视图和指标不能混为一谈

假设数据库中存在:

orders (
    id              bigint,
    tenant_id       text,
    user_id         bigint,
    order_status    text,
    total_cent      bigint,
    paid_at         timestamptz,
    created_at      timestamptz,
    refunded_cent   bigint
);

用户问:

统计 2026 年第二季度各部门的销售额。

模型可能生成:

SELECT department, SUM(total_cent)
FROM orders
JOIN users USING (user_id)
WHERE created_at >= '2026-04-01'
  AND created_at < '2026-07-01'
GROUP BY department;

这个 SQL 可能有三处错误:

  1. “销售额”也许应按 paid_at 而不是 created_at
  2. 可能只统计已支付订单;
  3. 退款金额可能需要扣除。

更准确的指标上下文应写成结构化定义:

{
  "metric": "sales_amount",
  "definition": "已支付订单金额减去退款金额",
  "time_dimension": "paid_at",
  "included_status": ["paid", "completed"],
  "expression": "total_cent - refunded_cent",
  "unit": "CNY",
  "storage_unit": "cent",
  "allowed_group_by": ["department", "month"]
}

然后模型生成:

SELECT
    u.department,
    SUM(o.total_cent - o.refunded_cent) / 100.0 AS sales_amount_cny
FROM tenant_visible_orders AS o
JOIN tenant_visible_users AS u
  ON u.id = o.user_id
WHERE o.paid_at >= TIMESTAMPTZ '2026-04-01 00:00:00+08'
  AND o.paid_at <  TIMESTAMPTZ '2026-07-01 00:00:00+08'
  AND o.order_status IN ('paid', 'completed')
GROUP BY u.department
ORDER BY sales_amount_cny DESC;

这里的 tenant_visible_orderstenant_visible_users 可以是经过权限封装的视图,也可以是应用动态注入租户过滤条件后的查询源。关键在于:权限约束不能只存在于提示词中。

3. Schema 上下文必须按权限裁剪

用户能够看到什么 Schema,本身就是权限问题。

错误做法:

把数据库所有表和字段描述一次性放进系统提示词。

这会产生两个问题:

  • 模型可以尝试访问用户没有权限的表;
  • 敏感表名、字段名和业务结构可能泄露给模型或日志系统。

更合理的流程是:

Actor
  │
  ├─ 查询对象权限
  ├─ 查询字段权限
  ├─ 查询租户范围
  └─ 查询允许的指标
       │
       ▼
生成“授权后的 Schema 上下文”

例如同一张员工表:

管理员可见:
- employee_id
- department
- salary
- bank_account

部门经理可见:
- employee_id
- department
- salary

普通员工可见:
- employee_id
- department

即使模型知道 bank_account 这个字段存在,也不应将其放入普通员工的 Schema 上下文,更不能依赖模型“不要使用它”。

4. 相关性检索不能代替权限过滤

当数据库表很多时,可以先根据问题检索相关表和指标,再生成上下文:

问题:各部门第二季度销售额
检索结果:
- sales_amount 指标
- orders 表
- users 表
- department 维度

但是检索顺序不能是:

全量 Schema → 相关性检索 → 权限判断

因为未授权的 Schema 已经进入了模型上下文。

正确顺序是:

权限裁剪后的 Schema → 相关性检索 → 提供给模型

可以把可见对象集合写成:

Oa={oPermission(a,o)=1}O_a = \{o \mid \operatorname{Permission}(a,o)=1\}

再从 OaO_a 中检索与问题相关的对象:

C(a,q)=Retrieve(q,Oa)C(a,q) = \operatorname{Retrieve}(q, O_a)

这保证了“相关性”不会扩大“授权范围”。


三、只读约束:提示词不是安全边界

1. 只读约束的含义

只读约束要求 SQL Agent 在执行阶段只能观察数据,不能改变数据或数据库结构。通常包括:

  • 允许 SELECT
  • 允许 WITH ... SELECT
  • 是否允许调用只读函数,需要单独判断;
  • 禁止 INSERTUPDATEDELETEMERGE
  • 禁止 CREATEALTERDROPTRUNCATE
  • 禁止事务控制语句;
  • 禁止复制、导出、文件写入和外部网络访问;
  • 禁止通过存储过程间接执行写操作。

“只允许 SELECT”并不等价于“只读”。例如:

SELECT dangerous_function();

如果 dangerous_function() 内部执行了写操作,外层看起来仍然是 SELECT

因此,只读约束至少需要三道防线:

SQL 静态检查
    +
数据库会话只读
    +
低权限数据库角色

2. 数据库会话只读

以 PostgreSQL 为例,可以为 Agent 创建专用数据库角色,并在每个连接中设置:

BEGIN READ ONLY;

SET LOCAL statement_timeout = '3000ms';
SET LOCAL lock_timeout = '500ms';
SET LOCAL idle_in_transaction_session_timeout = '5000ms';

SELECT
    department,
    COUNT(*) AS order_count
FROM tenant_visible_orders
WHERE paid_at >= TIMESTAMPTZ '2026-04-01 00:00:00+08'
  AND paid_at <  TIMESTAMPTZ '2026-07-01 00:00:00+08'
GROUP BY department;

ROLLBACK;

其中:

  • READ ONLY 让数据库在事务层拒绝写操作;
  • statement_timeout 限制单条语句执行时间;
  • lock_timeout 防止查询长时间等待锁;
  • ROLLBACK 确保即使执行了临时操作也不保留事务结果。

但数据库能力与数据库产品有关。不同数据库对只读事务、函数副作用、资源组和执行计划的支持不同,不能把 PostgreSQL 的行为直接推断到 MySQL、SQL Server 或数据仓库产品。

3. 只读角色

只读事务仍不是完整权限控制。Agent 连接使用的数据库角色应该明确授予权限:

CREATE ROLE sql_agent_readonly LOGIN PASSWORD '由密钥系统注入';

GRANT CONNECT ON DATABASE analytics TO sql_agent_readonly;
GRANT USAGE ON SCHEMA reporting TO sql_agent_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO sql_agent_readonly;

REVOKE ALL ON SCHEMA private FROM sql_agent_readonly;
REVOKE ALL ON ALL TABLES IN SCHEMA private FROM sql_agent_readonly;

更推荐授予报告视图权限,而不是直接授予原始业务表权限:

CREATE VIEW reporting.tenant_visible_orders AS
SELECT
    id,
    tenant_id,
    user_id,
    order_status,
    total_cent,
    refunded_cent,
    paid_at
FROM core.orders;

GRANT SELECT ON reporting.tenant_visible_orders
TO sql_agent_readonly;

如果租户条件需要动态绑定,则不能把 tenant_id 作为模型生成的普通字符串直接拼接到 SQL 中。应由应用通过参数绑定、数据库会话变量、行级安全策略或独立的租户视图实现。


四、查询校验:从字符串过滤升级为结构和语义校验

1. 为什么正则表达式不够

以下做法看似简单:

if "delete" in sql.lower():
    reject()

但它不能可靠识别:

/* delete */
SELECT ...

也不能处理:

WITH x AS (
    DELETE FROM orders RETURNING *
)
SELECT * FROM x;

还可能误判:

SELECT 'delete' AS keyword;

SQL 校验应当基于数据库方言解析器生成的 AST,而不是只扫描字符串。AST 是 SQL 的抽象语法树,能够区分:

  • 查询节点;
  • 写操作节点;
  • 子查询;
  • CTE;
  • 函数调用;
  • 表和字段引用;
  • JOIN
  • ORDER BY
  • LIMIT
  • 常量和参数。

2. 结构校验

一个保守的只读 AST 校验器可以按以下顺序工作:

  1. 解析 SQL;
  2. 确认只有一个语句;
  3. 根节点必须是 SELECT 或允许的只读查询节点;
  4. 遍历所有 CTE,禁止写入型 CTE;
  5. 遍历所有函数调用,仅允许函数白名单;
  6. 提取所有表、视图和字段引用;
  7. 与授权对象集合比较;
  8. 检查是否存在租户或行级约束;
  9. 检查是否有明确的行数和时间范围限制;
  10. 生成规范化 SQL 和校验报告。

校验结果不应只有布尔值,而应包含原因:

{
  "allowed": false,
  "violations": [
    {
      "code": "UNAUTHORIZED_COLUMN",
      "object": "core.employees.bank_account",
      "message": "当前 Actor 无权访问该字段"
    },
    {
      "code": "MISSING_TENANT_FILTER",
      "message": "查询未使用授权租户范围"
    }
  ]
}

这使得 Agent 可以在下一轮修正 SQL,而不是只收到一个模糊的“执行失败”。

3. 参数绑定必须由执行器负责

模型可以生成:

SELECT
    department,
    SUM(total_cent) / 100.0 AS amount
FROM reporting.tenant_visible_orders
WHERE paid_at >= :start_time
  AND paid_at < :end_time
GROUP BY department
ORDER BY amount DESC
LIMIT :limit;

参数由应用提供:

{
  "start_time": "2026-04-01T00:00:00+08:00",
  "end_time": "2026-07-01T00:00:00+08:00",
  "limit": 100
}

不要让模型直接生成:

WHERE tenant_id = '租户值'

更不能将自然语言原样拼接到 SQL 中:

sql = "SELECT * FROM orders WHERE department = '" + user_input + "'"

参数绑定解决的是 SQL 注入问题,但它不能解决权限问题。下面的 SQL 即使完全参数化,也可能越权:

SELECT * FROM orders WHERE tenant_id = :tenant_id;

如果 :tenant_id 由模型决定,模型仍可能请求其他租户。租户参数必须来自已认证的 Actor 上下文,而不是来自模型输出。


五、语义校验:合法 SQL 也可能是错误答案

1. 语法正确不代表意图正确

用户问:

2026 年第二季度销售额最高的三个部门是什么?

下面的 SQL 语法正确:

SELECT department, SUM(total_cent) AS amount
FROM orders
GROUP BY department
ORDER BY amount DESC
LIMIT 3;

但它没有时间过滤,因此回答的是“全历史前三名”。

另一个 SQL 也可能语法正确:

SELECT department, COUNT(*) AS amount
FROM orders
WHERE paid_at >= :start_time
  AND paid_at < :end_time
GROUP BY department
ORDER BY amount DESC
LIMIT 3;

它统计的是订单数,却把字段命名为 amount,仍然答非所问。

因此需要把自然语言问题先转换为查询意图:

{
  "metric": "sales_amount",
  "time_range": {
    "start": "2026-04-01T00:00:00+08:00",
    "end": "2026-07-01T00:00:00+08:00"
  },
  "group_by": ["department"],
  "sort": {
    "field": "sales_amount",
    "direction": "desc"
  },
  "limit": 3
}

然后校验 SQL 是否实现了这个意图:

SemanticValid(SQL,I)\operatorname{SemanticValid}(SQL, I)

其中 II 是结构化意图。一个最小判断集合可以包括:

  • 指标表达式是否来自授权指标定义;
  • 时间字段是否正确;
  • 时间范围是否存在且边界正确;
  • 分组字段是否匹配;
  • 排序字段是否为目标指标;
  • LIMIT 是否等于用户要求;
  • 是否包含必要状态过滤;
  • 是否重复计算或错误连接。

2. 时间边界是常见错误源

“2026 年第二季度”应转换为半开区间:

[2026-04-01 00:00:00+08:00,
 2026-07-01 00:00:00+08:00)

对应 SQL:

paid_at >= TIMESTAMPTZ '2026-04-01 00:00:00+08'
AND paid_at <  TIMESTAMPTZ '2026-07-01 00:00:00+08'

不推荐写成:

paid_at BETWEEN '2026-04-01' AND '2026-06-30 23:59:59'

因为数据库时间精度可能高于秒,2026-06-30 23:59:59.500 会被错误排除。半开区间还能自然拼接相邻时间段:

第一季度:[2026-01-01, 2026-04-01)
第二季度:[2026-04-01, 2026-07-01)

3. JOIN 基数必须校验

假设:

orders:一条订单一行
order_items:一条订单多行

错误查询:

SELECT SUM(o.total_cent)
FROM orders o
JOIN order_items i ON i.order_id = o.id;

如果一张订单有三条明细,订单金额就会被计算三次。

正确方式可能是先聚合明细:

SELECT SUM(o.total_cent)
FROM orders o
WHERE EXISTS (
    SELECT 1
    FROM order_items i
    WHERE i.order_id = o.id
);

或者只在确实需要明细指标时连接:

SELECT SUM(i.quantity * i.unit_price_cent)
FROM order_items i
JOIN orders o ON o.id = i.order_id;

SQL Agent 的语义校验器应该维护连接关系和基数信息:

{
  "orders": {
    "primary_key": ["id"]
  },
  "order_items": {
    "foreign_key": ["order_id"],
    "relationship": "many_to_one"
  }
}

当查询包含一对多连接,同时又聚合“一端”的金额字段时,应当要求模型解释聚合意图,或直接拒绝并重新生成。


六、成本控制:执行前估计,执行中限制,执行后归因

1. 成本不是只有模型 Token

SQL Agent 的成本至少包括:

Ctotal=Cmodel+Cdatabase+Cnetwork+Cresult+CretryC_{\text{total}} = C_{\text{model}} + C_{\text{database}} + C_{\text{network}} + C_{\text{result}} + C_{\text{retry}}

其中:

  • CmodelC_{\text{model}}:模型输入、输出和重复调用成本;
  • CdatabaseC_{\text{database}}:CPU、内存、扫描、并发和存储成本;
  • CnetworkC_{\text{network}}:数据库到 Agent 服务传输大量结果的成本;
  • CresultC_{\text{result}}:结果序列化、缓存和下游处理成本;
  • CretryC_{\text{retry}}:错误查询重试带来的额外成本。

一个只返回十行结果的查询,也可能扫描数十亿行。因此“返回结果少”不能证明查询便宜。

2. 查询成本估计

在允许执行前,应先运行数据库的计划估计,例如 PostgreSQL:

EXPLAIN (FORMAT JSON, COSTS TRUE)
SELECT
    department,
    SUM(total_cent) / 100.0 AS sales_amount
FROM reporting.tenant_visible_orders
WHERE paid_at >= TIMESTAMPTZ '2026-04-01 00:00:00+08'
  AND paid_at <  TIMESTAMPTZ '2026-07-01 00:00:00+08'
GROUP BY department;

系统可以从计划中读取:

  • 预计扫描行数;
  • 预计输出行数;
  • 预计成本;
  • 是否顺序扫描大表;
  • 是否出现笛卡尔积;
  • 是否出现高代价排序;
  • 是否使用了预期索引;
  • 是否有明显的估算异常。

定义一个简单的预算条件:

BudgetValid(q)=r^(q)rmaxc^(q)cmaxt^(q)tmax\operatorname{BudgetValid}(q) = \hat{r}(q) \le r_{\max} \land \hat{c}(q) \le c_{\max} \land \hat{t}(q) \le t_{\max}

其中:

  • r^(q)\hat{r}(q):预计扫描或处理行数;
  • c^(q)\hat{c}(q):优化器预计成本;
  • t^(q)\hat{t}(q):预计执行时间;
  • rmax,cmax,tmaxr_{\max}, c_{\max}, t_{\max}:当前租户或用户的预算上限。

估计值不等于真实值。数据分布变化、统计信息过期和参数选择都可能导致估计错误。因此还必须设置运行时限制:

SET LOCAL statement_timeout = '3000ms';
SET LOCAL work_mem = '16MB';

资源参数是否可由普通角色设置、是否对当前数据库产品生效,需要以具体数据库版本和部署方式为准。Agent 不应让模型自行决定这些参数。

3. 成本超限后的处理路径

当查询成本超过预算时,不应简单返回数据库错误。可以按以下顺序降级:

成本超限
  │
  ├─ 增加时间过滤?
  ├─ 改用预聚合表或指标视图?
  ├─ 减少分组维度?
  ├─ 先返回近似或抽样结果?
  ├─ 要求用户确认长查询?
  └─ 拒绝执行并说明原因

例如用户要求查询五年明细数据,系统可以先询问:

当前查询预计扫描范围较大。是否改为按月聚合,或将时间范围缩短到最近 90 天?

这不是模型“自主决定”,而是执行器根据预算状态向 Agent 提供结构化反馈:

{
  "status": "budget_exceeded",
  "estimated_rows": 820000000,
  "max_rows": 50000000,
  "suggestions": [
    "增加时间过滤",
    "使用 monthly_sales_summary",
    "减少明细字段"
  ]
}

七、审计:记录的不只是最终 SQL

1. 审计的目标

SQL Agent 审计要回答:

  • 谁发起了请求;
  • 代表哪个租户;
  • Agent 使用了什么权限;
  • 模型看到了哪些 Schema;
  • 模型生成过哪些候选 SQL;
  • 哪条 SQL 通过了哪些校验;
  • 最终执行了什么;
  • 查询读取了哪些对象;
  • 实际扫描和返回了多少数据;
  • 是否发生重试、降级或人工确认;
  • 最终回答是否基于该查询结果。

因此,下面这条日志远远不够:

2026-09-01 10:00:00 query succeeded

2. 审计事件结构

可以设计如下事件:

{
  "event_id": "evt_01J...",
  "trace_id": "trace_01J...",
  "occurred_at": "2026-09-01T10:00:00+08:00",
  "actor": {
    "type": "user",
    "id": "user_123"
  },
  "tenant_id": "tenant_a",
  "agent": {
    "name": "sales_sql_agent",
    "version": "2026-09-baseline"
  },
  "authorization": {
    "scope": ["reporting.sales.read"],
    "objects": [
      "reporting.tenant_visible_orders",
      "reporting.tenant_visible_users"
    ]
  },
  "schema_context": {
    "version": "schema_2026_08_31",
    "hash": "sha256:..."
  },
  "intent": {
    "metric": "sales_amount",
    "time_range": ["2026-04-01", "2026-07-01"],
    "group_by": ["department"]
  },
  "query": {
    "normalized_sql": "SELECT ...",
    "sql_hash": "sha256:...",
    "parameters_hash": "sha256:..."
  },
  "validation": {
    "syntax": "pass",
    "readonly": "pass",
    "object_auth": "pass",
    "semantic": "pass",
    "budget": "pass"
  },
  "execution": {
    "database": "analytics",
    "duration_ms": 184,
    "rows_scanned": 182340,
    "rows_returned": 8,
    "status": "success"
  },
  "result": {
    "schema_hash": "sha256:...",
    "sample_hash": "sha256:..."
  }
}

3. 不要默认记录原始敏感数据

审计需要可复原,但不等于把全部结果和提示词永久写入日志。

可以记录:

  • SQL 规范化文本;
  • 参数哈希;
  • Schema 版本;
  • 结果 Schema;
  • 行数、列数和统计摘要;
  • 结果内容的加密存档引用;
  • 访问策略版本;
  • 脱敏前后的字段集合。

例如,审计中可以记录:

{
  "columns": ["department", "sales_amount_cny"],
  "rows_returned": 8,
  "result_artifact": "encrypted://audit/evt_01J...",
  "retention_days": 30
}

而不是直接记录包含姓名、手机号或薪资的完整结果。


八、基于 Function Calling 的端到端工具设计

SQL Agent 不应向模型暴露一个过于宽泛的工具:

{
  "name": "run_sql",
  "description": "执行任意 SQL"
}

更合理的是把生命周期拆成具有明确边界的工具:

[
  {
    "name": "get_authorized_schema",
    "description": "获取当前 Actor 已授权且与问题相关的 Schema 和指标定义"
  },
  {
    "name": "validate_sql",
    "description": "解析并校验只读 SQL、对象权限、行级约束、语义和预算"
  },
  {
    "name": "execute_readonly_query",
    "description": "在只读、限时、限资源数据库会话中执行已通过校验的查询"
  }
]

工具参数也应当结构化:

{
  "type": "function",
  "function": {
    "name": "execute_readonly_query",
    "description": "执行已经由校验器签发的只读查询",
    "parameters": {
      "type": "object",
      "properties": {
        "validation_token": {
          "type": "string",
          "description": "由服务端校验器签发的一次性授权令牌"
        },
        "query_id": {
          "type": "string",
          "description": "已校验查询的服务端 ID"
        }
      },
      "required": ["validation_token", "query_id"],
      "additionalProperties": false
    },
    "strict": true
  }
}

这里的关键变化是:模型不能直接把任意 SQL 传给执行工具,而只能引用服务端已经保存并校验过的 query_idvalidation_token

OpenAI 的函数工具支持使用 JSON Schema 描述参数,并支持严格参数约束;但 Schema 约束解决的是工具调用参数的结构问题,不会自动替代数据库权限、SQL AST 校验或只读事务。(developers.openai.com)

一个典型调用生命周期如下:

sequenceDiagram
    participant U as 用户
    participant A as Agent 编排器
    participant P as 权限服务
    participant V as SQL 校验器
    participant D as 数据库
    participant L as 审计系统

    U->>A: 自然语言问题
    A->>P: 查询 Actor、租户、Scope、对象权限
    P-->>A: 授权后的 Schema 和指标
    A->>A: 生成查询意图与候选 SQL
    A->>V: SQL + 意图 + 授权上下文
    V->>V: AST、只读、对象、行级、语义、成本校验
    V-->>A: validation_token 或拒绝原因

    alt 校验通过
        A->>D: 只读事务 + 参数绑定 + 资源限制
        D-->>A: 结果与执行统计
        A->>L: 记录完整审计事件
        A-->>U: 结果、口径、时间范围、限制
    else 校验失败
        A->>L: 记录候选 SQL 和失败原因
        A-->>U: 澄清、修正或拒绝
    end

MCP 可以作为 SQL Agent 接入外部数据和工具的标准协议层。MCP 使用 JSON-RPC 2.0,并区分 Host、Client 和 Server;Server 可以提供 Resources、Prompts 和 Tools。(modelcontextprotocol.io)

在这个场景中,可以这样映射:

MCP Host       = Agent 应用
MCP Client     = Agent 内的 MCP 连接器
MCP Server     = 数据平台或 SQL 查询服务
Resource       = 授权 Schema、指标定义、数据字典
Tool           = validate_sql、execute_readonly_query
Prompt         = 预定义分析流程或指标问法

但 MCP 只是连接协议,不会自动替应用完成权限和安全隔离。MCP 规范明确强调,工具可能触发任意代码执行路径,工具描述不能天然被视为可信;实现方仍需要提供授权、用户确认和访问控制。(modelcontextprotocol.io)


九、失败路径必须是可诊断的

1. Schema 不完整

表现:

模型生成了不存在的字段 total_amount。

诊断:

  • Schema 版本是否过期;
  • 是否只检索了部分表;
  • 列名别名是否丢失;
  • 指标定义是否引用了已删除字段。

恢复:

重新获取 Schema → 返回数据库列错误 → 重新生成 → 再校验

不能直接让模型根据错误猜字段,因为猜测可能把 total_cent 错当成 total_amount

2. 权限失败

表现:

SQL 引用了未授权表 private.employee_salary。

处理:

  • 不把数据库详细错误原样暴露给用户;
  • 将失败原因编码为 UNAUTHORIZED_OBJECT
  • 从模型上下文中移除该对象;
  • 重新生成查询或明确告知当前权限不足。

3. 成本失败

表现:

statement_timeout

不能简单增加超时重试。应先检查:

  • 是否缺少时间过滤;
  • 是否使用了原始明细表;
  • 是否发生多对多连接;
  • 是否可以使用汇总表;
  • 是否需要异步任务或人工确认。

MCP 的规范包含进度跟踪、取消和错误报告等通用机制;这些能力适合长查询,但它们不会改变 SQL 本身的权限和成本要求。(modelcontextprotocol.io)

4. 结果为空

空结果可能表示:

  • 查询确实没有数据;
  • 时间区间使用了错误时区;
  • 状态条件过严;
  • 租户过滤错误;
  • 连接条件错误;
  • 数据延迟尚未完成。

Agent 不应把空结果直接解释为“业务上没有记录”。应进行最小化验证,例如:

SELECT
    MIN(paid_at) AS min_paid_at,
    MAX(paid_at) AS max_paid_at,
    COUNT(*) AS row_count
FROM reporting.tenant_visible_orders
WHERE paid_at >= :start_time
  AND paid_at < :end_time;

这个验证查询本身也要经过同样的权限和成本校验。


十、与数据分析 Agent 的边界

SQL Agent 通常只负责:

结构化数据访问
→ 查询
→ 聚合
→ 结果解释

而完整的数据分析 Agent 可能还需要:

文件读取
→ 代码执行
→ 表格处理
→ 图表生成
→ 结果复现
→ 交叉验证

两者不能因为都叫“分析 Agent”就共用同一个权限模型。

例如:

  • SQL 工具只能读取授权数据库;
  • 文件工具只能读取当前任务绑定的文件;
  • 代码执行环境不能自动访问生产数据库;
  • 图表工具只能接收已经脱敏的结果;
  • 复现流程需要保存查询、参数、Schema 版本和代码版本;
  • 结果验证需要区分“数据库原始结果”和“代码二次计算结果”。

如果 SQL Agent 输出了销售额,代码执行环境又从导出的 CSV 重新计算一次,应该比较:

Validate(Rsql,Rcode)\operatorname{Validate}(R_{\text{sql}}, R_{\text{code}})

比较内容至少包括:

  • 行数;
  • 主键集合;
  • 分组集合;
  • 数值误差;
  • 时间区间;
  • 空值处理;
  • 四舍五入规则。

否则图表看起来正确,也不能证明它使用了正确的查询结果。


十一、常见误解与反例

误解一:系统提示词写了“只能查询”,所以是只读的

反例:

用户:请删除昨天的测试订单。
模型:DELETE FROM orders WHERE ...

即使提示词要求只读,模型仍可能生成写语句。必须由 AST 校验、只读事务和低权限角色共同拒绝。

误解二:使用参数绑定后就不会越权

反例:

SELECT * FROM orders WHERE tenant_id = :tenant_id;

如果 tenant_id 由模型提供,参数绑定只防注入,不防租户越权。租户值必须来自经过认证的 Actor 上下文。

误解三:加 LIMIT 100 就不会产生高成本

反例:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 100;

如果 created_at 没有合适索引,数据库仍可能扫描和排序大量数据。

误解四:返回结果正确,所以 SQL 正确

反例:

SELECT SUM(total_cent)
FROM orders
WHERE created_at >= :start
  AND created_at < :end;

如果业务指标要求按支付时间和已支付状态统计,这条 SQL 的结果可能数值合理,却没有实现定义的指标。

误解五:审计最终回答就够了

最终回答无法证明:

  • 模型最初生成了哪些候选 SQL;
  • 哪些候选 SQL 被权限拒绝;
  • Schema 是否在执行前发生变化;
  • 查询是否经过成本评估;
  • 结果是否被代码执行环境修改。

审计必须覆盖从身份解析到结果生成的完整链路。


十二、生产基线

一个可接受的 SQL Agent 基线应至少满足:

1. 身份
   Actor、租户、Scope 在服务端确定,不由模型决定。

2. Schema
   只向模型提供当前 Actor 已授权的结构、指标和连接关系。

3. 生成
   模型输出结构化查询意图和候选 SQL,而不是直接获得数据库执行权。

4. 校验
   使用 SQL AST 检查单语句、只读、对象、字段、函数、租户条件和语义。

5. 执行
   使用专用低权限角色、只读事务、参数绑定、超时和资源限制。

6. 成本
   执行前检查计划,执行中限制资源,超限后降级或要求确认。

7. 结果
   返回数据口径、时间边界、行数、限制和必要的验证状态。

8. 审计
   记录 Actor、Schema 版本、意图、SQL 哈希、校验结果、执行统计和结果引用。

9. 复现
   保存查询文本、参数、权限上下文、Schema 版本和 Agent 版本。

10. 故障
    对权限、语法、语义、成本、超时和空结果提供独立诊断路径。

最终,SQL Agent 的信任边界应当是:

模型负责提出候选方案;
应用负责决定能否执行;
数据库负责拒绝越权和写入;
审计系统负责证明发生过什么。

这四个角色不能互相替代。Schema 上下文解决“模型是否理解数据”,只读约束解决“模型能否改变数据”,查询校验解决“生成的 SQL 是否被允许”,成本控制解决“查询是否值得执行”,审计则解决“执行结果能否被追溯和复现”。只有这些条件同时成立,SQL Agent 才不是一个把自然语言直接连接到数据库的高风险接口,而是一个具有明确权限、成本和证据链的分析组件。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。