lucas blog

Agent Loop 如何解决 tools 过多的问题

2026-05-09 · Agent, AI 工程化, 上下文工程, Claude Code

最开始给 Agent 接工具时,我的做法很直接:把所有工具的名称、描述、参数 schema 全部塞进一次模型请求里。工具少的时候这确实很方便,十几个工具以内,模型基本能看懂,上下文也够用。

但随着系统逐渐成熟,接入了 MCP、浏览器、搜索、文件操作、内容生成等各种能力后,工具数量很快膨胀到了几十个。问题立刻暴露出来:

我尝试过简单截断工具列表,但这会让模型不知道某些能力的存在,用户明明安装了某个 MCP 工具,Agent 却说“我做不到”。直到我读了 Claude Code 的源码,才发现他们用了一套更优雅的方案:动态工具加载

核心思路可以概括成一句话:

常用工具直接给模型,海量工具先只暴露名字,需要时再通过 ToolSearch 加载完整 schema。

下面按实现思路拆开讲,大家也可以照这个架构实现自己的 Agent 工具系统。

一、问题本质:工具不是越全越好

Agent loop 每一轮大致是这样:

  1. 组装 system prompt、历史消息和可用工具
  2. 调模型
  3. 模型返回文本或 tool call
  4. 执行工具
  5. 把 tool result 放回消息历史
  6. 继续下一轮

最容易出问题的是第一步:可用工具如何组装

一个工具通常不只是一个名字,还包括:

如果有 200 个工具,每个工具几百到几千 token,光工具定义就可能吃掉几万 token。更麻烦的是,很多工具在当前任务里根本不会用到。

所以“工具过多”的本质不是工具数量本身,而是:

模型在每一轮都被迫阅读大量当前任务不相关的工具定义。

Claude Code 的解决方向是把工具分成两类:

二、核心方案: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
}

这段逻辑体现了几个设计取舍:

  1. MCP 工具默认延迟加载,因为 MCP 工具数量多,而且通常和具体工作流相关。
  2. ToolSearch 本身不能延迟加载,否则模型就没有入口去加载其他工具。
  3. 某些工具可以通过 alwaysLoad 强制常驻,例如模型第一轮必须知道的核心通信工具。
  4. 非 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 后:

还有一个容易漏掉的实现细节:被延迟的工具不是简单从系统里消失,而是在构造 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 的名单:

  1. 通过临时的 <available-deferred-tools> 消息
  2. 通过持久化的 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 机制:只在工具池变化时告诉模型新增或移除的工具。

普通项目可以分阶段实现:

第一版:

第二版:

五、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

它的搜索流程不是单一打分,而是分几层快速路径。

第一层,裸工具名精确匹配。如果 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__githubmcp__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

意思是必须匹配 slacksend 只参与排序。源码里会先把 query term 分成 requiredTermsoptionalTerms

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
}

可以把权重理解成:

最后只保留分数大于 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,也可以自己实现同样的效果:

  1. ToolSearch 返回工具名列表
  2. Agent runtime 把这些工具名记录为 discoveredTools
  3. 下一轮请求组装工具列表时,把这些工具的完整 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 支持几种模式:

默认阈值是 10% 上下文窗口。

不过源码里的启用条件不只是阈值。一次请求真正启用 ToolSearch 前,还会检查几件事:

这几个 gate 很实用。因为 tool_referencedefer_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 明显膨胀时,动态加载才更划算。

一个实用建议:

八、权限过滤要发生在模型看到工具之前

Claude Code 还有一个重要细节:工具不只是调用时检查权限,也会在组装工具池时过滤掉被 deny 的工具。

这能避免模型看到自己不能用的工具。

伪代码:

function filterToolsByPermission(tools: Tool[], permissionContext: PermissionContext) {
  return tools.filter(tool => !isDenied(tool, permissionContext))
}

这个顺序很重要:

  1. 先根据权限过滤工具池
  2. 再判断哪些工具 deferred
  3. 再生成 ToolSearch 可搜索名单

否则 ToolSearch 可能搜出用户没有权限调用的工具,造成模型反复尝试、用户反复拒绝。

九、子 Agent 不应该继承全部工具

另一个容易忽略的问题是子 Agent。

很多 Agent 系统一开始会让子 Agent 继承主 Agent 的所有工具。这样实现简单,但风险很高:

Claude Code 对子 Agent 做了工具过滤:

普通项目可以设计一个 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 项目里实现,可以按下面步骤做。

第一步:定义工具元数据

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 里的工具过多问题,不适合靠简单截断解决。截断会让模型不知道某些能力存在,也会造成行为不可预测。

更好的方案是把工具系统做成两层:

  1. 核心工具层:少量、稳定、常驻,直接进入模型上下文。
  2. 长尾工具层:大量、工作流相关、按需搜索,通过 ToolSearch 动态加载。

Claude Code 的源码里,这套方案由几个关键点组成:

这套设计的价值在于:它没有牺牲工具扩展性,也没有让模型每轮背负完整工具宇宙。对普通开发者来说,即使不使用 Claude Code 同款 API,也可以用 ToolSearch + discoveredTools + buildRequestTools 这三个核心模块实现一个足够实用的版本。

← 返回首页