工具是声明式契约,这个认知转变是构建可靠 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 又避免了维护不一致。
禁用逻辑必须前后端双重校验。 只在工具组装层做过滤不够安全,执行层需要二次确认。同时,核心工具不应暴露禁用入口,配置项越多,用户把系统搞坏的概率越大。