让 Agent 查数,最常见的翻车方式不是它写不出 SQL,而是它写出的 SQL 跑得通、但答错了问题。根因通常有两个:一是没有统一的业务口径,二是给的权限太大。这篇教程把两件事一次解决——用只读角色 + 行级安全把权限收窄,再用语义层把口径固化下来,最后才把这条通道交给 Agent。
先理解:为什么「给 schema」不够
有个说法值得记住:把一个 400 张表的仓库 schema 丢给模型,它会编造 join;把一份有 80 个指标、50 个维度的有类型词表交给它,它就不会。差别不在模型大小,在于你给的「词汇量」是不是有界的。
| 你给的上下文 | Agent 的表现 | 风险 |
|---|---|---|
| 全部裸表 + 全部列 | 自由发挥,能跑但口径随意 | 同一问题两次得到不同数字 |
| 白名单视图 + 文档化的列 | 口径基本稳定 | 仍需人工核对 |
| 语义层(有类型的指标 + 维度) | 口径与看板一致,可解释 | 需要前期建模投入 |
下面按这个顺序推进:先把权限关小,再把口径固化,最后才开放给 Agent。
Step 1:建一个只读、限域的数据库账号
原则很简单:Agent 用的账号只有 SELECT,且只覆盖它该看的对象。不要用应用主账号,更不要用超级用户。
-- 1) 建一个专用登录角色
CREATE ROLE agent_ro LOGIN PASSWORD '换成强密码';
-- 2) 允许连接,但只给只读权限
GRANT CONNECT ON DATABASE analytics TO agent_ro;
GRANT USAGE ON SCHEMA public TO agent_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_ro;
-- 3) 以后新建的表也自动只给 SELECT(关键,否则新表会漏配)
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO agent_ro;
-- 4) 显式回收写入类权限(防手滑)
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA public FROM agent_ro;
只读账号也要配连接数与超时。一个跑飞的查询足以拖垮生产库。给这个角色单独设 statement_timeout(例如 30s)和 CONNECTION LIMIT,并优先连只读副本而不是主库。另外:只读不等于安全——如果表里有客户手机号、身份证号,Agent 依然能读出来,所以下一步必须做行级与列级控制。
Step 2:用行级安全把可见范围收窄
PostgreSQL 的行级安全(RLS)是在数据库层执行的,Agent 绕不过去——这正是它的价值所在:规则不写在提示词里,写在数据库里。
-- 开启行级安全
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 只对 Agent 角色生效的策略:只能看到与当前会话上下文一致的区域
CREATE POLICY agent_region_policy ON orders
FOR SELECT TO agent_ro
USING (region = current_setting('app.region', true));
-- 每次连接时由你的服务端设置上下文(不要交给模型决定)
SET app.region = 'AU';
Step 3:把业务口径固化成视图
指标口径最怕的是每个人(和每个 Agent)各算一遍。用视图把定义钉死,Agent 只能引用,不能重算:
-- 口径固化:净收入 = 成交额 - 退款 - 折扣
CREATE VIEW v_net_revenue AS
SELECT
date_trunc('month', o.created_at) AS month,
o.region,
SUM(o.amount - COALESCE(o.refunded, 0) - COALESCE(o.discount, 0)) AS net_revenue
FROM orders o
WHERE o.status = 'completed'
GROUP BY 1, 2;
-- Agent 只能看到视图,看不到底层表的裸列
GRANT SELECT ON v_net_revenue TO agent_ro;
REVOKE SELECT ON orders FROM agent_ro; -- 或者一开始就别授予
视图要配合同义词说明才有效。只给视图名,Agent 未必知道「净收入」对应 v_net_revenue。把指标名、同义词、口径说明写进列注释或单独的说明表,让它能查到「哪个视图对应哪个业务词」。这部分文档的深度,直接决定自然语言查询的准确率。
Step 4:给 Agent 一份「有界词表」而不是全部 schema
交给 Agent 的上下文(推荐):
· 允许访问的视图清单(通常 5-20 个)
· 每个视图的业务含义与适用场景
· 指标口径说明 + 常见同义词
· 明确列出「不可访问」的对象,让它知道自己不该碰什么
不要交给 Agent 的:
· 全库表结构
· 任何含 PII 的列(手机号、证件号、地址)
· 生产库的写连接串
Step 5:成熟方案——用语义层替代手写视图
当指标数量超过几十个、消费方从 1 个变成 5 个以上时,手写视图会开始失控。这时该上语义层:一份指标定义,服务所有下游。两条主流路线:
| 方案 | 定位 | 权限模型 | 适合谁 |
|---|---|---|---|
| Cube(Cube Core 开源,后端 Apache 2.0) | 独立于转换工具的无头语义层,多 API 层(SQL / REST / GraphQL / MDX-DAX)并自带 MCP Server | 行级与列级安全在 Cube 内部执行,查询时检查 | 需要跨多个 BI 工具与 Agent 的统一 API |
| dbt Semantic Layer(基于 MetricFlow,已改为 Apache 2.0) | 指标一致性,服务下游所有工具 | dbt 本身不执行行级安全,仍在数据库层面 | 团队已经在写 dbt 模型 |
Cube 可以用 Docker 在本地起一个最小实例验证思路:
docker run -p 4000:4000 -p 15432:15432 \
-v ${PWD}:/cube/conf \
-e CUBEJS_DEV_MODE=true \
cubejs/cube
# 然后在 model/cubes/ 下用 YAML 定义指标与维度,
# 例如给 orders 定义 revenue 指标、region 维度,
# 再用 access_policy 配置行级过滤(具体语法以官方文档为准)。
# 起好后访问 http://localhost:4000 打开 Playground 试查询。
语义层不是银弹,建模周期要提前算进去。企业级语义层通常需要数月的建模投入才能成熟;指标定义漂移(某个指标最近 30 天被改过)是上线后的主要风险。建议路径是:先给 Agent 只读权限 → 从小批文档良好的模型/视图开始 → 输出被证明可靠后再逐步扩大。反过来做(一上来就开放全库)几乎一定会出事。
Step 6:把通道接到 Agent
| 接入方式 | 做法 | 适用 |
|---|---|---|
| SQL 直连 | Agent 生成 SQL,用只读账号执行 | 最灵活,也最需要你自己兜底核对 |
| REST / GraphQL | 语义层把指标暴露成接口,Agent 只选指标和维度 | 口径稳定、不想让模型写 SQL |
| MCP | 语义层或数据库以 MCP Server 形式提供工具 | Agent 只看到受治理的有限词表 |
Step 7:上线后要盯的五个指标
| 盯什么 | 为什么 |
|---|---|
| 查询延迟 p50 / p95 / p99 | Agent 的响应体验取决于长尾,不是平均值 |
| 缓存命中率 | 命中率掉下来,通常意味着有人在用非常规维度查询 |
| 编译失败数 | 语义层编译失败的查询,往往是 Agent 在猜不存在的字段 |
| 定义漂移(近 30 天被改过的指标数) | 口径变了而没人通知,是「两个看板数字不一致」的经典根因 |
| 每个指标的消费方数量 | 突然多出消费方,意味着有新的自动化流程在依赖它 |
最后一条底线:给 Agent 的账号永远不要有写权限。无论语义层做得多完善,写权限都不该出现在这条通道上。需要 Agent 触发写入时,走「Agent 提议 → 人或服务端校验 → 用另一个账号执行」的三段式,而不是把写权限直接交出去。
常见问题速查
| 你遇到的现象 | 大概率原因 & 解决 |
|---|---|
| Agent 报「列不存在」 | 上下文里给了过期 schema,或它猜了列名。换成视图清单 + 明确禁止猜列名 |
| 同一个问题两次数字不同 | 口径没固化。把指标写成视图或语义层指标,禁止模型自行组合 |
| 查询把库拖慢 | 缺 statement_timeout,或没连只读副本 |
| RLS 策略没生效 | 表没 ENABLE ROW LEVEL SECURITY,或角色是表所有者(所有者默认绕过策略) |
| 语义层上线后仍对不上看板 | 指标定义漂移。先排查近 30 天有没有人改过该指标的 SQL 定义 |