本章内容
- 规范工具的基类,以在框架中统一工具的使用方式
- 定义支撑"工具调用(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 把 BaseTool 、ToolCall 、ToolCallResult 纳入了上一章的流程图。

图 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_name、arguments,无方法。 - ToolCallResult 也有三个属性:
tool_call_id、content、error,无方法。我们会在实现时解释它们的含义。 - BaseTool 的完整结构包含:
name、description、parameters_json_schema三个属性,以及__call__()等方法(开始时我们只提到其中两个)。
图中还出现两个新的 UML 概念:
- 继承关系 :子类指向父类的实线+空心三角箭头。可见 ToolCall 与 ToolCallResult 继承自外部的
pydantic.BaseModel。 - 抽象类 :BaseTool 名称旁不是 "C",而是 "A",表示 Abstract 。被标注为抽象的方法在基类中没有默认实现 ,任何继承 BaseTool 的工具必须提供这些方法的实现。稍后你会看到如何把方法设为抽象。
TIP
Pydantic 是一个非常适合定义"带有强校验的数据模型"的 Python 库。
理解了结构与协作方式之后,让我们开始实现它们吧!
2.1.1 实现 ToolCall 与 ToolCallResult
我们将依次实现两种新的数据结构,先从 ToolCall 开始。图 2.4 已展示了它的三个属性:id_ 为本次工具调用的字符串标识;tool_name 与 arguments 分别表示被选中的工具名与用于调用它的参数值。
代码清单 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_id、content、error。content 存放工具执行结果,error 表示执行是否出错,tool_call_id 用于把结果与对应的请求关联起来。图 2.6 展示了依据工具执行结果创建 ToolCallResult 的流程。

图 2.6 从所选工具的执行结果创建 ToolCallResult,同时处理成功与失败两种情况。
执行成功时,将结果赋给 content,并把 error 设为 False;若执行出错,则把 content 置为 None,error 为 True。
代码清单 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),包含三个属性与一个方法。
三个属性------name、description、parameters_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 子类,它可以被纳入工具调用流程。
要实现该工具,需提供四个成员:name、description、parameters_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 的 name、description 与 parameters_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 能自动 依据该函数的信息实现 name、description、parameters_json_schema 与 __call__(),如图 2.11 所示。

图 2.11 使用 SimpleFunctionTool 包装 Python 函数,使其可作为 LLM 与 LLM agent 的工具。
我们还会实现 SimpleFunctionTool 的异步版本 ,用于把异步 的 Python 函数封装为 AsyncBaseTool。图 2.12 展示了 SimpleFunctionTool 与 AsyncSimpleFunctionTool 的 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 ,因此必须实现 name、description、parameters_json_schema、__call__()。正如前文所述,我们将基于传入的函数及新属性 func 自动派生这些实现。
首先,name 使用函数的名称;description 优先使用函数的 docstring,如不存在则回退到一个模板。下列代码(清单 2.5)给出了 __init__()、name、description 的实现:
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=True,content 为包含错误信息的 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 类图:AsyncSimpleFunctionTool 与 SimpleFunctionTool 在结构上非常相近。主要区别在于:AsyncSimpleFunctionTool 的 func 属性保存的是异步 Python 函数。
由于两者高度相似,实现 AsyncSimpleFunctionTool 时可以复用大量 SimpleFunctionTool 的代码。因此,这里仅讲差异点。
第一个细微差异体现在构造函数 __init__():此处对 func 的类型注解更为具体------Callable[..., Awaitable[Any]]。这表明我们处理的是一个返回可等待对象 (可以用 await 等待)的函数。我们之前讨论过的**协程(coroutine)**就是可等待对象的一种。
提示
typing.Awaitable 比 typing.Coroutine 更灵活,凡是可等待的对象都属于它的范畴,包括协程、asyncio.Future、asyncio.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
name、description、parameters_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_schema由BaseModel.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接收输入参数用于执行业务逻辑。