Tool Search Defers Tools to Save Tokens
Claude Code can use dozens of built-in tools and an unlimited number of MCP tools. Every tool the model might call needs a definition — a name, description, and JSON schema — sent with each API request. A single MCP too…
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
Claude Code can use dozens of built-in tools and an unlimited number of MCP tools. Every tool the model might call needs a definition — a name, description, and JSON schema — sent with each API request. A single MCP tool definition might cost 200–800 tokens. Connect three MCP servers with 50 tools each, and you’re burning 60,000 tokens on tool definitions alone. Every turn. Before the model reads a single message.
That’s not sustainable. A 200K context window that loses 30% to tool definitions before the conversation starts is a bad experience. The model has less room to think, compaction triggers sooner, and cost per turn climbs.
The naive solution is obvious: don’t send tools the model doesn’t need. But which tools does the model need? You don’t know until it tries to use one. And if the tool definition isn’t there when the model tries to call it, the call fails.
Claude Code solves this with a system called tool search. When MCP tool definitions exceed a token threshold, most tools are deferred — their definitions are withheld from the API request. In their place, the model gets a single ToolSearch tool it can invoke to discover and load tools on demand. The API receives a tool_reference content block in the search result, expands it to the full definition, and the model can call the tool on its next turn.
Consider the concrete flow. A user has configured MCP servers for GitHub, Slack, and Jira — 147 tools total. Without tool search, every API call sends 147 tool definitions: ~90,000 tokens. With tool search, the API call sends ~25 built-in tool definitions plus ToolSearch itself: ~15,000 tokens. The model’s prompt tells it “147 deferred tools are available — use ToolSearch to load them.” When the model needs to create a GitHub issue, it calls ToolSearch({ query: "github create issue" }). The system returns a tool_reference for mcp__github__create_issue. On the next turn, that tool’s full schema is available, and the model calls it normally. Total overhead for this discovery: one extra turn, ~200 tokens. Savings over a 20-turn conversation: ~1.5 million tokens.
This article traces the entire pipeline: the deferral decision, the threshold calculation, the search algorithm, the discovery loop across turns, and the snapshot mechanism that preserves discovered tools across context compaction. Every layer is designed around the same principle: fail closed, fail toward asking. If anything is uncertain — an unknown model, a proxy gateway, a missing token count — the system falls back to loading all tools, never to silently hiding them.
Not every tool can be deferred. The model needs certain tools on turn one, before it has a chance to search for anything. The deferral decision is a priority-ordered checklist:
function isDeferredTool(tool):
Explicit opt-out: MCP tools can declare they must always load
if tool.alwaysLoad is true: return false
MCP tools are deferred by default (workflow-specific, often numerous)
if tool.isMcp is true: return true
ToolSearch itself is never deferred — it's the bootstrap
if tool.name is "ToolSearch": return false
Core communication tools are never deferred
(Agent, Brief — model needs these immediately)
if tool is a critical communication channel: return false
Everything else: defer only if explicitly marked
return tool.shouldDefer is true
The alwaysLoad opt-out is the escape hatch. An MCP server can set _meta['anthropic/alwaysLoad'] on a tool to force it into every API request regardless of deferral mode. This handles tools like a primary database query tool that the model will need on nearly every turn.
Notice the ordering. alwaysLoad is checked before the MCP check. This means an MCP tool can opt out of deferral even though MCP tools are deferred by default. And ToolSearch is checked after the MCP check, which means if someone wraps ToolSearch in an MCP server (don’t), it still won’t be deferred. The checklist is a priority chain where each rule can only override the ones below it.
The shouldDefer flag at the bottom is for built-in tools that want to participate in deferral without being MCP tools. Currently this isn’t widely used, but it exists as an extension point — a built-in tool could mark itself as deferrable if it’s rarely needed and expensive to describe.
The deferral system operates in one of three modes, controlled by an environment variable:
function getToolSearchMode():
Kill switch: if all beta features are disabled, never defer
if DISABLE_EXPERIMENTAL_BETAS: return "standard"
value = env.ENABLE_TOOL_SEARCH
Explicit "always defer" mode
if value is truthy or value is "auto:0": return "tst"
Threshold-based: only defer when tools exceed a token budget
if value is "auto" or value is "auto:N" where 1 <= N <= 99: return "tst-auto"
Explicit disable
if value is falsy: return "standard"
Default: always defer MCP and shouldDefer tools
return "tst"
The default mode is tst — always defer. This is the right default because any user with MCP tools has already accepted the latency of an extra search turn in exchange for a larger effective context window. The tst-auto mode provides a middle ground: defer only when the token cost actually justifies it.
In tst-auto mode, the system measures how many tokens the deferred tools would consume and compares against a budget:
threshold = floor(contextWindow * percentage / 100)
Default percentage: 10%
For a 200K context model: threshold = 20,000 tokens
The token count comes from the API’s countTokens endpoint when available. The system serializes each deferred tool into its API schema (name + description + JSON schema), sends them to the counting endpoint, and caches the result keyed by the tool name set. The cache invalidates when MCP servers connect or disconnect, changing the tool pool.
There’s a subtlety in the counting. The API adds a fixed preamble (~500 tokens) whenever tools are present in a request. When counting tools individually, each count includes this overhead, so counting N tools individually would report N × 500 tokens of phantom overhead. The system subtracts this constant from the total:
rawCount = countTokensViaAPI(deferredToolSchemas) adjustedCount = max(0, rawCount - 500)
When the token counting API is unavailable — perhaps the provider doesn’t support it, or the network request fails — the system falls back to a character-based heuristic. It sums the character lengths of each tool’s name, description, and serialized input schema, then converts using a ratio of 2.5 characters per token:
charThreshold = floor(tokenThreshold * 2.5) totalChars = sum(tool.name.length + tool.description.length + tool.schema.length for each deferred tool) enabled = totalChars >= charThreshold
This heuristic is intentionally conservative. Tool definitions are schema-heavy (lots of short keys and structural characters), which tokenize at a higher density than natural language. A 2.5 chars/token ratio slightly overestimates the token count, biasing toward enabling deferral — the safe direction.
When tool search is enabled, the model sees a ToolSearch tool in its tool list. The tool accepts a query string and returns up to 5 results (configurable). There are two query modes.
The model can request specific tools by name:
ToolSearch({ query: "select:mcp__github__create_issue" }) ToolSearch({ query: "select:Read,Edit,Grep" }) # comma-separated
Direct selection is a lookup, not a search. For each requested name, the system checks the deferred tool pool first, then falls back to the full tool set. Finding a tool in the full set that isn’t deferred is a no-op — the tool is already loaded — but returning it prevents the model from retrying in a loop.
Why does the fallback to the full tool set matter? After context compaction or in subagent conversations, the model sometimes tries to “select” a tool it previously used, not realizing the tool is already loaded (because its earlier search result was summarized away). Without the full-set fallback, the select would fail, the model would get “no matching deferred tools found,” and it would waste a turn figuring out the tool is already available. The fallback makes this a silent success.
When the model doesn’t know the exact tool name, it searches by keyword:
ToolSearch({ query: "slack send message" }) ToolSearch({ query: "+github pull request" }) # + requires term
The search algorithm scores each deferred tool against the query terms:
function scoreToolForQuery(tool, terms): parts = parseToolName(tool.name)
"mcp__slack__send_message" -> parts: ["slack", "send", "message"]
"NotebookEdit" -> parts: ["notebook", "edit"]
score = 0 for term in terms:
Exact part match (highest signal)
if term in parts: score += 12 if tool.isMcp else 10
Substring match within a part
elif any(term in part for part in parts): score += 6 if tool.isMcp else 5
Full name fallback
elif term in fullName and score is 0: score += 3
searchHint match (curated capability phrase)
if wordBoundaryMatch(term, tool.searchHint): score += 4
Description match (lowest signal, most noise)
if wordBoundaryMatch(term, tool.description): score += 2
return score
MCP tools get slightly higher weight on exact matches (12 vs 10) and substring matches (6 vs 5). This is deliberate: when tool search is active, most deferred tools are MCP tools. Boosting their scores ensures they rank above built-in tools that happen to share terminology.
The searchHint field is a curated string that tools can provide to improve discoverability. It’s weighted above description matches (4 vs 2) because it’s intentional signal — a tool author explicitly saying “this tool handles X” — rather than incidental keyword overlap in a long description.
Description matching uses word-boundary regex (\bterm\b) to avoid false positives. Without boundaries, a search for “read” would match every tool whose description contains “already”, “thread”, or “spreadsheet”.
There’s also a required-term mechanism. Prefixing a term with + makes it mandatory: only tools matching ALL required terms in their name, description, or search hint are scored. This lets the model narrow results when a server has many tools: +slack send finds tools with “slack” in the name AND ranks them by “send” relevance.
Suppose the deferred pool contains these tools:
mcp__slack__send_message (MCP) mcp__slack__list_channels (MCP) mcp__github__create_issue (MCP) mcp__email__send_email (MCP)
The model searches: ToolSearch({ query: "slack send" }). Here’s the scoring:
mcp__slack__send_message: parts = ["slack", "send", "message"] "slack": exact part match, MCP → +12 "send": exact part match, MCP → +12 Total: 24
mcp__slack__list_channels: parts = ["slack", "list", "channels"] "slack": exact part match, MCP → +12 "send": no match in parts, no match in name → +0 Total: 12
mcp__email__send_email: parts = ["email", "send", "email"] "slack": no match → +0 "send": exact part match, MCP → +12 Total: 12
mcp__github__create_issue: parts = ["github", "create", "issue"] "slack": no match → +0 "send": no match → +0 Total: 0
Result: ["mcp__slack__send_message", "mcp__slack__list_channels", "mcp__email__send_email"]. The Slack send tool wins, the other Slack tool ties with the email send tool, and the GitHub tool is excluded. Note how multi-term queries naturally boost tools that match on multiple dimensions — a tool matching both “slack” AND “send” scores 24, while one matching only “slack” scores 12.
The regex patterns are pre-compiled once per search to avoid creating them inside the hot loop (N tools × M terms × 2 checks). Each unique term gets one compiled regex, and all tools share them.
When the query starts with mcp__, the system checks for prefix matches before falling through to keyword search:
if query starts with "mcp__": matches = tools where name starts with query if matches found: return first maxResults matches
This hand
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。