Agent Harness 中的工具搜索

Harness 中的工具问题

在自己做 harness 时,很容易遇到工具的输入输出膨胀带来的过多上下文占用等问题,特别是需要引入 MCP 支持时,一个 MCP 可能就包含数十个工具调用。

以下是 Anthropic 总结的几种常见工具问题和对应策略:

工具定义导致的上下文膨胀 → 工具搜索工具
庞大的中间结果污染上下文 → 程序化工具调用
参数错误与调用格式不规范 → 工具使用示例

其中工具搜索工具是指添加额外的检索步骤来在 Agent 执行任务时动态发现工具目录,从而避免一次性加载太多工具;程序化则是指写 Python 之类的代码来编排工具调用以解决问题,这种方式在提高调用效率的同时解耦了上下文(只有编排后的输出被返回),且这么做的方式也是生成式语言模型的强项;而工具使用示例则是通过 one-shot 的思路来方便模型理解工具接口语义。

为了避免注意力丢失(实际还没空处理后面的问题),仅简单展开工具搜索工具这个解决方案——本质是在 Agent Loop 中对上下文中 Agent 的工具可见性进行控制。

主流 Agent 的最佳实践

随着模型工具调用能力的演进,旧的 Chat Completions 面向对话的设计在 Agent Loop 中不再合适,主流厂商都针对 API 的工具调用协议进行了拓展。在 Anthropic Messsage 和 OpenAI 的 Response API 中,工具调用在接口协议中属于一等公民,各家都针对工具调用场景做了参数设计和优化。但是由于开放的模型服务提供商还都在使用旧的 Chat Completions 接口,所以只能在 Agent 客户端侧定义工具 Schema 和模拟工具加载行为。

最新的主流实践中一般对于工具发现采用渐进式加载的策略,使用 defer_loading 参数来控制工具的延迟加载,设置为延迟加载的工具默认不会进入上下文,而是在需要用到工具搜索时按需加载。反之非延迟加载的常用工具会始终加载进上下文中。

Anthropic

在 Claude 的 Agent Loop 中,一个简单的 get_weather 工具定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "get_weather",
"description": "Get current weather for a location",
"input_schema": {
"type": "object",
"properties": {
"location": { "type": "string" },
"unit": { "type": "string", "enum": ["celsius", "fahrenheit"] }
},
"required": ["location"]
},
"defer_loading": true
}

Agent 视角的流程语义如下:

  1. 我需要工具查询天气
  2. 使用工具搜索工具(tool_search_tool_*
  3. 拿到工具搜索结果 get_weather
  4. 带参数调用 get_weather
  5. 获取天气工具返回

OpenAI

区别于类似 Claude 在服务端执行的工具搜索,OpenAI Response 还支持客户端工具搜索的三段式的调用:

  1. 模型发出一个 tool_search_call 并等待返回

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [
    {
    "type": "tool_search_call",
    "execution": "client",
    "call_id": "call_abc123",
    "status": "completed",
    "arguments": {
    "goal": "Find the shipping ETA tool for order_42."
    }
    }
    ]
  2. 客户端应用负责执行搜索,并返回一个包含其要加载工具的 tool_search_output

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    [
    {
    "type": "tool_search_output",
    "execution": "client",
    "call_id": "call_abc123",
    "status": "completed",
    "tools": [
    {
    "type": "function",
    "name": "get_shipping_eta",
    "description": "Look up shipping ETA details for an order.",
    "defer_loading": true,
    "parameters": {
    "type": "object",
    "properties": {
    "order_id": { "type": "string" }
    },
    "required": ["order_id"],
    "additionalProperties": false
    }
    }
    ]
    }
    ]
  3. 在下一轮调用时,已加载的工具即可通过 function_call 调用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    [
    {
    "type": "function_call",
    "name": "get_shipping_eta",
    "namespace": "get_shipping_eta",
    "call_id": "call_xyz456",
    "arguments": "{\"order_id\":\"order_42\"}"
    }
    ]

Hermes

对于 Hermes 这种开放 Agents,为了对接各种模型服务提供商,只能选择兼容最通用的 Chat Completions 格式了。一个常见的 /v1/chat/completions 请求格式如下:

1
2
3
4
5
6
7
8
{
"model": "...",
"messages": [
{"role": "system", "content": "..."},
{"role": "user", "content": "..."}
],
"tools": [...]
}

工具定义放在单独的工具数组中。理论上,客户端可以简单动态修改工具数组来控制模型的工具可见性。

Yes, But… 这里涉及到模型服务缓存命中的问题——主流模型服务端在将 API 转换为模型输入的过程中,一般遵循以下序列:

1
2
3
4
[工具定义]
[系统/用户提示]
[历史消息]
[本轮消息]

根据 KV Cache 的原理要求输入序列相同,那么就意味着工具定义部分不可轻易修改,修改一个工具数组中的 token 也会导致之前的缓存全部失效。

OpenAI 在工具搜索的说明中也明确了这一点:

工具搜索使模型能够根据需要动态地查找并加载工具到模型的上下文中。这样可以避免将所有工具的定义预先加载到模型的上下文中,从而有助于降低总的 token 使用量和成本。为了实现最优的成本与延迟,工具搜索设计为保留模型的缓存。当模型发现新的工具时,这些工具会被插入到上下文窗口的末尾。

对于缓存利用的需求和协议层面的妥协,意味着 Hermes 需要实现客户端侧的工具搜索来控制上下文序列,从而模拟工具定义的渐进式加载过程。

实现上 Hermes 首先会对 MCP 等非核心工具 Schema 进行估算,当超过最大上下文长度的 10% 后,则会触发延迟加载。Hermes 定义了三种工具来实现延迟加载:

1
2
3
tool_search(query, limit?) — 搜索延迟加载工具目录
tool_describe(name) — 获取工具完整定义
tool_call(name, arguments) — 调用工具

这种情况下,模型在工具数组中是看不到 MCP 等工具定义的,只有当触发调用 tool_search -> tool_describe 的过程后,才会使用 tool_call 工具在客户端解包调用真实工具。每次工具搜索到调用的完整过程都会作用在上下文的尾部而不进入工具数组,从而避免缓存失效。

工具搜索的工作原理

Hermes 直接使用 BM25 来检索工具。

Claude 服务端则提供了两种工具搜索模式:

  • 构建正则表达式来查找工具
  • 使用 BM25 通过自然语言来检索工具

其中前者很好理解,主要解决的是检索命中问题,而后者解决的则是相关度排序问题。

什么是 BM25

如何在大量文档(工具说明)中排序查询词(工具调用)的相关性?

最简单的判断维度是词频(TF,Tern Frequency),查询词在文档中出现的频次越高,越说明文档相关。

但是对于很多助词,比如 the 等,在文档中的词频往往都很高而导致很容易影响检索。这是就需要考虑逆文档频率(IDF,Inverse Document Frequency),即如果一个词到处都存在,则意味着它的信息量很低,应当减小它在检索中的权重。

TF-IDF 就是以上两点基本思路的结合,可以理解为“查询词如果在目标文档中经常出现,但是在所有文档中很少见,则意味着其和当前文档更相关“。所以 TF-IDF 常被用于检索,比较常见的场景是网页搜索,用于确定网页和查询的相关性。

简单理解的话,$\text{TF-IDF}=\text{词频}\times\text{词的稀有程度}$。

BM25(Best Matching 25,其中数字仅代表编号),则是继承了 TF-IDF 核心思想的一种算法,主要解决两个问题:

  • 词频(TF)在相关性计算中,带来的权重影响很容易线性增加。比如 0-1 的从无到有可能对相关性判断很有价值,但是 50-100 的情况下并不意味着相关性具备数值上的明显差异。所以需要减弱词频增加的收益。
  • 长文档更容易获得词频分数,所以需要对所有文档长度做归一化。

从直觉理解公式组成的话:

$\operatorname{score}(D,Q)=\sum_{q\in Q}\underbrace{\operatorname{IDF}(q)}_{\text{词有多稀有}}\times\underbrace{\frac{f(q,D)(k_1+1)}{f(q,D)+k_1\left(1-b+b\frac{|D|}{\operatorname{avgdl}}\right)}}_{\text{出现次数贡献,并按文档长度修正}}$

用一句最简单、最直白、最不绕弯的话说,$\text{BM25}=\text{稀有程度}\times\text{出现频率贡献}\times\text{长度修正}$。

附带由 gpt 提供的可视化理解:

分词结果
deferred tools 的 BM25 排名

索引字段:工具名称 + 工具描述 + 参数名称

参考