AI Agent工具调用机制深度解析:从function calling到自动编排
工具调用是Agent能力的核心。模型如何在对话过程中识别需要调用工具、选择正确的工具、构造正确的参数、处理调用的结果?本文从底层实现到上层策略,完整分析工具调用的技术原理。
一、工具调用的底层机制
1.1 Function Calling的结构
工具调用的底层实现是 function calling。模型输出一段结构化文本,描述要调用的工具名和参数。推理引擎解析这段文本,执行工具,将结果返回给模型。
一个典型的工具调用过程:
// 1. 系统提示词中注册工具定义
{
"tools": [
{
"type": "function",
"function": {
"name": "search_files",
"description": "在项目中搜索文件或文件内容",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "搜索关键词"},
"path": {"type": "string", "description": "搜索路径"}
},
"required": ["pattern"]
}
}
}
]
}
// 2. 模型输出(结构化JSON)
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "search_files",
"arguments": "{\"pattern\": \"User\", \"path\": \"/src\"}"
}
}
]
}
// 3. 推理引擎解析并执行工具
// 4. 工具结果返回给模型
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "src/UserService.java: 第42行"
}
1.2 tool_use API的底层实现
主流的tool calling API有OpenAI格式和Anthropic格式两种。
OpenAI格式:tool_calls是一个数组,每个元素包含id、type、function字段。工具调用在assistant消息中返回,工具结果在tool角色的消息中返回。一个assistant消息可以包含多个tool_calls(并行调用)。
Anthropic格式:tool_use是content block的一种类型。content数组中可以同时包含text block和tool_use block。工具结果以tool_result block的形式返回。
两种格式的底层逻辑相同:模型输出 -> 解析工具描述 -> 执行 -> 结果反馈。不同的是多工具调用的表示方式------OpenAI用数组,Anthropic用content blocks。
# 两种格式的对比
# OpenAI:
messages = [
{"role": "system", "content": "你是一个助手"},
{"role": "user", "content": "搜索src目录下的User文件"},
{"role": "assistant", "content": None, "tool_calls": [...]},
{"role": "tool", "tool_call_id": "call_xxx", "content": "..."}
]
# Anthropic:
messages = [
{"role": "user", "content": "搜索src目录下的User文件"},
{"role": "assistant", "content": [
{"type": "text", "text": "我来搜索..."},
{"type": "tool_use", "name": "search", "input": {"pattern": "User"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_xxx", "content": "..."}
]}
]
1.3 解析过程
模型输出的是token序列,不是结构化的JSON。推理引擎需要做两件事:检测模型是否在输出工具调用、解析工具调用参数。
检测是否在输出工具调用 :模型在遇到需要调用工具的场景时会输出一个特殊的token或token序列来标记开始tool call。OpenAI格式中这个标记是固定的JSON结构开头({"tool_calls":),Anthropic格式中是一个特殊的XML标记(<tool_use>)。
参数解析:模型输出的JSON需要解析为有效参数。JSON输出不稳定------模型偶尔会输出格式错误的JSON(缺少引号、多余的逗号、嵌套未闭合等)。推理引擎需要容错解析:
# JSON容错解析的几种策略
import json
import re
def parse_tool_args(text):
# 策略1: 直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 策略2: 修复常见错误后重试
fixed = re.sub(r",\s*([\]}])", r"\1", text) # 去掉末尾多余的逗号
try:
return json.loads(fixed)
except json.JSONDecodeError:
pass
# 策略3: 提取有效的JSON片段
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
# 策略4: 让模型重新生成(兜底)
return None # 需要模型重新输出
容错解析的命中率:直接解析约85-90%,修复末尾逗号后约93-95%,提取JSON片段后约96-98%。仍有约2-4%的情况需要让模型重新生成。
不同模型的JSON输出稳定性不同。DeepSeek V4的JSON稳定性最好(约98-99%直接解析成功),GLM-5.2稳定性其次(约95-97%)。稳定性差异来自训练数据的质量------训练时大量使用JSON格式输出的模型,生成JSON的能力更好。
二、工具选择策略
2.1 候选工具过多的问题
当注册的工具数量超过50个时,模型选择正确工具的概率显著下降。原因是工具描述在系统提示词中占用了大量token,模型需要在大量工具描述中寻找匹配的选项。
| 注册工具数 | 选择正确率(GPT-4级别) | 系统提示词中工具描述占比 |
|---|---|---|
| 10个 | ~95% | ~2K token |
| 30个 | ~90% | ~6K token |
| 50个 | ~82% | ~10K token |
| 100个 | ~70% | ~20K token |
| 200个 | ~55% | ~40K token |
正确率下降有两个原因:一是工具描述本身占据了模型的可分配注意力窗口,长提示词中模型对中间部分的关注度下降。二是相似工具之间的区分变得困难------当有多个搜索工具(search_files、search_database、search_web),模型可能选错。
2.2 工具过滤(Tool Filtering)
解决候选工具过多的问题,不是在注册端减少工具数量,而是在调用端动态过滤。
基于历史调用的过滤:维护一个工具调用频率表。当前会话中已经调用过的工具、该任务中常用的工具优先展示。一个运行30分钟的Agent任务,前5步使用的工具基本可以确定任务的类型,因此从第6步开始可以将候选工具列表从50个压缩到15个。
def filter_tools_by_history(tool_usage_history, all_tools, max_tools=20):
# 当前会话已使用的工具
used = set(t["name"] for t in tool_usage_history)
# 按使用频率排序
usage_count = Counter(t["name"] for t in tool_usage_history)
# 已使用的工具优先
selected = [t for t in all_tools if t["name"] in used]
# 未使用但高频的工具补充
remaining = sorted(
[t for t in all_tools if t["name"] not in used],
key=lambda t: usage_count.get(t["name"], 0),
reverse=True
)
selected.extend(remaining[:max_tools - len(selected)])
return selected
基于语义的过滤:将用户输入和工具描述分别做embedding,计算语义相似度。选择相似度最高的top-N个工具。相似度不高的工具不参与选择,减少模型的决策负担。
语义过滤的关键是工具描述的embedding质量。描述越详细,embedding越准确。一个搜索文件的工具,描述写"搜索文件内容"和"使用关键词搜索项目目录下的文件,支持正则表达式,返回匹配的文件路径和行号",后者的embedding效果更好。
def filter_tools_by_semantics(query, tools, embedding_model, top_k=20):
query_embedding = embedding_model.embed(query)
tool_embeddings = [embedding_model.embed(t["description"]) for t in tools]
similarities = [cosine_sim(query_embedding, te) for te in tool_embeddings]
ranked = sorted(zip(tools, similarities), key=lambda x: x[1], reverse=True)
return [t for t, _ in ranked[:top_k]]
语义过滤可以将候选工具从200个压缩到20个,正确率从55%回升到85%左右。embedding模型的维度影响压缩质量:768维(如bge-base)和1536维(如bge-large)的效果差异约3-5个百分点。
2.3 工具路由
工具路由是多级过滤的扩展:先判断任务类型,再选择该类型下的工具集。
# 工具路由的层级结构
task_classifier(user_input)
├── 代码类任务
│ ├── 代码工具集(search_files, read_file, write_file, patch, terminal)
│ └── Git工具集(git_commit, git_diff, git_push)
├── 数据类任务
│ ├── 数据库工具集(query_db, create_table, insert_data)
│ └── 文件工具集(read_csv, write_csv, read_json)
├── 网络类任务
│ ├── HTTP工具集(http_get, http_post, http_put)
│ └── 搜索工具集(web_search, web_fetch)
└── 通用工具
├── 对话工具集(clarify, confirm, explain)
└── 日志工具集(log_info, log_error, log_warning)
路由的关键是一个轻量的分类模型(classifier),它的任务是判断用户输入属于哪个任务类型,而不是判断具体要调用哪个工具。分类任务比工具选择简单,准确率可以做到98%以上。分类模型可以用更小的模型(如qwen-turbo级别),不占用主模型的计算资源。
三、并行工具调用
3.1 串行vs并行
当多个工具调用没有数据依赖时,可以并行执行。
# 串行(每个工具等待上一个完成)
result1 = search_files("User", "/src") # 耗时0.3秒
result2 = search_files("Login", "/src") # 耗时0.3秒
result3 = search_files("Auth", "/src") # 耗时0.3秒
# 总耗时:0.9秒
# 并行
results = parallel_execute([
("search_files", {"pattern": "User", "path": "/src"}),
("search_files", {"pattern": "Login", "path": "/src"}),
("search_files", {"pattern": "Auth", "path": "/src"}),
])
# 总耗时:0.3秒
并行调用在IO密集型工具(API调用、搜索、数据库查询)上效果明显。在计算密集型工具(代码分析、编译)上效果有限------计算资源是瓶颈而不是IO等待。
3.2 依赖检测
并行调用的前提是工具之间没有数据依赖。依赖检测是一个静态分析问题:
def detect_dependencies(tool_calls):
"""
检测工具调用之间的数据依赖。
如果工具B的参数引用了工具A的输出,则B依赖A。
"""
dependencies = {}
outputs = {} # 工具名 -> 输出字段列表
for i, call in enumerate(tool_calls):
for j, prev in enumerate(tool_calls[:i]):
dep_fields = []
for param_name, param_val in call["arguments"].items():
# 检查参数是否引用了前序工具的输出
if isinstance(param_val, str):
ref = check_reference(param_val, prev["name"])
if ref:
dep_fields.append((param_name, ref))
if dep_fields:
dependencies.setdefault(i, [])
dependencies[i].append(j)
return dependencies
当前的大模型在输出多个工具调用时,不会显式声明数据依赖。推理引擎只能通过参数内容来推断。一个简单的启发式:如果工具B的参数中包含了与工具A输出相同的变量名,则B依赖A。这个启发式的准确率约90-95%,少量情况会误判。
3.3 冲突处理
当多个工具调用写同一个文件时,需要使用锁或其他并发控制机制。
文件级别的冲突:用事务性写入(先写临时文件,再原子重命名)。工具A写文件X,先写入X.tmp_A,工具B写文件X,先写入X.tmp_B。两个工具都完成后,合并X.tmp_A和X.tmp_B到X。如果合并失败(同时修改了同一行),进入冲突解决流程。
冲突解决流程:暂停后续工具调用,启动一个冲突解决Agent检查两个版本的diff,决定保留哪个版本或手动合并。
数据库级别的冲突:用乐观锁(版本号或时间戳)。写入时检查数据版本是否与读取时一致,不一致则回退重试。
四、工具调用失败处理
4.1 失败类型
| 失败类型 | 原因 | 发生频率 | 恢复策略 |
|---|---|---|---|
| 工具未找到 | 模型选择了不存在的工具 | 低(约1%) | 重新选择 |
| 参数无效 | 模型构造了错误的参数 | 中(约5-8%) | 修正参数重试 |
| 执行超时 | 工具执行超过时间限制 | 低(IO密集型工具中较高) | 设置超时后重试 |
| 权限不足 | 工具需要但没有授权 | 低(约1-2%) | 跳过或提示用户 |
| 结果异常 | 工具执行成功但结果不符合预期 | 中(约5%) | 重新执行 |
4.2 参数修正
参数错误是最常见的工具调用失败。模型可能记错了参数名、参数类型、必填参数。
修复策略不需要重新让模型生成全部参数,只需要修正错误的参数:
def fix_tool_args(name, args, error_msg):
"""
根据工具定义和错误信息修正参数。
不需要再次调用模型,用规则即可处理大部分情况。
"""
tool_def = get_tool_definition(name)
# 类型修正
for param, value in args.items():
expected_type = tool_def["parameters"]["properties"][param].get("type")
if expected_type == "integer" and isinstance(value, str):
try:
args[param] = int(value)
except ValueError:
return None # 无法修正
elif expected_type == "array" and isinstance(value, str):
args[param] = [value] # 字符串转数组
# 必填参数补全
for required in tool_def["parameters"].get("required", []):
if required not in args or args[required] is None:
return None # 需要模型补充
return args
规则修正能处理约60-70%的参数错误。剩余的30-40%需要让模型重新生成参数,但可以让模型只重新生成工具调用的参数部分,而不是重新生成整个assistant消息。
4.3 工具链回退
当一个工具反复失败时,需要回退到替代工具或替代方法。
# 工具回退链
tool_chains = {
"search_files": [ # 主工具失败时的备选
"grep_tool", # 备选1:用系统命令替代
"find_tool", # 备选2:用其他搜索工具
None # 兜底:无法完成
],
"web_search": [
"curl_tool", # 备选1:直接请求搜索引擎
None
],
"code_analyze": [
"ast_parse_tool",
"simple_analyze_tool", # 备选:用简单的文本分析替代完整的AST分析
None
]
}
def execute_with_fallback(name, args, max_retries=2):
for attempt in range(max_retries + 1):
result = execute_tool(name, args)
if result["success"]:
return result
# 尝试用备选工具
alternatives = tool_chains.get(name, [None])
for alt in alternatives:
if alt is None:
return {"success": False, "error": "所有工具都失败"}
result = execute_tool(alt, args)
if result["success"]:
return result
return {"success": False, "error": f"重试{max_retries}次后仍失败"}
工具链回退的关键是备选工具的调用方式要和主工具兼容。grep_tool的参数(pattern, path)和search_files的参数类似,不需要模型重新生成参数。
五、工具描述的优化
5.1 描述质量的影响
工具描述的质量直接影响模型选择工具的正确率。
# 差描述
{
"name": "search_files",
"description": "搜索文件"
}
# 中等描述
{
"name": "search_files",
"description": "在项目目录中搜索文件"
}
# 好描述
{
"name": "search_files",
"description": "使用关键词搜索项目目录下的文件内容,支持正则表达式,返回匹配的文件路径和行号。适合在代码库中找到包含特定关键词的位置。"
}
工具描述的优化原则:
-
包含使用场景示例:"适合在代码库中找到包含特定关键词的位置"------这不仅描述了功能,还暗示了应该在什么场景下使用这个工具。
-
说明参数的含义和约束:参数描述写"搜索路径"不如写"从哪个目录开始搜索,支持绝对路径和相对路径,默认是当前工作目录"。
-
区分相似工具:当有search_files和search_database两个工具时,描述中需要明确指出它们的差异。"search_files搜索文件系统,search_database搜索数据库记录"比简单的"搜索"要好得多。
好的工具描述可以使正确率提升5-10个百分点(对比差描述)。
5.2 参数约束的表达
参数约束需要在description字段中表达,而不是依赖schema约束(因为部分推理引擎不支持JSON Schema的高级约束)。
{
"name": "fetch_web_page",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "要抓取的网页URL,必须以http://或https://开头,例如https://example.com"
},
"timeout": {
"type": "integer",
"description": "超时时间(秒),最小5秒,最大60秒,默认30秒"
}
},
"required": ["url"]
}
}
在description中包含约束信息比在JSON Schema中声明更稳定------不是所有推理引擎都支持JSON Schema的minimum/maximum/pattern约束。而description字段所有引擎都会传递给模型。
六、工具调用的安全
6.1 权限控制
工具调用的权限控制需要考虑:这个工具有没有权限执行?在当前任务下允不允许执行?
def check_tool_permission(name, args, context):
# 一级检查:是否在黑名单中
if name in BLOCKLIST_TOOLS:
return {"allowed": False, "reason": "该工具已被禁止使用"}
# 二级检查:参数是否在安全范围内
if name == "delete_file":
path = args.get("path", "")
if "/system/" in path or "/etc/" in path:
return {"allowed": False, "reason": "不允许修改系统文件"}
if not path.startswith(context.get("project_dir", "")):
return {"allowed": False, "reason": "不允许修改项目目录外的文件"}
# 三级检查:操作频率限制
if is_rate_limited(name):
return {"allowed": False, "reason": f"工具{name}被限流"}
return {"allowed": True}
6.2 调用前确认
高风险工具可以在执行前要求确认。但Agent在自动执行任务时,不能每次都停下来问用户------这会影响自动化效率。
分级确认策略:
CONFIRMATION_LEVELS = {
"read_only": {
"tools": ["search_files", "read_file", "web_search"],
"action": "auto" # 自动执行,不需要确认
},
"write_file": {
"tools": ["write_file", "patch"],
"action": "auto_if_pattern" # 如果修改符合预定模式,自动执行
},
"destructive": {
"tools": ["delete_file", "rm_rf", "database_drop"],
"action": "ask" # 必须由用户确认
},
"network_write": {
"tools": ["git_push", "http_post", "deploy"],
"action": "ask_once_per_task" # 每个任务只需确认一次
}
}
七、2026年的技术现状
| 项目 | 工具注册方式 | 并行调用 | 失败恢复 | 安全控制 | 工具数量上限 |
|---|---|---|---|---|---|
| OpenAI API | JSON Schema | 支持 | 无(需开发者实现) | API Key | ~128 |
| Anthropic API | JSON Schema | 支持 | 无 | API Key | ~64 |
| vLLM | JSON Schema | 支持 | 部分 | 无 | ~128 |
| Claude Code | 内置工具集 | 支持 | 自动重试 | 文件级 | ~30 |
| Hermes Agent | 配置文件+插件 | 支持 | 自动重试+回退 | 分级确认 | ~200 |
| OpenHands | 插件化 | 支持 | 自动重试 | 用户确认 | ~100 |
| LangChain | 函数注册 | 支持 | 开发者实现 | 开发者实现 | 不限 |
大部分项目在工具注册和调度上已经成熟,但在失败恢复和安全控制上仍需要开发者自己实现。Hermes Agent和OpenHands在自动重试上做得较好。Claude Code的自动确认机制(当模式匹配时自动执行写操作)是安全控制中比较务实的做法------不过度打扰用户,又不完全放权。
八、总结
-
工具调用的底层实现是function calling:模型输出结构化JSON -> 解析 ->执行 ->结果反馈
-
JSON容错解析:直接解析(85-90%),修复末尾逗号(93-95%),提取JSON片段(96-98%),剩余2-4%需模型重新生成
-
候选工具超过50个时选择正确率开始显著下降(从95%降到82%)
-
工具过滤:基于历史调用(已使用的工具优先)和基于语义(embedding相似度排序),可将200个工具压缩到20个
-
工具路由:先分类任务类型再选择对应工具集,分类准确率98%+
-
并行调用依赖检测:通过参数内容推断数据依赖,启发式检测准确率约90-95%
-
文件冲突用事务性写入+合并,数据库冲突用乐观锁
-
参数修正规则能处理60-70%的参数错误,剩余的需模型重试
-
工具链回退:主工具失败后按优先级尝试备选工具
-
工具描述质量影响正确率5-10个百分点,需包含使用场景和相似工具区分
-
权限控制分三级:黑名单->参数安全检查->频率限制
-
高风险工具的分级确认策略:只读自动、写文件(模式匹配时自动)、破坏性操作(必须确认)、网络写入(每任务确认一次)