1. 引言:为什么Agent需要工具
想象你聘了一位"过目不忘"的私人助理——他读过人类有史以来几乎所有的书,知识渊博、口才了得。可是你让他"查一下今天上海的天气",他却只能凭记忆猜一个数;你让他"算一下 1234 × 5678",他大概率会算错。原因很简单:他没有手机、没有计算器、没有联网能力,再聪明的脑子也巧妇难为无米之炊。
这正是LLM(大语言模型)的处境。在第4章里,我们给智能体装上了"记忆",它能维持连贯的多轮对话。但如果你问它实时天气、让它做精确算术、或让它去查你的订单库,它多半会"幻觉"出一个看似合理却完全错误的答案——因为它的知识停留在训练数据截止的那一天,而且它本质上是文字接龙机器,并不擅长精确计算,更无法主动访问外部系统。
要让LLM从"会聊天的鹦鹉"进化为"能办事的助手",必须给它装上"手和眼"——也就是工具(Tools)。工具让智能体获得三种关键能力:
- 获取实时信息:调用搜索API、天气API、数据库查询,突破训练数据的时间边界;
- 执行精确操作:用计算器做算术、用解释器跑代码、用SQL查数据,避免数值幻觉;
- 改变外部世界:发邮件、建工单、控制智能家居,让Agent真正"动"起来。
Function Calling(函数调用)是当前主流大模型厂商共同支持的一套标准机制:它让LLM能够结构化地选择工具、生成参数,再由外部代码执行后把结果回灌给LLM。本章我们从原理到实战,把这套机制彻底讲透。
💡 小贴士:别被"函数调用"这四个字误导。LLM自己一行代码都不会跑,它只是"决定"调用什么、传什么参数,真正动手执行的永远是你的Python程序。这一点理解透了,后面所有内容都顺了。
2. Function Calling原理
2.1 LLM如何理解工具描述
很多人误以为Function Calling是"LLM自己调用了函数",其实完全不是。我们用一个生活比喻来解释:
把整个系统想象成一家餐厅。用户是顾客,LLM是服务员,工具函数是后厨各位厨师,宿主代码(你写的Python)是传菜员兼调度员。服务员(LLM)自己不会炒菜,但他能听懂顾客要点什么,然后把需求写成一张结构化的"点菜单"(JSON),交给传菜员。传菜员拿着菜单去找对应的厨师(执行函数),厨师做好菜(返回结果)后,传菜员再把菜端回来交给服务员。服务员看一眼菜,决定是直接上给顾客,还是还要再追加几道菜。
对应到技术层面,真正发生的事情是这六步:
- 我们把若干工具的描述信息(名称、功能、参数schema)以结构化格式塞进prompt;
- LLM在生成回复时,根据用户意图判断"需不需要用工具、用哪个工具、参数填什么";
- 如果要用工具,LLM输出一个结构化的JSON调用请求,而不是自然语言;
- 我们的宿主代码解析这个JSON,真正调用对应函数,拿到结果;
- 把结果以
tool消息的形式追加到对话历史,再次喂给LLM;
- LLM基于工具返回结果,生成最终的自然语言回复。
所以Function Calling的本质是:LLM负责"决策",宿主代码负责"执行",两者通过JSON协议协作。LLM之所以能"听懂"工具描述,是因为它在训练阶段见过海量"工具描述→正确调用"的样本,学会了把自然语言意图映射到结构化参数——就像服务员经过培训,能把"来碗不辣的辣子鸡"翻译成"宫保鸡丁,少放辣椒"这张标准菜单。
2.2 工具调用的完整流程
一个标准的Function Calling闭环包含五步:
1
|
定义工具函数 → 描述工具给LLM → LLM选择并生成调用 → 宿主执行工具 → 结果回灌LLM
|
图示如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
用户输入
│
▼
[LLM] ──看到工具描述──> 决定调用 get_weather(location="上海")
│
▼
宿主代码解析 tool_calls
│
▼
执行 get_weather("上海") ──> {"temp": 32, "condition": "晴"}
│
▼
把结果作为 tool 消息追加到 messages
│
▼
[LLM] ──基于结果──> "上海今天32度,晴天,注意防晒"
|
注意这个循环可能要转好几圈:LLM可能先查天气,再查日程,最后综合回答。复杂的Agent甚至会串行调用十几个工具,每一步都基于上一步的结果做决策。
💡 小贴士:整个闭环里,最容易被新手忽略的是第5步——必须把工具结果作为role="tool"的消息追加回messages,再发起第二次LLM调用。少了这一步,LLM就永远"等不到"结果,对话也就断了。
2.3 工具描述schema(JSON Schema格式)
工具描述遵循一个叫 JSON Schema 的规范。听起来很高大上,其实你可以把它理解成一张填空表格——就像求职登记表上写着"姓名(文字)、年龄(数字)、应聘岗位(在以下选项中选一个)"。JSON Schema干的就是这件事:告诉LLM"这个工具需要哪几个字段、每个字段是什么类型、哪些必填、有哪些可选值"。
OpenAI的tools参数里,每个工具是一个对象,长这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的实时天气。支持中国地级市及以上行政区。",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "城市名称,例如:上海、北京、深圳"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,默认摄氏度"
}
},
"required": ["location"]
}
}
}
|
我们逐层拆开看:
- 最外层
type: "function"表示这是一个函数型工具(OpenAI预留了未来支持其他类型的能力);
function.name是工具名,要起得语义清晰,比如get_weather一看就知道是查天气;
function.description是最关键字段——这是LLM决定"该不该用这个工具"的唯一依据,必须写清"做什么、何时用、边界在哪";
parameters里,type: "object"表示参数整体是个对象(键值对集合);
properties列出每个参数的类型和说明,location是字符串、unit只能二选一(enum枚举);
required声明哪些参数必填,这里location必填,unit可省(因为有默认值)。
记住一条铁律:描述写得越清楚,LLM选错工具的概率越低。后面最佳实践部分会专门展开怎么写好描述。
💡 小贴士:description不仅是写给LLM看的"说明书",也相当于给未来的自己留的注释。一个好描述能让你三个月后回看代码时秒懂这个工具干嘛用的,一举两得。
3. OpenAI Function Calling实战
3.1 环境准备
动手之前先装两个依赖:openai是官方SDK,python-dotenv帮我们从.env文件读密钥,避免把密钥硬编码进代码(这是基本的安全卫生)。
1
|
pip install openai python-dotenv
|
然后在项目根目录创建一个 .env 文件,写入你的 OpenAI 密钥。格式是「变量名=变量值」一行:变量名固定为 OPENAI_API_KEY,变量值是你从 OpenAI 平台获取的密钥(以 sk- 开头)。等号两边不要加空格,整行不要加引号。
1
2
3
4
5
|
# .env 文件(项目根目录,每行一个变量,格式:变量名=变量值)
# 写入一行:OPENAI_API_KEY 等号 你的密钥
# - 变量名固定为 OPENAI_API_KEY(SDK据此自动读取)
# - 密钥从 https://platform.openai.com/api-keys 获取,以 sk- 开头
# - 等号两边不要加空格,整行不加引号
|
💡 小贴士:.env文件千万别提交到Git仓库,记得把它加进.gitignore。一旦密钥泄露,别人就能用你的额度跑模型,账单可能瞬间爆表。
本章示例统一使用gpt-5模型,它原生支持并行工具调用和结构化输出。
3.2 定义工具函数
工具函数就是普通的Python函数——接收参数、返回字典。我们写两个最经典的:天气查询和计算器。真实场景里天气要接第三方API,这里用模拟数据演示机制,重点在于"流程怎么走通",而不是API怎么调。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
# tools_impl.py
import json
from typing import Dict, Any
# 模拟天气数据库(真实项目里这里会改成调用第三方天气API)
WEATHER_DB = {
"上海": {"temp": 32, "condition": "晴", "humidity": 65},
"北京": {"temp": 28, "condition": "多云", "humidity": 50},
"深圳": {"temp": 30, "condition": "雷阵雨", "humidity": 80},
}
def get_weather(location: str, unit: str = "celsius") -> Dict[str, Any]:
"""查询指定城市的实时天气"""
# 去掉"市"字并去空格,让"上海市"和"上海"都能命中
city = location.replace("市", "").strip()
if city not in WEATHER_DB:
return {"error": f"暂不支持查询 {location} 的天气"}
data = WEATHER_DB[city].copy()
# 如果用户要华氏度,做个换算
if unit == "fahrenheit":
data["temp"] = round(data["temp"] * 9 / 5 + 32, 1)
data["unit"] = unit
return data
def calculator(expression: str) -> Dict[str, Any]:
"""安全计算数学表达式"""
# 白名单机制:只允许数字和基本运算符,防止代码注入
allowed = "0123456789+-*/().% "
if not all(c in allowed for c in expression):
return {"error": "表达式包含非法字符"}
try:
# 用受限的eval执行:禁用所有内置函数,防止逃逸
result = eval(expression, {"__builtins__": {}}, {})
return {"expression": expression, "result": result}
except Exception as e:
return {"error": f"计算失败: {e}"}
|
💡 小贴士:注意每个函数都返回字典而不是字符串。这是因为LLM处理结构化的JSON比处理自然语言更稳——它能把{"temp": 32}里的32准确取出来,而不会被"今天大约三十二度"这种模糊措辞带偏。
3.3 注册工具到LLM
接下来把工具的schema描述和Python实现绑定到一起,方便统一调度。这步相当于给服务员发一份"菜单+厨师对照表"。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
# agent.py
import os
import json
from dotenv import load_dotenv
from openai import OpenAI
from tools_impl import get_weather, calculator
load_dotenv() # 从.env文件加载环境变量
# 从环境变量读取API密钥,绝不硬编码到代码里
# 用**把字典展开成关键字参数传给OpenAI(Python传参的等价写法)
_opts = {"api_key": os.getenv("OPENAI_API_KEY")}
client = OpenAI(**_opts)
MODEL = "gpt-5"
# 工具schema定义:这就是给LLM看的"菜单"
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的实时天气,包括温度、天气状况和湿度。当用户询问天气、温度、是否下雨、穿衣建议时调用。",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "城市名称,如:上海、北京、深圳"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,默认摄氏度"
}
},
"required": ["location"]
}
}
},
{
"type": "function",
"function": {
"name": "calculator",
"description": "计算数学表达式。当用户需要做加减乘除、百分比、混合运算时调用。不支持代数或微积分。",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "数学表达式,如:1234 * 5678 或 (100+200)/3"
}
},
"required": ["expression"]
}
}
}
]
# 工具名 → 实现函数 的映射表:根据LLM点到的"菜名"找到对应的"厨师"
TOOL_REGISTRY = {
"get_weather": get_weather,
"calculator": calculator,
}
|
💡 小贴士:上面用 ** 把字典展开成关键字参数,是 Python 的标准用法。关键是密钥始终来自 os.getenv("OPENAI_API_KEY"),而不是写死在代码里——这样即使代码被分享或提交到 Git,密钥也不会泄露。如果你嫌麻烦,OpenAI SDK 其实会自动读取同名环境变量,直接写 client = OpenAI() 也能跑。
3.4 处理工具调用响应
这一节是全章的核心。我们要写一个工具调用循环:调用LLM → 检查它要不要调工具 → 要就执行并把结果回灌 → 再调LLM,直到LLM不再请求工具、给出最终的自然语言回复为止。
读代码前先理清思路:循环每一轮都先问LLM"你要不要用工具",如果它说"要"并给出tool_calls,我们就逐个执行、把结果塞回messages、进入下一轮;如果它说"不要"(直接给了content),循环结束,把文本返回给用户。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
def run_agent(user_query: str, max_turns: int = 5) -> str:
# 初始化对话历史:system设定人设,user放用户问题
messages = [
{"role": "system", "content": "你是一个能查天气和做计算的助手。回答要简洁。"},
{"role": "user", "content": user_query},
]
# 最多循环max_turns轮,防止意外死循环
for _ in range(max_turns):
# 把当前所有消息和工具清单一起发给LLM
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS_SCHEMA,
)
msg = response.choices[0].message
# 关键判断:如果LLM没有请求工具,说明它已经准备好直接回答用户
if not msg.tool_calls:
return msg.content
# 把assistant这条消息(含tool_calls)原样加入历史
# 注意:必须append原始msg对象,因为里面带着tool_calls引用
messages.append(msg)
# 依次执行LLM点名的每个工具调用
for tool_call in msg.tool_calls:
name = tool_call.function.name # 工具名
args = json.loads(tool_call.function.arguments) # 参数是JSON字符串,要解析
print(f"[调用工具] {name}({args})")
func = TOOL_REGISTRY.get(name) # 从注册表找到对应Python函数
if func is None:
result = {"error": f"未知工具: {name}"}
else:
try:
result = func(**args) # 用**解包把字典变成关键字参数
except Exception as e:
result = {"error": str(e)}
# 关键:把工具结果作为role="tool"的消息回灌
# tool_call_id必须和上面的tool_call.id对应,LLM靠它把结果归位
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=False),
})
return "达到最大轮数,处理中止。"
|
💡 小贴士:tool_call_id是这条结果和原始调用的"配套编号"。一个响应里LLM可能并行调用3个工具,回灌时必须用ID告诉LLM"这个结果对应哪个调用",否则它会对不上号。漏写tool_call_id是新手最常踩的坑之一。
3.5 完整运行示例
把上面几段拼起来,跑一个综合问题——同时问两个城市天气、还要做一道乘法题,看看Agent怎么应对:
1
2
|
if __name__ == "__main__":
print(run_agent("上海和北京今天多少度?哪个更热?顺便算一下 1234 × 5678"))
|
运行后你会看到类似输出:
1
2
3
4
|
[调用工具] get_weather({'location': '上海'})
[调用工具] get_weather({'location': '北京'})
[调用工具] calculator({'expression': '1234 * 5678'})
上海32°C,北京28°C,上海更热。1234 × 5678 = 7006652。
|
注意gpt-5会在一次响应里并行发出三个tool_calls,宿主代码把它们一次性执行完再统一回灌,效率远高于串行往返三次。这就是下一节要展开讲的并行调用。
💡 小贴士:如果运行报错AttributeError: 'NoneType' object has no attribute 'tool_calls',多半是API密钥没配好或网络不通,先回去检查.env和load_dotenv()是否生效。
4. 多工具编排
4.1 一个Agent管理多个工具
真实Agent往往挂载十几个工具:搜索、邮件、日历、数据库、文件、代码执行……此时核心挑战从"怎么调"变成了怎么选——LLM要在众多工具中挑对那一个。
继续用餐厅的比喻:服务员现在要面对一个有50道菜的菜单,顾客说"来点开胃的",他得在凉菜、汤品、沙拉里选对。菜单越长,选错的概率越高。同理,工具越多,LLM的"选择注意力"越分散。
应对这个挑战,工程上有三种编排策略:
- 扁平注册:所有工具一股脑塞给LLM。实现最简单,但工具数超过20个时选择准确率会明显下降;
- 分层路由:先用一个"路由LLM"判断用户意图属于哪个领域(天气?邮件?数据库?),再只加载该领域的工具子集给执行LLM;
- RAG工具检索:把所有工具描述做向量化索引,按用户query动态检索Top-K个最相关工具注入prompt。
💡 小贴士:经验法则——工具数小于15用扁平注册就够了;15到30考虑分层路由;超过30个建议上RAG工具检索。别一上来就上复杂架构,工具少的时候扁平注册反而更稳更快。
4.2 工具选择策略
无论用哪种编排,提升选择准确率都离不开这几条技巧:
- 描述里写清"何时用"和"何时不用":例如
send_email的描述加上"仅当用户明确要求发送邮件时调用,不要主动发邮件"——LLM很容易自作多情地主动发邮件,这句话能挡掉绝大多数误调用;
- 命名区分度高:
get_user_info和get_product_info远比get_info和query_info容易区分,前者一看就懂,后者会让LLM犯选择困难症;
- 参数避免歧义:每个参数都给
example,告诉LLM填什么格式;
- 避免功能重叠:如果两个工具都能完成同一件事,LLM会困惑。要么合并,要么在描述里明确分工,比如
search_internal_docs只搜公司内部文档、web_search只搜公网;
- 提供默认值:可选参数给合理默认值,减少LLM的决策负担,也降低参数填错的概率。
一个常见的反例是工具又多、描述又模糊,导致LLM频繁选错或反复调用同一个工具兜圈子。调试时建议把每轮的tool_calls都打印出来,观察LLM的"选择路径",再针对性优化描述。
4.3 并行工具调用
gpt-5、Claude 3.5+都支持在单次响应里返回多个tool_calls。要榨干这个并行能力,宿主代码就该用线程池或异步并发来执行这些调用,而不是老老实实地一个接一个串行跑——否则并行发出的调用到了你这边又变成串行,白瞎了模型的能力。
下面是把上一节run_agent改造为并行执行的版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
import json
from concurrent.futures import ThreadPoolExecutor
def run_tool_call(tool_call):
"""执行单个工具调用,返回对应的tool消息"""
name = tool_call.function.name
args = json.loads(tool_call.function.arguments) # 解析参数JSON
func = TOOL_REGISTRY.get(name) # 查注册表
try:
result = func(**args) if func else {"error": f"未知工具: {name}"}
except Exception as e:
result = {"error": str(e)}
# 返回标准格式的tool消息,tool_call_id用来和原调用配对
return {
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=False),
}
def run_agent_parallel(user_query: str, max_turns: int = 5) -> str:
messages = [
{"role": "system", "content": "你是一个能查天气和做计算的助手。"},
{"role": "user", "content": user_query},
]
for _ in range(max_turns):
response = client.chat.completions.create(
model=MODEL, messages=messages, tools=TOOLS_SCHEMA
)
msg = response.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
# 关键改动:用线程池并行执行本轮所有工具调用
with ThreadPoolExecutor() as pool:
tool_msgs = list(pool.map(run_tool_call, msg.tool_calls))
messages.extend(tool_msgs) # 一次性把所有结果回灌
return "达到最大轮数,处理中止。"
|
并行模式下,三个独立工具的总延迟约等于最慢那个工具的延迟,而不是三者之和。在调外部API(搜索、天气、数据库)时,这能带来3到5倍的吞吐提升。
💡 小贴士:并行只对彼此独立的工具调用有效。如果第二个调用的参数依赖第一个的结果(比如先查订单ID、再用ID查物流),LLM会自动拆成两轮串行,你不用担心错乱——它自己知道分寸。
5. 自定义工具开发
真实项目里,你需要为业务系统定制专属工具。下面给出三类常用工具的实现,并抽象出一个工具类设计模式,让工具可插拔、可复用。
5.1 搜索工具(模拟SerpAPI)
让Agent能"上网查资料"。这里用SerpAPI做演示,它封装了Google搜索:
1
|
pip install google-search-results
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# search_tool.py
import os
from serpapi import GoogleSearch
def web_search(query: str, num_results: int = 5) -> dict:
"""使用SerpAPI进行网络搜索,返回前N条结果摘要"""
search = GoogleSearch({
"q": query,
"num": num_results,
"api_key": os.getenv("SERPAPI_KEY") # 密钥同样从环境变量读
})
data = search.get_dict()
results = []
# 只取organic_results(自然搜索结果),裁剪成精简结构再返回
for item in data.get("organic_results", [])[:num_results]:
results.append({
"title": item.get("title"),
"snippet": item.get("snippet"),
"link": item.get("link"),
})
return {"query": query, "results": results}
|
💡 小贴士:没有SerpAPI密钥时,可以写一个本地mock版本返回假数据,机制完全一样——先把流程跑通,再换真实API,这是开发Agent的通用节奏。
5.2 文件操作工具
让Agent能读写本地文件,比如读一份配置、生成一份报告。文件工具最大的风险是路径穿越攻击——恶意输入可能让Agent读到你不想暴露的文件(比如/etc/passwd或../../.env)。所以必须做路径校验:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
# file_tool.py
import os
from pathlib import Path
# 把所有文件操作锁死在这个workspace目录里
WORK_DIR = Path("./workspace").resolve()
def read_file(path: str) -> dict:
"""读取指定路径的文本文件内容。路径相对于workspace目录。"""
target = (WORK_DIR / path).resolve()
# 防御路径穿越:解析后的真实路径必须仍然在WORK_DIR之下
if not str(target).startswith(str(WORK_DIR)):
return {"error": "禁止访问workspace之外的文件"}
if not target.exists():
return {"error": f"文件不存在: {path}"}
return {"path": path, "content": target.read_text(encoding="utf-8")}
def write_file(path: str, content: str) -> dict:
"""向指定路径写入文本内容。"""
target = (WORK_DIR / path).resolve()
if not str(target).startswith(str(WORK_DIR)):
return {"error": "禁止写入workspace之外的文件"}
# 自动创建父目录,避免"目录不存在"报错
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return {"path": path, "bytes_written": len(content.encode("utf-8"))}
|
💡 小贴士:resolve()会把../这种相对路径解析成绝对路径,所以校验必须放在resolve()之后。放在之前校验等于没校验——攻击者用一个..就绕过了。
5.3 数据库查询工具
让Agent能查业务数据库。这类工具的安全核心是只允许SELECT,绝不能让LLM有机会执行DROP TABLE之类的破坏性语句:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# db_tool.py
import sqlite3
import re
DB_PATH = "./data/agent.db"
def query_database(sql: str) -> dict:
"""执行只读SQL查询,返回结果行。仅允许SELECT语句。"""
# 用正则把非SELECT语句挡在门外(不区分大小写)
if not re.match(r"^\s*select\b", sql, re.IGNORECASE):
return {"error": "仅允许SELECT查询"}
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # 让查询结果可以按列名取值
try:
cur = conn.execute(sql) # 执行一次,拿到游标
columns = [d[0] for d in cur.description] # 从游标取列名
rows = cur.fetchall() # 再从同一游标取所有行
return {"columns": columns, "rows": [list(r) for r in rows]}
except Exception as e:
return {"error": str(e)}
finally:
conn.close() # 无论成功失败都关闭连接,防止连接泄漏
|
💡 小贴士:生产环境里光靠正则挡还不够稳,更安全的做法是用数据库账号权限做兜底——给Agent用的数据库账号只授SELECT权限,即使SQL校验被绕过也删不了数据。这就是"纵深防御"。
5.4 工具类设计模式
上面三个工具风格各异,难以统一管理。实际项目建议用一个BaseTool抽象类统一接口,让每个工具继承它、自动从函数签名生成schema,免去手写JSON的重复劳动:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
# base_tool.py
import inspect
import json
from typing import Callable, Dict, Any
class BaseTool:
"""工具基类:子类实现run方法,自动从签名生成schema"""
name: str = ""
description: str = ""
def run(self, **kwargs) -> Dict[str, Any]:
raise NotImplementedError # 子类必须实现
def to_schema(self) -> Dict[str, Any]:
"""从run方法的参数签名自动生成OpenAI工具schema"""
sig = inspect.signature(self.run)
properties = {}
required = []
for pname, param in sig.parameters.items():
properties[pname] = {"type": "string", "description": pname}
# 没有默认值的参数标记为必填
if param.default is inspect.Parameter.empty:
required.append(pname)
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
class WeatherTool(BaseTool):
name = "get_weather"
description = "查询指定城市实时天气"
def run(self, location: str, unit: str = "celsius") -> Dict[str, Any]:
from tools_impl import get_weather
return get_weather(location, unit)
class ToolRegistry:
"""统一管理多个工具:注册、生成schema列表、按名执行"""
def __init__(self):
self._tools: Dict[str, BaseTool] = {}
def register(self, tool: BaseTool):
self._tools[tool.name] = tool
return self # 链式调用:registry.register(A).register(B)
def schemas(self):
return [t.to_schema() for t in self._tools.values()]
def execute(self, name: str, args: dict):
tool = self._tools.get(name)
return tool.run(**args) if tool else {"error": f"未知工具: {name}"}
# 使用示例
registry = ToolRegistry()
registry.register(WeatherTool())
# registry.register(CalculatorTool()).register(SearchTool())...
|
这种模式让工具可插拔、可测试、可复用——加新工具只需写一个子类、register一下,不动主流程代码。这是构建中大型Agent项目的推荐架构。
💡 小贴士:to_schema这里把所有参数都简化成了type: "string",真实项目里可以用typing注解(int、bool、List[str]等)自动映射更精确的JSON类型,进一步减少LLM填错参数的概率。
6. 工具调用最佳实践
6.1 工具描述的写法技巧
描述是Function Calling成败的关键。一个好描述应包含三要素:做什么 + 何时用 + 边界。
❌ 差的写法:
1
|
{"description": "查询天气"}
|
✅ 好的写法:
1
|
{"description": "查询指定城市的实时天气,返回温度、天气状况、湿度。当用户询问天气、温度、是否需要带伞、穿衣建议时调用。仅支持中国地级市及以上行政区,不支持乡镇。"}
|
差在哪?差的写法只说了"做什么",LLM不知道什么时候该用它、它能不能处理用户的输入,于是要么该调不调、要么乱调一气。好的写法三要素齐全,LLM判断起来又快又准。
技巧清单:
- 用动词开头:
查询、发送、创建、搜索;
- 写明触发场景,帮LLM判断"该不该用";
- 写明边界和限制,避免误调用;
- 给参数加
example,让LLM知道填什么格式。
6.2 错误处理与重试
工具调用失败是常态——网络抖动、API限流、参数错误都可能发生。务必做两层防护:网络/系统级错误用重试扛过去,业务级错误直接把错误信息回灌给LLM让它自己调整:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import time
def safe_execute(func, args, retries=3):
for attempt in range(retries):
try:
result = func(**args)
# 业务级错误:不重试,直接返回给LLM让它自己处理
if isinstance(result, dict) and "error" in result:
return result
return result
except Exception as e:
# 系统/网络级错误:重试,指数退避避免雪崩
if attempt == retries - 1:
return {"error": f"工具执行失败({attempt+1}次): {e}"}
time.sleep(2 ** attempt) # 1秒、2秒、4秒...
return {"error": "未知错误"}
|
把错误信息以JSON形式回灌给LLM,它通常能自己读懂错误、调整参数重试或换一个工具——这比你在代码里写一堆if分支优雅得多。
💡 小贴士:2 ** attempt是经典的"指数退避"——重试间隔依次是1、2、4、8秒,避免在被限流时还密集重试把对方打挂。生产环境还可以加随机抖动(jitter)进一步分散请求。
6.3 工具权限与安全
工具一旦能改变外部世界(发邮件、删文件、转账),安全就成了头等大事。核心原则就一句话:永远不要无条件信任LLM的输出。
- 最小权限:每个工具只暴露必需能力。文件工具锁死在workspace内,DB工具只允许SELECT;
- 路径校验:文件类工具必须做路径穿越检查(见5.2);
- SQL防注入:拒绝非SELECT语句,参数化查询;
- 代码执行沙箱:要跑用户代码时,用Docker容器或nsjail隔离,别直接在宿主机上
eval;
- 人工确认(Human-in-the-loop):高危操作(发邮件、付款)执行前先让人确认。
1
2
3
4
5
6
7
8
9
|
CONFIRM_REQUIRED = {"send_email", "delete_file", "transfer_money"}
def maybe_confirm(tool_name, args):
"""高危工具调用前先问一下人"""
if tool_name in CONFIRM_REQUIRED:
# 实际项目弹UI或发审批消息,这里用input演示
if input(f"确认执行 {tool_name}({args})? (y/n): ").lower() != "y":
return {"error": "用户取消执行"}
return None
|
💡 小贴士:Human-in-the-loop是LLM时代最朴素也最有效的安全网。哪怕你的描述写得再好、权限控得再严,让真人在"扣钱、发外发邮件、删数据"前点一下确认,能挡掉99%的灾难性事故。
6.4 性能优化
- 并行执行:见第4节,单次响应多工具时务必并发;
- 结果裁剪:工具返回的数据可能很大(如搜索结果100条),先裁剪再回灌,省token也省LLM处理时间;
- 缓存:相同参数的工具调用结果缓存几分钟(天气、股价这类低频变化的数据);
- 流式输出:最终回复用
stream=True,让用户立即看到进度,体感更快;
- 工具数量控制:单次注入不超过15个工具,多了用分层路由或RAG检索。
7. 不同模型的Function Calling对比
主流模型都支持Function Calling,但实现细节和表现有差异。下表是当前几款主流模型的横向对比:
| 维度 |
OpenAI gpt-5 |
Claude 4.5 |
Gemini 3.0 |
开源(Llama 4/Qwen3) |
| 并行调用 |
✅ 原生 |
✅ 原生 |
✅ 原生 |
⚠️ 部分微调版支持 |
| 工具选择准确率 |
高 |
高 |
高 |
中 |
| 参数JSON稳定性 |
极高 |
极高 |
高 |
中(偶发格式错误) |
| 结构化输出约束 |
✅ JSON Schema强制 |
✅ tool_use强约束 |
✅ 严格模式 |
⚠️ 依赖prompt |
| 最大工具数 |
128 |
256 |
128 |
取决于上下文长度 |
| 流式工具调用 |
✅ |
✅ |
✅ |
⚠️ |
选型建议:
- 生产环境追求稳定 → OpenAI gpt-5 / Claude 4.5,二者Function Calling最成熟;
- 多模态工具调用(带图片) → Gemini 3.0;
- 私有化部署、成本敏感 → Qwen3-72B 或 Llama 4 70B,配合专门的function calling微调版;
- 极致长上下文工具管理 → Claude,支持工具数最多。
开源模型最大的坑是参数JSON格式不稳定,偶尔会漏字段或加多余引号。建议加一层容错解析,并配合JSON Schema验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import json
import jsonschema
def validate_tool_args(args_json, schema):
"""容错解析+校验:先看JSON能不能解析,再看符不符合schema"""
try:
args = json.loads(args_json)
except json.JSONDecodeError as e:
return None, f"JSON解析失败: {e}"
try:
jsonschema.validate(args, schema)
except jsonschema.ValidationError as e:
return None, f"参数校验失败: {e}"
return args, None
|
💡 小贴士:用开源模型时,validate_tool_args这类校验几乎是必选项。校验失败时可以把错误信息回灌给LLM让它重新生成参数,往往重试一两次就能拿到合法JSON。
8. 小结
本章我们完整走过了Function Calling的全链路:
- 原理:LLM负责决策、宿主负责执行,通过JSON协议协作,五步闭环:定义→描述→选择→执行→回灌;
- 实战:用OpenAI gpt-5实现了天气+计算器双工具Agent,演示了完整的工具调用循环;
- 编排:多工具管理、并行调用、用线程池榨取性能;
- 自定义工具:搜索、文件、数据库三类工具实现,以及统一的
BaseTool设计模式;
- 最佳实践:描述三要素、错误重试、安全权限、性能优化;
- 模型对比:OpenAI/Claude最稳,Gemini擅长多模态,开源模型需加容错。
记住核心心法:工具描述是LLM选工具的唯一依据,写得越清楚,Agent越可靠。Function Calling不是魔法,它本质是一套"LLM决策+代码执行"的工程协议。把协议走稳、把工具做扎实、把安全守好,你的Agent就有了真正"动手办事"的能力。
还有一个常被忽略的点:工具调用是可观测的。与传统prompt工程不同,Function Calling每一步都有明确的JSON输入输出,你可以记录、回放、断点调试。建议在生产环境把每次调用的tool_name、arguments、result、耗时都打日志,这不仅能排查问题,还能用来做工具使用的数据分析,反哺优化描述和参数设计。
9. 预告第6章:推理模式
至此,我们的智能体已经具备记忆(第4章)和工具使用(本章)两大基础能力。但它仍然是"被动响应"的——用户问一句它动一步。下一章,我们要给它装上推理引擎,让它能自主规划、分解任务、反思纠错,从"工具人"进化为"思考者"。
第6章将聚焦推理模式,内容包括:
- ReAct(Reasoning + Acting)范式:让LLM"边想边做"的经典框架
- Chain-of-Thought与Tree-of-Thought的工程实现
- Plan-and-Execute:先规划再执行的多步任务分解
- Reflection机制:让Agent自我反思、纠错、迭代
- 完整实战:构建一个能自主完成复杂研究报告的推理Agent
当工具遇上推理,智能体才真正开始"活"起来。我们第6章见。