· 9 分钟阅读

你的智能体不需要把每一个工具都放进上下文里

DynoTable 的 AI 智能体能够触及 38 个工具。它很少一次看到全部。我们在「模型立刻拿到什么」和「它得自己去找什么」之间划下的那条线,最终成了整个工具集里最有分量的一个决定 —— 而这条线的落点,和我们最初画的地方相去甚远。

我们构建了一套发现机制,好让模型从一个小集合出发,再去搜索其余的部分。然后我们看着廉价模型使用它,于是把 38 个工具中的 27 个搬回了始终可见的核心。机制留了下来。我们关于谁需要它的理论没有。

以下就是我们为那些不能指望它们主动去找的模型构建工具界面时学到的东西。

一个模型从不调用的工具,照样让你付出代价

你暴露的每一个工具,都是它的名字、它的描述和它完整的输入 schema,在用户敲下任何字之前就已经序列化进了请求。38 个这样的东西并不免费。

token 只是账单里较小的那一半。真正的代价是选择准确率:一个模型扫过的近乎雷同的选项越多,它挑错的次数就越多。而我们的目录里满是近乎雷同的选项 —— 这是故意的。我们有五个工具存在两份 —— openTableproposeOpenTableopenWorkbenchproposeOpenWorkbench,等等。每一对做的是同一件事;一个立刻就做,另一个则先发出一个 chip,由用户点击。这个区分对安全性是承重的,而在一份扁平的名字清单里几乎看不见。

自己的客户端指南开篇讲的就是这一点:预先加载每一个工具定义会浪费 token、增加延迟,并让模型的表现变差。同意这一点很容易。决定哪些工具失去座位,才是有意思的地方。

我们造了一个搜索工具。下限模型不肯调用它。

这套机制分两层。一组工具从第一步起就是活跃的。其余的在模型调用 searchTools(query) 之前都是不可见的 —— 该调用会按名字、描述和关键词给目录打分,返回匹配项,并把它们加入模型在后续步骤中被允许调用的工具集合。

工具目录智能体循环模型工具目录智能体循环模型第 1 步 —— 活跃集合 = 内联核心第 2 步 —— 活跃集合已扩大searchTools("export csv")按名字 + 关键词打分startExport, getExportStatus,listActiveExports匹配项(这些名字现在可调用)startExport({tabId})

然后我们拿它去跑我们的下限模型。我们不会对着一个前沿模型去调这个智能体 —— 它跑在你自己的 Bedrock 凭证上,所以人们会挑廉价模型,而我们为最便宜的那一个做优化。被问到一个附件文件时,那个模型跑去翻找打开的标签页清单了。它几乎根本不调用那个搜索工具。任何不直接可见的东西,对它来说就不存在。

这个结果宣判了那个显而易见的设计。如果发现是通往某个工具的唯一路径,那么每一个需要该工具的请求,都取决于模型是否选择去找 —— 而最可能需要帮助的模型,恰恰最不可能开口去问。

于是这条分割线不再是「小核心、大长尾」,而变成了一个关于请求、而非关于工具的问题:用户的措辞会点出这个工具吗? 我们保留为可发现的那 11 个工具,就是答案为「是」的那些。「把这个导出成 CSV」会让模型去搜索 export。「给我看上个月的订单」不会让它去搜索一个设置过滤条件的工具,所以那个工具留在内联里。索引统计、已保存的规格、关系自省,以及暂存改动的那些界面,全都是用户想要时会指名索取、而绝不会隐式触发的东西。

27 个内联不是我们事先会去辩护的数字。它是在与我们真正交付时所对标的那个模型交手之后,活下来的那个数字。

那个本会让发现机制悄悄失效的竞态

发现机制有一个时序约束,很容易搞错,又很难被注意到。

当搜索工具返回匹配项时,那些名字必须_在_模型的下一步被准备好_之前_加入允许集合。最显而易见的落点,是那个「一步完成时触发」的钩子。而文档写明,在某些 SDK 版本里,这个钩子下一步的准备工作之后才触发 —— 也就是说这次变更晚了整整一步。

失败模式很难缠。模型搜索。它拿到一个正确的结果,里面点名了它需要的工具。它在紧接着的下一步调用那个工具,却被告知这个工具不存在。它时有时无,取决于你解析到的是哪个 SDK 版本,而且它读起来像是模型太蠢,而不是承载它的那层框架坏了。

修复办法是在搜索工具自己的执行过程内部去改动这个允许集合 —— 这一步保证会在循环推进之前完成。这只是一行语句放在哪里的差别,而它就是「一套能用的发现机制」和「一套有一定概率失败、且没人会把原因归对的机制」之间的差别。

搜索三次,然后停下

搜索被限制在每个智能体回合 3 次调用。第四次不会执行,而是返回这个:

{"error": "search-budget-exhausted", "budgetCap": 3}

这个上限的存在,是因为一个具体的循环。模型搜索,没找到它想象中的东西,换个同义词再搜,还是没找到,然后在一次都没碰过数据库的情况下,把整个步骤预算烧在了搜索工具里。设上限会逼出一个决定 —— 要么就用已经找到的某个工具,要么去问用户 —— 而这个决定恰好落在继续搜索已经不再划算的那一刻。

当模型调用一个它尚未发现的工具时,错误消息遵循的,是我们对智能体里每一个校验器都采用的同一条原则:

Tool 'startExport' not in active set. Call searchTools(query='startExport')
to discover it, or use one of: <inline tool names>

一条点出了恢复动作的拒绝,代价是多走一步。一条只会说「不行」的拒绝,代价是整个回合。

每个工具一行,其余一切都由此派生

每个工具只声明一次,在一份扁平的清单里,而这一行承载着该工具的完整身份:它的名字和描述、搜索所匹配的关键词、它是内联起步还是可发现、它跑在哪一层,以及它如何通过 MCP 对外暴露。

这些层级和可见性的划分同样重要。21 个工具是静默的 —— 不打扰任何人就跑完的读操作。16 个是受门控的,挡在授权阶梯后面。恰好有一个两边都不属于,因为搜索工具并不是智能体作用在你数据上的一项能力;它是循环本身的一部分。MCP 暴露是同一行上的第三个维度:只读、暂存、完全,或者干脆排除在外 —— 有三个工具就属于这一类。

让这套东西保持诚实的规则是:系统里其他每一份清单都从这些行派生而来 —— 静默层集合、MCP 作用域层级、写入作用域集合 —— 而它们没有一份是手工维护的。一份手工维护的静默清单,旁边再放一份手工维护的 MCP 清单,正是一个工具在聊天里被正确门控、却对外部客户端悄悄放行的经典成因。

我们没料到的那个约束是:这份声明清单必须包含零个运行时 import。它由桌面端 UI 和后端共用,而只要有一个 import,就会经由某个工具的实现,传递性地牵扯到一个只能在 Node 上跑的加密依赖。把它拽进浏览器打包产物,应用就会在模块加载时崩掉。类型检查器和单元测试都抓不到它 —— 两者都能愉快地解析这个 import。能抓到它的,是一个把该文件当作文本读取、只要出现任何 import 语句就失败的测试;这做法看着很糙,直到它第一次救了你。

如果你也在做一个,哪些经验可以迁移

  • 在为你的架构辩护之前,先数一数你的工具。正确的划分是一次测量,而不是一条原则。
  • 拿你最弱的模型去测发现机制。前沿模型该搜的时候就会搜;这关于你的用户实际会挑的那个模型,什么也说明不了。
  • 用「用户自己的措辞会不会点出这个工具」来决定可见性。被隐式调用的工具属于内联;人们会指名索取的工具可以留给发现。
  • 在把任何对顺序敏感的东西放进框架的步骤钩子之前,先查清它们究竟在什么时候触发。
  • 给元工具设上限。任何能被反复调用而不触及真实状态的东西,就一定会被反复调用,而花在搜索上的步骤预算,等于浪费掉一个回合。
  • 让「工具未被发现」的错误点出恢复调用,和其他任何校验器错误一样。
  • 每个工具只声明一次,其余每一份清单都由它派生。两份手工维护的同一批工具清单终将出现分歧,而分歧会在一条安全边界上现形。
  • 如果一个模块承载着编译器无法表达的承重约束,那就写下那个把它当作文本来强制执行的糙测试。

这些跑在哪里

这一切都交付在 DynoTable工具目录之中 —— 在你自己的 凭证上进行具备 schema 感知的查询,而写入永远只会落入一个可审查的暂存区。同一批声明也驱动着外部智能体所连接的 MCP 服务器,在那里,每一行上的暴露层级就变成了外部客户端被授予的作用域;我们如何让那件事变得安全 —— OAuth、用户同意、凭证隔离 —— 则是另一个故事了。

而支撑这一切的底层,也就是那些让这里每一个工具都能被一个廉价模型扛下来的校验器,自成一篇

无需控制台即可使用 DynamoDB

一款快速的 DynamoDB 桌面客户端,可运行 DynamoDB 无法执行的真正 SQL——JOINs、GROUP BY、聚合——并支持可视化编辑和运行在你自己的 Bedrock 密钥上的 AI agent。

30 天免费试用,无需信用卡 — 之后为无时间限制的免费版。