L4 Tool Use 深入:让 Agent 拥有"双手"
课程目标:掌握工具设计最佳实践,理解 Function Calling 在底层到底发生了什么,学会让 Agent 同时调用多个工具并优雅处理错误。
一、从 L3 到 L4:我们解决了什么?#
L3 我们跑通了第一个 smolagents Agent,体验了 @tool 装饰器的神奇。但是有几个问题还没回答:
@tool装饰器背后到底发生了什么?为什么一个 Python 函数就能被 LLM 调用?我能不能不用任何框架,自己用 OpenAI/Anthropic 原生 API 实现一个 Agent?
当 Agent 同时需要查天气、查汇率、发邮件时,它怎么知道该先做哪个?
当工具执行失败(比如网络超时)时,Agent 怎么应对?
这节课我们回答这四个问题。学完后,你不仅会用框架,还能手写 Agent——这是面试和工作中的核心能力。
二、用一个比喻:工具就是 LLM 的"瑞士军刀"#
还记得 L1 的"私人管家"比喻吗?管家需要工具来完成任务——电话、地图、计算器、日程表。
但管家怎么知道每个工具是干什么的? 这是关键问题。
反面例子:
你递给管家一把瑞士军刀,说:"这是个工具,你自己看着用。" 管家困惑:"这是开瓶器?刀子?螺丝刀?我该用哪一面?"
正面例子:
你递给管家一份工具清单:
get_weather(city: str): 查询某城市的实时天气,返回温度+天气状况
convert_currency(amount: float, from: str, to: str): 汇率换算,支持 USD/CNY/EUR
send_email(to: str, subject: str, body: str): 发送邮件管家一眼就明白每个工具的用途、参数和返回。
关键洞察:工具描述就是 LLM 的"工具说明书"。说明书写得越好,LLM 用得越准。这和 docstring 完全是一回事——L3 我们看到的 @tool 装饰器本质就是自动从 Python 函数生成这份说明书。
三、@tool 装饰器背后到底发生了什么?#
我们撕开 smolagents 的魔法,看看 @tool 装饰器到底做了什么。
一个普通 Python 函数#
def add(a: int, b: int) -> int:
"""Returns the sum of two integers."""
return a + b
加上 @tool 之后,运行时发生了什么?#
我们可以用 Python 自省的 inspect 模块手动还原:
import inspect
import json
def tool_schema(func) -> dict:
"""把 Python 函数转换成 OpenAI function calling 的 JSON schema"""
sig = inspect.signature(func)
doc = inspect.getdoc(func) or ""
# 1. 提取参数类型
properties = {}
required = []
for name, param in sig.parameters.items():
properties[name] = {
"type": "integer", # 简化,实际要根据类型注解动态判断
"description": f"参数 {name}"
}
if param.default is inspect.Parameter.empty:
required.append(name)
# 2. 构造 OpenAI 风格的 schema
return {
"type": "function",
"function": {
"name": func.__name__,
"description": doc,
"parameters": {
"type": "object",
"properties": properties,
"required": required
}
}
}
print(json.dumps(tool_schema(add), indent=2, ensure_ascii=False))
输出:
{
"type": "function",
"function": {
"name": "add",
"description": "Returns the sum of two integers.",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "integer", "description": "参数 a"},
"b": {"type": "integer", "description": "参数 b"}
},
"required": ["a", "b"]
}
}
}
这就是 OpenAI Function Calling 的标准格式!
所以 @tool 装饰器的本质就是:
用
inspect读函数的签名(参数名、类型注解、默认值)用
inspect.getdoc读函数的 docstring把它们打包成 OpenAI 标准的 JSON schema
把这个 schema 塞进 system prompt,让 LLM 知道"你可以调用这些工具"
魔法破除完毕。 没有任何黑魔法,都是 Python 自省机制 + 字符串拼接。
四、手写一个完整的 Agent(60 行)#
学完上面的拆解,我们完全有能力不用任何框架,自己实现一个 Agent。下面是完整代码(基于 OpenAI API,可改成 Anthropic):
import json
import os
from openai import OpenAI
client = OpenAI() # 自动读 OPENAI_API_KEY 环境变量
# ============ 1. 定义工具 ============
def get_weather(city: str) -> str:
"""查询某城市的实时天气。
Args:
city: 城市名,必须是英文,如 "Beijing"
"""
# 模拟 API 调用
weather_db = {"Beijing": "晴 25°C", "Shanghai": "多云 28°C"}
return weather_db.get(city, f"未找到 {city} 的天气数据")
def send_email(to: str, subject: str, body: str) -> str:
"""发送邮件给指定收件人。
Args:
to: 收件人邮箱地址
subject: 邮件主题
body: 邮件正文
"""
# 模拟发送
return f"邮件已发送到 {to},主题:{subject}"
def calculate(expression: str) -> str:
"""计算数学表达式。
Args:
expression: 数学表达式,如 "(5+3)*2"
"""
try:
return str(eval(expression)) # 生产环境用 ast.literal_eval 更安全
except Exception as e:
return f"计算错误: {e}"
# ============ 2. 工具注册表(用 docstring 自动生成 schema)===========
TOOLS = [get_weather, send_email, calculate]
TOOL_REGISTRY = {func.__name__: func for func in TOOLS}
def func_to_schema(func) -> dict:
"""简化版的 schema 生成器(省略类型映射细节)"""
import inspect
sig = inspect.signature(func)
return {
"type": "function",
"function": {
"name": func.__name__,
"description": inspect.getdoc(func),
"parameters": {
"type": "object",
"properties": {
name: {"type": "string"} # 简化成 string
for name in sig.parameters
},
"required": list(sig.parameters.keys())
}
}
}
tools_schema = [func_to_schema(f) for f in TOOLS]
# ============ 3. Agent 主循环 ============
def run_agent(user_query: str, max_turns: int = 10) -> str:
messages = [
{"role": "system", "content": "你是一个助手,可以用工具完成任务。"},
{"role": "user", "content": user_query}
]
for turn in range(max_turns):
print(f"\n=== 第 {turn+1} 轮 ===")
# 3a. 调用 LLM(带工具定义)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools_schema,
tool_choice="auto" # 让 LLM 自己决定要不要调
)
msg = response.choices[0].message
messages.append(msg)
# 3b. 检查 LLM 是否想调工具
if not msg.tool_calls:
# 没有工具调用,说明 LLM 直接回答了
return msg.content
# 3c. 执行每个工具调用
for tool_call in msg.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
print(f" → 决定调用工具: {func_name}({func_args})")
if func_name not in TOOL_REGISTRY:
result = f"错误: 工具 {func_name} 不存在"
else:
try:
result = str(TOOL_REGISTRY[func_name](**func_args))
except Exception as e:
result = f"工具执行失败: {e}"
print(f" ← 工具返回: {result}")
# 3d. 把工具结果追加回 messages
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
})
return "达到最大轮次,任务未完成"
# ============ 4. 跑一个需要多工具协作的问题 ============
if __name__ == "__main__":
# 这个查询需要:1)查天气 2)发邮件
query = "查一下北京今天的天气,然后把结果发邮件给 alice@example.com,主题写'今日天气'"
answer = run_agent(query)
print(f"\n最终答案: {answer}")
预期运行过程(简化):
=== 第 1 轮 ===
→ 决定调用工具: get_weather({"city": "Beijing"})
← 工具返回: 晴 25°C
=== 第 2 轮 ===
→ 决定调用工具: send_email({"to": "alice@example.com", "subject": "今日天气", "body": "晴 25°C"})
← 工具返回: 邮件已发送到 alice@example.com,主题:今日天气
=== 第 3 轮 ===
最终答案: 我已经查到了北京今天是晴 25°C,并把天气信息发送到了 alice@example.com。
看清楚了吗? Agent 自己决定:
先查天气(因为发邮件需要天气数据)
再发邮件(用天气数据填充正文)
最后汇报结果
这就是 L2 讲的 ReAct 循环 + L3 的工具调用 = 真实可用的 Agent。
五、工具设计五大黄金法则#
跑完上面的代码,你应该能感受到:工具设计的好坏直接决定 Agent 能不能完成任务。下面是实战总结的五大黄金法则:
法则 1:docstring 是工具的"使用说明书"#
# ❌ 错误示范
def get_weather(city: str) -> str:
"""Get weather."""
return ...
# ✅ 正确示范
def get_weather(city: str) -> str:
"""查询某城市的实时天气。返回温度和天气状况。
Args:
city: 城市名,必须是英文名,如 "Beijing"、"Shanghai"。不支持中文。
Returns:
天气描述字符串,格式为"天气 温度",如"晴 25°C"。
如果城市不存在,返回"未找到 {城市名} 的天气数据"。
"""
return ...
关键技巧:
写明输入格式("必须是英文名")
写明输出格式("格式为'天气 温度'")
写明边界情况("如果城市不存在返回什么")
LLM 没有"读你的心"的能力,它只能看到 docstring。
法则 2:参数名要语义化#
# ❌ 错误示范:参数名太抽象
def query(d1: str, d2: int) -> str:
...
# ✅ 正确示范:参数名自解释
def search_models(task: str, max_results: int) -> str:
...
法则 3:限制工具数量在 20 个以内#
为什么? OpenAI 官方建议:每个请求的 tools 列表会作为 input tokens 计算费用。20 个工具大约占用 1-2K tokens。超过 20 个,LLM 选择工具的准确率会显著下降。
如果你的应用真的需要 50+ 工具,考虑:
分层架构:先让 LLM 选择"分类",再在分类内选具体工具
工具合并:把相似的小工具合并成大工具(带
action参数)
法则 4:工具要做单一职责#
# ❌ 反面例子:一个工具做太多事
def manage_database(action: str, table: str, data: dict) -> str:
"""管理数据库:action 可以是 create/read/update/delete"""
...
# ✅ 正面例子:拆成四个工具
def create_user(name: str, email: str) -> str: ...
def read_user(user_id: int) -> str: ...
def update_user(user_id: int, fields: dict) -> str: ...
def delete_user(user_id: int) -> str: ...
为什么? LLM 选择 "做太多事的工具" 时容易选错 action。拆开后每个工具职责清晰,LLM 选错率显著降低。
法则 5:错误信息要明确且可恢复#
# ❌ 反面例子:抛出异常让 LLM 困惑
def get_user(user_id: int) -> dict:
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
if user is None:
raise Exception("Not found") # LLM 不知道怎么恢复
return user
# ✅ 正面例子:返回结构化错误信息
def get_user(user_id: int) -> dict:
"""根据用户 ID 查询用户信息。
Returns:
成功时返回用户字典,格式 {"id": int, "name": str, "email": str}。
用户不存在时返回 {"error": "User {user_id} not found"},而不是抛出异常。
"""
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
if user is None:
return {"error": f"User {user_id} not found"}
return {"id": user.id, "name": user.name, "email": user.email}
关键洞察:错误信息也是 LLM 决策的依据。把错误信息设计成"LLM 能看懂并能据此调整下一步"的形式,是 Agent 健壮性的关键。
六、tool_choice 进阶:四种控制粒度#
OpenAI 提供四种 tool_choice 控制 LLM 调用工具的粒度:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools_schema,
tool_choice=... # ← 这里
)
| 取值 | 含义 | 适用场景 |
|---|---|---|
"auto" (默认) | LLM 自己决定要不要调、要不要多调 | 大多数场景 |
"none" | 强制不让调工具 | 想让 LLM 纯聊天 |
"required" | 强制至少调一个工具 | 已知需要工具的场景 |
{"type": "function", "name": "get_weather"} | 强制调用指定工具 | 工作流中固定步骤 |
实战建议:90% 的场景用 "auto" 即可。其他三个是进阶控制,初学者先不要碰。
七、Parallel Function Calling:让 Agent 同时调多个工具#
OpenAI 默认支持并行工具调用——LLM 可以一次性发起多个工具调用。
举个例子:
query = "北京和上海今天天气怎么样?"
LLM 可能一次性返回两个工具调用:
[tool_call_1: get_weather(city="Beijing"), tool_call_2: get_weather(city="Shanghai")]
你的代码需要循环处理所有工具调用(上面 60 行代码的 3c-3d 步就是这么做的)。
怎么禁用并行? 设置 parallel_tool_calls=False:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools_schema,
parallel_tool_calls=False # 强制每次最多一个工具调用
)
什么时候该禁用? 当后续工具调用依赖前一个工具的返回值时。比如"先查北京天气再发邮件"——这两步必须串行。
八、和 L3 的对比#
| 维度 | L3 smolagents | L4 手写 |
|---|---|---|
| 代码量 | 30 行 | 60 行 |
| 灵活性 | 中(框架约束) | 高(完全控制) |
| 安全 | 沙箱执行 | 自己处理错误 |
| 适用场景 | 快速原型 | 生产环境 + 面试 |
| 学习价值 | 工具感 | 原理深度 |
结论:生产环境推荐 smolagents/LangGraph(不要重新发明轮子)。但理解底层原理让你能在框架出错时快速 debug——这是高级工程师的标志。
九、动手时间:30 分钟亲手改造#
请你把上面的 60 行代码保存为 agent_v1.py,然后做以下三个改造:
改造 1:加一个 "工具调用计数器"#
每次调用工具时打印累计调用次数。观察一个复杂问题需要多少次工具调用。
改造 2:加一个 "最大重试次数" 机制#
当工具返回错误时(比如 send_email 模拟失败),让 Agent 最多重试 3 次再放弃。
改造 3:加一个 "日志记录器"#
把所有 messages 追加写入 agent_log.json,方便事后回溯 Agent 的决策过程。
📋 改造 2 的参考答案(点击展开)
def run_agent_with_retry(user_query: str, max_turns: int = 10, max_retries: int = 3) -> str:
messages = [{"role": "user", "content": user_query}]
for turn in range(max_turns):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools_schema
)
msg = response.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for tool_call in msg.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
# 重试逻辑
for attempt in range(max_retries):
try:
result = str(TOOL_REGISTRY[func_name](**func_args))
break # 成功就跳出重试循环
except Exception as e:
if attempt < max_retries - 1:
result = f"工具执行失败(重试 {attempt+1}/{max_retries}): {e}"
else:
result = f"工具执行失败(已达最大重试次数): {e}"
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
})
return "达到最大轮次"
关键点:把重试逻辑放在工具调用层,而不是 Agent 主循环里。这样 Agent 主循环保持简洁,重试逻辑独立可测。
十、小结#
| 知识点 | 一句话回顾 |
|---|---|
@tool 装饰器本质 | inspect 读签名 + inspect.getdoc 读 docstring → 拼成 JSON schema |
| Function Calling 协议 | OpenAI 标准格式:{type, function: {name, description, parameters}} |
| docstring 是说明书 | LLM 看不到代码,只能看到 docstring,写清楚才能用对 |
| 五大黄金法则 | 详细 docstring / 语义化参数名 / ≤20 工具 / 单一职责 / 错误可恢复 |
tool_choice | auto 默认 / none 禁用 / required 强制 / 对象指定具体工具 |
| 并行调用 | 默认支持,可用 parallel_tool_calls=False 禁用 |
十一、Read next#
下一节课 L5 用 LangGraph 构建有状态 Agent,我们进入主流生产框架的世界。学完 L5 你会:
理解"状态图"为什么是构建复杂 Agent 的最佳抽象
掌握 LangGraph 的 Node/Edge/State 三大核心概念
学会用 Checkpoint 实现长期记忆(Agent 跨对话保留上下文)
主要参考:
OpenAI Function Calling 官方文档 — JSON schema 的标准定义
src-002 §Unit 3 LangGraph framework — HF 课程对 LangGraph 的官方阐述
Tool Use — 工具使用的设计哲学
十二、Sources#
src-001 — Microsoft AI Agents for Beginners · T1 · tool-use
src-002 — Hugging Face Agents Course · T1 · Course
OpenAI Function Calling 官方文档 · T1 · Guides
LangGraph Building ReAct Agents · T2 · Machine Learning Mastery
💬 有疑问? 试试问我:"手写 Agent 和用框架哪个更好?"、"为什么工具数量超过 20 个 LLM 会变笨?"、"parallel_tool_calls 什么时候该禁用?"——我会用代码或比喻为你解答。
1. Function Calling 中的工具 schema 实质上是怎么来的?
2. 工具设计中为什么强调 docstring 要写清输入格式与返回格式?
3. 当某个工具需要先查天气、再根据天气发邮件时,应该怎么配置?