工具是声明式契约,这个认知转变是构建可靠 Agent 的关键一步。

设计动机:从函数到契约的认知转变

Alice 最初的思路也是把函数包装成工具。这能跑起来,但很快遇到了一面墙。

墙的本质是:框架需要了解工具的行为特征,但函数签名无法表达这些信息。一个文件写入函数,类型签名只能告诉你它接收什么返回什么。但框架需要知道的远不止这些:这个工具可以和其他工具并发执行吗?执行前需要用户确认吗?子 Agent 可以调用它吗?计划模式下应该怎么处理?结果太大时应该怎么处理?

这些信息不是执行逻辑的一部分,是工具的元数据。把元数据和执行逻辑放在一起,就得到了声明式工具契约:工具不只告诉框架我能做什么,还告诉框架应该怎么用我。

这个认知转变看似简单,但它决定了整个工具系统的架构方向。如果你把工具当函数,框架就只能做一个调用分发器。如果你把工具当契约,框架就能做智能编排:它知道哪些工具可以并行,哪些需要用户确认,哪些在上下文紧张时应该优先保留。

行业工具系统的四种流派

流派一:函数装饰器流(LangChain / AutoGen)

LangChain 的工具定义最为典型:写一个 Python 函数,加一个装饰器,框架自动从函数签名推断输入 Schema,从 docstring 提取描述。

这种方式开发效率极高,几行代码就能注册一个工具。但它的致命缺陷是:装饰器只能附加有限的元信息。没有字段表达并发安全性,没有字段表达权限需求,没有字段表达输出大小上限。这些信息要么靠上层代码硬编码,要么干脆不处理。

实际后果是,这类框架在遇到多工具并行请求时,只有两个选择:全部串行(安全但慢)或全部并行(快但不安全)。没有中间路径,因为框架不知道每个工具的并发安全性。

流派二:JSON Schema 声明流(OpenAI / Claude)

OpenAI 的 Function Calling 确立了这个范式:用 JSON Schema 定义工具的输入格式,用自然语言描述告诉模型何时使用。

这种方式的优势是标准化程度高,声明和执行彻底分离,适合跨语言跨平台的场景。但 JSON Schema 的定位是描述数据格式,不是描述工具行为。它能告诉模型这个工具接收什么参数,但无法告诉框架这个工具是否并发安全、是否需要权限检查、输出上限是多少。

模型厂商都没有在协议层面提供这些元数据字段,因为它们的立场是:工具的行为管理应该由应用层自行处理。这在 API 设计上是合理的,但对应用开发者来说意味着每个人都要自己造轮子。

流派三:任务委托流(CrewAI / MetaGPT)

这类框架的设计理念和前两种有本质区别。它不把工具当作模型直接调用的函数,而是把工具包装在任务里,由角色认领任务并在任务内部使用工具。模型不直接选择工具,而是选择任务。

这种设计在多 Agent 协作场景下有独特优势:任务天然带有角色归属和执行上下文,不需要额外的权限系统。但它的代价是抽象层次太高。当你需要模型在一次对话中灵活组合多个工具时,任务委托的间接性反而成了障碍。模型想同时读文件和搜索网络,需要创建两个任务分配给角色,这个间接层在简单场景下是纯粹的开销。

流派四:声明式契约流(Alice 的选择)

Alice 的设计吸收了 JSON Schema 声明的标准化优势,但在工具定义中增加了框架级元数据。每个工具不仅声明输入格式和功能描述,还声明行为特征:只读性、并发安全性、权限需求、加载层级。

这几个维度的组合驱动了框架的大量自动化决策,后面会逐一展开。代价是工具注册的复杂度上升:注册一个新工具需要思考这几个维度的取值。但这个前期投入在运行时获得了丰厚回报。

先读后写:一条用血泪换来的硬校验

在 Alice 的所有工具设计决策中,先读后写校验是最容易被忽视但影响最深远的一条。

没有这条校验时发生了什么

早期的 Alice 允许模型直接写入文件而不检查它是否读取过目标文件。模型表现出一种令人不安的自信:它会声称自己知道某个文件的内容,然后基于这个幻觉进行编辑。

有一次,用户要求 Alice 在某个配置文件中添加一个新的配置项。模型没有先读取文件,而是基于训练数据中类似文件的记忆,幻觉出了一整个文件内容,然后在这个幻觉版本上添加了配置项,把真实的文件完全覆盖了。用户的几十个自定义配置项全部丢失。

更隐蔽的情况是:模型读取了项目的 A 文件,然后在工具链的后续环节中被要求修改 B 文件。因为它在上下文中已经有了一些项目代码的印象,它会产生一种它也了解 B 文件的错觉,直接对 B 文件进行手术式修改。但这些修改是基于对 B 文件内容的推测,没有经过实际读取。

校验的实现

校验逻辑并不复杂:在文件写入工具的执行函数中,检查当前会话的上下文里是否包含对目标文件的读取记录。如果没有,直接拒绝执行,并返回一条明确的错误消息告诉模型它需要先读取文件。

对于创建新文件的场景做了特殊处理:如果目标文件本身不存在,则允许直接写入。

校验之后的效果

加入校验后,文件覆盖事故降为零。但出现了一个新问题:在某些场景下(比如创建全新文件),先读后写校验会导致不必要的阻断。模型想创建一个不存在的文件,读取操作返回文件不存在,然后模型才能执行写入。这多了一轮工具调用。

我们的处理方式是:如果读取操作返回文件不存在,这本身就算满足了先读后写的条件。关键是模型确认了文件的当前状态(不存在),基于幻觉假设文件内容的情况因此得以排除。

更深层的启示

先读后写校验反映了一个 Agent 系统设计的核心原则:不要信任模型的记忆,只信任工具返回的事实。模型可能声称它在上下文的很早之前读取过某个文件,但长上下文之后的回忆准确率远低于人们的直觉预期。强制重新读取的代价是一次额外的文件 IO,收益是消除了一整类难以调试的幻觉错误。

工具按需加载:三种方案

当工具数量增长到一定规模时,一个反直觉的现象出现了:工具越多,模型选对工具的概率反而下降。

工具数量对选择准确率的非线性影响

我们实测过工具数量和模型选择准确率的关系。总结起来是:

  • 工具数量少的时候,选择准确率极高,基本不出错
  • 工具数量增长到中等规模,开始偶尔选错名称相似的工具
  • 工具数量继续增加,在功能重叠的工具间犹豫变多
  • 工具数量较多时,准确率明显下降,出现在不需要工具时强行调用的情况
  • 工具数量很多时,出现遗忘效应,完全忽略某些工具的存在

这个衰减不是线性的。前期下降缓慢,后期急剧。这是因为模型在工具选择时的注意力分配遵循类似长尾分布的规律:头部几个常用工具总能被正确选择,但尾部工具的区分度随总数增加而急剧下降。

方案 A:全量注入

每次 LLM 调用都传入所有可用工具。

优势:实现最简单,不会遗漏任何工具。模型在任何时刻都能调用任何工具,灵活性最高。

代价:token 成本高(每个工具的描述和格式规范都占用 token,工具越多固定开销越大)。更严重的是选择准确率下降。当模型面前摆着大量工具时,它花在理解工具列表上的注意力会挤占真正推理的空间。

适用条件:工具总数较少的简单场景。

方案 B:分层注入

把工具分成不同的层级,根据场景只注入需要的层级。

Alice 的实现是三层:核心层包含 Agent 运行的基础工具(比如文件读写、搜索、命令执行),无论什么场景都注入。能力层包含特定能力的工具(比如记忆管理、图像生成、网络搜索),根据当前任务或用户设置决定是否注入。扩展层包含 MCP 工具和低频使用的自建工具,只在用户显式启用或上下文明确需要时注入。

优势:在保证核心能力的前提下减少工具数量,提升选择准确率。核心层工具数量有限,加上按需的扩展层通常总数可控。

代价:层级的划分需要仔细设计。一个工具放错了层级可能导致模型在需要它时找不到。而且层级之间的边界并非总是清晰的,有些工具在某些对话中是核心需求,在另一些对话中完全用不上。

适用条件:工具数量中等,且可以按使用频率和场景做合理的层级划分。

方案 C:动态发现

完全不预注入工具列表,让模型在需要时通过一个专用的搜索工具去发现可用工具。

优势:理论上可以支持无限数量的工具。模型只在需要时才查询相关工具,不会被无关工具干扰。

代价:多了一轮交互延迟(先搜索再调用)。更大的风险是:模型可能不知道自己应该搜索什么。如果用户说「帮我查一下明天的天气」,模型需要先意识到这个任务需要一个天气工具,然后用合适的关键词去搜索。如果搜索质量不高或关键词偏差,模型可能找不到最合适的工具。这本质上是把工具选择的难题从一次选择转移成了两次选择(先选关键词再选工具),总体错误率不一定更低。

适用条件:工具数量极多且来源高度异构(大量第三方外部工具)的场景。

Alice 的选择

Alice 采用方案 B 作为主策略,保留向方案 C 演进的接口。当前工具总数处于分层方案可有效管理的范围内。但外部工具生态在快速增长,未来用户可能接入大量外部工具源,届时需要迁移到动态发现方案。

工具指引内嵌:从系统提示词迁移到工具声明

设计动机

早期的 Alice 和很多 Agent 一样,把工具的使用指引放在系统提示词里。比如写上:当需要修改文件时,必须先确认当前状态再写入。

这种做法在工具少的时候可以接受,但随着工具数量增长,系统提示词里的工具指引段落越来越长,带来了三个问题。

第一,稀释效应。系统提示词的总 token 数越多,模型对其中每一段指引的关注度越低。当系统提示词里同时包含人格设定、安全规则、输出格式要求、工具使用指引、记忆内容,每个部分能分到的注意力权重都在下降。

第二,维护地狱。工具指引和工具定义分散在两个地方:描述在工具注册代码里,使用指引在系统提示词模板里。修改一个工具的行为需要同时更新两个地方,忘了其中一个就会出现不一致。

第三,条件性指引困难。有些指引只在特定工具被注入时才有意义。如果工具被分层系统排除了,它的指引仍然占用系统提示词空间,不仅浪费 token,还可能误导模型去使用一个不存在的工具。

工具使用指引内嵌的设计

每个工具声明中增加一个使用指引字段,内容是该工具的使用说明。框架在组装系统提示词时,自动收集所有被注入工具的使用指引,合并到工具指引段落中。

这意味着工具的使用指引和工具的生命周期完全绑定:工具被注入,指引自动出现;工具被排除,指引自动消失。功能描述告诉模型这个工具做什么,使用指引告诉模型在什么条件下使用、有什么注意事项。两者共同构成完整的工具使用说明。

代价分析

代价是工具注册代码变得更长。每个工具除了名称、功能描述、参数格式之外,还要写使用指引。但这个代价换来了两个收益:系统提示词的 token 使用更高效(只包含真正需要的指引),以及工具的所有定义信息集中在一处维护。

另一个微妙的代价是:工具使用指引的内容最终还是会被注入系统提示词,总 token 量并没有减少(当所有工具都被注入时)。但分层系统通常会排除一半以上的工具,动态组装的系统提示词比静态写死的要短得多。

工具禁用:前后端双保险

为什么只做一端不够

用户在设置界面关闭了某个工具,比如关闭了命令执行工具(因为不想让 Agent 执行系统命令)。前端把这个配置存入设置,下次组装工具列表时不注入该工具。看起来没问题。

但如果只在前端(工具组装层)做过滤,有一个隐蔽的风险:当框架代码有 bug、或者某个条件分支跳过了过滤逻辑时,被禁用的工具仍然可能被注入。模型一旦看到工具列表里有该工具,就可能调用它。

另一个场景是 MCP 工具的注入。MCP 工具的注入路径和内置工具不同,如果禁用逻辑只在内置工具的注入点做了检查,MCP 工具的同名工具可能绕过禁用。

后端执行层的二次校验

Alice 的设计是:工具禁用在组装层和执行层各做一次检查。组装层不注入被禁用的工具(模型看不到它们),执行层在收到工具调用时再次检查目标工具是否在禁用列表中(即使模型通过某种方式调用了它也不执行)。

不可关闭的核心工具

有一组工具被标记为不可关闭,用户无法通过设置界面禁用它们。这组工具包含 Agent 运行所必需的基础能力:如果用户关闭了文件读取工具,Agent 连代码都看不了;如果关闭了所有交互工具,Agent 无法和用户沟通。

不可关闭工具的列表是硬编码的,不通过配置管理。这是一个刻意的设计:配置项越多,用户把系统搞坏的概率越大。有些配置项不应该暴露给用户。

工具输出大小限制:三种方案的取舍

Agent 执行工具后,结果需要写回 LLM 上下文。当结果很大时(比如命令执行返回海量文件列表),直接放入上下文是灾难性的。

方案 A:截断

超过长度上限的结果直接截断,只保留前面部分。

优势:实现最简单,延迟最低。

代价:信息丢失。模型看到的只是结果的开头部分,可能遗漏关键信息。尤其是搜索结果,有价值的匹配可能出现在结果的中段或末尾。截断后模型甚至不知道还有更多结果。

方案 B:摘要

用 LLM 对大结果做摘要,把大量内容压缩到一段概要。

优势:保留了信息的全貌,模型能理解完整结果的概要。

代价:摘要本身是一次 LLM 调用,增加了延迟和成本。而且摘要可能丢失细节:模型想知道某个特定文件的路径,摘要可能因为判断这个路径不重要而将其省略。摘要的质量也取决于用什么模型做摘要,用大模型成本高,用小模型质量差。

方案 C:大结果落盘

超过长度上限的结果写入临时文件,返回文件路径和摘要提示。模型如果需要详细内容,可以再用文件读取工具去读。

优势:不丢失信息(完整结果在文件里),不增加上下文负担(只返回路径和提示),不需要额外的 LLM 调用。

代价:模型需要多一轮工具调用才能获取详细内容。对于只需要结果概要的场景,这一轮调用是浪费。而且临时文件的生命周期管理需要考虑:文件什么时候创建、什么时候删除、存储在哪个目录、磁盘空间怎么控制。

Alice 的选择

Alice 以方案 C 为主,辅以方案 A 的安全兜底。具体逻辑是:执行工具后检查结果大小是否超过阈值(每个工具可以自定义上限),超出则写入临时文件并返回路径提示,未超出则直接返回。如果工作目录不可用(只读文件系统等极端情况),降级为截断。

临时文件在进程退出时自动清理。这避免了临时文件堆积的问题,代价是异常退出时可能残留临时文件。我们选择接受这个小风险,引入复杂的定时清理机制不值得。

工具元数据的关键维度

Alice 的每个工具声明包含几个框架级元数据维度,它们的组合驱动了大量自动化决策。

权限需求

声明这个工具是否需要权限检查。需要权限的工具在执行前会触发权限系统的判断流程,可能需要用户确认。

文件读取工具不需要权限,因为读取不产生副作用。文件写入工具需要权限,因为写入是不可逆的。命令执行工具需要权限并且标记为高风险,因为它可以执行任意命令。

这个维度的价值在于:它让权限系统知道哪些工具需要关注,哪些可以直接放行。没有这个信息,权限系统要么对所有工具都做检查(太慢),要么对所有工具都不检查(不安全)。

只读性

声明这个工具是否不修改外部状态。只读工具在计划模式下可以真正执行(让模型能观察真实数据来制定计划),非只读工具在计划模式下只输出计划描述而不实际执行。

这个维度还影响子 Agent 的行为:只读工具在子 Agent 中可以更放心地使用,因为即使子 Agent 行为失控,只读工具也不会造成破坏。

并发安全性

声明这个工具是否可以和其他工具并行执行。这个维度有一个独特设计:它可以是静态声明,也可以根据输入参数动态判断。

大多数工具的并发安全性是固定的:文件搜索永远安全,文件写入永远不安全。但有些工具的安全性取决于输入参数。最典型的是子 Agent 工具:如果子任务是只读的(比如搜索代码),多个子 Agent 可以并行;如果子任务涉及文件修改,必须串行。

允许并发安全性为动态判断,让框架能做出更精细的编排决策。代价是工具注册者需要思考更多边界情况,但这个思考本身就是在做好的工程设计。

加载层级

声明工具属于哪个层级,决定它在什么条件下被注入到 LLM 的工具列表中。分层系统的设计在前面已经详细讨论过。

多维度的组合效应

这几个维度不是独立工作的,它们的组合产生了丰富的调度语义:

组合 调度行为
只读 + 并发安全 可并行、可在计划模式执行、无需权限,最自由的工具
非只读 + 需要权限 执行前需用户确认,计划模式下不实际执行
非只读 + 非并发安全 串行执行,需权限,计划模式不执行,最受限的工具
扩展层 + 需要权限 仅在显式启用后可用,且每次使用都需确认

这种基于元数据的声明式调度,让框架无需硬编码任何特定工具的名称。新增一个工具时,只要正确声明各个维度,框架自动知道怎么编排它。

并发调度:从简单到精细

当一轮对话里 LLM 发起多个工具调用时,应该串行还是并行?

最简方案:全部串行

安全,但慢。多个工具调用如果每个都需要几秒,累积等待时间足以让用户以为系统卡死了。

暴力方案:全部并行

快,但可能产生竞态。如果两个工具都写同一个文件,结果取决于操作系统的调度顺序,这是不可接受的非确定性。

Alice 的方案:基于声明的批次调度

把工具调用列表按并发安全性分成批次:连续的并发安全工具合为一批并行执行,不安全的工具单独一批串行执行。

举例说明:假设一轮中有四个工具调用,前两个是只读操作(如搜索),第三个是写操作,第四个又是只读操作。它们会被拆成三批。第一批前两个只读操作并行。第二批写操作单独执行。第三批最后的只读操作单独执行(因为前面有一个不安全的工具打断了连续性)。

这个策略在大多数场景下接近最优:安全的工具不被不必要地串行化,不安全的工具不被冒险地并行化。代价是调度逻辑比全部串行或全部并行复杂得多,需要仔细处理批次之间的错误传播(如果第一批的某个工具失败了,后续批次是否继续执行)。

执行上下文注入

工具执行时,除了用户提供的输入参数,还会注入一个执行上下文。这个上下文包含工具运行所需要的一切环境信息:工作目录、会话标识、权限申请回调、中止信号、密钥获取函数、当前设置、事件发射函数、渠道上下文等。

关键设计原则是:工具代码不依赖任何全局变量,所有需要的东西通过上下文注入。这让工具可以在任何环境里运行(主进程、独立线程、测试环境),也让工具的依赖关系清晰可见。

一个反面教训是:早期有几个工具直接引用了全局的设置对象。当我们尝试在独立线程里运行这些工具时,因为独立线程没有全局设置对象,工具直接崩溃了。把所有外部依赖统一收口到执行上下文之后,这类问题彻底消失。

子 Agent 的工具限制

子 Agent 不应该拥有与父 Agent 相同的完整工具集。这个限制不是技术上做不到,是必须做的安全边界。

需要限制的工具类型包括几大类:防止子 Agent 递归创建孙子 Agent 的工具(避免失控的链式派生)、用户交互类工具(子 Agent 不能直接弹窗问用户,信息传递应该通过父 Agent 中转)、会话级控制类工具(这是全局设置,子 Agent 不应改变)、渠道发送类工具(消息发送由主 Agent 统一负责)、记忆写入类工具(子任务的中间状态不应污染长期记忆)。

MCP 工具是一个例外:子 Agent 需要同样的外部工具能力来完成任务,MCP 工具始终保留。这是因为 MCP 工具通常提供的是领域能力(操作笔记、查询数据库),子 Agent 在执行子任务时很可能需要这些能力。

自建工具 vs MCP 工具:判断框架

什么时候把能力做成内置工具,什么时候做成 MCP 工具?这个决策在 Alice 的开发过程中反复出现。

核心判断维度

维度 倾向自建 倾向 MCP
核心程度 Agent 运行的基础能力 可选的增强能力
性能要求 需要低延迟、高频调用 可接受额外延迟
安全敏感度 涉及本地文件系统 中低敏感度
维护频率 接口稳定、很少变化 外部 API 经常变化
用户覆盖面 几乎所有用户都需要 特定用户群需要

一条经验法则

如果一个工具的维护成本主要在适配外部 API 变化上,做成 MCP。如果维护成本主要在优化与 Agent 循环的配合上,做成内置。

文件读写、命令执行、搜索这些工具需要和上下文管理、权限系统、并发调度深度配合,做成内置。笔记操作、日历查询、特定 SaaS 集成这些工具的复杂度主要在外部 API 适配上,做成 MCP。

内置保底,MCP 增强。这个模式在实践中被反复验证。

工具的类型分类

按功能分类,Alice 的工具体系覆盖以下几类:

类别 例子 并发安全性 权限需求
文件系统 读文件、写文件、搜索 读安全、写不安全 读免确认、写需确认
网络 搜索、抓取网页 并发安全 注意频率限制
系统执行 命令行、运行脚本 非并发安全 需要权限确认
记忆管理 读/写项目记忆、向量搜索 读安全、写不安全 写需确认
外部 API 天气、股价、日历 通常并发安全 通常免确认
AI 能力 图像生成、子 Agent 根据输入动态判断 根据场景决定
交互 询问用户 特殊处理 无需确认(本身就是交互)
自进化 修改 UI、生成页面 非并发安全 高权限,严格限制

这个分类不是为了学术上的整洁,是为了驱动实际的调度决策。每个类别对应一组典型的元数据取值,新增工具时可以参考同类别工具的配置,降低配置出错的概率。

可迁移的结论

工具是声明式契约。 函数只告诉框架能做什么,契约还告诉框架应该怎么用。元数据维度(只读性、并发安全性、权限需求、加载层级)的组合让框架能做出智能的编排决策,新增工具时无需修改框架核心代码。

先读后写是必须的硬校验。 不要信任模型的记忆,只信任工具返回的事实。这条规则消除了一整类幻觉驱动的文件破坏事故,代价仅仅是偶尔多一轮文件读取。

工具数量超过一定规模就必须考虑分层加载。 全量注入在小规模场景下可行,但工具数量和选择准确率的非线性关系意味着,超过某个临界点后准确率会急剧下降。分层注入是目前性价比最高的方案。

工具指引应该跟随工具的生命周期。 把使用指引从系统提示词迁移到工具声明内部,让指引随工具的注入和排除自动出现或消失,既节省了 token 又避免了维护不一致。

禁用逻辑必须前后端双重校验。 只在工具组装层做过滤不够安全,执行层需要二次确认。同时,核心工具不应暴露禁用入口,配置项越多,用户把系统搞坏的概率越大。

上一章:Agent 主循环 · 下一章:上下文与记忆

本文由 洛小山 发布。引用或转载时,请保留原文链接。