大多数智能体都会为它们不使用的工具付费。不是一次——而是每一轮都如此。
其机制简单到容易让人忽略。当你给模型一组工具时,每个工具的完整 JSON schema 都会进入请求中。名称、描述、参数类型、枚举值、嵌套对象,一应俱全。模型读取全部内容,选一个,然后调用。下一轮,整个目录再次通过线路传输,因为 API 是无状态的,工具列表是请求的一部分。
八个工具时,这几乎是看不见的。两百个工具时,它主导了你的输入 token 账单,挤掉了你真正关心的上下文,而且——更伤人的是——可测量地降低了工具选择的准确性。
微软 Foundry 在 Build 2026 上推出了 工具搜索,正是为了解决这个问题。这值得理解,而且值得超越 Foundry 去理解:同样的故障模式会出现在任何重 MCP 的智能体中,其缓解方案也具有普适性。
一个中等详细程度的工具 schema 一旦加上足以让模型正确使用的参数描述,就会消耗 150–400 个 token。廉价的 schema 会导致糟糕的工具调用,因此团队会编写详尽的 schema,这是正确的做法,同时也是昂贵的做法。
将这一成本乘以目录规模和轮次:
tokens_per_turn = base_prompt + conversation_history + (n_tools × avg_schema_tokens)
tokens_per_task = tokens_per_turn × turns
一个十二轮的任务,面对200个工具目录,每个模式250个token,仅工具定义就消耗大约60万个输入token。对话本身可能只有2万个。你大部分花费,是在反复阅读一个模型已经决定不用的目录,重复了十一次。
Token成本是可见的一半。不可见的一半更糟。随着目录增长,它们积累了近似重复项——get_customer、get_customer_profile、fetch_customer_record、lookup_account_by_customer——这些通常来自不同的团队、不同的MCP服务器、代码库的不同时期。选择准确率下降不是因为模型变笨了,而是因为你给了它一个真正含糊不清的菜单。
Foundry 不是提供一个扁平的列表,而是暴露了两个元工具:tool_search 和 call_tool。代理描述它试图做什么,返回一个小的排序候选集,然后调用其中一个。
代价是一次检索往返,以换取不携带整个目录。大约三十个工具以上,这种交换非常有利。低于这个数量,通常不利——这是采用它之前首先要诚实面对的事情。
工具搜索不是唯一的答案,也不总是正确的答案。
| 策略 | 每轮Token成本 | 大规模下的选择准确率 | 增加的延迟 | 运营成本 |
|---|---|---|---|---|
| 扁平工具列表 | 随目录大小线性增长 | 超过约50个工具后急剧下降 | 无 | 微不足道 |
| 按任务类型手动分区的目录 | 分区内较低 | 如果路由正确,则良好 | 无 | 高——路由规则随工具变化而失效 |
| 按领域划分的多代理 | 每个子代理较低 | 领域内良好,跨领域较差 | 移交开销 | 高——需要编排和共享状态 |
| 工具搜索 | 无论目录大小如何,大致持平 | 取决于检索质量 | 多一次往返 | 低——索引为您维护 |
| 工具搜索加固定 | 持平,加上固定的模式 | 现有最佳:热路径有保证,尾部通过检索获取 | 仅在尾部 | 低 |
固定(Pinning)是大家常常跳过而不应跳过的一环。Foundry 允许你固定关键工具,从而完全绕过搜索往返,添加上下文来说明你的团队实际上如何看待某个工具,并自动固定频繁使用的工具。实际上,少数几个工具就占了大部分调用;固定它们,检索其余内容,你就能获得平摊的 token 成本,而无需为常见路径付出检索延迟。
两条命令即可搭建一个附带有工具箱的托管代理。
mkdir my-toolbox-agent && cd my-toolbox-agent
azd ai agent init \
-m "https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/agent.manifest.yaml" \
--src src/toolbox-agent
然后根据样本的描述符创建工具箱:
azd ai toolbox create my-toolbox --from-file ./src/toolbox-agent/toolbox.yaml
这会打印出一个带版本号的 MCP 端点,也就是你的智能体所绑定的端点:
https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/my-toolbox/versions/1/mcp?api-version=v1
有一个值得指出的注意点,因为它让我花了二十分钟:azd ai toolbox create 需要一个本地的 azd 项目和运行环境,即使你显式传递了 --project-endpoint 参数也是如此。如果你不是从 azd ai agent init 开始,请先运行 azd init --minimal,并将端点设置到环境中:
azd init --minimal
azd env set FOUNDRY_PROJECT_ENDPOINT https://<account>.services.ai.azure.com/api/projects/<project>
你的 toolbox.yaml 文件用于声明目录及其搜索行为。下面的形状仅供参考——预览模式会移动,所以复制前请检查当前示例:
# toolbox.yaml — illustrative structure, verify against the current sample
name: my-toolbox
description: Order operations, customer lookup, and fulfilment tools
toolSearch:
enabled: true
# Pinned tools skip retrieval entirely and are always in context.
# Keep this list short — every pin is a permanent token cost.
pinned:
- get_order_status
- search_customers
autoPin:
enabled: true
minCallsPerWindow: 25
tools:
- name: get_order_status
source: mcp
server: orders-mcp
# Retrieval context: describe the tool the way your team describes it,
# including the vocabulary users actually type.
searchContext: >
Look up the current fulfilment state of a single order. Use when the
user mentions an order number, tracking number, "where is my package",
or asks whether something shipped.
- name: issue_refund
source: mcp
server: billing-mcp
searchContext: >
Issue a full or partial refund against a completed order. Requires an
order ID and an amount. Do not use for cancellations of unshipped
orders — use cancel_order instead.
最后那个searchContext确实在发挥实际作用。检索质量就是工具搜索的关键所在,而检索是基于你的描述进行的。明确的反面示例——不要用这个,用那个——是你能在那儿写下的最具价值的内容,因为近乎重复项恰恰是选择失败的地方。
这个话题之所以值得写下来而不只是直接启用,是因为这个收益是可衡量的,而收益的大小完全取决于你的目录。在改动任何内容之前,先接通追踪,取得一个基线。
pip install azure-ai-projects azure-identity opentelemetry-sdk azure-core-tracing-opentelemetry
from azure.core.settings import settings
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter
from azure.ai.projects.telemetry import AIProjectInstrumentor
settings.tracing_implementation = "opentelemetry"
span_exporter = ConsoleSpanExporter()
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
trace.set_tracer_provider(tracer_provider)
# Emits GenAI spans for every agent and model call in this process
AIProjectInstrumentor().instrument()
运行前设置 AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true,每一轮——提示、工具调用、模型响应——都会以附加了令牌使用量的 span 形式呈现。将 ConsoleSpanExporter 替换为 Azure Monitor 导出器,相同的 span 将出现在 Foundry 门户的 Observability 选项卡中。
现在运行比较。下面的测试工具故意设计得很简单:一个固定的任务集,两种配置,聚合这些 span。
import os
import json
from dataclasses import dataclass, field
from statistics import mean
@dataclass
class RunResult:
config: str
input_tokens: list = field(default_factory=list)
turns: list = field(default_factory=list)
correct_tool: list = field(default_factory=list)
TASKS = [
# (prompt, tool the model should end up calling)
("Where is order 88231?", "get_order_status"),
("Refund the second item on order 88231", "issue_refund"),
("Cancel order 90114, it hasn't shipped", "cancel_order"),
("Which customers in Ohio ordered twice?", "search_customers"),
("Send the June invoice to billing@acme.com", "email_invoice"),
]
def score_run(spans, expected_tool):
"""Pull token usage and the actually-invoked tool out of collected spans."""
input_tokens = sum(
s.attributes.get("gen_ai.usage.input_tokens", 0) for s in spans
)
invoked = [
s.attributes.get("gen_ai.tool.name")
for s in spans
if s.attributes.get("gen_ai.tool.name")
]
# With Tool Search the target arrives as a call_tool argument, so unwrap it.
resolved = [
json.loads(s.attributes["gen_ai.tool.arguments"]).get("name", n)
if n == "call_tool" else n
for s, n in zip(spans, invoked)
]
return input_tokens, len(invoked), expected_tool in resolved
def report(results):
for r in results:
print(f"\n{r.config}")
print(f" mean input tokens/task : {mean(r.input_tokens):>8,.0f}")
print(f" mean turns/task : {mean(r.turns):>8.1f}")
print(f" tool selection accuracy: {mean(r.correct_tool):>8.1%}")
先对一个扁平目录运行一次,再对启用了 Tool Search 的同一目录运行一次,然后在你固定了前五个工具后再运行一次。三个数字,一张表格,你就能知道这对你的目录来说是否值得做,而不是对博客文章里的目录而言。
坦率地说明它的局限,因为这些失败模式是真实的:
小目录会变得更糟,而不是更好。 在工具数量少于大约三十个时,你为节省本来就不会花费的 token,却增加了一次往返和一种检索失败模式。别这么做。
检索遗漏是无声的,也是令人困惑的。 当扁平列表代理选错工具时,追踪记录会显示它曾考虑过正确的那个。而当 Tool Search 从未把正确的工具呈现出来时,追踪记录显示的是一个在拿到同样信息后表现合理的代理。调试的问题就从“它为什么选错了”变成了“它为什么不在候选集中”,这是一种不同的技能,也更不容易看出来。
延迟被转移到了错误的地方。 额外的一次往返出现在回合的开头,先于任何有用的工作。对后台代理来说这没有代价;但任何有人在旁观的场景,请果断固定工具。
你的描述现在是承重的基础设施。 一个含糊的 searchContext 过去只是让你偶尔付出一次糟糕工具调用的代价;现在它让你付出的代价,是工具在功能上完全不可见。要像为 API 契约预留时间那样,为它们预留审查时间。
抛开 Foundry 再看,这个原则依然成立:你在每一轮发送的任何内容,都应当在每一轮中赢得自己的位置。 工具 schema 是最先撞上这堵墙的,因为它们会随着集成数量不断膨胀;但同样的审计也适用于:积累了六个月没人读过的指令的系统提示词、为某个你已不再运行的模型版本保留的少样本示例,以及因为修剪它曾是某人的 Q3 任务而被整段粘贴进来的检索上下文。
Tool Search 是一个朴素想法的出色实现——检索而不是广播。采用它的理由不是因为它新,而是因为你用一个下午就能量化这种差异,而且这个数字通常会比你预期的更大。
此领域的预览 API 变化很快——上面的 azd 命令和追踪配置在 2026 年 6 月的 Foundry 版本中是最新的,但发布前请对照当前示例核实 schema 级别的细节。
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。