Contents

工具调用机制:Function Calling实战

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),交给传菜员。传菜员拿着菜单去找对应的厨师(执行函数),厨师做好菜(返回结果)后,传菜员再把菜端回来交给服务员。服务员看一眼菜,决定是直接上给顾客,还是还要再追加几道菜。

对应到技术层面,真正发生的事情是这六步:

  1. 我们把若干工具的描述信息(名称、功能、参数schema)以结构化格式塞进prompt;
  2. LLM在生成回复时,根据用户意图判断"需不需要用工具、用哪个工具、参数填什么";
  3. 如果要用工具,LLM输出一个结构化的JSON调用请求,而不是自然语言;
  4. 我们的宿主代码解析这个JSON,真正调用对应函数,拿到结果;
  5. 把结果以tool消息的形式追加到对话历史,再次喂给LLM;
  6. 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密钥没配好或网络不通,先回去检查.envload_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 工具选择策略

无论用哪种编排,提升选择准确率都离不开这几条技巧:

  1. 描述里写清"何时用"和"何时不用":例如send_email的描述加上"仅当用户明确要求发送邮件时调用,不要主动发邮件"——LLM很容易自作多情地主动发邮件,这句话能挡掉绝大多数误调用;
  2. 命名区分度高get_user_infoget_product_info远比get_infoquery_info容易区分,前者一看就懂,后者会让LLM犯选择困难症;
  3. 参数避免歧义:每个参数都给example,告诉LLM填什么格式;
  4. 避免功能重叠:如果两个工具都能完成同一件事,LLM会困惑。要么合并,要么在描述里明确分工,比如search_internal_docs只搜公司内部文档、web_search只搜公网;
  5. 提供默认值:可选参数给合理默认值,减少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注解(intboolList[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-72BLlama 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_nameargumentsresult、耗时都打日志,这不仅能排查问题,还能用来做工具使用的数据分析,反哺优化描述和参数设计。

9. 预告第6章:推理模式

至此,我们的智能体已经具备记忆(第4章)和工具使用(本章)两大基础能力。但它仍然是"被动响应"的——用户问一句它动一步。下一章,我们要给它装上推理引擎,让它能自主规划、分解任务、反思纠错,从"工具人"进化为"思考者"。

第6章将聚焦推理模式,内容包括:

  • ReAct(Reasoning + Acting)范式:让LLM"边想边做"的经典框架
  • Chain-of-Thought与Tree-of-Thought的工程实现
  • Plan-and-Execute:先规划再执行的多步任务分解
  • Reflection机制:让Agent自我反思、纠错、迭代
  • 完整实战:构建一个能自主完成复杂研究报告的推理Agent

当工具遇上推理,智能体才真正开始"活"起来。我们第6章见。