工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
本文将讲解 AI Agent 里的 tool search 机制——当工具数量多到占满上下文窗口时,怎么按需加载模型需要的工具。本文内容覆盖实现原理、匹配算法、缓存影响,并配合 x-code-cli 的源码来讲解。
Tool 和 MCP Tool
LLM 本身不能执行操作
大语言模型(LLM)是纯文本生成器,只能接收文本输入并输出文本应答,无法主动执行外部操作。
AI Agent 之所以能做这些事,是因为模型可以通过工具调用的方式来执行这些操作。例如模型输出指令——“我要调用 readFile 工具,参数是 package.json”——客户端(这里指 MCP 客户端,即实现了 MCP 协议的 AI Agent 应用,如 x-code-cli、Claude Code、Cursor 等)代码将解析这条指令,并执行实际的文件读取操作,再把读到的内容传回给模型。模型拿到文件内容后继续推理,决定下一步做什么。
这些可被模型调用的操作——读文件、执行命令、搜索代码、查数据库、联网搜索——统称为 Tool(工具)。每个工具由三部分组成:一个名字(name)、一段自然语言描述(description,告诉模型这个工具能做什么)、一份 JSON Schema(inputSchema,定义工具接受什么参数)。以 x-code-cli 的 readFile 工具为例:
{
"name": "readFile",
"description": "Read the contents of a file at the specified path.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The absolute path of the file to read"
},
"offset": {
"type": "number",
"description": "Line number to start reading from (1-based)"
},
"limit": {
"type": "number",
"description": "Maximum number of lines to read"
}
},
"required": ["path"]
}
}
这三部分会被序列化后放进每次 API 请求的 tools 数组。模型根据 description 判断什么时候用这个工具,根据 inputSchema 生成合法的参数。一个工具的定义越复杂(参数多、描述长),占用的 token 就越多。
下面是 x-code-cli 里 Agent Loop 的执行流程:
在整个过程中,模型自主决定调用什么工具、传什么参数。客户端只负责执行和传递结果。这个循环可能执行一次,也可能执行很多次,主要取决于任务的复杂度。
工具数量的增长
内置工具
一个 Agent 产品刚上线时,内置工具的数量可能不多。但是随着产品的迭代,内置工具会逐渐增加。例如联网搜索、网页抓取、子 agent 委派、待办管理、后台任务管理等工具。Claude Code、Codex、Cursor 这些主流 AI Agent 产品都经历了这个增长过程,一个成熟的 AI Agent 产品拥有几十个内置工具是很正常的。
MCP 工具
MCP(Model Context Protocol,模型上下文协议)是一套让 Agent 接入外部服务的开放协议。例如接入 GitHub MCP server,Agent 就能创建 issue、合并 PR;接入一个数据库 MCP server,Agent 就能执行 SQL 查询。
各个 MCP Server 会通过 tools/list 接口向客户端注册自身对外开放的全部工具;该接口采用全量返回模式,客户端在建立与某一服务端的连接后,即可一次性获取该服务端完整的工具定义清单。
----------这里要补一个 mcp server 连接的 mermaid 流程图-------------
MCP tool 和内置 tool 在结构上没有区别,都是名字 + 描述 + JSON Schema。区别只在于是谁提供的:内置 tool 是产品代码里写死的,MCP tool 是外部 server 动态注册的。
一个 MCP server 提供的工具数量往往远超内置工具。以一个加密货币交易所的 MCP server 为例,它覆盖现货交易、合约交易、资产划转、行情查询、余额查询等操作,一共 400 个工具。如果用户本身又接了一些其他的 MCP server,工具总数很容易超过 400 个。
工具数量增多带来的问题
如果要让大模型调用这些工具,所有工具定义必须随请求一起发给模型。由于大语言模型本身是无状态的,每次发起推理请求时,都需要重新携带完整的工具列表。
一个中等复杂的工具定义大约 150–300 token(名字几个 token、描述一两行、JSON Schema 里的属性名和类型描述是大头)。400 个工具 × 平均 160 token ≈ 64,000 token。用户还没开始提问,光工具定义就占掉了 64k token。
我们来看一下 x-code-cli 里调用 LLM 时的请求结构。下面是 packages/core/src/agent/loop.ts 里 runTurn 函数的核心代码(简化后):
result = streamText({
model, // LLM 模型实例
system: cached.system, // System Prompt(系统提示),约 2-5k token
messages: cached.messages, // Messages(对话历史 + 用户消息)
tools: cached.tools, // Tools(工具定义)← 全量注入时 64k token
maxRetries: 3,
abortSignal: options.abortSignal,
maxOutputTokens: getMaxOutputTokens(options.modelId),
providerOptions: mergedProviderOptions,
})
一次 API 请求的输入由 system、tools、messages 三个字段组成。token 的分布大致是这样的:
一次 API 请求的 Token 构成:
┌────────────────────────────────────┐
│ system(系统提示) │ 约 2-5k token
├────────────────────────────────────┤
│ tools(工具定义) │ ← 全量注入时:64k token(400 个工具)
├────────────────────────────────────┤
│ messages(对话历史 + 用户消息) │ 根据对话长度变化
└────────────────────────────────────┘
如果模型的上下文窗口是 128k,工具列表占掉了将近一半
关于费用需要说明一点:主流 LLM API 都有前缀缓存(prefix caching)。system + tools 这些每轮不变的前缀部分,第一次请求按全价计费,后续请求命中缓存后按折扣价计费(通常是原价的 10%–50%),所以不是每轮都需要付全价。
但 64k token 的工具列表即使命中缓存,也在持续产生开销:它占用了上下文窗口的物理空间,增加了首字延迟(TTFT),挤压了留给对话历史和用户消息的空间。工具列表越大,能用来做实际对话的窗口就越小。
这就引出了本文的主题——tool search。
Tool Search
tool search 是一个用于“按需加载工具定义”的内置工具。默认情况下,所有工具的完整定义(名字 + 描述 + JSON Schema)都会放进每次请求的 tools 数组里。tool search 改变了这个策略:只在系统提示里放一份工具名字清单,不放完整定义;当模型判断需要调用某个工具时,先通过内置的 toolSearch 工具加载该工具的完整 schema,下一轮再执行调用。
模型仍然知道有哪些工具——系统提示里列出了所有工具的名字。但每个工具的完整定义(描述 + JSON Schema)不会预先放进请求,而是等模型实际要调用时再加载。这样上下文占用就从全量 64k 降到了只加载工具名字清单的 3-5k,再加上按需加载的少数几个工具的 schema。
Deferred 机制
Deferred 直译是“延迟”。在 tool search 的语境下,deferred 工具就是被延迟加载的工具——它的完整定义(描述 + JSON Schema)不随请求发送,只在模型通过 toolSearch 主动请求时才加载进来。与之对应的是直接加载的工具(directly loaded),即每轮请求都带着完整 schema 的核心工具。
下面来看按需加载具体如何实现,主要有以下几个步骤:
第一步:客户端照常从 MCP server 全量拿到工具。 tools/list 接口还是全量返回。客户端启动时从每个 MCP server 拉到所有工具的完整定义——名字、描述、JSON Schema——全部存在本地内存里。这一步和没有 tool search 时完全一样。
第二步:只把工具名字告诉模型。 完整的工具 schema 不放进请求的 tools 数组。工具名字按 server 分组,写在系统提示的 ## Deferred Tools 段里。模型每轮都能看到这份名字清单,知道有哪些工具可用,但看不到每个工具接受什么参数。
下面是 x-code-cli 实际生成的 ## Deferred Tools 段(截取部分):
## Deferred Tools
The tools below are available but NOT loaded — only their names are listed,
with no schema. To use one, first call `toolSearch` (keyword search, or
`select:<exact_name>` to load specific tools by name); its schema is then
added to your tool set and it becomes directly callable on your next step.
Core tools (readFile, writeFile, edit, shell, grep, glob, listDir, task)
are always loaded — never search for those.
### Built-in
- webSearch, webFetch, todoWrite, activateSkill
### Server: gate-mcp
- mcp__gate__cex_spot_get_ticker, mcp__gate__cex_spot_create_order,
mcp__gate__cex_spot_get_balance, mcp__gate__cex_futures_get_ticker,
... (共 400 个工具)
### Server: github
- mcp__github__create_issue, mcp__github__list_pull_requests,
mcp__github__merge_pull_request, ...
可以看到,这段文字包含两部分信息:一是使用说明(告诉模型怎么通过 toolSearch 加载工具),二是按来源分组的工具名字清单。模型每轮都能看到这份清单,但看不到任何工具的参数定义。
第三步:给模型一个内置的 toolSearch 工具。 当模型需要调用某个 deferred 工具时,它先调用 toolSearch,传入关键词或工具名。客户端在内存里的工具目录上做匹配,把命中的工具加入下一轮请求的 tools 数组。模型在下一轮就能像调任何普通工具一样调用它。
要强调的一点:deferred 是客户端做的事。MCP 协议的 tools/list 依旧全量返回,协议本身没有按需发现的能力。deferred 机制完全是客户端在拿到全量工具列表之后,自行决定哪些放进请求、哪些延迟加载——这是客户端的策略,不是协议的功能。
模型只看到名字,怎么找到对的工具?
如果只提供工具名称、不附带对应的 Schema,模型无法明确该工具的入参结构。为了解决该问题,系统设计了多层校验保障机制。
工具名称自身已具备充足的语义描述能力。 mcp__github__create_issue、cex_spot_get_ticker 这种名字,模型一看就知道是什么功能。MCP 工具名一般遵循 server__action 或 server__resource__verb 的命名规范,名字本身就是一层语义索引。如果工具名全是 tool_001、tool_002,这套方案就不成立了。
名单放在模型看得到的地方。 系统提示的 ## Deferred Tools 段按 server 分组列出了所有 deferred 工具名。模型不是盲目搜索——它看到了完整名单,只是没看到每个工具的参数定义。
客户端完成 toolSearch 的匹配运算。 toolSearch 支持两种调用方式:关键词搜索和精确匹配。
方式一:关键词搜索。 模型不确定工具的确切名字时使用。客户端拿 query 参数和每个工具的预处理文本做关键词匹配,按得分排序,返回前几个。
用一个例子来说明。假设 deferred 目录里有这几个工具:
工具目录(每个工具在注册时预处理好搜索文本):
工具名 搜索文本(名字拆词 + 描述 + schema 属性名)
───────────────────────────────── ────────────────────────────────────────
mcp__gate__cex_spot_get_ticker gate cex spot get ticker 获取现货行情 currency_pair
mcp__gate__cex_spot_create_order gate cex spot create order 创建现货订单 currency_pair side amount price
mcp__gate__cex_spot_cancel_order gate cex spot cancel order 取消现货订单 order_id
mcp__github__create_issue github create issue 创建 issue title body labels
模型传入 toolSearch({query: "spot ticker"}) 后,客户端对每个工具打分:
query = "spot ticker"
mcp__gate__cex_spot_get_ticker:
"spot" → 名字拆词里有 "spot" → +10
"ticker" → 名字拆词里有 "ticker" → +10
总分 = 20 ✓
mcp__gate__cex_spot_create_order:
"spot" → 名字拆词里有 "spot" → +10
"ticker" → 名字拆词里没有,搜索文本里也没有 → +0
总分 = 10
mcp__gate__cex_spot_cancel_order:
"spot" → 名字拆词里有 "spot" → +10
"ticker" → 名字拆词里没有,搜索文本里也没有 → +0
总分 = 10
mcp__github__create_issue:
"spot" → 都没有 → +0
"ticker" → 都没有 → +0
总分 = 0
结果(按分数降序):
1. mcp__gate__cex_spot_get_ticker (20) ← 返回
2. mcp__gate__cex_spot_create_order (10) ← 返回
3. mcp__gate__cex_spot_cancel_order (10) ← 返回
4. mcp__github__create_issue (0) ← 过滤掉
Codex 的搜索逻辑类似,也是给每个工具预先生成搜索文本,但打分用的是 BM25 算法(信息检索领域的经典排序算法,搜索引擎用它来给搜索结果排序)。核心思路一样:拿 query 的关键词去匹配工具的搜索文本,按相关度排序。
两种方式都不需使用 embedding,也不需要调用模型,都是做的字符串匹配——同样的 query、同样的工具集,得到的结果完全一致。
另外,关键词搜索可能会同时命中多个工具。toolSearch 按打分排序取前 N 个(x-code-cli 默认 5 个),所有命中的工具都会被激活——加入 activated 集合,下一轮全部出现在 tools 数组里。模型拿到这几个工具的 schema 后,自己判断该使用哪个,客户端不会进行限制。
比如用户说“帮我下一个限价单买 BTC”,模型调用 toolSearch({query: "spot order"}),可能会同时命中 cex_spot_create_order、cex_spot_get_ticker、cex_spot_cancel_order 等多个工具。多激活几个工具的代价是每个工具多占一份 schema 的 token,但比全量注入 400 个工具要好得多。而且激活是持久的——一旦激活,后续轮次不用重复搜索同一个工具,可以直接使用。
方式二:select: 精确匹配。 模型直接传 select:工具名,客户端按名字精确查找,不需要打分。
模型怎么知道确切的工具名?因为系统提示的 ## Deferred Tools 段里列出了所有 deferred 工具的完整名字。模型每轮都能看到这份名单,只要它在名单里找到了目标工具名,就可以直接用 select: 加载。
什么时候用哪种方式?这个逻辑写在 toolSearch 工具自身的 description 里,模型在调用前会读到这段说明:
Pass `query` as either:
- keywords describing the capability you need
(e.g. "search the web", "github create issue")
→ returns the best-matching deferred tools, or
- "select:<name>,<name>" to load specific tools by their
EXACT name from the Deferred Tools list
(prefer this when you already know the name).
也就是说工具描述直接告诉模型:如果已经知道名字,优先用 select:;不确定名字时,用关键词描述能力。 实际使用中,模型大多数情况会走 select:,因为它能直接从名单里看到工具名。关键词搜索是模型对工具名不确定时的备选路径——比如工具名比较长或者有多个相似工具时,模型可能用 "spot order" 这样的关键词让客户端帮它筛选。
两种调用方式对比:
关键词搜索(不确定工具名时用):
toolSearch({query: "spot ticker"})
→ 客户端对所有工具做关键词打分,返回得分最高的几个
精确匹配(已知工具名时用,推荐方式):
toolSearch({query: "select:mcp__gate__cex_spot_get_ticker"})
→ 客户端直接按名字查找,找到就返回,不打分
关键词搜索是一层保底机制。模型大多数情况下能直接从名单里找到目标工具名,走 select: 精确匹配。只有当模型不确定具体用哪个工具时,才会退而使用关键词搜索让客户端帮它筛选。
哪些工具该 defer,哪些直接加载
不是所有的工具都需要 defer,一些核心工具会直接加载,而一些偶尔才用的工具才会被 defer。
直接加载的工具(每轮请求都带完整 schema):
这类工具是 Agent 的基础操作能力,用户随便问个问题都可能用到:
| 工具 | 功能 | 为什么必须直接加载 |
|---|---|---|
readFile |
读文件 | 几乎所有编码任务都需要先读文件 |
edit |
编辑文件 | 写代码的核心操作 |
writeFile |
写文件 | 创建新文件 |
shell |
执行命令 | 运行测试、安装依赖、git 操作 |
grep |
搜索代码 | 定位代码位置 |
glob |
查找文件 | 找文件路径 |
task |
子 agent | 委派子任务 |
toolSearch |
工具搜索 | 加载 deferred 工具的唯一入口 |
延迟加载(defer)的工具:
- 非核心内置工具:
webSearch(联网搜索)、webFetch(抓取网页)、todoWrite(TODO 清单)。这些工具不是每个任务都会被用到,所以需要时再加载。 - 所有 MCP 工具:MCP 工具由外部 server 提供,面向特定场景(交易、GitHub 操作、数据库查询等),不存在“每个任务都会用到”的 MCP 工具。
用一个具体例子来说明分类过程。假设 Agent 注册了以下工具:
分类示例:
readFile → 核心内置工具,每个任务都用 → 直接加载
edit → 核心内置工具,每个任务都用 → 直接加载
shell → 核心内置工具,每个任务都用 → 直接加载
toolSearch → 加载入口,必须直接可用 → 直接加载
webSearch → 非核心内置,偶尔才用 → defer
webFetch → 非核心内置,偶尔才用 → defer
mcp__gate__cex_spot_get_ticker → MCP 工具 → defer
mcp__gate__cex_spot_create_order → MCP 工具 → defer
mcp__github__create_issue → MCP 工具 → defer
...(其余 MCP 工具全部 defer)
下面是判断逻辑的流程图:
完整循环:搜索 → 激活 → 调用
我们来看一个具体的示例,用“查 BTC 价格”来演示一遍实际的调用过程:
注意步骤 3 和步骤 5 之间:模型在第一轮调用了 toolSearch 工具,但不能在同一轮里调用 cex_spot_get_ticker——因为这个工具在第一轮的 tools 数组里还不存在。必须等客户端把它的 schema 加进去,下一轮模型才能调用。
这就是 deferred 方案的代价:首次使用某个 deferred 工具时,至少需要 2 轮 API 调用——先搜索,下一轮才能调用。
整个流程里 state 的变化
从启动到多轮对话,各个 state 字段的变化过程:
| 阶段 | state 变化 |
|---|---|
| 启动 | catalog = 从所有 MCP server 拿到的 400 个 deferred 工具的完整定义 |
activated = 空集合 |
|
systemPromptCache = 系统提示,包含 ## Deferred Tools 名字清单 |
|
baseTools = [readFile, edit, shell, grep, ..., toolSearch] |
|
| 第 1 轮请求 | tools = composeTurnTools(baseTools, activated) = baseTools(activated 为空) |
| 用户:“查 BTC 价格” | 发给 LLM:system + tools(9 个核心工具)+ messages |
| 第 1 轮响应 | LLM 返回:toolSearch({query: "select:mcp__gate__cex_spot_get_ticker"}) |
handleToolSearch() 在 catalog 里精确匹配命中 |
|
activated = { "mcp__gate__cex_spot_get_ticker" } |
|
返回 tool_result 给模型:Loaded 1 tool(s) — now callable directly on your next step: |
|
- mcp__gate__cex_spot_get_ticker: 获取现货行情 |
|
| 第 2 轮请求 | tools = [...baseTools, cex_spot_get_ticker 的完整 schema](尾部多了一个工具) |
messages 里追加了上一轮的 tool_call + tool_result |
|
发给 LLM:system + tools(10 个工具)+ messages,前缀缓存 miss 一次 |
|
| 第 2 轮响应 | LLM 返回:cex_spot_get_ticker({currency_pair: "BTC_USDT"}) |
客户端执行 MCP 工具调用,拿到价格。activated 不变 |
|
| 第 3 轮 | tools 同第 2 轮(activated 没变,前缀稳定,缓存命中) |
| LLM 返回文本回复 | |
| 后续轮次 | activated 持久保留,不用再搜 cex_spot_get_ticker |
| 如果激活新工具 → activated 增长 → tools 追加 → 再 miss 一次 |
几个关键点:
catalog和systemPromptCache在启动时固定,整个 session 不变activated只增不减tools数组每轮动态合成 =baseTools+ activated 对应的 schema- 前缀缓存只在 activated 增长的那一轮 miss,之后稳定
工程细节
前面讲的是 tool search 的核心流程。这一节主要讲一下几个实现过程中遇到的问题以及解决方法。
阈值判断:什么时候该启用 defer。 工具少的时候,全量注入更简单,也不需要多一轮往返。x-code-cli 用 DEFERRAL_THRESHOLD_PERCENT 做判断:把所有候选 deferred 工具的 schema 序列化后估算 token 量,如果不超过模型上下文窗口的 10%,就跳过 defer,退回全量加载。
模型兼容性。 tool search 依赖模型主动去调 toolSearch。如果模型不支持或不配合,deferred 工具就永远不会被加载。各家的处理方式不同:
- Codex 的做法最精确:在
models.json里按具体模型版本配置supports_search_tool布尔字段。只有明确标记为支持的模型才启用 tool search,未知模型默认关闭。 - Claude Code 判断的是 API 能力而非模型智力:用
modelSupportsToolReference()检查模型是否支持tool_reference协议块。默认排除列表只有['haiku'],但可以通过远程配置动态更新,不需要发版。 - x-code-cli 目前用的是粗粒度的字符串匹配:
WEAK_MODEL_PATTERNS = ['haiku', 'nano', 'glm-4v'],模型名包含这些子串就退回全量注入。这个方案的问题是区分不了同一系列的不同版本——比如 Claude 3 Haiku 和 Claude 3.5 Haiku 能力差距很大,但都会被一刀切地禁用。
系统提示的字节稳定性。 LLM API 的前缀缓存依赖请求前缀不变。system + tools 组成的前缀在后续请求中保持一致,就能命中缓存,按折扣价计费。这要求系统提示里的 deferred 名单不能在运行中动态修改。x-code-cli 的做法是在启动时生成 systemPromptCache 并冻结,整个 session 不再改动。
这带来一个副作用:工具激活后,系统提示里的名单仍然写着这个工具是 deferred 状态,但 tools 数组里已经有了它的完整 schema。模型看到的系统提示和实际的工具列表之间存在不一致:系统提示说工具未加载,但工具实际上已经在列表里了。
在实际使用中,这个不一致很少造成问题。强模型(Sonnet、GPT-4o 等级)在收到第一次 toolSearch 的 tool_result 后,会记住工具已经加载,后续直接调用而不会重复搜索。重复搜索只在极端情况下出现——比如对话轮次非常多、早期的 tool_result 被压缩掉了、模型又看到系统提示里写着"未加载"时,才可能再搜一次。x-code-cli 加了一层防御:如果搜索的工具已经在 activated 集合里,返回 Already loaded — call xxx directly now.,避免模型陷入搜索循环。这是防御性代码,不是常规路径。
Claude Code 和 Codex 从架构上避免了这种不一致。Claude Code 靠 Anthropic API 的 tool_reference 机制,激活的工具由服务端展开 schema,客户端不改 tools 数组。Codex 靠 OpenAI Responses API 的 tool_search_output 历史项,同样由服务端处理。两者都不需要在系统提示里维护一份可能过时的状态。x-code-cli 因为要跨多个 provider,没法依赖单一 API 的服务端能力,只能走客户端重注入——但从实际效果看,强模型下这种不一致几乎不会触发重复搜索。
子 agent 不开 defer。 子 agent 的工具集由白名单控制,大多数子 agent 只有几个内置工具。比如 explore 子 agent 只有 readFile、glob、grep、listDir、shell 五个工具,plan 子 agent 更少,只有四个只读工具。工具这么少,加 tool search 没有意义。唯一的例外是 general-purpose 子 agent——它继承了父 session 的 MCP 工具,工具数量可能很多,但即便如此,子 agent 目前也走全量注入,因为子 agent 的生命周期短、轮次少,defer 带来的节省不明显。
总结
tool search 的完整流程回顾:启动时从 MCP server 全量拿到工具定义,按使用频率分成直接加载和 deferred 两类。直接加载的工具,完整 schema 放进每轮请求的 tools 数组;deferred 工具只在系统提示里列名字。模型需要某个 deferred 工具时,调 toolSearch 加载它的 schema,下一轮就能正常调用。
几个主流 AI Agent CLI 的实现对比:
| 全量注入? | 动态机制 | 匹配算法 | |
|---|---|---|---|
| Claude Code | 否 | ToolSearch 工具 |
关键词打分 + select: |
| Codex | 否 | tool_search 工具 |
BM25 全文检索 |
| Gemini CLI | 是 | 无 | — |
| x-code-cli | 否 | toolSearch 工具 |
关键词打分 + select: |
Claude Code、Codex、x-code-cli 三家的思路几乎一样,区别在于激活后的 schema 怎么对模型可用——Claude Code 和 Codex 靠各自 API 的服务端能力,x-code-cli 靠客户端重注入(直接把 schema 加进 tools 数组)。Gemini CLI 目前没有内置 tool search。
我的其他 AI Agent 文章
- 让 AI Agent 系统自己发现 bug、自己提修复 PR:自我进化的 Harness
- 掘金小册–从零打造一个 AI Agent CLI
- AI Agent 工程师入门指南
- 如何从零开始实现一个 AI Agent CLI
参考资料
更多推荐


所有评论(0)