手搓 Agent 系列 02|Tool 规模化的 3 个工程问题:成本、选择、维护
在上一篇里,我们把第一个 Agent 跑起来了。它根据用户的要求决定下一步做什么,能通过一个简单的循环不断行动,直到任务完成。
但这个 Agent 还有个明显的短板:它能"想",却不一定能真正"做"。比如我让它写一篇关于 AI Agent 的公众号文章,它可以直接产出一篇出来,却不能主动搜索最近的资料、读取文件,或者调用外部服务。
要让它具备这些能力,就是 Tool要做的事。
这一篇我们继续用同一个例子演示:帮我写一篇关于 AI Agent 的公众号文章,先搜索资料,再完成写作。 我们先自己实现最小版本,再看看当 Tool 越来越多、调用越来越复杂时,工程上会遇到什么问题,业界又是怎么处理的。
代码重点展示核心逻辑。示例中的
search_web使用模拟数据,方便直接看懂调用过程;接真实搜索服务时,只需要替换它的内部实现。不同Agent 的 Tool Calling 接口会有差异,下面用接近通用结构的代码说明运行机制。
一、Tool Calling 的完整执行链路
普通的 LLM 程序通常是:把用户的问题发给模型,再把模型生成的文本返回给用户。模型输出的内容本身不会自动变成一次真实的程序调用。
Tool Calling 改变了这一点。我们先告诉模型当前有哪些 Tool、每个 Tool 能做什么、需要什么参数。模型判断任务需要调用某个 Tool 时,会返回一条结构化的调用请求;真正执行函数的仍然是我们的程序。
整个过程可以理解为:
text
用户提出需求
↓
把可用 Tool 的定义交给 LLM
↓
LLM 返回 Tool Call
↓
Agent Tool Runtime 校验并执行 Tool
↓
把 Tool Result 返回给 LLM
↓
LLM 决定下一步:继续调用 Tool,还是回答用户
比如用户要写一篇近期的 AI Agent 文章,模型可能先请求 search_web。程序执行搜索,把搜索结果交还给模型,模型再根据这些结果组织文章。如果模型还需要查其他资料,就可以继续发起下一次调用。
二、先手搓一个最小 Tool
我们只定义一个搜索函数,再用一个字典把 Tool 名称和 Python 函数对应起来。
python
def search_web(query: str) -> dict:
"""搜索互联网资料。这里用模拟结果演示调用过程。"""
return {
"query": query,
"results": [
{
"title": "Agent 工程实践",
"snippet": "Agent 通常需要结合模型决策、工具执行和结果反馈。"
},
{
"title": "Tool Calling 入门",
"snippet": "模型返回结构化调用请求,由应用程序执行对应工具。"
}
]
}
# Runtime 保存真正的执行函数
tool_registry = {
"search_web": search_web,
}
模型不能只凭一个函数名就知道该传什么参数,所以我们还需要一份给模型看的 Tool Definition。它描述工具的名称、用途和输入参数,但不包含真正的 Python 执行代码。
python
tool_definitions = [
{
"name": "search_web",
"description": "搜索互联网资料,适合查找近期信息或外部参考资料",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "需要搜索的关键词或问题"
}
},
"required": ["query"]
}
}
]
注意,这里有两份东西:tool_definitions 是交给模型看的能力说明,tool_registry 是Agent Runtime 用来找到执行函数的映射。它们通过 Tool Name 对应起来。这就是 Tool Definition 与 Tool Implementation 分离的最小例子。
当模型返回 Tool Call 后,Runtime 就可以按名称找到函数并执行。真实项目里还需要检查工具是否存在、参数是否合法,并处理异常;这里先看核心逻辑:
python
def execute_tool(name: str, arguments: dict):
tool = tool_registry.get(name)
if tool is None:
raise ValueError(f"未知的 Tool: {name}")
return tool(**arguments)
# 模拟 LLM 返回的 Tool Call
tool_call = {
"name": "search_web",
"arguments": {"query": "AI Agent 工程实践"}
}
result = execute_tool(
tool_call["name"],
tool_call["arguments"]
)
print(result)
实际接模型 API 时,不需要自己让模型随意拼一段文本再猜它的意思,而是把 tool_definitions 通过模型 API 的 Tool Calling 参数传入,读取 API 返回的结构化 Tool Call,再交给 execute_tool 执行。具体字段名取决于所使用的模型服务。
执行完还没结束。Runtime 得把结果作为 Tool Result 回传给模型,让它基于结果继续决策。模型可能再调用一个工具,也可能直接生成最终回答。这就是 Tool Calling 的完整闭环。
三、Tool 执行失败怎么办
只要开始调用外部能力,失败就不可避免。搜索服务可能超时,API 可能暂时不可用,也可能返回"账号配额已用完"。这些错误看起来都是失败,但处理方式并不相同。
比如网络超时有可能稍后重试就成功;如果服务明确告诉我们配额已经用完,再重试通常只会浪费时间和成本。Runtime 不应该不分情况地重试所有错误。
先把执行过程包上一层异常处理:
python
def execute_tool_safely(name: str, arguments: dict) -> dict:
try:
result = execute_tool(name, arguments)
return {"ok": True, "result": result}
except TimeoutError:
# 真实系统可以按策略有限重试;这里不实现重试细节
return {
"ok": False,
"error": {
"type": "TIMEOUT",
"message": "工具调用超时"
}
}
except Exception as exc:
return {
"ok": False,
"error": {
"type": "TOOL_ERROR",
"message": str(exc)
}
}
如果第三方工具返回下面这样的业务错误:
python
{
"code": 789,
"message": "quota exceeded"
}
Runtime 未必知道 789 代表什么。如果它无法可靠判断错误是否可重试,可以把错误结果交回模型,让模型决定是否换一个工具、调整查询,或者向用户说明限制。
不过,模型提出"再试一次"也不等于 Runtime 必须执行。重试次数、超时、成本和权限仍应由 Runtime 控制。对于明确不可重试的错误,程序应该直接阻止重试,而不是把决定权全部交给模型。
这里可以把错误大致分成三类:
- 基础设施错误:网络中断、超时、临时服务不可用。Runtime 可以根据明确规则决定是否有限重试。
- 业务错误:配额不足、账号冻结、权限不足。通常需要工具提供方或业务逻辑给出语义。
- 模型决策问题:工具返回的结果不符合任务需要,模型可以尝试换一种办法,但仍要受 Runtime 的限制。
业界常见做法不是要求所有工具都用完全相同的业务错误码,而是把调用状态、错误信息和结果结构化,让 Runtime 处理自己能判断的部分,把必要的错误信息作为 Observation 返回给模型。具体协议可能不同;例如 MCP 定义了工具调用及结果表达方式,但不会替所有业务系统理解每一个自定义错误码。
四、Tool 标准化与 Runtime 如何管理 Tool
刚才的例子只有一个 Python 函数,所以用字典就能管理。但如果有的工具是 HTTP API,有的是数据库查询,有的是 Shell 命令,还有的来自远程 MCP Server,Runtime 怎么知道该怎么执行?
关键是:Tool 的定义与执行实现是两回事。 给模型看的仍然是名称、描述和参数结构;Runtime 内部则把名称映射到相应的执行逻辑。
text
Tool Definition Runtime Registry
search_web ───────────────→ Python 函数
get_weather ───────────────→ HTTP 请求
query_db ───────────────→ 数据库执行器
search_repo ───────────────→ MCP Client
因此,Tool Registry 不只是一个"工具名字列表"。在一个简单实现中,它可以保存 Definition 和函数映射;在更复杂的 Runtime 中,还可能保存执行方式、权限、超时、来源等元数据。
python
tool_registry = {
"search_web": {
"definition": {
"name": "search_web",
"description": "搜索互联网资料",
"parameters": {"type": "object"}
},
"executor": search_web,
}
}
这里没有一种适用于所有 Agent 平台的强制分类,要求每个 Tool 都必须声明自己是 Python、HTTP 或 Shell。不同Agent框架和平台可以设计自己的执行器体系。重要的是,Runtime 必须能把模型请求的 Tool 名称映射到真实执行路径。
五、Tool 越来越多的问题
如果 Agent 只有 search_web、get_weather 这几个工具,把全部 Definition 都交给模型很简单。但假设你的 Agent 已经有几百个工具,甚至接入了数千个第三方工具,还能每次都全部传给模型吗?
这时至少会遇到三个问题:
- Context 成本增加:工具描述和参数 Schema 本身也会占用上下文空间。
- 选择难度上升:多个工具的功能可能很相似,模型更容易选错。
- 维护成本增加:工具越多,权限、版本、参数和可用性管理就越复杂。
可以先把问题简化成一个工具目录:
python
tool_catalog = [
{"name": "search_web", "description": "搜索互联网网页"},
{"name": "search_news", "description": "搜索近期新闻"},
{"name": "search_papers", "description": "搜索学术论文"},
{"name": "search_github", "description": "搜索 GitHub 仓库"},
# 实际系统中可以有更多工具
]
如果每次都把整个目录连同所有参数 Schema 一起发给模型,随着工具数量增长,开销也会增加。于是自然出现了一个新问题:能不能先从大量工具里找出一小批候选,再只把这些工具交给模型?
这就是 Tool Retrieval 要解决的问题。
六、Tool Retrieval:先找到候选工具,再让模型调用
Tool Retrieval 可以理解为"从工具目录里检索与当前任务有关的工具"。它和 Tool Selection 有关联,但不完全相同:Retrieval 负责从大量工具中缩小范围,Selection 则是在当前可用的候选工具中决定用哪一个。
text
1000 个 Tool
↓ Tool Retrieval
10 个候选 Tool
↓ LLM Tool Selection
选出需要调用的 Tool
6.1 先用最简单的关键词检索
我们先不引入向量数据库。只要把工具名称和描述作为检索文本,用关键词找出相关工具,就能演示基本原理。
python
def retrieve_tools(query: str, catalog: list[dict]) -> list[dict]:
terms = set(query.lower().split())
scored = []
for tool in catalog:
text = f"{tool['name']} {tool['description']}".lower()
score = sum(1 for term in terms if term in text)
if score:
scored.append((score, tool))
scored.sort(key=lambda item: item[0], reverse=True)
return [tool for _, tool in scored[:5]]
这是极简版本,不是生产级搜索器:没有处理同义词、相关性排序等问题。实际的工具目录可能会使用关键词搜索、向量检索或混合检索,也可能按类别、权限和可用状态先做过滤。
6.2 让 LLM 自己请求搜索 Tool:search_tool
刚才是程序主动做检索:先从用户需求里提取关键词,再调用 retrieve_tools。但有时程序并不知道用户具体需要什么工具,比如让LLM写一篇文章, LLM分析的第一步是搜索网络, 但程序这时并不知道要发送搜索相关的tool给到LLM。
python
def search_tool(query: str) -> dict:
matches = retrieve_tools(query, tool_catalog)
return {"matched_tools": matches}
假设用户说:"帮我写一篇关于 AI Agent 的公众号文章,先搜索最近的资料。"
第一轮模型不一定直接调用 search_web,它也可以先返回:
python
{
"name": "search_tool",
"arguments": {
"query": "搜索近期 AI Agent 新闻和资料"
}
}
Runtime 执行 search_tool,得到候选工具。随后,程序把这些候选工具的完整 Definition 加入下一轮模型请求中。模型这时才可以真正调用 search_web、search_news 等工具。
text
第一次 LLM:只看到 search_tool
↓
请求 search_tool("近期 AI Agent 新闻和资料")
↓
Runtime 执行 Tool Retrieval
↓
返回候选 Tool Definitions
↓
第二次 LLM:看到候选工具
↓
调用 search_web(...)
↓
Runtime 执行搜索并回传结果
要注意:检索出一个工具的名字,并不等于模型已经获得了调用它的完整定义。 下一轮请求需要提供足够的 Definition 和参数 Schema,模型才能按约定构造调用。
这个设计很适合演示"动态发现工具"的思路。它也带来一些工程问题:比如检索没找到合适工具,模型反复搜索工具却一直不调用,这些都需要通过循环限制和错误处理来控制。
6.3 业界怎么做 Tool Retrieval?
常见方案包括:
- 关键词检索:简单、可解释,适合目录规模较小或描述写得比较规范的场景。
- 向量检索:把任务描述和工具描述转成向量,寻找语义相近的工具,适合表达方式不一致的情况。
- 混合检索:结合关键词与向量结果,再做排序。
- 分层检索:先找工具类别或命名空间,再从类别中找具体工具。
- 按需加载:初始只暴露少量工具,模型需要时再搜索并加载完整定义。
现代 Agent 平台也开始支持 Tool Search 或类似机制,不再要求把所有工具定义一次性塞进模型上下文。核心思路是先缩小候选范围,再让模型调用具体工具。
七、Skill 和Tool的关系
接下来我们在聊聊Skill和tool的关系。假设我们有一个"公众号写作 Skill",它告诉 Agent 完成文章的基本流程:
python
wechat_writing_skill = {
"name": "wechat_writing",
"instructions": [
"先收集近期资料",
"整理核心观点",
"撰写公众号文章",
"检查并修改文章"
]
}
Skill 描述的是一种做事方法或工作流程。但"先收集资料"并没有告诉 Agent 具体该调用哪个搜索工具。如果当前上下文里没有搜索工具,Skill 也不会凭空创造出一个。
因此,需要把 Skill 和 Tool Retrieval 接起来:
text
用户要求写文章
↓
加载公众号写作 Skill
↓
Skill 提示需要搜索资料
↓
Tool Retrieval 搜索可用工具
↓
向 LLM 提供候选 Tool Definitions
↓
LLM 决定调用哪个工具
可以把 Skill 看成"怎么完成任务的指导",把 Tool 看成"实际可调用的能力"。Skill 可以让 LLM 使用多个 Tool,也可能执行Skill自身的脚本。
八、多个 Tool 调用与调度:谁决定串行还是并行?
文章写作往往不止需要一个搜索工具。为了写好一篇关于 AI Agent 的文章,我们可能要搜索近期新闻、学术论文和 GitHub 项目。
text
search_news
search_papers
search_github
如果三个搜索任务彼此独立,就可以并行执行;如果后一个任务依赖前一个任务的结果,就必须按顺序执行。模型可能在一次响应中提出多个 Tool Call, Runtime 可以同时执行它们。
8.1 独立任务可以并行
下面用 Python 的 asyncio 展示核心执行方式:
python
import asyncio
async def run_tool_async(name, arguments):
# 实际系统中应接入异步工具实现,并加超时与错误处理
return await asyncio.to_thread(
execute_tool, name, arguments
)
async def run_independent_calls(tool_calls):
tasks = [
run_tool_async(call["name"], call["arguments"])
for call in tool_calls
]
return await asyncio.gather(*tasks, return_exceptions=True)
这段代码假设调用之间互相独立。真实系统还要限制并发数、处理超时和异常,并判断工具是否允许并行执行。asyncio.gather 本身不会替我们分析任务之间的依赖关系。
8.2 有依赖的任务不能盲目并行
比如先搜索资料,再根据搜索结果选出最有价值的内容,最后才能写文章:
text
search_material
↓
select_evidence
↓
write_article
Runtime 如果不知道这些依赖,就无法仅凭三个 Tool Call 的名称判断应该怎么调度。可以让模型输出任务依赖,也可以由程序根据预定义流程控制,比如动态规划和执行图。
8.3 业界怎么处理?
常见方式有:
- 串行执行:简单可靠,适合有依赖的任务。
- 并行 Tool Calls:适合互相独立的调用。
- 程序化调度:Runtime 负责循环、条件分支、并发和中间结果处理。
- 工作流或执行图:把节点、依赖和执行顺序显式表示出来。
重点是:Tool Calling 解决的是模型如何提出工具调用请求;多个调用怎么排队、并发、重试和控制权限,仍然需要 Agent Runtime 的执行策略。
九、从 Tool Calling 走向 Planning / Orchestration
我们最初只是给 Agent 加了一个 search_web,让它能够完成一次外部操作。但随着需求升级,它需要从几百个工具中找到合适的工具,需要把 Skill 的工作步骤与工具连接起来,还需要决定多个工具的执行顺序、处理失败并管理中间结果。
这条演进路径:Tool 让 Agent 能执行动作;Tool Retrieval 让 Agent 能从大量能力中找到合适的工具;调度让多个工具协同工作;Planning 则进一步决定为了完成目标,需要做哪些步骤、先做什么、后做什么。
本篇小结
这一篇我们学会实现一个简单tool,沿着一个任务,看到 Tool 能力不断增长后带来的工程问题:
- Tool Calling 建立了模型决策与程序执行之间的闭环。
- Runtime 需要管理工具定义、执行实现和调用错误。
- Tool 数量增长后,全部暴露给模型会带来 Context、成本和选择问题。
- Tool Retrieval 可以先缩小候选范围,
search_tool则展示了模型主动发现工具的一种设计。 - Skill 描述做事方法,Tool 提供实际能力,Runtime 将两者连接起来。
- 多个 Tool 的调用需要考虑并行、串行、依赖和错误处理。
- 当多步任务需要动态规划与调度时,就自然走向 Planning / Orchestration。
希望这篇文章能对你有所帮助。关注我,持续分享更多实用、好用的 AI 干货。