构建多智能体系统——使用工具

本章内容

  • 规范工具的基类,以在框架中统一工具的使用方式
  • 定义支撑"工具调用(tool-call)"流程的数据结构
  • 将 Python 函数包装成可供 LLM 与 LLM Agent 使用的"工具"

你已经知道,工具是 LLM Agent 的关键组成部分。诸如网页搜索、数据绘图、在沙箱里执行代码等工具,能显著提升 LLM Agent 的能力与可完成任务的范围。

要给 LLM "加装"某个工具,我们需要提供该工具的文本描述,使 LLM 明白如何使用它。LLM 通过"工具调用(tool-calling)"过程来使用工具:LLM 生成一次工具调用请求,实际执行该工具,并把结果返回给 LLM 进行综合与应答。

本章的重点如图 2.1 所示:为"如何定义工具以及如何使用工具"构建必要的基础设施,并把所有代码打包进我们的 LLM Agent 框架------llm-agents-from-scratch

图 2.1 本章在第 1 章构建规划中的位置。在构建 LLM Agent 之前,必须先把"工具及其用法"的定义打牢。

更具体地说,我们将定义一个用于工具的基类接口 BaseTool ,它作为在框架中新增工具的蓝图。该蓝图也会帮助我们统一 工具调用的执行方式,以及工具的文本描述 如何准备并传给 LLM。为彻底标准化框架中的工具调用过程,我们还将引入两种新的数据结构,分别表示工具调用的输入输出

除了定义基类和相关数据结构,我们还会实现若干子类,帮助把 Python 函数快速变成工具,供 LLM 使用。

要跟着代码实践,建议先 fork 本书的 GitHub 仓库,并启用框架的专用虚拟环境。在项目根目录执行:

bash 复制代码
uv sync  #A

# mac 或 linux
source .venv/bin/activate

# windows (powershell)
.venv\Scripts\activate
#A 需要先安装 uv CLI(安装方法见第 1 章)

本章配套的 Jupyter 笔记本见仓库:
github.com/nerdai/llm-...

文中标注了 # Included in examples/ch02.ipynb 的代码片段都可在该笔记本中直接运行。

为保证依赖完整,推荐用 uv 启动 Jupyter Lab(在项目根目录运行):

arduino 复制代码
uv run --with jupyter jupyter lab

2.1 BaseTool:工具的"蓝图"

不同工具的功能差异很大:网页搜索与饼图绘制显然完全不同。装配了多种功能工具的 LLM Agent 潜力更强,但如果每个工具的调用方式也都不一样,实际运用就会很痛苦,LLM 在请求工具调用时也容易犯错。

更好的做法是统一工具的调用方式 ,无论功能如何。同时也要统一工具文本描述的格式 。这种标准化将使我们能编写可靠的代码来驱动 LLM/LLM Agent 与工具协作(你会在第 3、4 章看到)。

我们通过实现一个特殊的基类 BaseTool 来定义这套标准。更精确地说,还会实现一个相关的异步基类 AsyncBaseTool 。二者共同定义所有加入框架的工具必须遵循的规范。

为简化起见,先聚焦 BaseTool ,稍后再回到 AsyncBaseTool 。图 2.2 展示了之前的同一个 LLM Agent 及其工具,每个工具都继承自 BaseTool

图 2.2 每个工具都继承自 BaseTool,LLM Agent 因而可以用相同方式获取工具描述并执行其逻辑。

我们的 BaseTool 会规定一组方法属性 ,所有继承它的工具都必须提供。为简单起见,图 2.2 仅展示了 __call__() 方法与 parameters_json_schema 属性,完整结构稍后给出。

前面提到,仅有 BaseTool 还不够。我们还需要两个新的数据结构来表示工具调用的输入输出

  • ToolCall:工具调用的输入
  • ToolCallResult:工具调用的输出

在写代码之前,先快速回顾上一章的工具调用流程,看看这些新类如何嵌入其中。图 2.3 把 BaseToolToolCallToolCallResult 纳入了上一章的流程图。

图 2.3 把 BaseTool、ToolCall、ToolCallResult 融入第 1 章介绍的工具调用流程。

如你在上一章所学,流程第一步是 LLM 选择工具并生成工具调用请求 。现在我们给这一步加上结构化封装:把请求打包为一个 ToolCall 对象。下一步,用该请求里的参数来执行所选工具 的逻辑。因为所有工具都遵循 BaseTool 的统一接口,所以我们可以用同一种方式 执行任何工具:调用该工具的 __call__() 方法,并把 ToolCall 作为输入。工具执行完毕后,把结果打包成 ToolCallResult 对象,再传回 LLM。

理解了三者如何协作后,我们来看它们的结构细节,见图 2.4 的 UML 类图。

图 2.4 BaseTool、ToolCall、ToolCallResult 的 UML 类图。

回顾第 1 章的 UML 基础:类图矩形上半部分是属性 ,下半部分是方法。图 2.4 显示:

  • ToolCall 有三个属性:id_tool_namearguments,无方法。
  • ToolCallResult 也有三个属性:tool_call_idcontenterror,无方法。我们会在实现时解释它们的含义。
  • BaseTool 的完整结构包含:namedescriptionparameters_json_schema 三个属性,以及 __call__() 等方法(开始时我们只提到其中两个)。

图中还出现两个新的 UML 概念:

  1. 继承关系 :子类指向父类的实线+空心三角箭头。可见 ToolCallToolCallResult 继承自外部的 pydantic.BaseModel
  2. 抽象类 :BaseTool 名称旁不是 "C",而是 "A",表示 Abstract 。被标注为抽象的方法在基类中没有默认实现 ,任何继承 BaseTool 的工具必须提供这些方法的实现。稍后你会看到如何把方法设为抽象。

TIP

Pydantic 是一个非常适合定义"带有强校验的数据模型"的 Python 库。

理解了结构与协作方式之后,让我们开始实现它们吧!

2.1.1 实现 ToolCall 与 ToolCallResult

我们将依次实现两种新的数据结构,先从 ToolCall 开始。图 2.4 已展示了它的三个属性:id_ 为本次工具调用的字符串标识;tool_namearguments 分别表示被选中的工具名与用于调用它的参数值。

代码清单 2.1 实现 ToolCall

python 复制代码
# llm_agents_from_scratch/data_structures/tool.py  #A
import uuid
from typing import Any
 
from pydantic import BaseModel, Field
 
 
class ToolCall(BaseModel):
    """Tool call.
 
    Attributes:
        id_: A string identifier for the tool call.
        tool_name: Name of tool to call.
        arguments: The arguments to pass to the tool execution.
    """
    
    id_: str = Field(default_factory=lambda: str(uuid.uuid4()))
    tool_name: str  #B
    arguments: dict[str, Any]  #C
#A 该代码在我们框架源码中的位置
#B 由 LLM 选择的工具名
#C 调用所选工具时传入的参数

由于 ToolCall 继承自 pydantic.BaseModel,我们在类体内声明属性(在 Pydantic 中称为"模型字段")。可以为字段添加自定义项,例如这里为 id_ 提供了一个默认工厂方法,因此创建 ToolCall 时无需显式提供 id_。另外,pydantic.BaseModel 提供了默认构造函数,所以不必自己实现 __init__()

正如前述,我们需要把 LLM 生成的工具调用请求封装为 ToolCall 对象。回到上一章"纽约市最佳性价比可颂"的例子:假设底座 LLM 以自然语言生成了请求------"我需要使用 web-search-tool 运行搜索:'纽约市出售可颂的面包店及其价格'"。图 2.5 展示了如何据此创建一个 ToolCall 对象。

图 2.5 将 LLM 的自然语言工具调用请求转为 ToolCall 对象。由于 web-search 工具实现为 BaseTool,我们必须先构造 ToolCall 再去调用它。

将该请求转换为 ToolCall 需要识别所选工具名与调用参数。下面的代码演示如何据此创建对象:

ini 复制代码
# Included in examples/ch02.ipynb  #A
from llm_agents_from_scratch.data_structures.tool import ToolCall
 
croissant_tool_call = ToolCall(
    tool_name="web-search-tool",  #B
    arguments={
        "query": "Croissant bakeries in New York City and their      
            prices.",  #C
    }
)
#A 示例 1
#B 底座 LLM 选择的工具
#C LLM 提供的必填参数 query 的值

注意

实际中,我们会使用 LLM 提供商的 API/SDK 来获取工具调用请求。这些请求通常按预定义的结构化 格式(而非自然语言)生成,这样比处理非结构化文本稳健得多,也便于稳定地抽取构建 ToolCall 所需的元素。下一章实现 LLM 基类时会用到其中一种 API。

构造好 ToolCall 后,就可以调用工具的 __call__() 来执行搜索。但因为我们尚未实现 BaseTool,这一步要稍后再看。

现在继续实现 ToolCallResult 。如图 2.4 所示,它包含三个属性:tool_call_idcontenterrorcontent 存放工具执行结果,error 表示执行是否出错,tool_call_id 用于把结果与对应的请求关联起来。图 2.6 展示了依据工具执行结果创建 ToolCallResult 的流程。

图 2.6 从所选工具的执行结果创建 ToolCallResult,同时处理成功与失败两种情况。

执行成功时,将结果赋给 content,并把 error 设为 False;若执行出错,则把 content 置为 NoneerrorTrue

代码清单 2.2 实现 ToolCallResult

python 复制代码
# llm_agents_from_scratch/data_structures/tool.py
import uuid
from typing import Any
 
from pydantic import BaseModel, Field
 
 
...  #A
 
class ToolCallResult(BaseModel):
    """Result of a tool call execution.
 
    Attributes:
        tool_call_id: The id of the associated tool call.
        content: The content of tool call.
        error: Whether or not the tool call yielded an error.
    """
 
    tool_call_id: str  #B
    content: Any | None  #C
    error: bool = False  #D
#A ToolCall 与 ToolCallResult 位于同一文件
#B 关联 ToolCall 的 id_
#C 成功执行时的结果
#D 取决于执行是否出错

可以看到,ToolCallResult 的实现方式与 ToolCall 类似,因为二者都继承自 pydantic.BaseModel

延续之前的例子,若 web-search-tool 执行成功,我们会这样创建 ToolCallResult

ini 复制代码
# Included in examples/ch02.ipynb  #A
from llm_agents_from_scratch.data_structures.tool import ToolCallResult
 
result = ToolCallResult(
    tool_call_id=tool_call.id_,  #B
    content={
        "search_results": ...  #C
    }
)
#A 示例 2
#B 前面 croissant_tool_call 的 id_
#C web-search-tool 的执行结果

2.1.2 实现 BaseTool

既然已实现了与 BaseTool 配套的数据结构,接下来实现它本身。根据图 2.4 的 UML,BaseTool 是一个抽象类(abstract class),包含三个属性与一个方法。

三个属性------namedescriptionparameters_json_schema------提供给 LLM 的"工具说明"所需信息(见图 2.7),用于把工具"装配"进 LLM。

图 2.7 复习工具装配过程:借助 BaseTool,可用其属性填充传给 LLM 的文本描述。

一旦工具装配到 LLM,就可以在工具调用流程中使用。正如前面所述,__call__() 方法负责执行:输入 一个 ToolCall输出 一个 ToolCallResult。我们将把 __call__() 标记为抽象方法,要求子类必须实现。

代码清单 2.3 实现 BaseTool

python 复制代码
# llm_agents_from_scratch/base/tool.py  #A
from abc import ABC, abstractmethod
from llm_agents_from_scratch.data_structures.tool import (
    ToolCall,  #B
    ToolCallResult,  #B
)
 
class BaseTool(ABC):
    """Base Tool Class."""
 
    @property  #C
    @abstractmethod
    def name(self) -> str:
        """Name of tool."""
 
 
    @property
    @abstractmethod  #D
    def description(self) -> str:
        """Description of what this tool does."""
 
    @property
    @abstractmethod
    def parameters_json_schema(self) -> dict[str, Any]:
        """JSON Schema for tool parameters."""
 
    @abstractmethod
    def __call__(
        self,
        tool_call: ToolCall,
        *args: Any,
        **kwargs: Any,
    ) -> ToolCallResult:
        """Execute the tool call."""  #E
#A 代码位于 base 模块
#B 前文实现的两个数据结构
#C 使用 property 以获得更灵活的"属性式"接口
#D 抽象方法标记
#E 工具执行逻辑在此方法中实现

我们用 @abstractmethod__call__() 标为抽象。同样,你可能注意到三个"属性"也被标为抽象方法;在 Python 中结合 @property@abstractmethod 可以既保留属性式访问 ,又迫使子类必须实现 它们,从而带来封装与校验的灵活性,并确保接口一致性。若子类未实现这些成员,将抛出 AbstractMethodError

注意

多数 LLM 提供商的工具调用 API/SDK 都要求以 JSON Schema 描述工具参数。我们在框架中采用 JSON Schema 既技术合理,又能最大化与现有 LLM 工具/服务的兼容性。

下面以子类化 BaseTool 的方式,构建一个最简单的示例工具:Hailstone 。它对正整数执行一次"冰雹(Hailstone)"步:若为偶数,输出其一半;若为奇数,输出 3x + 1。图 2.8 展示了该工具及其在工具调用流程中的执行逻辑。

图 2.8 Hailstone 工具对输入正整数执行一次"冰雹步"。作为 BaseTool 子类,它可以被纳入工具调用流程。

要实现该工具,需提供四个成员:namedescriptionparameters_json_schema__call__()

  • name"hailstone"
  • description 用 "对输入整数执行一次 Hailstone 步";
  • parameters_json_schema 需给出符合 JSON Schema 规范 的参数描述。本例仅需一个输入参数 x(见图 2.8 的命名),其 Schema 如下:
bash 复制代码
{
    "type": "object",
    "properties": {  #A
        "x": {  #B
            "type": "number",
            "description": "The input number."
        },
    },
    "required": ["x"]  #C
}

#A 工具参数在此处定义

#B 单一输入参数

#C 指定必填参数

JSON Schema 基础 (简要概览,原文保留):

JSON Schema 用 JSON 文档描述数据的形状与格式,便于创建与共享数据结构。......(此处原文关于对象、数组、必填字段等的例子与说明同样适用,略)

最后实现 __call__()。有了参数 Schema,我们可以假定传入的 ToolCall.arguments 中包含键 x。实现逻辑如下:

ini 复制代码
x = tool_call.arguments.get("x")  #A
if x % 2 == 0:
    result = x // 2  #B
else:
    result = (x * 3) + 1  #C
 
return ToolCallResult(  #D
    tool_call_id=tool_call.id_,
    content=result,
    error=False,
)
#A 从 ToolCall 中取输入
#B 偶数分支
#C 奇数分支
#D 封装为 ToolCallResult

这段实现完成了 Hailstone 步并按 __call__() 约定返回 ToolCallResult。更健壮的版本可以增加对 x 的校验与异常处理。

把所有部分合在一起,完整实现如下:

python 复制代码
# Included in examples/ch02.ipynb  #A
from typing import Any
from llm_agents_from_scratch.base.tool import BaseTool
from llm_agents_from_scratch.data_structures.tool import (
   ToolCall,
   ToolCallResult,
)
 
 
class Hailstone(BaseTool):  #B
    @property
    def name(self) -> str:
        return "hailstone"
 
    @property
    def description(self) -> str:
        return "A tool that performs a Hailstone step on a given input
            number."
    
    @property
    def parameters_json_schema(self) -> dict[str, Any]:
        """JSON Schema for tool parameters."""
        return {
            "type": "object",
            "properties": {
                "x": {
                    "type": "number",
                    "description": "The input number."
                },
            },
            "required": ["x"]
        }
 
    def __call__(
        self,
        tool_call: ToolCall,
        *args: Any,
        **kwargs: Any,
    ) -> ToolCallResult:
        """Execute the tool call."""
        x = tool_call.arguments.get("x")
        if x % 2 == 0:
            result = x // 2
        else:
            result = (x * 3) + 1
 
        return ToolCallResult(
            tool_call_id=tool_call.id_,
            content=result,
            error=False,
        )
#A 示例 3
#B 继承 BaseTool

运行图 2.8 所示的 Hailstone 调用:

ini 复制代码
# Included in examples/ch02.ipynb  #A
hailstone_tool = Hailstone()  #B
 
tool_call = ToolCall(  #C
    tool_name="hailstone",
    arguments={"x": 3},
)
 
tool_call_result = hailstone_tool(tool_call)  #D
print(tool_call_result)
#A 示例 3 的一部分
#B 初始化工具实例
#C 构造 ToolCall
#D 执行工具调用

返回的 ToolCallResult 表明对 x = 3 执行一步 Hailstone 的结果是 10

ini 复制代码
tool_call_id='112233', content='10', error=False

至于如何用 Hailstone 的 namedescriptionparameters_json_schema 去生成传给 LLM 的"工具文本描述",等我们在下一章实现 BaseLLM 再示范。现在,继续介绍之前提到的另一个基类:AsyncBaseTool

2.1.3 AsyncBaseTool(异步基础工具)

本节最后,我们给出 BaseTool 的一个重要变体:AsyncBaseTool ,它专为异步 执行逻辑的工具而设计。凡是需要调用外部 API 的工具(例如查询天气数据)都非常适合异步执行。等待外部 API 返回期间,异步工具允许应用程序的其他部分同时 运行。图 2.9 展示了同步(阻塞)的 BaseTool 与非阻塞的 AsyncBaseTool 在执行机制上的差异。

图 2.9 同步与异步工具执行的对比。

AsyncBaseTool 的执行过程中,如果需要等待外部结果,应用的其他部分可以继续或开始运行。与其同步对照实现(BaseTool)相比,这种并发执行通常能显著提升速度与资源利用率。图 2.10 展示了 AsyncBaseTool 的 UML 类图。

图 2.10 AsyncBaseTool 的 UML 类图,其结构与 BaseTool 几乎一致。

AsyncBaseTool 的接口与 BaseTool 相似:具有相同的属性,且同样提供 __call__() 方法;不同之处在于,AsyncBaseTool 的 __call__() 是异步方法 。在 Python 中,用 async 关键字标记方法为异步。完整实现如下:

代码清单 2.4 实现 AsyncBaseTool

python 复制代码
# llm_agents_from_scratch/base/tool.py
from abc import ABC, abstractmethod
from llm_agents_from_scratch.data_structures.tool import (
    ToolCall,
    ToolCallResult,
)
 
class BaseTool(ABC):
    """Base Tool Class."""
    ...  #A
 
class AsyncBaseTool(ABC):
    """Async Base Tool Class."""
 
    @property
    @abstractmethod
    def name(self) -> str:
        """Name of tool."""
 
    @property
    @abstractmethod
    def description(self) -> str:
        """Description of what this tool does."""
 
    @property
    @abstractmethod
    def parameters_json_schema(self) -> dict[str, Any]:
        """JSON schema for tool parameters."""
 
    @abstractmethod
    async def __call__(  #B
        self,
        tool_call: ToolCall,
        *args: Any,
        **kwargs: Any,
    ) -> ToolCallResult:
        """Asynchronously execute the tool call."""
#A 这里省略的 BaseTool 接口见代码清单 2.2
#B 将 __call__ 标记为异步方法

举例来说,如果我们把前面的 Hailstone 工具改为继承 AsyncBaseTool 而不是 BaseTool,调用方式如下:

ini 复制代码
hailstone_tool = Hailstone()  #A
 
tool_call = ToolCall(  #B
    tool_name="hailstone",
    arguments={"x": 3},
)
 
tool_call_result = await hailstone_tool(tool_call)  #C
#A 假设该类继承自 AsyncBaseTool
#B AsyncBaseTool 仍以 ToolCall 作为输入
#C 调用异步工具需要使用 await 等待结果

注意
async 关键字把函数变为异步函数,调用时返回协程 。协程是非阻塞对象,可在异步事件循环(如 asyncio 提供的)中并发执行;在异步方法中必须用 await 获取其结果。若需在同步代码里运行协程,可使用 asyncio.run()

练习 2.1:将 Hailstone 改为 AsyncBaseTool

请将前文的 Hailstone 工具改为继承 AsyncBaseTool 。在其执行逻辑中,先使用 asyncio.sleep(1) 让其延迟 1 秒,然后再执行一次 Hailstone 步。

2.2 SimpleFunctionTool:BaseTool 的子类

虽然我们此前能比较轻松地实现 Hailstone 工具,但我们还可以通过一个抽象层来进一步提升便利性:自动把 Python 函数封装成 BaseTool 工具 。本节将创建 SimpleFunctionTool ------一个继承自 BaseTool 的包装类,用来以这种方式创建工具。

当传入一个函数时,SimpleFunctionTool 能自动 依据该函数的信息实现 namedescriptionparameters_json_schema__call__(),如图 2.11 所示。

图 2.11 使用 SimpleFunctionTool 包装 Python 函数,使其可作为 LLM 与 LLM agent 的工具。

我们还会实现 SimpleFunctionTool 的异步版本 ,用于把异步 的 Python 函数封装为 AsyncBaseTool。图 2.12 展示了 SimpleFunctionToolAsyncSimpleFunctionTool 的 UML 类图。

图 2.12 SimpleFunctionTool 与 AsyncSimpleFunctionTool 的 UML 类图。

这两个类分别继承自各自的基础工具类,并新增一个名为 func 的属性,用以保存被包装的函数。对于 SimpleFunctionTool ,被包装的是同步函数;对于 AsyncSimpleFunctionTool,则是异步函数。

回到 Hailstone 工具,新建的 SimpleFunctionTool 允许我们用一个实现 Hailstone 步逻辑的函数来完成替代实现,示例如下:

python 复制代码
# Included in examples/ch02.ipynb  #A
def hailstone_step_func(x: int) -> int:
    """Performs a single step of the Hailstone sequence."""
    if x % 2 == 0:
        return x // 2  #B
    else:
        return 3 * x + 1  #B
#A Example 4
#B 与前文相同的 Hailstone 逻辑

我们的目标是用 SimpleFunctionTool 来包装 hailstone_step_func(),从而得到一个 LLM/LLM agent 可在工具调用流程中使用的新工具。

2.2.1 实现 SimpleFunctionTool

明确目标后就开始实现 SimpleFunctionTool 。如图 2.12 所示,它继承自 BaseTool ,因此必须实现 namedescriptionparameters_json_schema__call__()。正如前文所述,我们将基于传入的函数及新属性 func 自动派生这些实现。

首先,name 使用函数的名称;description 优先使用函数的 docstring,如不存在则回退到一个模板。下列代码(清单 2.5)给出了 __init__()namedescription 的实现:

python 复制代码
# llm_agents_from_scratch/tools/simple_function.py
from typing import Any, Callable
from llm_agents_from_scratch.base.tool import BaseTool
 
class SimpleFunctionTool(BaseTool):
    """Simple function calling tool.
 
    Turn a Python function into a tool for an LLM.
    """
    def __init__(
        self,
        func: Callable[..., Any],  #A
        desc: str | None = None,
    ) -> None:
        """Initialize a SimpleFunctionTool.
 
        Args:
            func (Callable): The Python function to expose as a tool to the
                LLM.
            desc (str | None, optional): Description of the function.
                Defaults to None.
        """
        self.func = func
        self._desc = desc  #B
 
    @property
    def name(self) -> str:
        """Name of function tool."""
        return self.func.__name__  #C
 
    @property
    def description(self) -> str:
        """Description of what this function tool does."""
        return (
            self._desc or self.func.__doc__ or f"Tool for {self.func.__name__}"  #D
        )
#A 需要包装并构建 BaseTool 的函数
#B 存放自定义描述的内部属性
#C 工具名设为函数名
#D 优先用自定义描述,否则用 docstring,再否则用模板

接下来实现 parameters_json_schema。此前在实现 Hailstone 工具时,我们手写 了参数 JSON Schema。那时提到稍后会写一个辅助函数自动生成 Schema------现在就来实现:通过检查函数签名 来构建 JSON Schema 的辅助函数 function_signature_to_json_schema()

它的核心是:将 Python 类型映射到 JSON Schema 类型 ,并判断哪些参数是必填(无默认值)。

说明:这里我们从零实现 JSON Schema 生成器,帮助你理解如何为 LLM 准备工具的文本描述。在下一小节,我们还会引入另一个 BaseTool 子类,利用 pydantic 的 JSON Schema 生成功能更健壮地处理这件事。

该辅助函数逻辑较多,图 2.13 给出了三步法的可视化说明:

1)反射函数签名获取参数及类型注解;

2)遍历参数,构建各自的 schema 片段(类型映射、是否必填);

3)组装并返回整体 JSON Schema。完整实现见清单 2.6:

python 复制代码
# llm_agents_from_scratch/tools/simple_function.py
import inspect
from typing import Any, Callable, get_type_hints
 
def function_signature_to_json_schema(func: Callable) -> dict[str, Any]:
    """Turn a function signature into a JSON schema.
 
    Inspects the signature of the function and maps types to the 
    appropriate JSON schema type.
 
    Args:
        func (Callable): The function for which to get the JSON schema.
 
    Returns:
        dict[str, Any]: The JSON schema
    """
    sig = inspect.signature(func)  #A
    type_hints = get_type_hints(func)
    python_to_json_schema_type = {  #B
        str: "string",
        int: "number",
        float: "number",
        dict: "object",
        list: "array",
        type(None): "null",
        bool: "boolean",
        tuple: "array",
        bytes: "string",
        set: "array",
    }
 
    properties = {}
    required = []
    for param in sig.parameters.values():  #C
        # 跳过 *args / **kwargs
        if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
            continue
 
        annotation = type_hints.get(param.name, param.annotation)
        if annotation in python_to_json_schema_type:
            this_params_json_schema = {
                "type": python_to_json_schema_type[annotation],
            }
        else:
            # 兜底:接受任意类型
            this_params_json_schema = {}
        properties[param.name] = this_params_json_schema
 
        # 是否必填:无默认值则为必填
        if param.default == inspect._empty:  #D
            required.append(param.name)
 
    return {  #E
        "type": "object",
        "properties": properties,
        "required": required,
    }
#A 第一步:函数签名反射
#B Python → JSON Schema 类型映射
#C 第二步:遍历参数构建属性 schema
#D 判断是否必填
#E 第三步:组装整体 Schema

有了该辅助函数,实现 parameters_json_schema 就只需调用它(清单 2.7):

python 复制代码
# llm_agents_from_scratch/tools/simple_function.py
import inspect
from typing import Any, Callable, get_type_hints
from llm_agents_from_scratch.base.tool import BaseTool
 
def function_signature_to_json_schema(func: Callable) -> dict[str, Any]:
    ...  #A
 
class SimpleFunctionTool(BaseTool):
    """Simple function calling tool.
 
    Turn a Python function into a tool for an LLM.
    """
    ...  #B
 
    @property
    def parameters_json_schema(self) -> dict[str, Any]:
        """JSON schema for tool parameters."""
        return function_signature_to_json_schema(self.func)  #C
#A 见清单 2.6
#B 见清单 2.5 的 name/description 实现
#C 调用辅助函数生成 Schema

最后实现 __call__()。SimpleFunctionTool 作为包装类,__call__() 的核心是把校验后的参数 转交给 func 执行。但在此之前,我们需要对 LLM 在 tool-call 请求里提供的参数做校验(见图 2.14)。

若校验或执行过程中出错,就返回一个 ToolCallResult,其中 error=Truecontent 为包含错误信息的 JSON 字符串。

参数校验使用 jsonschema 库:当 __call__() 接收到 tool_call 时,我们用 validate(tool_call.arguments, schema=self.parameters_json_schema) 进行校验。清单 2.8 展示了校验部分:

python 复制代码
# llm_agents_from_scratch/tools/simple_function.py
...  #A
from jsonschema import SchemaError, ValidationError, validate
 
class SimpleFunctionTool(BaseTool):
    """Simple function calling tool.
 
    Turn a Python function into a tool for an LLM.
    """
    ...  #B
 
    def __call__(
        self,
        tool_call: ToolCall,
        *args: Any,
        **kwargs: Any,
    ) -> ToolCallResult:
        ...  #C
        try:
            # 校验参数
            validate(tool_call.arguments,  #D
                     schema=self.parameters_json_schema)
        except (SchemaError, ValidationError) as e:
            error_details = {
                "error_type": e.__class__.__name__,
                "message": e.message,
            }
            return ToolCallResult(  #E
                tool_call_id=tool_call.id_,
                content=json.dumps(error_details),
                error=True,
            )
 
        ...  #F
#A 省略导入
#B 省略属性实现(见前两份清单)
#C 省略 docstring
#D 按 JSON Schema 校验参数
#E 出错则返回错误结果
#F 下面实现委托执行

把请求委托 给被包装函数,只需把校验后的 tool_call.arguments 作为参数解包传给 func 即可。清单 2.9 展示了委托执行部分:

python 复制代码
# llm_agents_from_scratch/tools/simple_function.py
...  #A
 
class SimpleFunctionTool(BaseTool):
    """Simple function calling tool.
 
    Turn a Python function into a tool for an LLM.
    """
    ...  #B
 
    def __call__(
        self,
        tool_call: ToolCall,
        *args: Any,
        **kwargs: Any,
    ) -> ToolCallResult:
        ...  #C
 
        ...  #D
 
        try:
            # 执行底层函数
            res = self.func(**tool_call.arguments)  #E
        except Exception as e:
            error_details = {
                "error_type": e.__class__.__name__,
                "message": f"Internal error while executing tool: {str(e)}",
            }
            return ToolCallResult(  #F
                tool_call_id=tool_call.id_,
                content=json.dumps(error_details),
                error=True,
            )
 
        return ToolCallResult(
            tool_call_id=tool_call.id_,
            content=str(res),
            error=False,
        )
#A 省略导入
#B 省略属性实现(见 2.5 与 2.6)
#C 省略 docstring
#D 上文清单 2.8 的校验部分
#E 调用被包装函数
#F 出错返回错误结果

至此,SimpleFunctionTool 实现完成。接着用它来完成基于 hailstone_step_func() 的 Hailstone 工具替代实现:

scss 复制代码
# Included in examples/ch02.ipynb  #A
from llm_agents_from_scratch.tools.simple_function import (
    SimpleFunctionTool
)
 
# 将 Python 函数转换成 BaseTool
hailstone_tool = SimpleFunctionTool(hailstone_step_func)
 
print(hailstone_tool.name)
print(hailstone_tool.description)
print(hailstone_tool.parameters_json_schema)
#A Example 5

输出应与被包装函数的信息一致:

bash 复制代码
hailstone_step_func            #A
Performs a single step of the Hailstone sequence.   #B
{'type': 'object', 'properties': {'x': {'type': 'number'}}, 'required': ['x']}  #C
#A 自动取自函数名
#B 自动取自函数 docstring
#C 由辅助函数自动生成

像原实现一样,通过 ToolCall 调用该工具也完全一致:

ini 复制代码
# Included in examples/ch02.ipynb  #A
from llm_agents_from_scratch.data_structures import ToolCall
 
tool_call = ToolCall(
    tool_name="hailstone_fn",
    arguments={"x": 3}
)
 
res = hailstone_tool(tool_call)  #B
#A Example 6
#B 调用方式相同,因为 SimpleFunctionTool 仍是 BaseTool

很好!借助 SimpleFunctionTool ,我们解锁了"由 Python 函数快速构建工具 "的高效模式,方便 LLM 与 LLM agent 直接使用。下面我们将快速实现它的异步对应物。

2.2.2 AsyncSimpleFunctionTool

借助 AsyncSimpleFunctionTool ,我们希望像 SimpleFunctionTool 那样自动创建工具,但面向异步函数。它的心智模型与图 2.11 中的 SimpleFunctionTool 十分类似,其异步版如图 2.15 所示。

图 2.15 将一个异步 Python 函数包装起来,自动生成一个 AsyncBaseTool 对象。

你或许还记得图 2.12 的 UML 类图:AsyncSimpleFunctionToolSimpleFunctionTool 在结构上非常相近。主要区别在于:AsyncSimpleFunctionTool 的 func 属性保存的是异步 Python 函数。

由于两者高度相似,实现 AsyncSimpleFunctionTool 时可以复用大量 SimpleFunctionTool 的代码。因此,这里仅讲差异点。

第一个细微差异体现在构造函数 __init__():此处对 func 的类型注解更为具体------Callable[..., Awaitable[Any]]。这表明我们处理的是一个返回可等待对象 (可以用 await 等待)的函数。我们之前讨论过的**协程(coroutine)**就是可等待对象的一种。

提示
typing.Awaitabletyping.Coroutine 更灵活,凡是可等待的对象都属于它的范畴,包括协程、asyncio.Futureasyncio.Task 等。

最后一个差异是 __call__() 的实现:由于被包装函数是异步 的,委托调用时需要使用 await。参数校验逻辑与 SimpleFunctionTool 完全一致。下面给出 AsyncSimpleFunctionTool 的实现(仅展示差异部分):

清单 2.10 实现 AsyncSimpleFunctionTool(仅差异)

python 复制代码
# llm_agents_from_scratch/tools/simple_function.py
...  #A
from llm_agents_from_scratch.base.tool import AsyncBaseTool
 
class AsyncSimpleFunctionTool(AsyncBaseTool):  #B
    """Async simple function calling tool.
 
    Turn a Python function into a tool for an LLM.
    """
 
    def __init__(
        self,
        func: Callable[..., Awaitable[Any]],  #C
        desc: str | None = None,
    ) -> None:
        ...  #D
 
    ...  #E
 
    async def __call__(  #F
        self,
        tool_call: ToolCall,
        *args: Any,
        **kwargs: Any,
    ) -> ToolCallResult:
        ...  #G
        
        ...  #H
 
        try:
            # execute the function
            res = await self.func(**tool_call.arguments)  #I
        ...  #J
  • #A 与清单 2.9 相同的导入
  • #B 继承 AsyncBaseTool
  • #C func 的新类型注解
  • #D 与清单 2.5 相同
  • #E namedescriptionparameters_json_schema 与清单 2.5、2.7 相同
  • #F __call__()异步
  • #G 省略 docstring
  • #H 参数校验同清单 2.8
  • #I 委托调用时须使用 await
  • #J 其余实现同清单 2.9

练习 2.2:Hailstone 的另一种异步实现

AsyncSimpleFunctionTool 重新实现你在练习 2.1 中编写的异步 Hailstone。为此,需要把 hailstone_step_func() 改写为异步函数。在相同输入上测试两个版本,验证得到的输出一致。

2.3 PydanticFunctionTool:BaseTool 的另一个子类

上一节的 SimpleFunctionTool 借助我们自写的辅助函数 function_signature_to_json_schema(),能根据函数签名自动推导 JSON Schema 。本节再提供一个替代的函数工具包装类 PydanticFunctionTool ------它拥有与 SimpleFunctionTool 类似的能力,但利用 pydantic 提供的更强大且稳健的 JSON Schema 生成与校验能力。

PydanticFunctionTool 也继承自 BaseTool ,其实现过程与 SimpleFunctionTool 十分相似。因此这里不再展开全部实现,重点展示它在本框架中的使用方式 ;完整实现详见附录 C

与 SimpleFunctionTool 的使用方式相比,PydanticFunctionTool 的主要差异是:被包装函数的参数需要通过一个 pydantic.BaseModel 提供,如下:

python 复制代码
# Included in examples/ch02.ipynb  #A
from pydantic import BaseModel
 
class MyFuncParams(BaseModel):  #B
    x: int
 
def my_func(params: MyFuncParams) -> int:  #C
    print(params.x)  #D
#A Example 7
#B 函数参数使用 BaseModel
#C 函数入参为该 BaseModel
#D 使用该 BaseModel 执行函数逻辑

定义好 my_func() 后,即可像 SimpleFunctionTool 一样用 PydanticFunctionTool 进行包装,自动创建一个可供 LLM/LLM agent 使用的工具:

csharp 复制代码
# Included in examples/ch02.ipynb  #A
from llm_agents_from_scratch.tools.pydantic_function import (
    PydanticFunctionTool
)
 
tool = PydanticFunctionTool(my_func)  #B
#A Part of Example 7
#B 包装 my_func

得到的新工具可像本章其它工具一样,接收 ToolCall 对象并参与工具调用流程。

练习 2.3:用 PydanticFunctionTool 实现 Hailstone

请使用 PydanticFunctionTool 重写 Hailstone 工具。具体用法可参考附录 C

为何选择 PydanticFunctionTool?

优势主要体现在实现细节而非使用方式:

  • parameters_json_schemaBaseModel.model_json_schema() 生成,更规范/全面
  • __call__() 中的参数校验可用 BaseModel.model_validate(),更稳健
    此外,框架还提供了其异步对应物 AsyncPydanticFunctionTool 。两者均可从 llm_agents_from_scratch.tools.pydantic_function 导入。

本章工作量不小:我们既实现了基础工具接口 ,又添加了两个函数到工具的工厂类,使 Python 函数可直接供 LLM/LLM agent 调用。

下一章将实现至关重要的 BaseLLM 类,以及其子类 OllamaLLM ,以便在本框架中使用 Ollama 支持的开源 LLM。

2.4 小结

  • 要构建一个 LLM 代理,需要先搭建必要的基础设施,其中包括用于表示"工具"的抽象,这些工具将被代理用来执行任务。
  • 为了让 LLM 能对某个工具发起调用请求,必须提供:工具名称、功能描述,以及其输入参数的 JSON Schema
  • 一个 BaseTool 对象负责执行一次 ToolCall ,并返回一个 ToolCallResult 对象。
  • AsyncBaseTool 适用于以异步方式执行其逻辑的工具。
  • SimpleFunctionTool 是一个包装类,可将 Python 同步函数 转换为 BaseTool 对象。
  • AsyncSimpleFunctionTool 是对应的异步版本,用于将 Python 异步函数 转换为 AsyncBaseTool 对象。
  • PydanticFunctionTool 与 SimpleFunctionTool 类似,但它包装的是一种特殊的函数(文中称为 PydanticFunction ):这类函数通过 pydantic.BaseModel 接收输入参数用于执行业务逻辑。
相关推荐
数据智能老司机3 小时前
构建一个 DeepSeek 模型——通过键值缓存(Key-Value Cache, KV Cache)解决推理瓶颈
架构·llm·deepseek
在未来等你4 小时前
AI Agent设计模式 Day 3:Self-Ask模式:自我提问驱动的推理链
设计模式·llm·react·ai agent·plan-and-execute
赋范大模型技术社区5 小时前
LangChain 1.0 实战: NL2SQL 数据分析 Agent
数据分析·langchain·实战·agent·教程·nl2sql·langchain1.0
Larcher19 小时前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
架构师日志20 小时前
使用大模型+LangExtract从复杂文本提取结构化数据(三)——提取表格列表类型数据
llm
智泊AI21 小时前
AI圈炸锅了!大模型的下一片蓝海,彻底爆发了!
llm
常先森1 天前
【解密源码】 RAGFlow 切分最佳实践- naive parser 语义切块(excel & csv & txt 篇)
架构·llm·agent
York·Zhang1 天前
AI 下的 Agent 技术全览
人工智能·大模型·agent
思绪漂移1 天前
ReAct对“智能”做了一件什么事情
人工智能·agent