Agent Loop 如何解决 tools 过多的问题
最开始给 Agent 接工具时,我的做法很直接:把所有工具的名称、描述、参数 schema 全部塞进一次模型请求里。工具少的时候这确实很方便,十几个工具以内,模型基本能看懂,上下文也够用。
但随着系统逐渐成熟,接入了 MCP、浏览器、搜索、文件操作、内容生成等各种能力后,工具数量很快膨胀到了几十个。问题立刻暴露出来:
- 工具 schema 占用了大量上下文窗口,光工具定义就吃掉上万 token
- 首 token 明显变慢,每次请求都要等模型“读完”所有工具
- 工具描述互相干扰,模型开始选错工具或者反复在相似工具间犹豫
- 每次 MCP 连接变化都会让 prompt cache 失效,成本直线上升
- 子 Agent 继承了太多能力,行为变得不可控
我尝试过简单截断工具列表,但这会让模型不知道某些能力的存在,用户明明安装了某个 MCP 工具,Agent 却说“我做不到”。直到我读了 Claude Code 的源码,才发现他们用了一套更优雅的方案:动态工具加载。
核心思路可以概括成一句话:
常用工具直接给模型,海量工具先只暴露名字,需要时再通过 ToolSearch 加载完整 schema。
下面按实现思路拆开讲,大家也可以照这个架构实现自己的 Agent 工具系统。
一、问题本质:工具不是越全越好
Agent loop 每一轮大致是这样:
- 组装 system prompt、历史消息和可用工具
- 调模型
- 模型返回文本或 tool call
- 执行工具
- 把 tool result 放回消息历史
- 继续下一轮
最容易出问题的是第一步:可用工具如何组装。
一个工具通常不只是一个名字,还包括:
namedescriptioninput_schema- 权限信息
- 是否只读
- 是否支持并发
- UI 展示信息
如果有 200 个工具,每个工具几百到几千 token,光工具定义就可能吃掉几万 token。更麻烦的是,很多工具在当前任务里根本不会用到。
所以“工具过多”的本质不是工具数量本身,而是:
模型在每一轮都被迫阅读大量当前任务不相关的工具定义。
Claude Code 的解决方向是把工具分成两类:
- 立即可用工具:模型一开始就能看到完整 schema
- 延迟加载工具:模型一开始只知道名字,需要时通过搜索工具加载完整 schema
二、核心方案:ToolSearch + deferred tools
Claude Code 源码里有一个关键概念:deferred tools。
可以理解为“先不完整加载的工具”。这些工具不会在请求一开始就把完整 schema 交给模型,而是通过一个特殊工具 ToolSearch 来发现和加载。
源码中的判断逻辑大致是:
function isDeferredTool(tool: Tool): boolean {
if (tool.alwaysLoad === true) return false
if (tool.isMcp === true) return true
if (tool.name === 'ToolSearch') return false
return tool.shouldDefer === true
}
这段逻辑体现了几个设计取舍:
- MCP 工具默认延迟加载,因为 MCP 工具数量多,而且通常和具体工作流相关。
ToolSearch本身不能延迟加载,否则模型就没有入口去加载其他工具。- 某些工具可以通过
alwaysLoad强制常驻,例如模型第一轮必须知道的核心通信工具。 - 非 MCP 工具也可以通过
shouldDefer加入延迟加载。
这个设计很值得参考。不要只按“内置工具”和“外部工具”划分,而应该按“当前是否必须完整暴露给模型”划分。
三、请求前如何裁剪工具列表
在真正调用模型前,Claude Code 会决定是否启用 ToolSearch。启用后,请求中的工具列表不是完整工具池,而是经过过滤的结果。
伪代码如下:
const useToolSearch = await isToolSearchEnabled(model, tools)
const deferredToolNames = new Set(
tools.filter(isDeferredTool).map(tool => tool.name),
)
if (useToolSearch) {
const discoveredToolNames = extractDiscoveredToolNames(messages)
filteredTools = tools.filter(tool => {
if (!deferredToolNames.has(tool.name)) return true
if (tool.name === 'ToolSearch') return true
return discoveredToolNames.has(tool.name)
})
} else {
filteredTools = tools.filter(tool => tool.name !== 'ToolSearch')
}
这段逻辑是整个方案的核心。
启用 ToolSearch 后:
- 非 deferred 工具照常发送
ToolSearch一定发送- deferred 工具只有在被发现后才发送完整 schema
还有一个容易漏掉的实现细节:被延迟的工具不是简单从系统里消失,而是在构造 API tool schema 时带上 defer_loading 标记。Claude Code 在 src/services/api/claude.ts 构造 toolSchemas 时,会把 willDefer(tool) 传给 toolToAPISchema,后者在 src/utils/api.ts 里把 schema.defer_loading = true 加到工具定义上。
这意味着运行时仍然知道完整工具池,只是请求模型时把长尾工具变成“可按需加载”的状态。
也就是说,第一次请求时,模型可能只看到十几个核心工具和一个 ToolSearch。如果用户问“帮我去 Slack 发消息”,模型可以先调用:
ToolSearch("slack send message")
ToolSearch 返回匹配工具后,下一轮请求才会把 Slack 相关工具的完整 schema 放进工具列表。模型随后就可以正常调用 Slack 工具。
这比一次性发送所有 Slack、GitHub、Jira、Notion、浏览器、数据库工具要轻得多。
四、模型如何知道有哪些延迟工具
只提供 ToolSearch 还不够。模型至少要知道“有哪些工具名字可以搜”。
Claude Code 有两种方式给模型提供 deferred tool 的名单:
- 通过临时的
<available-deferred-tools>消息 - 通过持久化的 deferred tools delta attachment
简单实现可以先用第一种。
例如:
<available-deferred-tools>
mcp__slack__send_message
mcp__slack__read_channel
mcp__github__create_issue
mcp__github__search_prs
</available-deferred-tools>
模型看到这些名字后,就可以决定是否通过 ToolSearch 加载完整 schema。
但在生产系统里,每次都把完整 deferred tool 名单塞进请求也会影响缓存。因此 Claude Code 后续又做了 delta 机制:只在工具池变化时告诉模型新增或移除的工具。
普通项目可以分阶段实现:
第一版:
- 每次请求都带完整 deferred tool name list
- 简单可靠,便于调试
第二版:
- 把已公布的工具名单记录进会话
- 只发送新增和移除的工具
- 减少 prompt cache 失效
五、ToolSearch 应该怎么实现
ToolSearch 本质上是一个只读工具。它接受 query,返回匹配到的工具引用。
这里的 query 不是业务代码提前写死的,而是模型自己生成的。运行时只负责把 ToolSearch 暴露给模型,并在工具描述和 input schema 里告诉模型可用的查询格式。模型根据用户任务和 deferred tool 名单,决定要调用:
ToolSearch("slack send message")
还是:
ToolSearch("select:mcp__slack__send_message")
源码里 ToolSearchTool.ts 的 input schema 对 query 的描述就是:Use "select:<tool_name>" for direct selection, or keywords to search. 这句说明会进入工具 schema,指导模型生成 query。
输入可以设计成这样:
type ToolSearchInput = {
query: string
max_results?: number
}
支持两种查询方式:
1. 精确选择
select:mcp__github__create_issue
适合模型已经知道工具名的情况。
也可以支持多个:
select:mcp__github__create_issue,mcp__github__search_prs
2. 关键词搜索
github issue create
slack send message
browser screenshot
关键词搜索可以先不用复杂向量检索。Claude Code 的实现就是一套工程化的关键词评分,源码集中在 src/tools/ToolSearchTool/ToolSearchTool.ts:
parseToolName:把工具名拆成可搜索片段,见ToolSearchTool.ts:132searchToolsWithKeywords:关键词搜索主逻辑,见ToolSearchTool.ts:186ToolSearchTool.call:工具入口,见ToolSearchTool.ts:328select:A,B,C精确选择逻辑,见ToolSearchTool.ts:358- 打分和排序逻辑,见
ToolSearchTool.ts:259和ToolSearchTool.ts:297
它的搜索流程不是单一打分,而是分几层快速路径。
第一层,裸工具名精确匹配。如果 query 本身就等于某个工具名,直接返回这个工具:
const exactMatch =
deferredTools.find(t => t.name.toLowerCase() === queryLower) ??
tools.find(t => t.name.toLowerCase() === queryLower)
if (exactMatch) {
return [exactMatch.name]
}
这里先查 deferredTools,再查完整 tools。第二步是为了容错:如果模型搜了一个已经加载过的工具,也不要让它失败重试。
第二层,MCP 前缀匹配。如果 query 像 mcp__github 或 mcp__slack,就返回所有以这个前缀开头的 deferred tools:
if (queryLower.startsWith('mcp__') && queryLower.length > 5) {
const prefixMatches = deferredTools
.filter(t => t.name.toLowerCase().startsWith(queryLower))
.slice(0, maxResults)
.map(t => t.name)
if (prefixMatches.length > 0) {
return prefixMatches
}
}
这对 MCP 很重要,因为 MCP 工具名通常长这样:
mcp__github__create_issue
mcp__github__search_prs
mcp__slack__send_message
第三层,普通关键词打分。Claude Code 会先把工具名拆词。MCP 工具按 __ 和 _ 拆:
mcp__github__create_issue
会变成:
parts = ['github', 'create', 'issue']
full = 'github create issue'
isMcp = true
普通工具名会按 CamelCase 和下划线拆,比如:
FileReadTool
会变成:
parts = ['file', 'read', 'tool']
query 也会按空格拆词。除此之外,还支持 +term 必选词:
+slack send
意思是必须匹配 slack,send 只参与排序。源码里会先把 query term 分成 requiredTerms 和 optionalTerms:
const requiredTerms: string[] = []
const optionalTerms: string[] = []
for (const term of queryTerms) {
if (term.startsWith('+') && term.length > 1) {
requiredTerms.push(term.slice(1))
} else {
optionalTerms.push(term)
}
}
如果有必选词,会先做一轮预过滤:工具名片段、description 或 searchHint 必须命中所有 required terms,才会进入最终打分。
最终评分规则是:
if (parsed.parts.includes(term)) {
score += parsed.isMcp ? 12 : 10
} else if (parsed.parts.some(part => part.includes(term))) {
score += parsed.isMcp ? 6 : 5
}
if (parsed.full.includes(term) && score === 0) {
score += 3
}
if (hintNormalized && pattern.test(hintNormalized)) {
score += 4
}
if (pattern.test(descNormalized)) {
score += 2
}
可以把权重理解成:
- MCP 工具名片段精确命中:
+12 - 普通工具名片段精确命中:
+10 - MCP 工具名片段部分命中:
+6 - 普通工具名片段部分命中:
+5 searchHint命中:+4- full name fallback 命中:
+3 - description 命中:
+2
最后只保留分数大于 0 的结果,按分数倒序取前 maxResults:
return scored
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults)
.map(item => item.name)
这个实现很值得借鉴:它没有一上来就引入向量数据库,而是利用了工具命名约定。只要工具名设计成 mcp__server__action_object,关键词搜索就已经足够好用。
如果要在自己的项目里实现,可以先照这个简化版本做:
function parseToolName(name: string) {
if (name.startsWith('mcp__')) {
const withoutPrefix = name.replace(/^mcp__/, '').toLowerCase()
return {
parts: withoutPrefix.split('__').flatMap(p => p.split('_')).filter(Boolean),
full: withoutPrefix.replace(/__/g, ' ').replace(/_/g, ' '),
isMcp: true,
}
}
const parts = name
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
return { parts, full: parts.join(' '), isMcp: false }
}
async function searchToolsWithKeywords(query: string, deferredTools: Tool[]) {
const queryLower = query.toLowerCase().trim()
const exact = deferredTools.find(t => t.name.toLowerCase() === queryLower)
if (exact) return [exact.name]
if (queryLower.startsWith('mcp__')) {
const prefixMatches = deferredTools
.filter(t => t.name.toLowerCase().startsWith(queryLower))
.slice(0, 5)
.map(t => t.name)
if (prefixMatches.length) return prefixMatches
}
const terms = queryLower.split(/\s+/).filter(Boolean)
const scored = deferredTools.map(tool => {
const parsed = parseToolName(tool.name)
const description = tool.description.toLowerCase()
const hint = tool.searchHint?.toLowerCase() ?? ''
let score = 0
for (const term of terms) {
if (parsed.parts.includes(term)) score += parsed.isMcp ? 12 : 10
else if (parsed.parts.some(part => part.includes(term))) score += parsed.isMcp ? 6 : 5
if (parsed.full.includes(term) && score === 0) score += 3
if (hint.includes(term)) score += 4
if (description.includes(term)) score += 2
}
return { name: tool.name, score }
})
return scored
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map(item => item.name)
}
这个实现已经能覆盖很多实际场景。后面如果工具量更大,再换成 BM25、SQLite FTS 或向量检索都可以。
六、ToolSearch 返回什么
Claude Code 使用了模型 API 支持的 tool_reference 机制:ToolSearch 返回工具引用,服务端会把这些引用扩展成完整工具定义。
概念上类似:
{
"type": "tool_result",
"content": [
{
"type": "tool_reference",
"tool_name": "mcp__github__create_issue"
}
]
}
Claude Code 后续会通过 extractDiscoveredToolNames(messages) 从消息历史里扫描这些 tool_reference。也就是说,“哪些 deferred tools 已经加载过”不是存在一个单独的全局变量里,而是可以从对话历史恢复出来。
这点对长会话很重要。压缩上下文时,如果包含 tool_reference 的消息被摘要替换,系统会把压缩前已经发现的工具集合带到 compact boundary 里,后续扫描还能恢复这些工具,不会因为 compact 丢掉已加载状态。
如果你的模型 API 不支持 tool_reference,也可以自己实现同样的效果:
- ToolSearch 返回工具名列表
- Agent runtime 把这些工具名记录为
discoveredTools - 下一轮请求组装工具列表时,把这些工具的完整 schema 加进去
这其实就是动态加载的本质:
const discoveredTools = new Set<string>()
function handleToolSearchResult(result: ToolSearchResult) {
for (const name of result.matches) {
discoveredTools.add(name)
}
}
function buildToolsForNextRequest(allTools: Tool[]) {
return allTools.filter(tool => {
if (!isDeferredTool(tool)) return true
if (tool.name === 'ToolSearch') return true
return discoveredTools.has(tool.name)
})
}
不依赖特定 API,也能实现同样的思路。
七、什么时候开启 ToolSearch
Claude Code 支持几种模式:
true:总是开启auto:超过阈值才开启auto:N:deferred 工具描述超过上下文窗口 N% 时开启false:关闭
默认阈值是 10% 上下文窗口。
不过源码里的启用条件不只是阈值。一次请求真正启用 ToolSearch 前,还会检查几件事:
- 当前模型是否支持
tool_reference ToolSearch自己是否在工具列表里,没有被权限或配置过滤掉- auto 模式下 deferred tool schema 是否超过阈值
- 是否有 deferred tools,或者是否还有 MCP server 正在连接
- 是否被实验 beta kill switch 或不兼容代理网关关闭
这几个 gate 很实用。因为 tool_reference 和 defer_loading 属于需要模型/API 支持的能力,如果接在不兼容的代理网关后面,最好自动降级成普通工具列表,或者允许用户通过环境变量关闭。
你可以这样实现:
function shouldEnableToolSearch(tools: Tool[], contextWindow: number) {
const deferredTools = tools.filter(isDeferredTool)
const estimatedTokens = estimateToolTokens(deferredTools)
return estimatedTokens >= contextWindow * 0.1
}
为什么需要 auto 模式?
因为 ToolSearch 也有成本。工具很少时,直接暴露所有工具更简单,模型也少一次搜索调用。只有当工具 schema 明显膨胀时,动态加载才更划算。
一个实用建议:
- 20 个工具以内:可以先不开
- MCP 工具很多:默认开
- 工具描述超过上下文窗口 5%-10%:建议开
- 对延迟非常敏感的场景:保留核心工具常驻,把长尾工具 defer
八、权限过滤要发生在模型看到工具之前
Claude Code 还有一个重要细节:工具不只是调用时检查权限,也会在组装工具池时过滤掉被 deny 的工具。
这能避免模型看到自己不能用的工具。
伪代码:
function filterToolsByPermission(tools: Tool[], permissionContext: PermissionContext) {
return tools.filter(tool => !isDenied(tool, permissionContext))
}
这个顺序很重要:
- 先根据权限过滤工具池
- 再判断哪些工具 deferred
- 再生成 ToolSearch 可搜索名单
否则 ToolSearch 可能搜出用户没有权限调用的工具,造成模型反复尝试、用户反复拒绝。
九、子 Agent 不应该继承全部工具
另一个容易忽略的问题是子 Agent。
很多 Agent 系统一开始会让子 Agent 继承主 Agent 的所有工具。这样实现简单,但风险很高:
- 子 Agent 能力过大,行为不可控
- 递归创建 Agent 可能失控
- 后台 Agent 调用交互式工具会卡住
- 专用 Agent 的 prompt 和工具能力不匹配
Claude Code 对子 Agent 做了工具过滤:
- 禁止某些递归或主线程专用工具
- async Agent 只能使用白名单工具
- 自定义 Agent 可以通过
tools指定允许工具 - 也可以通过
disallowedTools排除工具 - MCP 工具仍可进入 ToolSearch 的延迟加载体系
普通项目可以设计一个 agent definition:
name: code-reviewer
tools:
- Read
- Grep
- Bash
disallowedTools:
- Write
- DeleteFile
运行子 Agent 前解析:
function resolveAgentTools(agent: AgentDefinition, allTools: Tool[]) {
let tools = allTools
tools = tools.filter(tool => !GLOBAL_AGENT_DENYLIST.has(tool.name))
if (agent.tools?.length) {
const allow = new Set(agent.tools)
tools = tools.filter(tool => allow.has(tool.name))
}
if (agent.disallowedTools?.length) {
const deny = new Set(agent.disallowedTools)
tools = tools.filter(tool => !deny.has(tool.name))
}
return tools
}
这一步和 ToolSearch 是互补的:
- Agent 工具过滤解决“这个 Agent 应该有哪些能力”
- ToolSearch 解决“这些能力里哪些要延迟加载”
十、一个可落地的最小实现
如果你要在自己的 Agent 项目里实现,可以按下面步骤做。
第一步:定义工具元数据
type Tool = {
name: string
description: string
inputSchema: unknown
isMcp?: boolean
shouldDefer?: boolean
alwaysLoad?: boolean
}
第二步:判断 deferred tool
function isDeferredTool(tool: Tool) {
if (tool.alwaysLoad) return false
if (tool.name === 'ToolSearch') return false
if (tool.isMcp) return true
return tool.shouldDefer === true
}
第三步:实现 ToolSearch
function toolSearch(query: string, allTools: Tool[]) {
const deferredTools = allTools.filter(isDeferredTool)
if (query.startsWith('select:')) {
const names = query.slice('select:'.length).split(',').map(s => s.trim())
return deferredTools.filter(tool => names.includes(tool.name))
}
return keywordSearch(query, deferredTools)
}
第四步:维护 discovered tools
const discoveredTools = new Set<string>()
function recordToolSearchResult(matches: Tool[]) {
for (const tool of matches) {
discoveredTools.add(tool.name)
}
}
第五步:每轮请求前构造工具列表
function buildRequestTools(allTools: Tool[], useToolSearch: boolean) {
if (!useToolSearch) {
return allTools.filter(tool => tool.name !== 'ToolSearch')
}
return allTools.filter(tool => {
if (!isDeferredTool(tool)) return true
if (tool.name === 'ToolSearch') return true
return discoveredTools.has(tool.name)
})
}
第六步:给模型 deferred tool 名单
function buildDeferredToolMessage(allTools: Tool[]) {
const names = allTools
.filter(isDeferredTool)
.map(tool => tool.name)
.sort()
return `<available-deferred-tools>\n${names.join('\n')}\n</available-deferred-tools>`
}
做到这里,一个简单可用的动态工具加载系统就完成了。
十一、实践建议
实现时有几个坑要注意。
第一,ToolSearch 必须是只读工具,而且要尽量稳定。它是加载其他工具的入口,如果它自身描述经常变化,会影响模型行为。
第二,工具名要可搜索。MCP 工具名最好包含 server 和 action,例如:
mcp__github__create_issue
mcp__slack__send_message
这比 tool_123 更适合关键词搜索。
第三,不要把权限检查只放在调用阶段。模型看不到不可用工具,行为会稳定很多。
第四,核心工具不要延迟加载。读文件、编辑文件、执行 shell、任务管理这类高频工具通常应该常驻。
第五,保留开关。生产环境里最好支持:
ENABLE_TOOL_SEARCH=true
ENABLE_TOOL_SEARCH=auto
ENABLE_TOOL_SEARCH=false
这样遇到模型兼容性、代理网关、调试问题时,可以快速切换。
第六,处理 MCP server 仍在连接中的情况。Claude Code 在 ToolSearch 没搜到结果时,会把 pending MCP server 名字一起返回,提示模型稍后再试。这能避免 MCP 启动慢时,模型误以为工具不存在。
第七,ToolSearch 关闭时要清理历史里的 tool_reference。源码在模型不支持或功能关闭时,会从 user message 的 tool result 里剥离 tool_reference,避免 API 因为不认识 beta content block 报错。
总结
Agent loop 里的工具过多问题,不适合靠简单截断解决。截断会让模型不知道某些能力存在,也会造成行为不可预测。
更好的方案是把工具系统做成两层:
- 核心工具层:少量、稳定、常驻,直接进入模型上下文。
- 长尾工具层:大量、工作流相关、按需搜索,通过 ToolSearch 动态加载。
Claude Code 的源码里,这套方案由几个关键点组成:
isDeferredTool决定哪些工具延迟加载ToolSearch负责按名称或关键词发现工具tool_reference或运行时状态负责把发现的工具加入后续请求- 请求前过滤工具列表,避免一次性发送所有 schema
- agent definition 和权限系统进一步缩小工具面
这套设计的价值在于:它没有牺牲工具扩展性,也没有让模型每轮背负完整工具宇宙。对普通开发者来说,即使不使用 Claude Code 同款 API,也可以用 ToolSearch + discoveredTools + buildRequestTools 这三个核心模块实现一个足够实用的版本。