构建一个 AI 智能体——赋能行动:工具使用

135 阅读44分钟

本章内容

  • LLM 的局限以及为何需要工具
  • 工具调用与执行
  • 自定义工具的构建与集成
  • 通过类与装饰器进行工具抽象
  • 用 MCP(Model Context Protocol)标准化工具

到目前为止,你已经了解了 LLM 如何作为智能体的大脑:处理非结构化数据、理解用户意图并执行通用任务。然而,仅靠 LLM 自身既无法访问外部数据,也不能与外部系统交互。它们需要“工具”,以及选择并使用这些工具的能力——即“工具调用(tool calling)”。这是实现智能体的核心:一个基础型智能体会不断重复“选择下一步行动”的过程,而这正是工具调用所承担的职责。

让我们把目光投向智能体架构中这个关键部分(见图 3.1)。我们将探讨如何构建工具并让 LLM 了解如何使用它们;还会学习 LLM 如何为给定任务选择合适的工具,以及如何把工具执行结果回传给 LLM。随后我们会介绍 Anthropic 提出的 Model Context Protocol(MCP),它旨在标准化工具的开发与使用。

image.png

图 3.1 工具是 LLM 智能体访问外部信息与施加影响的通道,也是其行动的最小单元。

如图 3.2 所示,我们会构建一个让 LLM 通过工具在真实世界采取行动的系统,重点放在“工具调用”和“工具执行”两个组件。我们会为 LLM 提供包含可用工具及其用法的信息(图中标注为 “tool_info”)。如果 LLM 判断需要使用某个工具(1),系统就会执行该工具(2),并把结果加入 LLM 的上下文(3)。若 LLM 随后判断无需再使用工具、可以直接生成最终答案,便输出该响应(4),它既会被加入上下文,也会返回给用户(5)。

image.png

图 3.2 LLM 接收用户输入、进行推理、选择所需工具、接收执行结果并产出最终响应。

那么,要让 LLM 成为能够与外部世界交互并达成目标的“智能体”,需要哪些工具?我们来细看帮助 LLM 克服局限的工具类型。

3.1 LLM 工具的类型

如图 3.3 所示,工具可按“用途”和“是否定制化”进行分类。按用途可分为三类:信息增强类、行动执行类与领域专用类;按来源又可分为开发者自定义工具与 LLM 提供方预置工具。

信息增强类工具 用于弥补 LLM 的时效性限制。LLM 无法访问训练数据之外或实时变化的数据,因此需要各种信息增强工具来“补脑”,例如:基于 Google/Bing 的网页搜索、arXiv 检索或自建库检索、天气/股价/新闻等实时数据 API,以及企业内部知识系统(数据库/文档库)。如图 3.2 所示,这些工具通过“工具结果”将最新知识注入上下文,从而有效扩展 LLM 的实时认知。

行动执行类工具 用于克服 LLM 的交互限制。LLM 不能直接影响外部世界或执行实际操作。智能体可借助工具完成预订(酒店、餐厅、机票)、通信(发邮件/消息、日程管理)、自动化(文档生成、数据更新、报告撰写)或 IoT 控制(智能家居/工业设备)等。这些工具在图 3.2 中触发真实世界的“行动”,并接收“观察”作为反馈,形成与环境的闭环。

领域专用类工具 用于弥补 LLM 的功能性限制。它们处理特定领域中的复杂任务——例如精确计算、代码执行、数据转换或媒体生成——这些往往不是单靠 LLM 能可靠完成的。常见例子包括:计算器与统计引擎、Python 解释器或编译器、格式转换器、图像处理器、图像生成/语音合成工具,甚至 3D 建模引擎。通过这些工具,LLM 的推理能力与各类专用任务的确定性执行相结合,形成“混合式”解题能力。

image.png

图 3.3 按类型划分的 LLM 工具。

从来源角度看,自定义工具 由开发者为特定环境开发或改造,例如对接内部系统的 API,或用于专业数据分析的脚本。它们可高度贴合项目需求,但开发者需要自行构建、维护与加固安全——出现缺陷与安全问题也需自担。

非自定义工具 则由 LLM 提供方直接提供,如 OpenAI 的网页搜索、Anthropic 的网页搜索工具等。它们开箱即用,稳定性与性能由提供方保障,但可定制性弱,且可能被提供方随时调整或下线。具体清单以各家官方文档为准。

3.2 LLM 如何使用工具

一个只能“生成文本”的模型,如何“选择并执行”工具?答案是 工具调用(Tool Calling) 。打个比方:把 LLM 想象成一位博学的智者被关在房间里——它学识渊博,但只能通过塞纸条与外界交流,不知道外面有哪些工具、也不会直接使用。这时我们写好“说明书”塞进去,告诉它有哪些工具、各自能做什么、如何使用,以及当前要完成的任务。

这份“说明书”就是 工具定义(tool definition) :以结构化的描述告诉 LLM 可用的工具(函数、API 或动作)、需要的参数以及用途。得到这些信息后,LLM 便能通过“生成结构化文本”的方式,推理应当调用哪个工具以及如何填参。当 LLM 决定使用哪个工具后,它会生成一个 工具调用:其中包含工具名与调用所需的参数。随后,这个工具调用会被交给外部执行器实际运行。

下面我们更细致地看:工具定义如何结构化、如何提供给模型、工具调用如何产生与执行。相关代码示例见项目仓库:github.com/shangrilar/…

3.2.1 工具调用的工作机制

工具调用包含多个步骤(见图 3.4)。首先(步骤 1),开发者把用户输入与工具定义一并发送给 LLM。

需要强调:LLM 自己并不执行工具。它只会以文本形式“指定”要调用哪个工具及其参数。也就是说,LLM 在用户请求与可用工具之间扮演“协调者”的角色。真正的执行由外部系统完成(步骤 3)。工具执行完成后,结果回传给 LLM;LLM 解读输出并继续对话,最终给用户一个融合工具结果的答复。

image.png

图 3.4 工具调用关键步骤:定义工具、传递给模型、由模型选择工具(来源:OpenAI 文档)。

要实现工具调用,我们需要:

  1. 定义要提供给 LLM 的 工具定义
  2. 实现 工具本体
  3. 把“任务 + 工具定义”一起提供给 LLM(步骤 1);
  4. 构建一个 执行系统,根据 LLM 生成的工具调用来实际运行工具;
  5. 回灌 工具执行的结果到 LLM 的上下文。

下面逐步实现这些步骤。

步骤 1:编写工具定义

第一步是用结构化的方式告诉 LLM 可用工具及其用法。可把它看作“给 LLM 的使用手册”。以计算器为例,从外向内看,我们指定 "type": "function" 表示这是一个可调用的工具;内部的 function 对象包含三个核心部分:

  • name:工具标识(如 "calculator");
  • description:何时/为何使用(如“执行基础算术运算”);
  • parameters:输入参数的规范。

parameters 采用 JSON Schema 格式,定义工具期望的每个输入。对计算器而言,需要运算符(限制为 add/subtract/multiply/divide 的枚举)和两个数值。required 字段确保 LLM 总是提供必要的参数。

Listing 3.1 计算器工具定义 Schema

calculator_tool_definition = { 
    "type": "function",
    "function": {
        "name": "calculator", 
        "description": "Perform basic arithmetic operations.",
        "parameters": {
            "type": "object",
            "properties": {
                "operator": {
                    "type": "string",
                    "description": "Arithmetic operation to perform",
                    "enum": ["add", "subtract", "multiply", "divide"]
                },
                "first_number": {
                    "type": "number",
                    "description": "First number for the calculation"
                },
                "second_number": {
                    "type": "number",
                    "description": "Second number for the calculation"
                }
            },
            "required": ["operator", "first_number", "second_number"],
        }
    }
}

步骤 2:实现工具函数

实现与上述 Schema 对应的 Python 函数 calculator()。当 LLM 生成的工具调用包含函数名与参数后,我们据此调用正确的函数并传入参数。

Listing 3.2 计算器函数实现

def calculator(operator: str, first_number: float, second_number: float):
   if operator == 'add':
       return first_number + second_number
   elif operator == 'subtract':
       return first_number - second_number
   elif operator == 'multiply':
       return first_number * second_number
   elif operator == 'divide':
       if second_number == 0:
           raise ValueError("Cannot divide by zero")
       return first_number / second_number
   else:
       raise ValueError(f"Unsupported operator: {operator}")

步骤 3:触发工具调用

现在,LLM 收到了工具定义与用户问题,它会判断是否需要工具以及用哪个。比如两个问题:“韩国的首都是哪?”和“1234 x 5678 等于多少?”。在提供计算器工具定义的前提下,模型会对第一个问题直接给出文本答案;对第二个问题,会生成一个工具调用(运算符为 multiply,操作数 1234 与 5678)。

Listing 3.3 工具调用执行示例

tools = [calculator_tool_definition]
 
response_without_tool = client.chat.completions.create(
        model='gpt-5-mini',
        messages=[{"role": "user", 
                   "content": "What is the capital of South Korea?"}],
        tools=tools
)
print(response_without_tool.choices[0].message.content)
print(response_without_tool.choices[0].message.tool_calls)
 
response_with_tool = client.chat.completions.create(
        model='gpt-5-mini',
        messages=[{"role": "user", "content": "What is 1234 x 5678?"}],
        tools=tools
)
print(response_with_tool.choices[0].message.content)
print(response_with_tool.choices[0].message.tool_calls)

输出示例:

# 首都问题无需工具
The capital of South Korea is Seoul.
None
# 乘法问题需要工具
None
[ChatCompletionMessageFunctionToolCall(id='call_viaOEiQJ5VEB9YvKl95qlDjM', function=Function(arguments='{"operator":"multiply","first_number":1234,"second_number":5678}', name='calculator'), type='function')]

可以看到,模型具备“智能选择工具”的能力:对于已知事实问题(首都),它无需工具直接回答(tool_calls 为 None);对于计算问题,它识别到需要计算器,并给出包含正确参数的工具调用,同时 content 为空。这种有选择的行为表明:LLM 能区分“何时需要工具、何时可直接作答”。

步骤 4:运行工具

接下来需要实际执行 LLM 请求的工具。由于 LLM 不能运行代码,宿主系统 必须负责执行。

我们从工具调用中提取函数名与参数(function_namefunction_args),若是 calculator,则按参数调用该函数。

Listing 3.4 从响应中提取并执行工具

ai_message = response_with_tool.choices[0].message
 
if ai_message.tool_calls:
   for tool_call in ai_message.tool_calls:
       function_name = tool_call.function.name
       function_args = json.loads(tool_call.function.arguments)
       
       if function_name == "calculator":
           result = calculator(**function_args)

步骤 5:把结果回灌给 LLM

最后,将工具执行结果回传给 LLM:在对话中追加一条 role: "tool" 的消息,并在 content 中放入输出。把更新后的上下文再次传给 LLM,它会据此生成最终答复。

Listing 3.5 将工具结果回灌至 LLM

ai_message = response_with_tool.choices[0].message
messages.append({  #A
   "role": "assistant",  #A
   "content": ai_message.content,  #A
   "tool_calls": ai_message.tool_calls  #A
})
 
if ai_message.tool_calls:
   for tool_call in ai_message.tool_calls:
       function_name = tool_call.function.name  #B
       function_args = json.loads(tool_call.function.arguments)  #B
       
       if function_name == "calculator":
           result = calculator(**function_args)  #B
           
           messages.append({  #C
               "role": "tool",  #C
               "tool_call_id": tool_call.id,  #C
               "content": str(result)  #C
           })

final_response = client.chat.completions.create(
   model='gpt-5-mini',
   messages=messages
)
print("Messages: ", messages)
print("Final Answer:", final_response.choices[0].message.content)

输出示例:

Messages: [{'role': 'user', 'content': 'What is 1234 x 5678?'}, 
 {'role': 'assistant', 'content': None, 'tool_calls': [ChatCompletionMessageFunctionToolCall(...)]}, 
 {'role': 'tool', 'tool_call_id': 'call_TN8z8oUZ4g8hLwRfYTYFhDGD', 'content': '7006652'}]
Final Answer: 1234 × 5678 = 7,006,652

整个流程形成了三步反馈回路:先把 assistant 的工具调用请求加入消息;随后用解析出的参数(multiply, 1234, 5678)执行 calculator 得到 7006652;再把该结果以 role: "tool"、并带上匹配的 tool_call_id 追加到消息中。随后把完整消息史重新发给 LLM,模型会把原始结果组织成清晰易读的最终答复(包含恰当的数字格式)。

在这个计算器示例里,工具结果本身就是最终答案,回灌似乎有些“多余”。但在更复杂的工具(如网页搜索)中,这一步至关重要:搜索结果必须回给 LLM,由它筛选、综合后形成最终回答。 “调用 → 执行 → 回灌 → 再推理” 的闭环,是稳定使用工具的关键。

3.2.2 LLM 如何选择工具

那么,LLM 是如何学会选择工具并生成所需输入的呢?正如你在第 2 章中猜到的,答案在于训练方式——模型在训练时见过“使用工具”的示例。

像 NousResearch 的 hermes-function-calling-v1 这类数据集,会用 <tools> 标签给出工具定义,用 <tool_call> 标签包裹 LLM 的响应(其中包含函数名与参数)。这些示例与我们通过结构化提示把工具定义传给 LLM、并在需要时让它产出结构化工具调用(Tool Call)的方式相呼应。

Listing 3.6 工具选择的训练数据示例

[
  {
    "from": "system",
    "value": """You are a function calling AI model. 
    You are provided with function signatures within <tools> </tools> 
    ➥XML tags.
<tools>
  [{
    "type": "function",
    "function": {
      "name": "set_blind_openness",
      "description": "Sets the openness of smart window blinds to a 
      ➥specified percentage.",
      "parameters": {
        "type": "object",
        "properties": {
          "room": {
            "type": "string",
            "description": "The room where the blinds are located."
          },
          "openness_percentage": {
            "type": "integer",
            "description": "The percentage of openness for the blinds."
          }
        },
        "required": ["room", "openness_percentage"]
      }
    }
  }]
</tools>
For each function call return a json object within <tool_call> </tool_call> 
➥tags:
<tool_call>\n{'arguments': <args-dict>, 'name': <function-name>}\n
➥</tool_call>"""
  },
  {
    "from": "human", 
    "value": "Please set the living room blinds to 75% openness."
  },
  {
    "from": "gpt",
    "value": "<tool_call>\n{'arguments': {'room': 'living room', 
    ➥'openness_percentage': 75}, 'name': 'set_blind_openness'}\n
    ➥</tool_call>"
  }
]

在训练中,LLM 以逐 token 预测的方式学习:包括是否需要工具、该用哪个工具、以及应提供什么参数。你可以在 Hugging Face 上查看整个数据集。

3.2.3 有效工具调用的指南

下面的最佳实践改编自 OpenAI 的函数调用官方文档。为了让 LLM 可靠地调用工具,工具的 schema 必须把“何时用、用什么、怎么用”讲得清清楚楚。每组示例都给出一个有问题的 schema、其改进版,以及简短说明为什么改进有效。

明确而具体的函数定义

LLM 会根据函数名与参数描述来学习何时、如何使用工具。务必明确工具的目的、参数类型与期望输出。一个简单的检验标准是:如果实习生仅凭 schema 就能理解并用起来,LLM 也能。

Listing 3.7 反例:含糊的工具定义

{
  "name": "book",
  "description": "Book something",
  "parameters": {
    "type": "object",
    "properties": { "when": { "type": "string" } }
  }
}

这个 schema 隐藏了动作对象(“预订什么?”),缺少必填项,也没规定时间格式。模型无法推断哪些输入是必需的、又应如何格式化。

Listing 3.8 改进版:明确的工具定义

{
  "name": "reserve_table",
  "description": "Create a restaurant table reservation.",
  "parameters": {
    "type": "object",
    "properties": {
      "restaurant_id": {
        "type": "string",
        "description": "Internal ID, e.g., rst_123"
      },
      "datetime": {
        "type": "string",
        "format": "date-time",
        "description": "ISO-8601 in the user's local time"
      },
      "party_size": {
        "type": "integer",
        "minimum": 1,
        "maximum": 20
      },
      "notes": {
        "type": "string",
        "description": "Allergies, occasion, etc. Optional."
      }
    }
    "required": ["restaurant_id", "datetime", "party_size"],
  }
}

改进版通过名称与描述清晰表达意图,区分必填与可选参数,约束格式与取值范围,让输入更可预测,并(按需)定义明确的错误码,便于模型一致地暴露或处理错误。

应用软件工程最佳实践

避免歧义或逻辑冲突的参数设计(例如 toggle_light(on: bool, off: bool) 可能产生矛盾)。用单一枚举替代互斥布尔。还应在 schema 中校验范围与格式,以便早失败并返回清晰的错误码。

Listing 3.9 反例:参数冲突

{
  "name": "toggle_light",
  "parameters": {
    "type": "object",
    "properties": {
      "on": { "type": "boolean" },
      "off": { "type": "boolean" }
    }
  }
}

这里模型可能给出 on=trueoff=true 的不可能组合,这会引入额外提示词逻辑并提高失败概率。

Listing 3.10 改进版:单一枚举参数

{
  "name": "toggle_light",
  "description": "Control the light state",
  "parameters": {
    "type": "object",
    "properties": {
      "state": {
        "type": "string",
        "enum": ["on", "off"],
        "description": "The desired state of the light"
      }
    }
    "required": ["state"],
  }
}

这种设计用单枚举消除矛盾,也便于针对(例如亮度范围等)做确定性的早期校验,并提供具体错误码,能与恢复策略(重试、引导用户、回退)清晰映射。

减轻模型负担、让代码更好“托管上下文”

不要让模型重复提供你已有的标识符。由代码记住上下文,让模型专注于决策本身。

让模型重新生成已拿到的信息是低效的。比如前一步已经拿到了 order_id,更高效的方式是提供一个无参submit_refund(),由代码自动注入 order_id,而不是要求模型调用一个需要 order_id 的函数。

Listing 3.11 反例:由模型提供标识符

{
  "name": "submit_refund",
  "parameters": {
    "type": "object",
    "required": ["order_id", "reason"],
    "properties": {
      "order_id": {
        "type": "string",
        "description": "The order ID to refund"
      },
      "reason": {
        "type": "string",
        "description": "Reason for the refund"
      }
    }
  }
}

这种做法要求模型在每次调用时都正确回忆并传入 order_id。在多步流程中,标识符容易漂移或混淆,增加错误风险与 token 成本。

Listing 3.12 改进版:仅提供决策所需参数

{
  "name": "submit_refund",
  "parameters": {
    "type": "object",
    "required": ["reason"],
    "properties": {
      "reason": {
        "type": "string",
      }
    }
  }
}

现在模型只需提供“退款原因”这一决策性输入;调用端代码从上下文注入正确的 order_id。这样能降低误退错单的风险、减少提示成本,并提升多轮流程中的成功率。

限制函数数量

一般建议把工具数量控制在 20 个以内。工具过多会导致选择错误。如果任务确实需要大量工具或复杂逻辑,考虑对模型进行微调以提升工具调用的精确度。

3.3 为 LLM 构建工具与工具定义

到目前为止,我们已经讨论了为什么 LLM 需要工具,以及它们如何选择并使用工具。现在,是时候真正动手构建并集成工具了。我们将通过解决一个真实世界的问题来走完整个工具开发流程:

“以他最佳马拉松配速计算,埃鲁德·基普乔格(Eliud Kipchoge)到达月球(按地月最近距离计)需要多长时间?请将最终答案四舍五入到最接近的 1,000 小时。”

这是 GAIA(General AI Assistant,一套用来评估 AI 代理是否能解决复杂真实问题的基准测试)中的一道真题。GAIA 任务要求多步推理,且常常需要组合多种工具(如网页搜索、维基百科检索与计算器)——本题就是典型例子。

要解题,我们需要以下数据:

  • 基普乔格的马拉松配速:每公里 2 分 52 秒
  • 月球到地球的最近距离:362,600 公里
  • 总时间:362,600 × 172 秒 ÷ 60 ÷ 60 = 17,324 小时
  • 取最接近的 1,000 小时后为 17

为此,LLM 需要一个计算器工具(我们已实现过)、一个网页搜索工具以及一个维基百科检索工具。本节将实现缺失的两个。

3.3.1 实现网页搜索工具

网页搜索方面,我们将选择 Tavily(而非 Google Search API、Bing API 等),因为它针对 LLM 做了优化,能提供面向 RAG 的结果。使用 Tavily 前,请先在 www.tavily.com/ 注册,并在 app.tavily.com/home 获取 API Key。Tavily 每月提供最多 1,000 次免费调用

继续前,请安装 Tavily 的 Python 客户端:

uv add tavily-python

然后在 .env 文件中保存你的 API Key(变量名 TAVILY_API_KEY):

TAVILY_API_KEY=<Your Tavily API KEY>

接下来使用 tavily-python 编写搜索函数。该函数通过 load_dotenv() 载入密钥,传入 TavilyClient,并调用 search() 方法完成搜索。我们将 max_results 限制为 2、每个来源的文本块限制为 2,以避免过多 token 消耗。

Listing 3.13 使用 Tavily 的网页搜索函数

import os
from tavily import TavilyClient
from dotenv import load_dotenv
 
load_dotenv()
 
tavily_client = TavilyClient(os.getenv("TAVILY_API_KEY"))
 
def search_web(query: str) -> str:
    """
    Search the web for the given query.
    """
    response = 
➥tavily_client.search(query, max_results=2, chunks_per_source=2)
    return response.get("results")

尝试检索基普乔格的马拉松纪录,验证 search_web 是否可用。第一条结果会是他的维基百科页面,第二条是 BBC 关于其表现的报道。根据内容,他的官方最好成绩2 小时 1 分 9 秒

Listing 3.14 示例网页搜索查询

search_web("Kipchoge's marathon world record")

输出如下:

[
    {
        'title': 'Eliud Kipchoge - Wikipedia',
        'url': 'https://en.wikipedia.org/wiki/Eliud_Kipchoge',
        'content': 'Eliud Kipchoge is a Kenyan long-distance runner who 
        ➥competes in the marathon and formerly specialized in the 5000 metres. 
        ➥Kipchoge is the 2016 and 2020 Olympic marathon champion, and was the 
        ➥world record holder in the marathon from 2018 to 2023, until that 
        ➥record was broken by Kelvin Kiptum at the 2023 Chicago Marathon. 
        ➥Kipchoge has run 4 of the 10 fastest marathons in history... 
        ➥Personal bests Marathon: 2:01:09 (Berlin 2022)...',
        'score': 0.92125154,
        'raw_content': None
    },
    {
        'title': 'Eliud Kipchoge breaks two-hour marathon mark by 20 seconds',
        'url': 'https://www.bbc.co.uk/sport/athletics/50025543',
        'content': 'Eliud Kipchoge has become the first athlete to run a 
        ➥marathon in under two hours, beating the mark by 20 seconds. The 
        ➥Olympic champion - who holds the official marathon world record of2:01:39, set in Berlin, Germany in 2018 - missed out by 25 seconds 
        ➥in a previous attempt at the Italian Grand Prix circuit at Monza in2017...',
        'score': 0.9014448,
        'raw_content': None
    }
]

3.3.2 实现维基百科检索工具

接着,我们构建一个用于获取维基百科内容的工具。先安装 wikipedia 库:

uv add wikipedia

从维基百科获取信息通常分两步:先用 search 找到相关的页面标题列表;再从中选择最合适的页面,用 page 加载其内容wikipedia 库原生支持该流程。

例如,我们要获取“月球”的信息,可以用关键词 "moon" 搜索。搜索结果会包含 “Moon”“To the Moon”“Sailor Moon”等相关标题。我们选择标题 “Moon”,并打印其内容。返回结果包含类似:“The Moon is Earth's only natural satellite. ”这证明该页面包含关于月球的权威描述。

Listing 3.15 维基百科搜索与页面读取

import wikipedia
 
search_results = wikipedia.search("moon")
print(“search_results:”)
print(search_results)
 
page = wikipedia.page("Moon", auto_suggest=False)
print(“page content:”)
print(page.content[:100])

输出如下:

search_results:
['Moon', 'Moon (disambiguation)', 'Moon Moon Sen', 'To the Moon', 'Mooning', 
➥'Sailor Moon', 'Moon Knight', 'Moon landing', 'Rebel Moon', 
➥'Moon landing conspiracy theories']
page content:
The Moon is Earth's only natural satellite. It orbits around Earth at an 
➥average distance of 384399...

随后,将该能力封装为供 LLM 调用的 Python 函数:search_wikipedia 接受检索词并返回相关页面标题列表;get_wikipedia_page 根据标题返回对应页面内容。

Listing 3.16 维基百科函数封装

def search_wikipedia(query:str):
    """Search Wikipedia for a query and return titles of wikipedia pages"""
    search_results = wikipedia.search(query)
    return search_results
 
def get_wikipedia_page(title:str):
    """Get a wikipedia page by title"""
    page = wikipedia.page(title, auto_suggest=False)
    return page.content

3.3.3 转换为工具定义(tool definitions)

到目前为止,我们已经实现了一个网页搜索工具和一个维基百科检索工具。在本章前面,我们还实现了一个用于计算的计算器函数。要让这些工具能被 LLM 使用,我们需要把它们用标准化的工具定义格式描述出来。

由于目前的函数都很简单,手工把它们改写成工具定义并不费时。但在开发过程中,函数可能频繁变化或变得更复杂,手工维护既低效又容易出错。为此,我们可以编写并使用一个实用函数,把 Python 函数自动转换为工具定义。

要从函数生成工具定义,需要抽取函数的名称、文档字符串(docstring)以及参数信息。在 Python 中可以通过 inspect 模块完成。

下面用一个名为 example_tool 的示例函数演示该过程。该函数有两个入参:input_1(字符串)和 input_2(整数,默认值 1)。

Listing 3.17 使用 inspect 抽取工具元数据

import inspect
 
def example_tool(input_1:str, input_2:int=1):
    """docstring for example_tool"""
    return
        
print(f"function name: {example_tool.__name__}")
print(f"function docstring: {example_tool.__doc__}")
print(f"function signature: {inspect.signature(example_tool)}")

输出如下:

function name: example_tool
function docstring: docstring for example_tool
function signature: (input_1: str, input_2: int = 1)

可见,我们能用 __name__ 拿到函数名,用 __doc__ 拿到说明文档。借助 inspect.signature,可以获取参数类型以及是否必填。从输出可以看出:input_1 是必填的字符串;input_2 是可选的整数(默认值为 1)。

基于这些信息,我们可以实现一个**工具定义输入模式(schema)**的生成函数:

Listing 3.18 函数→工具输入 schema 转换器

def function_to_input_schema(func) -> dict:
    type_map = {
        str: "string",
        int: "integer",
        float: "number",
        bool: "boolean",
        list: "array",
        dict: "object",
        type(None): "null",
    }
    
    try:
        signature = inspect.signature(func)
    except ValueError as e:
        raise ValueError(
            f"Failed to get signature for function {func.__name__}: 
            ➥{str(e)}"
        )
    
    parameters = {}
    for param in signature.parameters.values():
        try:
            param_type = type_map.get(param.annotation, "string")
        except KeyError as e:
            raise KeyError(
                f"Unknown type annotation {param.annotation} for parameter 
                ➥{param.name}: {str(e)}"
            )
        parameters[param.name] = {"type": param_type}
    
    required = [
        param.name
        for param in signature.parameters.values()
        if param.default == inspect._empty
    ]
    
    return {
            "type": "object",
            "properties": parameters,
            "required": required,
        }

从示例可见,我们用 inspect.signature 抽取参数,再把参数类型映射到工具定义所需的 JSON Schema 类型;通过是否存在默认值判定该参数是否必填,最终按标准化的工具定义格式组织这些信息。

接下来验证该实用函数是否工作正常,尝试把我们的网页搜索工具转换为工具定义:

Listing 3.19 函数→工具定义转换器

def format_tool_definition(name: str, description: str, parameters: dict) -> dict:
    return {
        "type": "function",
        "function": {
            "name": name,
            "description": description,
            "parameters": parameters,
        },
    }
 
def function_to_tool_definition(func) -> dict:
    return format_tool_definition(
        func.__name__,
        func.__doc__ or "",
        function_to_input_schema(func)
    )
 
function_to_tool_definition(search_web)
# {'type': 'function', 'function': {'name': 'search_web', 
# ➥'description': 'Search the web for the given query.', 
# ➥'parameters': {'type': 'object', 'properties': 
# ➥{'query': {'type': 'string'}}, 'required': ['query']}}}

结果 schema 会包含函数名、描述,以及每个参数的类型与是否必填等信息,和预期一致。

tools = [search_web, search_wikipedia, get_wikipedia_page, calculator]
tool_box = {tool.__name__: tool for tool in tools}
tool_definitions = [function_to_tool_definition(tool) for tool in tools]

现在,我们就能把目前创建的所有工具批量转换为 schema,为接下来解决目标问题做准备。

3.3.4 解决问题:组合工具得到答案

现在把我们构建的工具组合起来,端到端地解决“基普乔格奔月用时”问题。我们先实现一个工具执行系统,然后按 4 个步骤得到最终答案。

构建工具执行系统

先定义一个执行工具并返回结果的实用函数。tool_execution 接收工具箱和 LLM 生成的 tool call,抽取函数名与参数,从工具箱取到目标函数、执行并返回结果。

Listing 3.20 工具执行辅助函数

def tool_execution(tool_box, tool_call):
    function_name = tool_call.function.name
    function_args = json.loads(tool_call.function.arguments)
    
    tool_result = tool_box[function_name](**function_args)
    return tool_result

接着,构建一个主控循环 run_step,管理 LLM 与工具之间的交互。它接收 system prompt 与用户输入,发送给 LLM 并等待工具调用请求。若有工具调用,则执行工具并把结果回填到上下文;循环往复,直到 LLM 返回最终答案。run_step 很关键——第 4 章我们会基于它扩展出一个完整的 LLM 代理

Listing 3.21 工具执行主循环

def run_step(system_prompt, question):
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": question}
    ]
    
    while True:
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            tools=tool_schemas
        )
        
        assistant_message = response.choices[0].message
        
        if assistant_message.tool_calls:
            messages.append(assistant_message)
            for tool_call in assistant_message.tool_calls:
                tool_result = tool_execution(tool_box, tool_call)
                messages.append({
                    "role": "tool", 
                    "content": str(tool_result), 
                    "tool_call_id": tool_call.id
                })
        else:
            return assistant_message.content

步骤 1:检索基普乔格的马拉松纪录

第一步获取 Eliud Kipchoge 的马拉松世界纪录,并换算为“每公里 X 分 Y 秒”。执行时,LLM 会使用 search_web 找到 2:01:09 的纪录,然后调用计算器将其换算为每公里配速,得到 2.88 min/km

Listing 3.22 第 1 步:检索基普乔格纪录

def step_1_search_kipchoge():
    question = """I need to find Eliud Kipchoge's record-making marathon pace. 
    ➥Please search for information about his world record marathon time and 
    ➥calculate his pace per kilometer.
 
    FINAL ANSWER should be in the format: "X.XX minutes per km"."""
    
    result = run_step(question)
    return result
 
kipchoge_result = step_1_search_kipchoge()
print(f"Step 1 Complete - Kipchoge pace: {kipchoge_result}")

输出:

Step 1 Complete - Kipchoge pace: 2.88 minutes per km

步骤 2:查找地月最近距离

接下来检索地月距离的最小近地点(perigee) 。我们在提示中明确要求以维基百科为准。LLM 将使用 search_wikipediaget_wikipedia_page 返回 356,400 km

快速查看月球的维基页面可验证该数值:

Perigee: 356,400 km (356,400–370,400 km)
… varies from around 356,400 km (perigee) to 406,700 km (apogee).

Listing 3.23 第 2 步:查找地月距离

def step_2_search_moon_distance():
    question = """I need to find the minimum perigee value (closest approach 
    ➥distance) between Earth and Moon from the Wikipedia page for the Moon. 
    ➥Please search for this information.
 
    FINAL ANSWER should be in the format: "X km"."""
    
    result = run_step(question)
    return result
 
moon_result = step_2_search_moon_distance()
print(f"Step 2 Complete - Moon distance: {moon_result}")

输出:

Step 2 Complete - Moon distance: 356400 km

步骤 3:计算所需时间

第三步计算按基普乔格配速跑完最小地月距离所需的小时数。提示中包含第 1、2 步的结果,并要求返回小时。此处 LLM 直接计算并返回 17,091.2 hours(正确答案应为 17,107 hours,略有误差)。出现误差是因为 LLM 认为这类“距离÷速度=时间”的计算足够简单,无需调用计算器工具。

若要保证每次都精确,可在提示中强制使用计算器,例如:“You MUST use the calculator tool for ALL arithmetic operations.” 这会增加一点延迟,但能换取稳定的正确率。此处我们暂且接受微小误差——这也提醒我们:LLM 是推理引擎而非计算器,这正是构建工具的意义。

Listing 3.24 第 3 步:计算时间

def step_3_calculate(kipchoge_pace, moon_distance):
    question = f"""Given the following information:
- Kipchoge's pace: {kipchoge_pace}
- Moon distance: {moon_distance}
 
    Please calculate how many hours it would take Kipchoge to run this distance 
    ➥at his record pace. Make sure to handle unit conversions properly.
 
    FINAL ANSWER should be in the format: "X hours"."""
    
    result = run_step(question)
    return result
 
time_result = step_3_calculate(kipchoge_result, moon_result)
print(f"Step 3 Complete - Time needed: {time_result}")

输出:

Step 3 Complete - Time needed: 17,091.2 hours

步骤 4:四舍五入到最接近的 1,000 小时

最后,把计算结果四舍五入最接近的 1,000 小时。这是简单算术,LLM 再次选择不使用工具,并正确输出 17

Listing 3.25 第 4 步:取整到最近的 1,000 小时

def step_4_final_answer(total_hours):
    question = f"""Given that the total time is {total_hours}, I need to round 
    ➥this to the nearest 1000 hours and express the answer in thousand hours.
 
    The original question asks for the result rounded to the nearest 1000 hours.
 
    FINAL ANSWER should be just the number (in thousand hours)."""
    
    result = run_step(question)
    return result
 
final_result = step_4_final_answer(time_result)
print(f"Step 4 Complete - Final answer: {final_result}")

输出:

Step 4 Complete - Final answer: 17

通过 tool_executionrun_step 的组合,我们成功搭建了一个能够解决复杂 GAIA 基准问题的工具化系统。在每个阶段,LLM 都能选择合适的工具、把结果纳入后续上下文,并朝最终答案逐步推进

这也说明:工具并不是孤立使用的,而是被集成进一个多步推理流水线中,协同解决复合问题。接下来我们将讨论 MCP(Model Context Protocol) ——一个帮助进一步规范工具开发与使用的标准。

3.4 工具抽象:实现 Tool 类

到目前为止,我们把工具实现为简单的 Python 函数。我们编写了 calculator()search_web()get_wikipedia_page() 等函数,然后将它们转换为工具定义供 LLM 使用。这种方式在简单场景下很好用,但当工具数量与复杂度上升时,其局限就会显现。

例如:如何实现需要维护数据库连接或管理搜索索引的工具?如果每次函数调用都要建立并关闭连接,效率会很低。而且当我们要维护几十个工具、且它们的实现风格各不相同时,维护难度会迅速上升。

本节我们将实现一个工具抽象层来解决这些问题:创建一个所有工具都要继承的 BaseTool 基类,编写一个把简单函数包装成类的 FunctionTool,并提供一个便捷的 @tool 装饰器。借此,我们可以用一致的方式处理从简单函数到复杂有状态工具的一切场景。

3.4.1 为什么需要工具类

在编程中,我们不会把所有东西都实现为独立函数。当数据与行为强相关时,会把它们组合成类。LLM 工具也一样。

函数非常适合无状态操作——比如计算器:接收输入、返回结果、结束调用。每次调用互不影响。
而类在需要状态管理时大显身手。以数据库工具为例:建立连接代价高,如果每次查询都连接/断开,效率很低。使用类可以在实例化时建立一条连接,并在多次查询中复用。

把工具实现为类的好处,与一般编程中的好处一致:

  • 状态管理:把昂贵的初始化(数据库连接、索引构建等)做一次,多次复用。
  • 类型安全:当所有工具都继承自 BaseTool,类型检查可以保证一致的用法;所有工具都有相同的方法,因此可以用相同方式执行。
  • 统一接口:同样的方法名、同样的 schema、同样的执行模式,让代理更容易、也更稳定地调用工具。

看一个实际例子:实现一个文档库搜索工具。函数式实现会暴露几个问题:要么每次调用都重建存储(低效),要么把存储对象当参数传来传去(别扭)。函数没有合适的位置在调用间维持状态。类的做法则是在初始化时构建一次存储、把它作为实例状态保存,后续操作既快又清晰。

Listing 3.26 函数式 vs 面向对象工具实现对比

def search_documents(query: str, documents: List[str] = None, 
➥storage = None):
    if documents:  #A
        storage = build_storage(documents)  #A
    if not storage:
        raise ValueError("No storage available")
    return storage.search(query)
 
class DocumentSearchTool(BaseTool):
    def __init__(self):
        super().__init__(name="document_search")
        self.storage = None  #B
    
    def initialize(self, documents: List[str]):
        """Initialize document storage once"""
        self.storage = build_storage(documents)  #C
    
    def execute(self, action: str, **kwargs):
        if action == "add_documents":
            self.storage.add(kwargs["documents"])
        elif action == "search":
            return self.storage.search(kwargs["query"])  #D

随之而来的新挑战是:如何一致地使用既有的函数式工具(如计算器、网页搜索),又能使用类式的复杂有状态工具(如 DocumentSearchTool)?

问题出在不同工具的调用约定不同:函数工具直接调用函数,类工具则调用方法。这导致我们很难对一组混合工具进行统一编排——我们无法遍历一个工具列表并用同样的方式调用它们。

要解决这个问题,我们需要一种方式把函数“类化” 。这正是 FunctionTool 和装饰器所要解决的。在下一节,我们先实现作为所有工具基石的 BaseTool

3.4.2 实现 BaseTool

接下来实现所有工具的基类 BaseTool。它是一个抽象基类(ABC),用于定义所有工具必须实现的统一接口。

BaseTool 的核心设计原则是**“在灵活与一致之间取得平衡” 。由于工具复杂度差异很大,我们允许不同的实现方式,但要求在使用层面都提供同样的接口**。初始化方法允许灵活配置工具的名称与描述;若未显式提供,则自动使用类名与 docstring。这既减少样板代码,又能在需要时自定义。

Listing 3.27 BaseTool 抽象基类实现

from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Union, Type
import json
 
class BaseTool(ABC):
    
    def __init__(
        self, 
        name: str = None, 
        description: str = None, 
        tool_definition: Union[Dict[str, Any], str] = None,
        pydantic_input_model: Type = None
    ):
        self.name = name or self.__class__.__name__
        self.description = description or self.__doc__ or ""
        self.pydantic_input_model = pydantic_input_model
        
        if isinstance(tool_definition, str):
            self._tool_definition = json.loads(tool_definition)
        elif tool_definition is not None:
            self._tool_definition = tool_definition
        else:
            self._tool_definition = None  # Generate later

每个工具都必须实现 execute 方法。把 execute 设为抽象方法可以强制实现,确保所有工具都有一致的执行接口。我们把它定义为 async,因为多数工具涉及网络或文件 I/O 等异步操作。此外提供 __call__,让工具可以像函数一样被调用(tool(**kwargs)),更直观。

Listing 3.28 BaseTool 的属性与方法

    @property
    def tool_definition(self) -> Dict[str, Any]:
        if self._tool_definition is None:
            self._generate_definition()
        return self._tool_definition
    
    def _generate_definition(self) -> Dict[str, Any]:
        if self.pydantic_input_model:
            try:
                from pydantic import BaseModel
                if issubclass(self.pydantic_input_model, BaseModel):
                    parameters = self.pydantic_input_model.model_json_schema()
                    return format_tool_definition(
                        self.name, self.description, parameters
                    )
            except ImportError:
                pass
        
        raise NotImplementedError(...)

tool_definition 使用惰性生成:只有在第一次访问时才生成 schema,随后缓存返回,避免不必要的计算,同时支持多次调用的安全性。

schema 生成优先级为:

  1. 手工定义:若提供了 tool_definition,优先使用;
  2. Pydantic 模型:若提供了 pydantic_input_model,则自动生成;
  3. 报错:若都未提供,则抛出实现错误。

这个设计在便捷性精确性之间取了平衡:简单工具只需给一个 Pydantic 模型即可;复杂工具则可直接提供更精确的 schema。

有了 BaseTool 这个统一基类,代理层就可以在不知道工具内部实现的情况下,一致地使用各种工具。下一节我们将实现 FunctionTool,把现有的简单函数平滑地适配到 BaseTool 接口之上。

3.4.3 实现 FunctionTool

现在来实现 FunctionTool,它用 BaseTool 接口把常规 Python 函数包起来。它充当一个适配器,把我们之前创建的 calculatorsearch_web 之类的函数转成基于类的工具。

FunctionTool 的核心作用是在函数与类之间搭桥:开发者可以用熟悉的函数形式编写工具,而代理则通过统一的类接口来使用它们。注意初始化方法中的自动信息提取:它会用函数的 __name____doc__ 自动设置工具的名称与描述。这样我们几乎不用改动既有函数就能把它们转换为工具。它还会自动检测函数是否接收 Pydantic 模型作为参数,从而支持需要类型安全与输入校验的复杂工具。

Listing 3.29 FunctionTool 类实现

import asyncio
import inspect
from typing import Callable, Any, Dict, Union, Optional, Type
from .base_tool import BaseTool
 
class FunctionTool(BaseTool):
    
    def __init__(
        self, 
        func: Callable, 
        name: str = None, 
        description: str = None,
        tool_definition: Union[Dict[str, Any], str] = None
    ):
        self.func = func
        self.pydantic_input_model = self._detect_pydantic_model(func)
        
        name = name or func.__name__
        description = description or (func.__doc__ or "").strip()
        
        super().__init__(
            name=name, 
            description=description, 
            tool_definition=tool_definition,
            pydantic_input_model=self.pydantic_input_model
        )

最重要的部分是负责执行函数execute 方法。它的设计有两点关键考量:

  • Pydantic 模型支持:如果函数接收 Pydantic 模型,先对输入数据做校验并转换为模型实例,确保类型安全。
  • 同时支持异步与同步函数:虽然 BaseTool.execute 定义为异步,但被包装的函数可能是同步的。对于同步函数,使用 run_in_executor 在线程池中执行,避免阻塞事件循环。这样我们就能在异步环境中安全地复用现有同步函数。

Listing 3.30 FunctionTool 的 execute 方法

    async def execute(self, **kwargs) -> Any:
        if self.pydantic_input_model:
            args = (self.pydantic_input_model.model_validate(kwargs),)
            call_kwargs = {}
        else:
            args = ()
            call_kwargs = kwargs
        
        if inspect.iscoroutinefunction(self.func):
            return await self.func(*args, **call_kwargs)
        else:
            loop = asyncio.get_event_loop()
            return await loop.run_in_executor(
                None, lambda: self.func(*args, **call_kwargs)
            )

自动生成工具定义的逻辑同样关键。它会分析函数的参数与类型注解,转换为 OpenAI 工具定义格式。该流程全自动,开发者无需单独编写 schema。

Listing 3.31 FunctionTool 生成定义

    def _generate_definition(self) -> Dict[str, Any]:
        if self.pydantic_input_model:
            return super()._generate_definition()
        
        parameters = function_to_parameters_schema(self.func)
        return format_tool_definition(
            self.name, self.description, parameters
        )

此外还有一个Pydantic 模型检测的辅助方法。它检测“函数只接收一个 Pydantic 模型参数”的常见模式——这在处理复杂输入的工具中非常常见。与其写 create_user(name: str, email: str, age: int, address: str, ...),不如写 create_user(user: UserModel),其中 UserModel 是 Pydantic 模型。这样能获得更好的类型安全、自动校验与更整洁的代码。该检测方法会检查函数签名,确认是否“恰好一个参数且其类型是 BaseModel 子类”。

Listing 3.32 Pydantic 模型检测辅助方法

    def _detect_pydantic_model(self, func: Callable) -> Optional[Type]:
        try:
            from pydantic import BaseModel
            sig = inspect.signature(func)
            params = list(sig.parameters.values())
            
            if len(params) == 1 and params[0].annotation != inspect._empty:
                param_type = params[0].annotation
                if isinstance(param_type, type) 
➥and issubclass(param_type, BaseModel):
                    return param_type
        except ImportError:
            pass
        return None

使用 FunctionTool,可以非常容易地把函数转换为工具。注意函数名、文档字符串和参数会被自动转换成工具定义。

Listing 3.33 使用 FunctionTool 转换函数

def search_web(query: str) -> str:
    """Search for information on the web"""
    # Actual search logic
    return f"Search results: {query}"
 
search_tool = FunctionTool(search_web)
 
print(type(search_tool))   
print(search_tool.description)  
print(search_tool.tool_definition)

输出如下:

<class 'FunctionTool'>
"Search for information on the web"
{
  "type": "function",
  "function": {
    "name": "search_web",
    "description": "Search for information on the web",
    "parameters": {
      "type": "object",
      "properties": {
        "query": {"type": "string"}
      },
      "required": ["query"]
    }
  }
}

在下一节,我们将实现 @tool 装饰器,让这一过程更为简洁优雅。

3.4.4 实现 @tool 装饰器

虽然 FunctionTool 已能把函数包装为 BaseTool 接口,但我们还可以用 Python 装饰器让转换更优雅。装饰器是一种语法糖,允许我们在不改动函数本体的前提下增强其行为。它非常适合我们的场景:用极少的语法把常规函数变为工具。

下面是 @tool 装饰器的实现。装饰器本质上是“接收一个函数,返回一个改造后的对象”。在这里,我们不返回改造后的函数,而是返回一个包装了原函数的 FunctionTool 实例。难点在于同时支持两种用法:**@tool(无括号)用于快速转换, @tool(name="custom_name")(带括号)**用于自定义。这就要求对参数进行小心处理:无括号时装饰器直接接收函数;带括号时,装饰器先接收配置参数并返回一个真正接收目标函数的函数。

Listing 3.34 装饰器实现

from typing import Callable, Union, Dict, Any
from .function_tool import FunctionTool
 
def tool(
    func: Callable = None,
    *,
    name: str = None,
    description: str = None,
    tool_definition: Union[Dict[str, Any], str] = None
) -> Union[Callable, FunctionTool]:
    
    def decorator(f: Callable) -> FunctionTool:
        return FunctionTool(
            func=f,
            name=name,
            description=description,
            tool_definition=tool_definition
        )
    
    # 同时支持 @tool 与 @tool() 两种写法
    if func is not None:
        return decorator(func)
    return decorator

该装饰器支持两种模式:

  • 简单形式 @tool 直接转换函数;
  • 参数化形式 @tool(name="custom_name") 允许自定义。

这通过末尾的条件逻辑实现:若 func 已提供,则立即应用装饰器;否则返回真正的装饰器函数,等待后续接收目标函数。

对比手动包装,装饰器能进一步简化工具的创建:

Listing 3.35 装饰器使用示例

def search_web(query: str) -> str:
    """Search for information on the web"""
    return f"{query}_result"
 
search_tool_v1 = FunctionTool(search_web)
 
@tool
def search_web_v2(query: str) -> str:
    """Search for information on the web"""
    return f"{query}_result"
 
@tool(name="internet_search",
      description="Query the internet for latest information")
def search_web_custom(query: str) -> str:
    """Search for information on the web"""
    return f"{query}_result"
 
print(search_tool_v1.tool_definition)
print(search_web_v2.tool_definition)
print(search_web_custom.tool_definition)

执行后你会看到,search_tool_v1search_web_v2 生成相同的工具定义——二者都使用函数名("search_web")与 docstring 作为工具名与描述。而 search_web_custom 则生成了不同的定义,名称变为 "internet_search",描述也被覆盖,展示了装饰器如何将工具的外部接口与函数实现细节解耦

借助 @tool 装饰器,我们在简洁灵活之间取得了理想平衡:简单工具只需一行装饰器;复杂工具仍可自定义名称、描述,甚至完整的工具定义。最重要的是,所有被装饰的函数都会自动成为 BaseTool 实例,确保它们能与我们的代理框架无缝协作

3.5 MCP:工具标准化

正如我们在第 3.3 节的亲身实践中体验到的,为 LLM 代理开发可用的工具很耗时,而且实现方式不一致,复用起来也很困难。

这种状况让人想起 Web 开发的早期:每个网站都有各自的 API 结构。那时要对接任一服务,都得重新学习它的 URL 格式、响应模式和行为,即便功能相同也不例外。后来 REST API 出现改变了一切:服务开始采用共享约定(HTTP 方法、URL 路径、JSON 负载),开发者可以用统一范式集成多个服务。

2024 年 11 月,Anthropic 推出了 MCP(Model Context Protocol,模型上下文协议),试图在 LLM 工具集成领域带来类似变革。就像 REST 统一了服务 API,MCP 旨在标准化工具在 LLM 生态中的定义、共享与执行。自发布以来,MCP 在 2025 年初受到越来越多关注。它为开发者打开了大门:可以复用企业或社区构建的工具,而无需从零开始。

如图 3.5 所示,有了 MCP,代理可以轻松集成 Google Drive、Slack、Tavily、Brave Search、GitHub、Figma 等服务的工具——无需为每个工具再写一层自定义封装。

image.png

图 3.5 MCP 让代理更容易通过集成第三方工具来扩展能力。

下面我们来看看为什么需要标准化、MCP 的结构是什么,并通过一个简单的动手练习直观体验它。

3.5.1 为何需要标准化

在第 3.3 节求解 Kipchoge 问题中,我们分别实现了 Web 搜索工具、Wikipedia 工具和计算器工具。这一过程暴露出几个关键挑战:

  • 多家模型提供商的差异:不同 LLM 提供商(如 Anthropic Claude、Google Gemini)对工具定义方式不同,迫使开发者为每个平台重复实现相同功能。
  • 项目内复用困难:工具往往绑定于具体项目,不易复用。开发者需要拷贝代码、解决冲突,很多时候干脆重写更省事。

标准化在两块尤为紧迫:

  1. 工具信息交换——用统一方式描述“提供了哪些工具、如何使用”。
  2. 工具执行接口——用统一方式“调用工具并拿到结果”。

如果这两块被标准化,工具开发者实现一次即可;代理开发者就能直接组合与复用他人提供的工具,无需改造或重写。这正是 MCP(Model Context Protocol)的承诺。

3.5.2 MCP 的核心:服务端–客户端架构

2024 年 11 月,Anthropic 正式发布 MCP 来解决上述问题。其核心思想是将代理的“大脑”(推理)与“手脚”(工具)分离。

图 3.6 展示了 MCP 的客户端–服务端结构。左侧是 MCP 客户端,代表代理的大脑——接收用户输入、通过 LLM 推理,并决定何时使用何种工具。右侧是 MCP 服务端,代表代理的四肢——实际承载工具,响应工具信息请求,并按需执行工具。

image.png

图 3.6 MCP 的服务端–客户端架构:MCP 客户端负责 LLM 推理与上下文管理,MCP 服务端通过内部或外部服务提供工具定义并执行工具。

两类标准化接口

MCP 聚焦标准化两条接口(图中均有展示):

工具信息交换(请求工具信息 ↔ 返回工具信息)
在第 3.3 节我们为每个工具手写了 schema。有了 MCP,客户端可以向服务端请求“可用工具定义”,服务端按标准格式返回。工具开发者只需在服务端实现 schema,一切 MCP 兼容客户端都能自动获取并使用。

工具执行(工具调用 ↔ 工具结果)
此前我们写了 tool_execution 来桥接 LLM 输出与真实函数调用。MCP 也将这一步标准化:客户端把执行请求发给服务端,服务端负责运行并返回结果。借助统一格式,客户端开发者无需为每个工具再写一份执行逻辑。

清晰的职责分工

这套架构最大的优势是职责清晰:

  • MCP 客户端(大脑) :负责 LLM 推理、规划、提示词与上下文管理。
  • MCP 服务端(手脚) :专注实现具体工具、执行并回传结果。

可以把它类比为 App Store 模式。应用开发者构建功能丰富的“应用”(工具),平台方聚焦交互与 OS 级服务。同理,Slack、GitHub、Google Drive 等领域的专家可以专注构建可靠的 MCP 服务端;代理开发者专注在 LLM 客户端侧做好推理逻辑。

3.5.3 动手实践:实现一个 MCP 服务端

现在把第 3.3.1 节的 Tavily Web 搜索工具改造成 MCP 服务端。出乎意料地简单:借助 FastMCP 库,给既有函数加个装饰器就能把它暴露为 MCP 工具。

先安装依赖:

uv add fastmcp tavily-python python-dotenv

然后新建 tavily_mcp_server.py 并编写服务端代码。给 search_web 函数加上 @mcp.tool 装饰器以标记为 MCP 工具。函数的 docstring 描述用途并注明参数。脚本运行时调用 mcp.run() 启动 MCP 服务端。

Listing 3.36 Tavily MCP 服务端实现

import os
from tavily import TavilyClient
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
 
load_dotenv()
 
tavily_client = TavilyClient(os.getenv("TAVILY_API_KEY"))
 
mcp = FastMCP("tavily-search")
 
@mcp.tool()
def search_web(query: str, max_results: int = 5) -> str:
    """
    Search the web using Tavily API.
 
    Args:
        query: Search query string
        max_results: Maximum number of results to return (default: 5)
 
    Returns:
        Search results as formatted string
    """
    try:
        response = tavily_client.search(
            query,
            max_results=max_results,
            chunks_per_source=2
        )
 
        return "\n".join(response.get("results"))
 
    except Exception as e:
        return f"Error searching web: {str(e)}"
 
if __name__ == "__main__":
    mcp.run(transport='stdio')

要测试新的 MCP 服务端,可以用 MCP Inspector——一个基于浏览器的工具,用于交互式检查与测试 MCP 服务端。在终端执行下列命令后,你会看到服务启动并生成一个本地 URL 的提示。

npx @modelcontextprotocol/inspector uv run tavily_mcp_server.py

点击终端输出中显示的那个 URL(如下图所示)。如果该 URL 已包含会话令牌(session token),则无需手动输入令牌即可直接连接。 image.png

图 3.7 MCP Inspector 启动日志,展示了服务器初始化过程与可连接的 URL。

当界面加载完成后,点击左下角的 Connect 按钮。若连接成功,你会看到 ResourcesPromptsTools 等选项卡——这些反映了你的 MCP 服务器所暴露的能力。选择 Tools 选项卡,并点击 List Tools。此时应能看到已列出的 search_web 工具。尝试输入一个查询并点击 Run Tool。Tavily API 将把搜索结果直接返回并显示在浏览器中。 image.png

图 3.8 MCP Inspector 界面展示我们自定义的 Tavily MCP 服务端及其 search_web 工具。

接着,对比下官方的 Tavily MCP 服务端。它功能更丰富,提示词模板也更精细。可用一条命令连接 Tavily 的公共服务端:

npx @modelcontextprotocol/inspector npx -y tavily-mcp@latest

image.png

图 3.9 MCP Inspector 界面展示官方 Tavily MCP 服务端,提供比 tavily-search 更丰富的工具,如 tavily-extracttavily-crawltavily-map 等。

运行后你会发现它暴露了多种工具,而不仅仅是 tavily-search。如果你需要更强的搜索或文档抽取能力,可以直接使用 Tavily 官方 MCP 服务端,无需自行重做。

到这里,我们通过实现一个标准化工具的 MCP 服务端,理解了 MCP 是什么以及如何工作。MCP 的真正威力在于:不仅能便捷地接入我们自己实现的工具,也能快速连接社区提供的大量工具——正如上面体验的 Tavily MCP 服务端。

要在代理中使用 MCP 工具,你还需要实现一个 MCP 客户端。MCP 客户端连接已运行的 MCP 服务端以获取工具信息,并在 LLM 触发工具调用时,把请求转发给 MCP 服务端执行。若你想实现 MCP 客户端,请参考官方文档(modelcontextprotocol.io/quickstart/…)。这部分留作读者的扩展练习。

接下来进入第 4 章,我们将把已学内容落到实处,直接实现当今多数代理所依赖的最基础框架。

3.6 小结

  • LLM 很强大但也有局限——没有外部帮助,它们无法获取实时数据、进行精确计算或与外部系统交互。
  • 工具扩展了 LLM 的能力,使其能够访问各类 API、执行计算并检索外部信息。
  • 工具调用(Tool calling)是指 LLM 以结构化输出的形式指定要使用的工具及其参数——LLM 在用户请求与可用工具之间充当“调度者/中介”。
  • 工具执行由外部系统完成——LLM 本身不运行工具,它只生成调用规范;执行结果再被回填到 LLM 的上下文中。
  • 工具定义是描述可用工具、其参数与期望输出的结构化模式(schema)——要想让 LLM 准确选用工具,定义必须清晰、明确。
  • 通过 BaseTool 类与装饰器进行工具抽象,可为简单函数与复杂有状态工具提供一致的接口。
  • MCP(模型上下文协议)对工具的定义与使用进行标准化,将 LLM 的推理(客户端)与工具执行(服务端)解耦,提升复用性与互操作性。