第七章 工具调用
7.1 Function Calling 基础
在上一章中,我们深入探讨了检索增强生成(RAG)技术。RAG 通过向量检索为模型注入了外部知识,让 LLM 能够"看到"训练数据之外的信息——文档、手册、数据库记录。然而,RAG 本质上是一种"只读"能力:模型可以查询知识,却无法改变任何东西。它不能发一封邮件,不能下单采购,不能执行一段代码,也不能调用实时 API 获取此刻的天气。
想象一个场景:用户说"帮我查一下北京现在的天气,如果超过 30 度就给张经理发邮件提醒"。RAG 无法完成这个任务——它既不能获取实时天气,更不能发邮件。这就需要一种新的能力:让模型不仅能"看",还能"做"。
这就是本章的主题——工具调用(Tool Calling),也被称为 Function Calling。如果说 RAG 给了模型一双"眼睛"去阅读外部知识,那么 Function Calling 就是给模型装上了一双"手",让它能够实际操作外部系统。从"能说会道"到"动手做事",这是 Agent 从聊天机器人走向真正智能代理的关键一步。
本章将从 Function Calling 的基础概念出发,逐步深入工具定义、多平台对比、Agent 工具循环,最终涵盖 MCP 协议等前沿话题。
7.1.1 什么是 Function Calling
Function Calling(函数调用) 是大模型与外部世界交互的核心机制。它让模型在对话过程中,能够识别用户的意图,决定调用哪个外部工具,生成结构化的调用参数,由开发者的代码实际执行工具,再将执行结果返回给模型做最终回复。
用一个类比来理解:Function Calling 就是给 AI 装上手。
想象一个聪明但没有手脚的人坐在一个房间里。他能听懂你说的话,知道应该做什么,但他自己无法行动——他知道"北京今天很热"需要查天气,但他没有手机可以打开天气 App;他知道"帮我发邮件"需要用邮件系统,但他碰不到键盘。Function Calling 就是给这个聪明人装上了手:他可以拿起"手机"查天气,可以操作"键盘"发邮件,可以在"计算器"上做精确运算。
不过,有一个关键细节:模型本身并不执行工具。它只是决定"该调用什么工具、用什么参数",实际执行由开发者编写的代码完成。继续用上面的类比——聪明人的"手"是被外部控制的:他说"我要查北京天气",然后有人(开发者代码)帮他执行了查询,把结果递到他手里,他再根据结果给出回答。模型是"大脑",代码是"手",Function Calling 是连接两者的"神经"。
为什么需要 Function Calling?
传统 LLM 存在几个根本性限制:
- 知识截止日期:训练数据有截止时间,无法获取实时信息(如天气、股价、新闻)
- 无法执行操作:只能生成文本,不能真正"发邮件"或"下订单"
- 缺乏精确计算:数学计算能力有限,不如专门的函数精确
- 无法访问私有数据:无法直接查询企业内部数据库、API
Function Calling 优雅地解决了这些问题:模型识别用户意图后,生成结构化的函数调用请求,由开发者代码实际执行,再将结果返回模型做最终回复。整个过程中,模型负责"理解与决策",代码负责"执行与反馈",两者各司其职。
7.1.2 Function Calling 的工作原理
整个 Function Calling 流程分为五个关键步骤。让我们以"北京今天天气怎么样?"为例,走完整条链路。
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ 用户输入 │ ──▶ │ LLM 推理决策 │ ──▶ │ 生成 Tool Call │
└──────────┘ └──────────────┘ └──────┬───────┘
│
┌──────────────────────────┘
▼
┌──────────────┐
│ 执行函数(代码) │
└──────┬───────┘
│
▼
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ 最终回复 │ ◀── │ LLM 整合结果 │ ◀── │ 返回函数结果 │
└──────────┘ └──────────────┘ └──────────────┘图 7-1:Function Calling 完整流程
步骤一:定义工具(Define Tools)
开发者以 JSON Schema 格式定义工具,描述工具的名称、用途和参数。模型并不执行工具,它只是"阅读"这些描述,然后决定何时、是否调用。
# 定义一个天气查询工具的 JSON Schema 描述
weather_tool = {
"type": "function", # 声明这是一个函数类型的工具
"function": {
"name": "get_weather", # 工具名称,模型据此生成调用
"description": "获取指定城市的实时天气信息", # 描述工具用途,模型据此判断何时调用
"strict": True, # 开启严格模式,确保参数 100% 符合 Schema
"parameters": { # 参数定义,使用 JSON Schema 规范
"type": "object", # 顶层是一个对象
"properties": { # 定义对象的各个属性
"city": { # city 参数:城市名
"type": "string",
"description": "城市名称,如 Beijing, Shanghai"
},
"unit": { # unit 参数:温度单位(可选)
"type": "string",
"enum": ["celsius", "fahrenheit"], # 限定可选值
"description": "温度单位"
}
},
"required": ["city"], # city 是必填参数
"additionalProperties": False # strict 模式要求:禁止额外字段
}
}
}关键理解:
description是最重要的字段。模型完全依赖描述来判断何时调用工具。一个模糊的描述(如"查询数据")会导致模型在错误的时机调用工具。
步骤二:发送请求(Send Request)
将工具定义和用户消息一起发送给模型:
from openai import OpenAI # 导入 OpenAI SDK
client = OpenAI() # 初始化客户端,自动读取环境变量中的 API Key
response = client.chat.completions.create( # 发起聊天补全请求
model="gpt-4o", # 使用支持 Function Calling 的模型
messages=[ # 消息列表
{"role": "user", "content": "北京今天天气怎么样?"} # 用户的提问
],
tools=[weather_tool] # 将工具定义传给模型,模型据此决定是否调用
)步骤三:接收工具调用(Receive Tool Call)
如果模型判断需要调用工具,返回的 response 中会包含 tool_calls 而非普通文本回复:
# 模型返回的 tool_calls 结构
tool_calls = response.choices[0].message.tool_calls
# tool_calls[0].function.name -> "get_weather"
# tool_calls[0].function.arguments -> '{"city": "Beijing", "unit": "celsius"}'
# tool_calls[0].id -> "call_abc123"(用于后续关联结果)注意 arguments 是一个 JSON 字符串,不是 Python 字典——使用前需要 json.loads() 解析。
步骤四:执行工具(Execute Tool)
开发者代码解析模型返回的参数,实际执行函数:
import json # 用于解析模型返回的 JSON 参数字符串
def get_weather(city: str, unit: str = "celsius") -> dict:
"""模拟天气查询(实际项目应调用真实天气 API)"""
return {
"city": city,
"temperature": 26,
"unit": unit,
"condition": "晴"
}
# 遍历模型返回的所有工具调用(可能包含多个并行调用)
for tool_call in tool_calls:
fn_name = tool_call.function.name # 获取函数名,如 "get_weather"
fn_args = json.loads(tool_call.function.arguments) # 解析参数 JSON 字符串为字典
result = get_weather(**fn_args) # 用 ** 解包字典为关键字参数,实际执行函数步骤五:返回结果(Return Results)
将工具执行结果返回给模型,让它生成最终的自然语言回复:
# 将工具调用结果追加到消息历史中
messages.append({
"role": "tool", # 使用 tool 角色标记工具结果
"tool_call_id": tool_call.id, # 关联到对应的 tool_call,模型据此匹配结果
"content": json.dumps(result, ensure_ascii=False) # 将结果序列化为 JSON 字符串
})
# 再次调用模型,让它读取工具结果并生成最终回复
final_response = client.chat.completions.create(
model="gpt-4o",
messages=messages # 此时消息包含:用户问题 + 模型工具调用 + 工具结果
)
# 最终输出:"北京今天天气晴朗,气温26°C"这就是一个完整的 Function Calling 循环。回顾整个过程:用户提问 → 模型决定调用 get_weather → 开发者代码执行查询 → 结果返回模型 → 模型生成自然语言回答。模型从头到尾没有真正"执行"过任何函数,它只是做出了"调用决策"和"结果整合"。
7.1.3 JSON Schema 定义规范
OpenAI 在 2024 年 8 月引入了 strict: true 模式,这是生产环境的最佳实践。开启 strict 后,模型输出的工具调用参数 100% 符合 Schema 定义,不会出现类型错误、缺少必填字段等问题。
strict 模式的强制规则:
| 规则 | 说明 |
|---|---|
additionalProperties: false | 每个 object 必须显式禁止额外字段 |
required 显式列出 | 所有必填字段必须在 required 数组中 |
| 类型严格匹配 | 不允许 string 输出成 number |
| enum 精确匹配 | 不允许输出枚举之外的值 |
常用 JSON Schema 类型与约束:
# 一个完整的工具参数定义示例,展示了常用类型与约束
{
"type": "object", # 顶层为对象类型
"properties": {
"query": { # 字符串类型,带长度约束
"type": "string",
"description": "搜索关键词",
"minLength": 1, # 最短 1 个字符
"maxLength": 200 # 最长 200 个字符
},
"limit": { # 整数类型,带数值范围
"type": "integer",
"description": "返回结果数量",
"minimum": 1, # 最小值
"maximum": 50, # 最大值
"default": 10 # 默认值
},
"sort_by": { # 字符串类型,带枚举约束
"type": "string",
"enum": ["relevance", "date", "popularity"], # 只能从这三个值中选
"description": "排序方式"
},
"filters": { # 数组类型
"type": "array",
"description": "过滤条件",
"items": { # 数组元素的定义
"type": "string"
}
}
},
"required": ["query"], # query 是必填,其余可选
"additionalProperties": False # strict 模式要求:禁止额外字段
}实践建议:始终在描述中给出示例值。例如
"description": "城市名称,如 Beijing, Shanghai"比"description": "城市名称"效果好得多,因为模型会从示例中学习预期的格式。
7.1.4 OpenAI vs Anthropic vs 开源模型对比
不同模型的工具调用机制有显著差异,理解这些差异对跨平台开发至关重要。
设计哲学差异
| 维度 | OpenAI | Anthropic Claude | 开源模型(Qwen/Llama) |
|---|---|---|---|
| 设计理念 | 工具是"插件",独立扩展 | 工具是"对话的一部分",深度融入 | 兼容 OpenAI 格式,各有特色 |
| 并行调用 | 原生支持,默认开启 | 支持,Claude 4 后可禁用 | Qwen 支持,Llama 依赖推理框架 |
| 调用格式 | tool_calls 数组 | tool_use 内容块 | 多用 tool_calls 兼容格式 |
| 工具选择 | auto/none/required | auto/any/tool 三级控制 | 基本 auto/none |
| 混合输出 | 不支持(文本或工具调用二选一) | 支持(一段回复中同时含文本和工具调用) | 取决于具体实现 |
OpenAI 风格——插件式调用:
# OpenAI 工具调用返回格式:返回一个 tool_calls 数组
response.choices[0].message.tool_calls
# [
# ToolCall(id="call_1", function=Function(
# name="get_weather",
# arguments='{"city":"Beijing"}'
# ))
# ]OpenAI 的设计将工具调用视为独立于文本回复的事件。一条助手消息要么是文本,要么是工具调用,二者不同时出现。
Anthropic 风格——对话流中的工具使用:
import anthropic # 导入 Anthropic SDK
client = anthropic.Anthropic() # 初始化客户端
response = client.messages.create( # 发起消息请求
model="claude-sonnet-4-20250514", # 使用支持工具调用的 Claude 模型
max_tokens=1024, # 最大输出 token 数
tools=[{ # 工具定义(格式与 OpenAI 不同)
"name": "get_weather",
"description": "获取天气信息",
"input_schema": { # Anthropic 用 input_schema 代替 parameters
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名"}
},
"required": ["city"]
}
}],
messages=[{"role": "user", "content": "北京天气怎么样?"}]
)
# Claude 返回的 content 是一个内容块列表,可同时包含文本和工具调用
for block in response.content: # 遍历内容块
if block.type == "tool_use": # 找到工具使用块
print(block.name) # 输出: "get_weather"
print(block.input) # 输出: {"city": "北京"}Anthropic 的设计将工具调用融入对话流。一条回复中可以同时包含自然语言文本和工具调用——例如 Claude 可以先说"让我帮你查一下",然后附带一个 tool_use 块。
开源模型(Qwen2.5)示例:
import ollama # 导入 Ollama 本地推理框架
response = ollama.chat( # 发起聊天请求
model="qwen2.5:7b", # 使用 Qwen2.5 7B 模型(本地运行)
messages=[{"role": "user", "content": "北京天气怎么样?"}],
tools=[weather_tool] # 兼容 OpenAI 格式的工具定义
)关键要点:Anthropic 的
tool_use和tool_result作为内容块嵌入对话流,不破坏 user↔assistant 的严格交替节奏。OpenAI 则使用独立的toolrole,形成更明显的"插件调用"模式。选择哪种方式取决于你的应用架构——需要更多上下文感知时用 Anthropic 风格,需要简洁的调用-执行分离时用 OpenAI 风格。
工具调用控制对比:
| 控制模式 | OpenAI | Anthropic |
|---|---|---|
| 自动选择 | tool_choice="auto" | tool_choice={"type": "auto"} |
| 禁止调用 | tool_choice="none" | 不传 tools 参数 |
| 强制调用任一 | tool_choice="required" | tool_choice={"type": "any"} |
| 强制调用指定工具 | tool_choice={"type": "function", "function": {"name": "x"}} | tool_choice={"type": "tool", "name": "x"} |
7.1.5 完整实战:天气查询 Agent
下面我们实现一个完整的天气查询 Agent,将前面五个步骤串联起来。这个 Agent 支持两个工具:天气查询和空气质量查询,并能够处理多工具并行调用。
import json # 用于 JSON 序列化与反序列化
import openai # OpenAI SDK
from typing import List, Dict, Any # 类型注解
client = openai.OpenAI() # 初始化 OpenAI 客户端
# ========== 第一步:定义工具 ==========
tools = [
{
"type": "function", # 工具类型为函数
"function": {
"name": "get_weather", # 天气查询工具
"description": "获取指定城市的实时天气信息",
"strict": True, # 开启严格模式
"parameters": {
"type": "object",
"properties": {
"city": { # 城市名参数
"type": "string",
"description": "城市名称,如 Beijing, Shanghai, Tokyo"
},
"unit": { # 温度单位参数(可选)
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,celsius(摄氏度) 或 fahrenheit(华氏度)"
}
},
"required": ["city"], # city 必填
"additionalProperties": False # 禁止额外字段
}
}
},
{
"type": "function", # 第二个工具
"function": {
"name": "get_air_quality", # 空气质量查询
"description": "获取指定城市的空气质量指数(AQI)",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称"
}
},
"required": ["city"],
"additionalProperties": False
}
}
}
]
# ========== 第二步:实现工具函数 ==========
def get_weather(city: str, unit: str = "celsius") -> Dict[str, Any]:
"""模拟天气查询(实际项目应调用真实天气 API)"""
weather_data = { # 模拟天气数据库
"Beijing": {"temperature": 28, "condition": "晴", "humidity": 45},
"Shanghai": {"temperature": 32, "condition": "多云", "humidity": 70},
"Tokyo": {"temperature": 25, "condition": "小雨", "humidity": 80},
}
data = weather_data.get(city, {"temperature": 22, "condition": "未知", "humidity": 50})
return {
"city": city,
"temperature": data["temperature"],
"unit": unit,
"condition": data["condition"],
"humidity": data["humidity"]
}
def get_air_quality(city: str) -> Dict[str, Any]:
"""模拟空气质量查询"""
aqi_data = { # 模拟 AQI 数据
"Beijing": {"aqi": 85, "level": "良"},
"Shanghai": {"aqi": 55, "level": "良"},
"Tokyo": {"aqi": 40, "level": "优"},
}
return aqi_data.get(city, {"aqi": 60, "level": "良"})
# 工具函数映射表:函数名 -> 可调用函数
available_functions = {
"get_weather": get_weather,
"get_air_quality": get_air_quality,
}
# ========== 第三步:执行对话循环 ==========
def run_agent(user_query: str) -> str:
"""Agent 主循环:接收用户提问,返回最终回复"""
messages = [{"role": "user", "content": user_query}] # 初始化消息列表
# 第一次调用模型:模型决定是否需要调用工具
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools, # 传入可用工具列表
tool_choice="auto" # 让模型自动决定是否调用工具
)
response_message = response.choices[0].message # 获取模型返回的消息
tool_calls = response_message.tool_calls # 提取工具调用列表
if tool_calls: # 模型决定调用工具
messages.append(response_message) # 将助手的工具调用消息加入历史
# 执行每一个工具调用
for tool_call in tool_calls:
fn_name = tool_call.function.name # 获取函数名
fn_args = json.loads(tool_call.function.arguments) # 解析参数
print(f"🔧 调用工具: {fn_name}({fn_args})")
fn_result = available_functions[fn_name](**fn_args) # 执行函数
# 将工具执行结果加入消息历史
messages.append({
"role": "tool",
"tool_call_id": tool_call.id, # 关联工具调用 ID
"content": json.dumps(fn_result, ensure_ascii=False)
})
# 第二次调用模型:模型整合工具结果,生成最终自然语言回复
final_response = client.chat.completions.create(
model="gpt-4o",
messages=messages # 此时包含完整的上下文
)
return final_response.choices[0].message.content
# 如果模型没有调用工具,直接返回文本回复
return response_message.content
# ========== 测试 ==========
if __name__ == "__main__":
# 测试 1:单工具调用
result1 = run_agent("北京今天天气怎么样?")
print(f"🤖 回复: {result1}\n")
# 测试 2:多工具并行调用(模型自动判断需要并行查询)
result2 = run_agent("北京和上海的天气和空气质量分别怎么样?")
print(f"🤖 回复: {result2}")预期输出:
🔧 调用工具: get_weather({'city': 'Beijing', 'unit': 'celsius'})
🤖 回复: 北京今天天气晴朗,气温28°C,湿度45%。
🔧 调用工具: get_weather({'city': 'Beijing', 'unit': 'celsius'})
🔧 调用工具: get_weather({'city': 'Shanghai', 'unit': 'celsius'})
🔧 调用工具: get_air_quality({'city': 'Beijing'})
🔧 调用工具: get_air_quality({'city': 'Shanghai'})
🤖 回复: 北京天气晴,28°C,AQI 85(良);上海多云,32°C,AQI 55(良)。注意第二个测试用例:当用户同时询问两个城市的天气和空气质量时,模型一次性返回了 4 个工具调用,开发者代码依次执行后将所有结果一并返回,模型再综合生成最终回复。这就是并行工具调用(Parallel Tool Calling),它显著提升了多工具场景的效率。
7.1.6 使用 LangChain 简化工具调用
上面的代码虽然清晰,但样板代码较多。LangChain 等框架对 Function Calling 做了封装,让工具定义更简洁:
from langchain_openai import ChatOpenAI # LangChain 的 OpenAI 封装
from langchain_core.tools import tool # @tool 装饰器
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from typing import List
# 使用 @tool 装饰器:自动从函数签名和 docstring 生成 JSON Schema
@tool
def search_documents(query: str, top_k: int = 5) -> str:
"""搜索内部知识库文档,返回最相关的内容。
Args:
query: 搜索关键词
top_k: 返回结果数量,默认5条
"""
docs = [ # 模拟文档库
"Python 3.12 引入了新的类型注解语法",
"LangChain 是一个构建 LLM 应用的框架",
"Function Calling 允许模型调用外部工具",
"MCP 协议是 Anthropic 提出的上下文管理标准",
"Agent 是能够自主决策和行动的 AI 系统"
]
return "\n".join(docs[:top_k])
@tool
def calculate(expression: str) -> str:
"""执行数学计算,支持加减乘除和括号。
Args:
expression: 数学表达式,如 '2 + 3 * 4'
"""
try:
result = eval(expression) # 注意:生产环境需用安全方式处理
return f"计算结果: {result}"
except Exception as e:
return f"计算错误: {str(e)}"
# 创建 LLM 并绑定工具
llm = ChatOpenAI(model="gpt-4o", temperature=0) # temperature=0 保证输出稳定
tools = [search_documents, calculate]
llm_with_tools = llm.bind_tools(tools) # 将工具绑定到 LLM
# 对话
messages = [
SystemMessage(content="你是一个有用的助手,可以使用工具来回答问题。"),
HumanMessage(content="帮我搜索 LangChain 相关的文档,然后计算 156 * 23")
]
# 第一轮:模型决定调用工具
response = llm_with_tools.invoke(messages)
print("工具调用:", response.tool_calls)
# 执行工具并将结果加入消息
messages.append(response) # 将模型的工具调用消息加入历史
for tool_call in response.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
# 查找并执行对应工具
for t in tools:
if t.name == tool_name:
result = t.invoke(tool_args) # 调用工具
break
messages.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))
print(f"🔧 {tool_name}: {result}")
# 第二轮:模型生成最终回复
final_response = llm_with_tools.invoke(messages)
print(f"🤖 最终回复: {final_response.content}")LangChain 的 @tool 装饰器自动从 Python 函数的类型注解和 docstring 生成 JSON Schema,省去了手写工具定义的繁琐。这是从"原始 API"到"框架封装"的进步,后续章节中我们会看到更多框架对工具调用的进一步抽象。
7.1.7 常见误区
在学习和使用 Function Calling 时,初学者容易陷入以下几个误区:
误区一:模型会"执行"工具
"我定义了
get_weather工具,模型应该会自己帮我查天气吧?"
事实:模型从不执行任何工具。它只生成调用意图(函数名 + 参数 JSON),实际执行完全依赖开发者编写的代码。如果你不写执行逻辑,工具调用就只是一段没人执行的 JSON。
误区二:工具越多越好
"我把所有 API 都注册成工具,让模型自己选。"
事实:工具过多会显著降低模型的决策准确率。研究表明,当工具数量超过 10-15 个时,模型选错工具的概率明显上升。最佳实践是按场景分组,动态注入相关工具集。
误区三:description 不重要
"函数名已经很清楚了,描述随便写写就行。"
事实:description 是模型判断"何时调用工具"的唯一依据。一个模糊的描述会导致模型在不该调用时调用,或者在该调用时不调用。好的描述应该说明:工具做什么、何时该用、何时不该用。
误区四:strict 模式可有可无
"非 strict 模式也能用,干嘛多此一举?"
事实:非 strict 模式下,模型可能输出不符合 Schema 的参数(如类型错误、缺少字段、多余字段),导致代码执行时崩溃。生产环境中务必开启 strict: true。
误区五:工具调用结果可以不返回给模型
"我执行完工具拿到结果了,直接返回给用户不行吗?"
事实:工具执行结果通常需要返回给模型进行整合和格式化。直接返回原始 JSON 给用户体验很差。模型擅长将结构化数据转化为自然语言——这正是它的强项。
7.1.8 本节小结
本节我们从"给 AI 装上手"这个类比出发,系统介绍了 Function Calling 的核心概念和工作原理:
| 序号 | 核心要点 |
|---|---|
| 1 | Function Calling 是 LLM 与外部世界交互的桥梁。模型负责"理解与决策",代码负责"执行与反馈",Function Calling 是连接两者的"神经" |
| 2 | 五步流程:定义工具 → 发送请求 → 接收调用 → 执行工具 → 返回结果,构成一个完整的工具调用循环 |
| 3 | JSON Schema 是工具定义的通用语言,生产环境务必使用 strict: true 和 additionalProperties: false |
| 4 | OpenAI 采用"插件"设计,工具调用通过独立 tool_calls 数组和 tool role 实现 |
| 5 | Anthropic 将工具调用融入对话流,通过 tool_use 和 tool_result 内容块实现,支持文本与工具调用混合输出 |
| 6 | 开源模型(Qwen2.5/Llama 3.1+)大多兼容 OpenAI 工具调用格式,但细节有差异 |
| 7 | 并行调用是现代模型的核心能力,模型可一次返回多个独立工具调用,显著提升效率 |
| 8 | LangChain 等框架通过 @tool 装饰器简化了工具定义,自动从函数签名生成 Schema |
至此,我们理解了 Function Calling 的"是什么"和"怎么用"。但在实际项目中,工具不可能像示例那样写死在代码里——你需要一套系统化的方式来定义、注册和管理工具。7.2 节将深入探讨工具的定义与注册,包括如何用 Pydantic 规范参数验证、如何组织工具注册表、以及如何在多 Agent 场景下动态分发工具。