site logo

Marico's space

为什么你的 agent tool list 不断偏离你的 database

AI技术与应用 2026-08-11 20:58:20 13

最近折腾 GraphQL 给 agent 用,踩了几个坑,这篇把问题说清楚。

GraphQL 和 agent 的标准卖点是:模型按需请求字段,避免过度获取。这是带宽层面的说法。但带宽这东西,在 agent 循环里早就不是瓶颈了——模型接下来要花四秒钟和真金白银去思考返回的内容,线上传个 8KB 根本不是问题所在。

几乎所有讲 GraphQL 给 agent 用的帖子都是这个开头,所以真正做过这套东西的人,第二段就看不下去了。表面下边其实有更实在的论据,只是对谁都不太友好,包括 GraphQL 本身。

你其实在维护两套 schema

你有 PostgreSQL,PostgreSQL 有自己的 schema。它是权威的、机器可读的,每次迁移都会同步更新,因为迁移本身就是干这个的。

但你还有一个 agent,挂着四十个工具,每个工具都有名字、描述、参数 JSON Schema,还有个活在某个人脑子里的返回值结构。全是手写的,部署节奏跟数据库八竿子打不着。

这第二堆东西也是 schema,只是有损的,而且没有任何机制能感知到第一堆 schema 什么时候动了。没人坐下来宣布"我要维护两套 schema"——你只是决定写四十个工具,然后发现八个月后四十个工具就等于这个局面。

通常听到的抱怨是冗长,但抱怨错了点。真正的问题是漂移是静默的:

PostgreSQL 改动 手写的 REST 工具 生成的 GraphQL schema
新增字段 直到有人手动去改工具,否则无感知 立即可查询
字段改名 500,或者静默丢字段 执行前按字段名校验,报错
新增表 新端点、新工具、新描述、新部署 可查询
收紧了权限 工具仍然声称有权限;运气好的话运行时才失败 该角色 schema 里字段直接消失

第二列才是痛点所在,因为这些失败发生在运行时、循环内部,以一种奇怪的工具返回值形式出现,模型还得想办法绕过去。Agent 绕开坏掉的工具这件事上,能力强得离谱。它会重试、换说法、试试隔壁的工具,最后给你一份信心满满的总结——内容基于一个三月就不存在的字段。等客户问"这数字怎么变了",你才发现问题。

这四行里,字段改名这个我最担心,因为它最容易触发"去检查一下 agent"这个念头。

Introspection,以及为什么"直接 introspect"是错的

Introspection 是修复漂移的方案——让 agent 自己问"现在有什么",而不是提前告诉它。问题是 introspection 默认是穷举式的,而生成的 schema 体积巨大。

一张表在生成的 schema 里不是一个 type。它产生表类型,然后是 _bool_exp_order_by_insert_input_set_input_inc_input(如果字段是数字的话)、_on_conflict_constraint_update_column_select_column_mutation_response_aggregate_aggregate_fields,还有一堆统计相关的 type:_avg_fields_max_fields_min_fields_sum_fields_stddev_fields_stddev_pop_fields_var_samp_fields 等等。你拥有的每张表都有一个计算总体方差的类型,万一以后用得上。

一张表对应二十多个生成的 type,业务逻辑还没写呢。我不引用自己项目里的数字,因为唯一重要的数字是你的。两条命令就能看到:

nhost schema dump --role admin -o schema.admin.graphqls
nhost schema dump --role user -o schema.user.graphqls
wc -c schema.*.graphqls

跑一下,然后把两个文件丢给 tokenizer 看看。admin 那边的数字通常是个惊喜。更有意义的是两个数字之间的差距——那个差距就是这篇文章用字节表达出来的东西。

把原始 introspection 结果直接塞进 context window,等于把 schema 优势兑换成了 token 账单。谁跟你说这样做没问题的,基本是在用四张表做演示。

Schema 就是权限边界

让这一切值得折腾的那个特性,在这类帖子里很少被提到:角色不只是过滤行,还过滤 schema。internal_notes 这个字段如果角色没有 select 权限,角色去查的时候不会得到权限错误——那个字段根本不在它那版的 type 里。一张表没有权限,角色连这张表都看不到。

所以以 agent 身份做 introspection,返回的文档比以 admin 身份 introspection 要小得多,也真实得多。默认值也有帮助。在零信任设置下,新角色默认什么都访问不到,然后你往上一步步授予。这比往 REST 服务里加第四十个端点强多了——后者的默认值取决于周围 handler 当时怎么写的。

把角色收窄能同时压缩两个问题,因为 context 膨胀和爆炸半径一起缩小了。你不再手工维护一个工具列表,而是开始写权限配置,工具列表从权限配置里自然派生出来。

agent 角色对 invoices 表的 select 权限大致长这样:

role: agent
table: invoices
permission: columns: # internal_notes 不在这里。对这个角色来说它不存在。 - id - amount - status - customer_id - created_at filter: organization: members: user_id: _eq: X-Hasura-User-Id limit: 100

这里的租户隔离是个行级过滤器,在数据层做评估,用的是请求中签名 JWT 里的会话变量。不是让 agent 记得加的 WHERE 子句。而 internal_notes 也不是被隐藏了,而是根本不存在——没有任何 prompt injection 能提取一个 type 系统里没有的字段。limit 干的事比看起来多,我后面还会提到。

常见的替代方案是系统提示里写一行"只查询当前用户组织的数据"。那是个请求,不是控制。它跟不可信的用户文本在同一个 context window 里,没有强制执行,没有审计日志,对抗性输入下的失败率已经被大量案例证明了。

手写 REST 当然也能正确执行隔离,这不是能不能做到的问题。问题在于:是在一个声明式的地方统一处理,还是分散在四十个 handler 里,其中某个悄悄少写了个 WHERE 子句。通常是后者,而且缺失的那个往往是当初为了快速出管理报告加的端点,后来根本没打算长期保留。

这些也不是免费的。深度权限过滤器编译进生成的 SQL(关系型数据库管理系统),可能在某些地方变慢,而且直到出问题了才会暴露。征兆是同样的数据,admin 角色查很快,user 角色查很慢。Nhost 文档里有这个问题的说明,指向 PostgreSQL JIT 编译这个常见原因。下结论说 GraphQL 慢之前先了解这一点——它不慢,是你的权限树慢。

会踩的坑

Agent 会写人类不会写的查询,这产生了 GraphQL 本身通常不会有的失败模式。无界列表是最麻烦的,因为模型对基数没有概念——users 对它就是一个词,不是四十万行。这就是权限里那个 limit 存在的原因,而且应该加在每一个非人类角色的 select 权限上,不只是你来得及加的那几个。

嵌套紧随其后,因为对 agent 来说写嵌套零成本,所以它就写了。约束靠深度限制:Nhost 上是 [graphql.security] 下的 maxDepthQueries,在某个计划里才有。自托管的 Hasura 也把这项放在套餐后面,所以你要是没有,就得自己搭网关。设得比你觉得舒服的值更低。Agent 没有合理理由需要查四层深,而它想查四层深的那些场景,恰恰是你不希望它跑的。

同一个配置块里有 forbidAdminSecret,拒绝任何带 admin-secret 头的请求。打开它,"agent 不小心以 admin 身份跑了"就从事后审计的事情变成了结构上不可能的事。文档警告说开启可能影响部署,这是个委婉说法,意思是你的某个东西现在正以 admin 身份跑着,你自己都忘了。

Mutation 是更难看的那一半,因为 GraphQL 会很开心地让你一个调用往三张表里插入。这种部分失败语义跟它听起来一样模糊——规范基本拒绝在错误处理上给意见。Agent 拿到一个部分成功的结果,就会临场发挥。

我避免去发现它临场发挥成什么样:一次 mutation、一张表、一次调用、一个幂等 key。这很无聊,我知道这是回避不是解决。另外默认给 agent 只读角色,写操作放在它必须显式承担的第二个角色后面——introspection 让探索很便宜,而模型好奇心很重。

我还没解决的部分

我没有好答案的是角色激增问题,它直接来自上面的所有东西。窄角色是同时控制 context 和权限的方式。顺着这个思路走下去,你会得到每个 agent 任务一个角色,然后每个租户一个变体,然后reporting agent 的角色和 support agent 的角色"几乎一样"。现在你的权限元数据变成了需要手工维护的东西。

你会注意到,这和手写工具列表的结构性问题是一样的。它更小、更声明式、放在一个地方,所以是个更好的版本。但要是说解决了,我就是在过度推销。如果你在这个问题上有干净的思路,我想听听。

什么时候忽略这些

如果你只有六个端点,写六个工具就行,上面的都不用看。规模小的时候这是过度设计,你会觉得自己很傻。门槛不是表的数量,是变动频率。一旦 schema 变化的速度快过人类记得更新工具描述的速度,手工维护就已经输了——只是还没碰到那个 bug 罢了。

如果你的数据层不是关系型的,大部分论证就失效了,因为权限过滤 schema 这个特性是核心。而这个特性来自关系型存储上成熟的权限层,不是 GraphQL 这种语言本身。GraphQL 跑在手写 resolver 的微服务上,你得到的只是 schema,没有强制执行,这是这个交易里最差的版本。

如果你来这里是因为觉得 agent 用 GraphQL 效果更好,并不是。模型写 GraphQL 的水平一般,写 SQL 一般,写 REST 调用也一般。收益从来不是查询质量——而是表面层不用任何人维护就能保持诚实。

具体怎么搭

我描述的方案是 PostgreSQL 上面叠一个感知权限的 GraphQL 引擎,这就是 Nhost 打包的东西——PostgreSQL、GraphQL、认证、存储、云函数、事件触发器。关键在于认证和权限层是同一套系统。所以上面那个行过滤器里的 x-hasura-user-id 是签名 token 里的 claim,不是应用层断言然后期望它是对的。

它不解决 context 问题,也不阻止你的 agent 写一个蠢查询。你还是得设计角色,这部分才是真正的工作。大多数价值来自花一个下午决定 agent 角色能看什么,没有平台替你过那个下午。

你得到的是:边界在一个声明式的地方,而不是四十个 handler;schema 不可能跟权限漂移,因为它们是同一个东西。