课程首页 › Module 2 · 工具使用(Tool Use / Function Calling) › L4

L4 Tool Use 深入:让 Agent 拥有"双手"

课程目标:掌握工具设计最佳实践,理解 Function Calling 在底层到底发生了什么,学会让 Agent 同时调用多个工具并优雅处理错误。


一、从 L3 到 L4:我们解决了什么?#

L3 我们跑通了第一个 smolagents Agent,体验了 @tool 装饰器的神奇。但是有几个问题还没回答:

  1. @tool 装饰器背后到底发生了什么?为什么一个 Python 函数就能被 LLM 调用?

  2. 我能不能不用任何框架,自己用 OpenAI/Anthropic 原生 API 实现一个 Agent?

  3. 当 Agent 同时需要查天气、查汇率、发邮件时,它怎么知道该先做哪个?

  4. 当工具执行失败(比如网络超时)时,Agent 怎么应对?

这节课我们回答这四个问题。学完后,你不仅会用框架,还能手写 Agent——这是面试和工作中的核心能力。


二、用一个比喻:工具就是 LLM 的"瑞士军刀"#

还记得 L1 的"私人管家"比喻吗?管家需要工具来完成任务——电话、地图、计算器、日程表。

但管家怎么知道每个工具是干什么的? 这是关键问题。

反面例子:

你递给管家一把瑞士军刀,说:"这是个工具,你自己看着用。" 管家困惑:"这是开瓶器?刀子?螺丝刀?我该用哪一面?"

正面例子:

你递给管家一份工具清单:

管家一眼就明白每个工具的用途、参数和返回。

关键洞察:工具描述就是 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 装饰器的本质就是:

  1. 用 inspect 读函数的签名(参数名、类型注解、默认值)

  2. 用 inspect.getdoc 读函数的 docstring

  3. 把它们打包成 OpenAI 标准的 JSON schema

  4. 把这个 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 自己决定:

  1. 先查天气(因为发邮件需要天气数据)

  2. 再发邮件(用天气数据填充正文)

  3. 最后汇报结果

这就是 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+ 工具,考虑:

法则 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 smolagentsL4 手写
代码量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_choiceauto 默认 / none 禁用 / required 强制 / 对象指定具体工具
并行调用默认支持,可用 parallel_tool_calls=False 禁用

十一、Read next#

下一节课 L5 用 LangGraph 构建有状态 Agent,我们进入主流生产框架的世界。学完 L5 你会:

主要参考:


十二、Sources#


💬 有疑问? 试试问我:"手写 Agent 和用框架哪个更好?"、"为什么工具数量超过 20 个 LLM 会变笨?"、"parallel_tool_calls 什么时候该禁用?"——我会用代码或比喻为你解答。

自测 · 检验是否真的掌握了

1. Function Calling 中的工具 schema 实质上是怎么来的?

@tool 装饰器的本质就是用 inspect 读取函数签名和 docstring,再拼成 OpenAI 标准的 JSON schema 交给模型。schema 在调用前就已生成并随请求发送,不是模型猜的,也不是提示词模板硬编码,更不是执行之后回填的。

2. 工具设计中为什么强调 docstring 要写清输入格式与返回格式?

LLM 在选择工具时看不到 Python 函数体,只能读到 docstring 和类型注解生成的描述,所以 docstring 就是工具说明书,写清楚它才可能用对。描述长度与执行速度无关,框架也不会因为缺少接口文档而拒绝加载,单元测试同样不是自动生成的。

3. 当某个工具需要先查天气、再根据天气发邮件时,应该怎么配置?

后一步依赖前一步的返回值,必须串行,禁用并行工具调用即可避免模型一次性发起两个互相依赖的调用。合并成一个工具虽然也能解决,但违背了单一职责原则;强制指定单个工具会让流程失去灵活性,也无法满足先查后发的顺序要求。