本章内容
- 我们框架中用于对接 LLM 的基础类
- 基于 Ollama 实现一个可使用任意开源 LLM 的类
- 一次完整的工具调用(tool-call)流程演示
在上一章中,我们开启了 “从零实现 llm-agents-from-scratch” 的第一阶段构建:编写工具(tools)的基础类以及它们所需的数据结构。本章我们将继续第一阶段的工作,为 LLM 同样增加一个基础类,以及一组用于标准化与 LLM 交互模式的数据结构。
其中一种交互模式就是工具调用流程;到本章末尾,我们终于可以把它完整地跑通:具体来说,学习如何从 LLM 处“引出”一条工具调用请求,以及如何把上章中我们执行工具得到的结果回传给 LLM 进行综合与答复。
在确立基础类 BaseLLM 之后,我们会实现一个令人兴奋的集成:Ollama——一个非常流行的开源 LLM 推理框架。我们将通过实现 BaseLLM 的子类 OllamaLLM,让框架能够使用 Ollama 所支持的众多开源模型(包括 Llama、Qwen 系列等)。图 3.1 展示了我们的最新构建计划,标注了当前已完成的部分与本章的关注点。
图 3.1 在为框架加入工具之后,本章聚焦于为 LLM 代理加入另一核心组件——“骨干 LLM”。我们将通过 BaseLLM 定义所有未来 LLM 必须遵循的接口,并实现 OllamaLLM 以使用任意 Ollama 支持的开源模型。
提示:你可以 fork 本书的 GitHub 仓库,并按前两章所述激活本框架的专用虚拟环境来跟进示例代码。为更便利,我也准备了一个 Jupyter 笔记本作为本章示例的执行环境:
github.com/nerdai/llm-…
文中标注为如下格式的代码片段可在该笔记本中找到。
# Included in examples/ch03.ipynb #A
… #B
#A 笔记本中的示例编号
#B 对应示例的实际代码
建议在项目根目录使用 uv 启动 Jupyter Lab(确保依赖就绪):
uv run --with jupyter jupyter lab
3.1 BaseLLM:LLM 的蓝图
如今有多家 LLM 提供方:OpenAI 的 GPT 系列、Anthropic 的 Claude 是主流的闭源选择;而开源 LLM(如 Llama、Qwen、DeepSeek 等)则可通过 HuggingFace、Ollama、vLLM 等框架使用。与这些提供方/框架交互,需要处理各自的 API 或 SDK。尽管它们都支持若干“标准交互模式”(下文即将介绍),但在应用开发层面仍存在差异。
如果我们在框架里分别暴露每家的 API,使用体验会非常割裂。更合理的做法是通过一个基础类定义统一且灵活的接口,使我们能在“单一、统一的 API” 下对接不同 LLM 提供方/框架。这个标准接口就是本节将定义的 BaseLLM。
所有 LLM 提供方/框架普遍支持的两种交互模式是文本补全(text completion)与聊天(chat) 。我们也会在 BaseLLM 中分别通过 complete() 与 chat() 方法支持它们。还有另外两种交互模式 BaseLLM 也会支持,但为便于聚焦,暂且按下不表。图 3.2 展示了之前的 LLM 代理,其中骨干 LLM 现在继承自新的 BaseLLM 类。
图 3.2 具有骨干 LLM 与所配工具的 LLM 代理。骨干 LLM 是本章稍后将实现的 OllamaLLM 中的 Qwen3-7b 模型。
complete():面向“给定一个提示,生成一段文本”的简单补全。chat():面向“消息序列”的对话式交互。工具调用通常通过聊天式交互完成,本框架也将沿用这一做法。- 注意:BaseLLM 以异步(async-first)为先,所有与 LLM 的交互均以异步方式执行。
和第 2 章一样,为了标准化对 LLM 的使用,我们还需要几种新的数据结构。其一是 CompleteResult:用于封装 complete() 的结果。更具体地说,我们向 complete() 传入一个字符串 prompt,它返回一个包含 LLM 生成内容的 CompleteResult。图 3.3 展示了该流程。
图 3.3 通过 complete() 支持文本补全。
下一种数据结构是 ChatMessage,用于便捷地进行聊天交互。ChatMessage 包含消息内容以及通过 ChatRole 指定的发送方。图 3.4 展示了借助 chat() 与这些数据结构进行聊天的过程。
图 3.4 通过 chat() 支持与 LLM 的聊天交互。
我们以一个输入字符串调用 chat(),并可选地传入由 ChatMessage 构成的历史记录,以及一组希望“装备给 LLM”的工具。chat() 的结果是一对新的 ChatMessage:一个由用户输入构造,另一个由 LLM 的回复构造;如果 LLM 产生了工具调用请求,它们会被打包在第二个 ChatMessage 中。该返回的消息对随后可追加到会话历史中,供下一次 chat() 使用。
说明
出于教学与易用性考虑,我选择让chat()接受更简单的输入类型(字符串)。而返回“用户输入 + LLM 回复”两条ChatMessage的做法,则便于维护会话历史。
现在,我们已经理解了 BaseLLM 与这些新数据结构如何支持标准交互模式,接下来看看它们的结构细节。图 3.5 给出了 BaseLLM、CompleteResult、ChatMessage、ChatRole 的 UML 类图。
图 3.5 BaseLLM 及其配套数据结构的 UML 类图。
这里引入了几项新的 UML 概念。首先是 枚举(enum) 类的标记(圆圈内字母 “E”)。枚举指定了一组有限命名常量,实例只能取其一。ChatRole 恰好适合作为枚举:有效取值仅限 SYSTEM、USER、ASSISTANT、TOOL。其次是 组合(composition) 关系(实线 + 实心菱形箭头),表示“一个类由另一些类构成”。被包含的类脱离该组合并无独立意义。图 3.5 中 ChatMessage 由 ChatRole 组成,契合我们前文的描述:没有 ChatMessage 的上下文,ChatRole 没有实际意义。
回到 ChatMessage:它继承自 pydantic.BaseModel,拥有三个属性:role、content、tool_calls。我们会在实现时解释这些属性的含义。同时你会看到 ChatMessage 还有一个被标注为 <> 的方法 from_tool_call_result()——这在 Python 语义里等价于 @classmethod。可将其理解为“从 ToolCallResult 便捷地构造一条 ChatMessage”,这会在实现工具调用最后一步时帮上忙。
CompleteResult 也继承自 pydantic.BaseModel,具有两个属性:response 与 prompt。顾名思义,我们稍后实现时再详述。
最后是 BaseLLM:它是一个抽象类,无属性,包含四个方法:complete()、chat()、continue_chat_with_tool_results()、structured_output()。如图所示,这四个方法均标注了 <> ,再次强调其异步特性。我们已讨论过前两者及其与 CompleteResult/ChatMessage 的配合。
下面说明两个新方法及其交互模式:
continue_chat_with_tool_results():在chat()的基础上,提供把工具执行结果回传给 LLM 的便捷方式,以便 LLM 进行综合与回复。由于这是在延续一次既有会话,因此仍然与既定的ChatMessage/ChatRole数据结构配合使用。structured_output():用于另一种常用模式——结构化输出。我们希望 LLM 按预先指定的格式(最常见是 JSON)返回结果。图 3.5 在该方法签名中展示了泛型T,使用户可以用自定义类来声明期望的结构化输出。我们将在实现 BaseLLM 接口时详细展开这两个方法。
与第 2 章相同,我们会先实现这些新的数据结构,再实现核心基础类 BaseLLM。
3.1.1 实现 CompleteResult、ChatMessage 与 ChatRole
我们将添加到框架中的第一个新数据结构是 CompleteResult。它很简单,包含 prompt 与 response 两个字符串属性(见图 3.5)。prompt 保存用于提示 LLM 的输入,response 保存 LLM 生成的输出。图 3.6 展示了先前“文本补全”交互的同一流程,但这次包含了一个示例输入提示与返回的 CompleteResult 对象及其属性。
图 3.6 调用 complete() 后返回的 CompleteResult 示例。
下面的代码实现了 CompleteResult。
代码清单 3.1 实现 CompleteResult
# llm_agents_from_scratch/data_structures/llm.py
from pydantic import BaseModel
from typing_extensions import Self
from llm_agents_from_scratch.data_structures.tool import ToolCall
class CompleteResult(BaseModel):
"""The LLM completion result data model.
Attributes
response: The completion response provided by the LLM.
full_response: Input prompt and completion text.
"""
response: str #A
prompt: str #B
#A LLM 生成的文本补全
#B 提供给 LLM 的提示
接下来实现用于聊天交互的数据结构:ChatMessage 与 ChatRole。图 3.7 展示了图 3.4 中的聊天交互,这次叠加了这两个数据结构的示例。
图 3.7 在一次 chat() 调用中的 ChatMessage 与 ChatRole 示例。
如你所见,用户输入会以 USER 角色的 ChatMessage 返回,而 LLM 的回复是 ASSISTANT 角色的 ChatMessage。在此示例中,LLM 的回复携带了一个 ToolCall 对象;在这种情况下,该 ChatMessage 的 content 为空字符串。
标准化的消息角色
不同 LLM 提供方/框架对消息角色有一定程度的标准化,可概括如下:SYSTEM 用于为 LLM/LLM 代理设置整体上下文(如在接下来的对话中规定其扮演的角色);USER 保留给用户发送的消息;ASSISTANT 用于 LLM 的消息;TOOL 则用于携带回传给 LLM 的工具执行结果的消息。
为方便起见,我们还提供了一个从 ToolCallResult 构造 ChatMessage 的构造器方法 from_tool_call_result()。该方法返回一个 TOOL 角色的 ChatMessage,其 content 为该 ToolCallResult 的字符串序列化结果。
下面是 ChatMessage 与 ChatRole 的实现。
代码清单 3.2 实现 ChatMessage 与 ChatRole
# llm_agents_from_scratch/data_structures/llm.py
from pydantic import BaseModel
from typing_extensions import Self
from llm_agents_from_scratch.data_structures.tool import ToolCall
… #A
class ChatRole(str, Enum):
"""Roles for chat messages."""
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
TOOL = "tool"
class ChatMessage(BaseModel):
"""The chat message data model.
Attributes:
role: The role of the message.
content: The content of the message.
tool_calls: Tool calls associated with the message.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
role: ChatRole
content: str
tool_calls: list[ToolCall] | None = None #B
@classmethod #C
def from_tool_call_result(
cls,
tool_call_result: ToolCallResult
) -> Self:
"""Create a ChatMessage from a ToolCallResult."""
return cls(
role=ChatRole.TOOL, #D
content=tool_call_result.model_dump_json(indent=4), #E
)
#A 代码清单 3.1 中的 CompleteResult
#B 除非 LLM 的消息中包含工具调用请求,否则应为 None
#C 使用 classmethod 标注的“构造器”方法
#D 该消息用于承载工具结果,角色为 TOOL
#E 工具调用结果的字符串序列化
3.1.2 实现 BaseLLM
现在可以开始实现 BaseLLM。如图 3.5 的 UML 类图所示,BaseLLM 是一个无属性的抽象类,包含四个方法:complete()、chat()、continue_chat_with_tool_results() 与 structured_output()。
我们先从 complete() 与 chat() 开始。这两个方法都会标记为抽象方法,其入参与返回类型前文已详细介绍。
代码清单 3.3 实现 BaseLLM:chat() 与 complete()
# llm_agents_from_scratch/base/llm.py
from abc import ABC, abstractmethod
from llm_agents_from_scratch.base.tool import AsyncBaseTool, BaseTool
from llm_agents_from_scratch.data_structures import (
ChatMessage,
CompleteResult,
ToolCallResult,
)
from typing import Any, Sequence
Tool: TypeAlias = BaseTool | AsyncBaseTool #A
class BaseLLM(ABC):
"""Base LLM Class."""
@abstractmethod
async def complete(
self,
prompt: str, #B
**kwargs: Any
) -> CompleteResult: #C
"""Text Complete."""
@abstractmethod
async def chat(
self,
input: str, #D
chat_messages: Sequence[ChatMessage] | None = None, #E
tools: Sequence[Tool] | None = None, #F
**kwargs: Any,
) -> tuple[ChatMessage, ChatMessage]: #G
"""Chat interface."""
#A BaseTool 与 AsyncBaseTool 的并集类型别名
#B 输入提示字符串
#C 返回包含 LLM 响应的 CompleteResult
#D 输入字符串
#E 可选的聊天历史
#F 赋予 LLM 可用的工具
#G 返回一对 ChatMessage(用户输入 + LLM 回复)
与第 2 章中的 BaseTool 相同,BaseLLM 的子类必须为所有抽象方法提供实现。
接着指定我们为 chat() 提供的便捷扩展:continue_chat_with_tool_results() ,用于把工具执行结果回传给 LLM。在动手写代码前,再回顾一次工具调用流程。图 3.8 展示了第 1 章首次介绍的熟悉流程,这次投影到了 LLM 的聊天交互里。
图 3.8 将熟悉的工具调用流程嵌入到一次 chat() 交互中。
在聊天交互里,工具调用请求由一个 ChatMessage 表示:其 tool_calls 属性中包含 ToolCall 对象。注意:只有 ASSISTANT 角色的 ChatMessage 才能携带工具调用请求。我们需要从此类消息中提取 ToolCall,并将其传给被选中工具的 __call__() 执行。工具执行完成后,需要把得到的 ToolCallResult 回传给 LLM 以便其综合与回复。为此我们使用 continue_chat_with_tool_results():它首先将输入的 ToolCallResult 列表转换为若干 TOOL 角色的 ChatMessage,然后将这些消息提交给 LLM。
continue_chat_with_tool_results() 的入参几乎与 chat() 相同,唯一的区别是它接收的是一组 ToolCallResult,而不是用户输入字符串。
它的返回值与 chat() 一样是一个二元组:第一项为由输入的 ToolCallResult 转换而来的 ChatMessage 列表(角色为 TOOL),第二项为 LLM 针对这些工具结果生成的回复 ChatMessage。这保持了维护会话历史的同一模式:无论使用 chat() 还是 continue_chat_with_tool_results(),都将返回的两部分依次追加到历史中。
说明
也可以不使用continue_chat_with_tool_results(),而直接调用chat():手动把ToolCallResult转为TOOL角色的ChatMessage,并连同更新后的历史传给下一次chat()。但出于便捷性与教学清晰度,我们保留该方法以让“在聊天中完成工具调用”的全流程更清楚。
代码清单 3.4 实现 BaseLLM:continue_chat_with_tool_results()
# llm_agents_from_scratch/base/llm.py
from abc import ABC, abstractmethod
from llm_agents_from_scratch.base.tool import AsyncBaseTool, BaseTool
from llm_agents_from_scratch.data_structures import (
ChatMessage,
CompleteResult,
ToolCallResult,
)
from typing import Any
class BaseLLM(ABC):
"""Base LLM Class."""
… #A
@abstractmethod
async def continue_chat_with_tool_results(
self,
tool_call_results: Sequence[ToolCallResult], #B
chat_history: Sequence[ChatMessage], #C
tools: Sequence[Tool] | None = None,
**kwargs: Any,
) -> tuple[list[ChatMessage], ChatMessage]: #D
"""Continue a chat submitting tool call results."""
#A 代码清单 3.3 中的 chat 与 complete 抽象方法
#B 将要回传给 LLM 的 ToolCallResult 序列
#C 截止到包含工具调用请求那一条消息的聊天历史
#D 返回(输入的工具结果消息列表,LLM 的回复)
最后实现 BaseLLM 的 structured_output()。先说明为何要在接口中包含它。
作为文本生成器,LLM 也能产出结构化输出:即让 LLM 生成符合预先指定格式(最常见是 JSON)的文本。结构化输出能显著简化下游处理,减少对脆弱、易错的字符串解析逻辑的依赖。下面用一个简单例子说明不结构化输出的脆弱性。
提示:从数学、物理、生物三类中任选一类,讲一个笑话;同时给出笑话所属的类别。
非结构化响应(示例一)
“给你一个:
为什么 DNA 要去做心理治疗?因为它有点‘扭’!(Biology)”
从这条回答中,靠人工很容易看出类别是生物(biology)。但同样的提示再次询问,输出形式可能完全不同。
非结构化响应(示例二)
“给你一个:
为什么数学书看起来那么忧伤?因为它有太多‘问题’。
Subject: Math”
人工仍能看出类别是数学(math)。但如果我们要写稳健程序去解析这种自由格式的输出,就很容易出错。更好的方式是要求 LLM 按结构化格式返回,如下:
提示同上,并要求输出格式:
{'subject': …, 'joke': …}
结构化响应
{
"subject": "math",
"joke": "Why did the math book look so sad? Because it had too many problems."
}
这种结构化输出更易处理,便于可靠的下游逻辑。图 3.9 展示了如何通过 structured_output() 执行该示例。
说明
与工具调用类似,许多 LLM 提供方/框架也提供结构化输出专用 API:用户只需提供目标输出模式(通常为 JSON 模式),其余由服务端(或框架)负责对 LLM 做好提示与约束。对比之下,我们的示例是“手写提示”来让 LLM 产出指定格式。
图 3.9 结构化输出交互:向 structured_output() 传入指令 prompt 与目标输出模型 mdl,LLM 生成的响应可据此构造 mdl(pydantic.BaseModel 的子类)的实例。
理解了 structured_output() 的动机后,来看实现。图 3.5 中的方法签名表明,入参为 prompt(字符串)与 mdl(期望的输出格式)。如前所述,mdl 依赖泛型 T,我们将 T 绑定为 pydantic.BaseModel,这意味着期望的结构化输出类型必须定义为 BaseModel 的子类。
structured_output() 返回一个 T 的实例,也就是“期望的结构化输出类”的实例。为更清晰,我们将 T 命名为 StructuredOutputType。
代码清单 3.5 实现 BaseLLM:structured_output()
# llm_agents_from_scratch/base/llm.py
from abc import ABC, abstractmethod
from typing import Any, Sequence, TypeVar
from pydantic import BaseModel
from llm_agents_from_scratch.base.tool import AsyncBaseTool, BaseTool
from llm_agents_from_scratch.data_structures import (
ChatMessage,
CompleteResult,
ToolCallResult,
)
StructuredOutputType = TypeVar("StructuredOutputType", bound=BaseModel) #A
class BaseLLM(ABC):
"""Base LLM Class."""
… #B
@abstractmethod
async def structured_output(
self,
prompt: str,
mdl: type[StructuredOutputType], #C
**kwargs: Any,
) -> StructuredOutputType: #D
"""Structured output interface for returning ~pydantic.BaseModels.
#A 绑定到 pydantic.BaseModel 的结构化输出泛型
#B 代码清单 3.3 与 3.4 中的其他方法
#C 预期的结构化输出模型(BaseModel 子类)
#D 返回该模型的一个实例
说明
将StructuredOutputType绑定到pydantic.BaseModel是一种实现选择,便于充分利用 Pydantic 强大的校验能力。
让我们用之前的笑话示例构建一个结构化输出模型。该模型将作为 structured_output() 调用时的 mdl 参数传入。它需要继承 pydantic.BaseModel:
# Included in examples/ch03.ipynb #A
from typing import Literal
from pydantic import BaseModel
class Joke(BaseModel): #B
"""A structured output model for Jokes."""
subject: Literal["math", "physics", "biology"]
joke: str
#A 示例 1
#B 可作为 structured_output() 的 mdl 参数
要演示一次真实的 structured_output() 调用,我们还需要一个 BaseLLM 的具体子类 来实现该方法——这正是下一节将要完成的工作。
将“工具调用”视为“结构化输出”,反之亦然
工具调用同样可以被视为一种结构化输出。我们甚至可以用 structured_output() 让 LLM 直接产出“工具调用请求”的 JSON(包含所选工具名及其参数)。
不过,大多数 LLM 服务商/框架都为工具调用提供了现成的 API 与提示模板:既能在需要时才使用工具,也能在收到工具结果后自动组织综合与答复。因此,工具调用优先走原生 API,而不是强行用结构化输出来“模拟”。
相反地,也完全可以把“结构化输出”作为一次“工具调用”来实现:定义一个“生成指定结构的输出”的工具,并强制指示 LLM 使用它。
3.2 OllamaLLM:BaseLLM 的一个子类
既然我们已经通过 BaseLLM 类规定了在框架中与 LLM 交互的方式,接下来就实现一个具体的类。本节我们将与 Ollama LLM 推理框架集成。这一集成允许我们使用 Ollama 所支持的任意开源 LLM。我们会实现 OllamaLLM 类,它与一个 Ollama 服务交互(通常运行在你的本机)。
实现 OllamaLLM 需要使用 ollama 的 Python 库。完成后,我们会演示如何通过 chat()、complete()、continue_chat_with_tool_results() 与 structured_output() 与 LLM 交互。
说明
像这样的集成工作通常需要阅读该库的文档、源码与其他资料。在撰写本节内容时,我参考了 ollama 的官方文档甚至其源码,以确定如何高效地完成集成。当前的 Ollama 集成会带你走查已完成的代码,你无需重复这一步,但知道参考过哪些资源会有所帮助。
图 3.10 展示了我们通过 OllamaLLM(BaseLLM 的子类)完成的 Ollama 集成。
图 3.10 与 Ollama LLM 推理框架的集成。OllamaLLM 连接到一个正在运行的 Ollama 服务器(通常在本机),以使用任何受支持的开源 LLM。
除了 BaseLLM 的方法与属性,OllamaLLM 还引入了一个 _client 属性,用于与运行中的 Ollama 服务交互。OllamaLLM 的完整结构见图 3.11 的 UML 类图。
图 3.11 OllamaLLM 的 UML 类图。
你可以看到,OllamaLLM 新增了两个属性与一个方法:model 指定使用的 LLM;_client 连接到运行中的 Ollama 服务,类型为 ollama 库的 AsyncClient;__init__() 用于初始化 OllamaLLM 实例。
说明
要运行本节的代码示例,你需要在本机安装并启动 Ollama。下载地址:ollama.com/download。安装后服务可能会自动启动;否则可在终端执行 ollama serve 启动。
3.2.1 实现 OllamaLLM
我们按步骤实现,从 __init__() 方法开始。
如图 3.11 所示,__init__() 接收 model 与 host 两个参数。model 是字符串,指定所用 LLM 的名称,如 llama3.2:1b。可选的 host 指定要交互的 Ollama 服务地址;如果未提供,则使用 Ollama 的默认地址。
说明
在本机执行 ollama serve 会在默认地址 http://127.0.0.1:11434 启动服务。如果在初始化 OllamaLLM 时不提供 host,将使用该默认地址。
在 __init__() 中,我们用这两个参数为实例设置 model 与 _client 属性。实现如下。
代码清单 3.6 实现 OllamaLLM.__init__()
# llm_agents_from_scratch/llms/ollama/llm.py
from typing import Any
from ollama import AsyncClient #A
from llm_agents_from_scratch.base.llm import BaseLLM
class OllamaLLM(BaseLLM):
"""Ollama LLM class."""
def __init__(
self,
model: str, #B
host: str | None = None,
*args: Any,
**kwargs: Any,
) -> None:
"""Create an OllamaLLM instance.
Args:
model (str): The name of the LLM model.
host (str | None): Host of running Ollama service. Defaults to
None.
*args (Any): Additional positional arguments.
**kwargs (Any): Additional keyword arguments.
"""
super().__init__(*args, **kwargs)
self.model = model
self._client = AsyncClient(host=host) #C
#A 与 Ollama 服务交互的异步客户端
#B Ollama 支持的模型名称
#C 用于与运行中的 Ollama 服务交互的客户端
看下 __init__() 的实际用法:创建一个 OllamaLLM 实例,连接默认主机并使用 Qwen 2.5 的 30 亿参数模型。
# Included in examples/ch03.ipynb #A
from llm_agents_from_scratch.llms.ollama import OllamaLLM
llm = OllamaLLM(model="qwen2.5:3b")
#A 示例 2
说明
本章余下示例将使用 Ollama 支持的 qwen2.5:3b。你需要先在终端执行 ollama pull qwen2.5:3b 以拉取模型。
接下来实现 BaseLLM 要求的抽象方法,从 complete() 开始。
complete() 的逻辑会使用实例的 _client 与运行中的 Ollama 服务交互。具体来说,ollama.AsyncClient 提供了 generate() 方法来支持“文本补全”模式。
generate() 需要两个参数:model 与 prompt。我们将实例属性 model 传给 model,将外部 complete() 的 prompt 传给 prompt。generate() 返回的是 Ollama 的数据类型,我们会据此构造并返回 CompleteResult。图 3.12 展示了 complete() 如何通过 generate() 与 Ollama 集成。
图 3.12 与 Ollama 文本补全接口的集成。一次 complete() 调用会调用 ollama.AsyncClient.generate(),再由返回的 Ollama 数据类型构造最终的 CompleteResult。
代码清单 3.7 实现 OllamaLLM.complete()
# llm_agents_from_scratch/llms/ollama/llm.py
from typing import Any
from ollama import AsyncClient
from llm_agents_from_scratch.base.llm import BaseLLM
from llm_agents_from_scratch.data_structures import (
CompleteResult,
)
class OllamaLLM(BaseLLM):
"""Ollama LLM class."""
… #A
async def complete(self, prompt: str, **kwargs: Any) -> CompleteResult:
"""Complete a prompt with an Ollama LLM.
Args:
prompt (str): The prompt to complete.
**kwargs (Any): Additional keyword arguments.
Returns:
CompleteResult: The text completion result.
"""
response = await self._client.generate( #B
model=self.model, #C
prompt=prompt, #D
**kwargs,
)
return CompleteResult(
response=response.response, #E
prompt=prompt,
)
#A 代码清单 3.6 的 __init__()
#B 调用 ollama.AsyncClient.generate
#C 指定使用的模型
#D 传入补全提示词
#E 从 Ollama 的响应中取文本,构造 CompleteResult
演示 complete():用 qwen2.5:3b 讲一个笑话。
# Included in examples/ch03.ipynb #A
import asyncio
from llm_agents_from_scratch.llms.ollama import OllamaLLM
async def main():
llm = OllamaLLM(model="qwen2.5:3b")
response = await llm.complete("Tell me a joke.")
print(response)
asyncio.run(main())
#A 示例 3
complete() 返回 CompleteResult,包含 response 与 prompt。因此 print(response) 会输出该类的字符串表示,类似:
response="Sure! Here's one for you:
Why don't scientists trust atoms?
Because they make up everything!" prompt='Tell me a joke.'
说明
异步方法需要在异步事件循环中运行。asyncio.run() 会创建事件循环并运行协程。在 Jupyter 笔记本里已有事件循环,可直接 await 调用。
在实现了 complete() 之后,我们先实现 structured_output(),再实现 chat()。原因是我们将复用刚才“讲笑话”的例子,但这次用之前定义的 Joke 模型作为目标结构化输出。
实现 structured_output() 需要使用 Ollama 的“结构化输出接口”,其底层由聊天接口提供(为避免混淆,我们称之为 ollama.chat())。
ollama.chat() 允许可选的 format 参数,用户可提供目标结构化输出的 JSON Schema。上一章我们已经介绍了 JSON Schema,也知道可以通过 pydantic.BaseModel 子类的 model_json_schema() 方法得到 Schema。由于 structured_output() 的 mdl 参数被限定为 pydantic.BaseModel 的子类,我们也将据此实现。
在上代码前,我们先讨论一个集成 LLM 框架时常见的问题:数据结构的相互转换。我们已在 complete() 中看到过:从 Ollama 返回的对象中提取 response 去构造 CompleteResult。在本次集成中,我们将使用三个工具函数在两套框架的数据结构之间转换(如图 3.13):
chat_message_to_ollama_message()ollama_message_to_chat_message()tool_to_ollama_tool()
图 3.13 三个用于在框架数据类型与 Ollama 数据类型之间转换的工具函数。
为简洁起见,这些工具函数的实现就不在此展开;你可在此处查看完整实现:
github.com/nerdai/llm-…
现在我们具备实现 structured_output() 所需的一切。首先,调用 chat_message_to_ollama_message() 构造包含我们指令提示的 Ollama 消息对象;然后调用 ollama.chat(),传入该消息对象与 mdl.model_json_schema() 作为 format;ollama.chat() 返回一个 ollama.ChatResponse,我们需要从中提取 JSON 并用来校验、构造 mdl 的实例作为最终返回。流程如图 3.14。
图 3.14 与 Ollama 结构化输出接口的集成(底层由其聊天接口提供)。一次 structured_output() 调用会先创建 Ollama 消息对象,再调用 ollama.chat();返回结果中的 JSON 负载将被用来校验并实例化 mdl。
代码清单 3.8 实现 OllamaLLM.structured_output()
# llm_agents_from_scratch/llms/ollama/llm.py
from typing import Any
from llm_agents_from_scratch.base.llm import (
BaseLLM,
StructuredOutputType,
)
from llm_agents_from_scratch.llms.utils import (
chat_message_to_ollama_message,
)
from llm_agents_from_scratch.data_structures import (
ChatMessage,
)
class OllamaLLM(BaseLLM):
"""Ollama LLM class."""
… #A
async def structured_output(
self,
prompt: str,
mdl: type[StructuredOutputType],
**kwargs: Any,
) -> StructuredOutputType:
"""Structured output interface implementation for Ollama LLM.
… #B
"""
o_messages = [
chat_message_to_ollama_message(
ChatMessage(role="user", content=prompt),
),
]
result = await self._client.chat( #C
model=self.model,
messages=o_messages,
format=mdl.model_json_schema(), #D
)
return mdl.model_validate_json(result.message.content) #E
#A 见清单 3.6 与 3.7
#B 省略长注释
#C 调用 ollama.chat()
#D 传入目标结构化输出模型的 JSON Schema
#E 从返回结果中取 JSON,用其校验并构造 mdl 实例
说明
在 structured_output() 中,我们本可以直接手写 Ollama 的消息对象,而不是使用 chat_message_to_ollama_message()。此处选择使用工具函数,是为了循序渐进引入该概念,后续实现 chat() 与 continue_chat_with_tool_results() 还会用到。
演示 structured_output():仍让 qwen2.5:3b 讲笑话,但输出采用我们之前定义的 Joke 类(再次贴出便于查看)。
# Included in examples/ch03.ipynb #A
import asyncio
from pydantic import BaseModel
from typing import Literal
from llm_agents_from_scratch.llms.ollama import OllamaLLM
class Joke(BaseModel):
"""A structured output model for Jokes."""
subject: Literal["math", "physics", "biology"]
joke: str
async def main():
llm = OllamaLLM(model="qwen2.5:3b")
prompt = ("Tell me a joke.")
joke = await llm.structured_output(prompt=prompt, mdl=Joke) #B
print(joke.__class__.__name__)
print(joke)
asyncio.run(main())
#A 示例 4
#B 以 Joke 为目标结构化输出模型调用 structured_output()
第一条 print 输出返回对象的类名,第二条输出 Joke 实例的字符串表示。类似于:
Joke
subject='math', joke='Why did the math book look so sad? Because it had lots of problems.'
很好!我们已经为 OllamaLLM 实现了四种交互模式中的两种。接下来实现 chat() 与其便捷扩展 continue_chat_with_tool_results()。我们已在 structured_output() 的实现中接触了 Ollama 的聊天接口 ollama.chat(),自然这里还会再次使用它。
说明
chat() 与 continue_chat_with_tool_results() 的演示我们放到下一节,在完整工具调用流程的端到端示例中一并展示。
为了构建这两者,我们需要更深入理解 ollama.chat():它接收一个 Ollama 消息对象列表 与一个可选的 Ollama 工具对象列表;调用后返回 ollama.ChatResponse,可从中提取 LLM 的响应消息。
正如之前所述,与其他 LLM 框架集成时,需要在两者的数据类型之间转换。这里我们会继续使用前面提到的工具函数。
根据图 3.5 与 3.7,chat() 接收三个参数:input、chat_history、tools。我们需要对 input 与 chat_history 应用 chat_message_to_ollama_message() 得到 Ollama 消息对象,对 tools 应用 tool_to_ollama_tool() 得到 Ollama 工具对象。
准备好消息与工具后,调用 ollama.chat(),再从其返回中抽取 LLM 的响应消息。由于该消息是 Ollama 的数据类型,需要用 ollama_message_to_chat_message() 转回 ChatMessage。方法返回一个二元组:由用户输入构造的 ChatMessage 与 LLM 的 ChatMessage。流程见图 3.15。
图 3.15 通过 Ollama 的聊天接口实现 chat():将用户输入与历史转为 Ollama 消息,将工具转为 Ollama 工具,调用 ollama.chat(),再把响应转回 ChatMessage,与用户输入的 ChatMessage 一并返回。
代码清单 3.9 实现 OllamaLLM.chat()
# llm_agents_from_scratch/llms/ollama/llm.py
… #A
from llm_agents_from_scratch.llms.utils import ( #B
chat_message_to_ollama_message,
ollama_message_to_chat_message,
tool_to_ollama_tool,
)
class OllamaLLM(BaseLLM):
"""Ollama LLM class."""
… #C
async def chat(
self,
input: str,
chat_history: list[ChatMessage] | None = None,
tools: list[BaseTool | AsyncBaseTool] | None = None,
**kwargs: Any,
) -> tuple[ChatMessage, ChatMessage]:
… #D
# prepare messages #E
o_messages = (
[chat_message_to_ollama_message(cm) for cm in chat_history]
if chat_history
else []
)
user_message = ChatMessage(role="user", content=input)
o_messages.append(
chat_message_to_ollama_message(
user_message,
),
)
# prepare tools #F
o_tools = (
[tool_to_ollama_tool(t) for t in tools]
if tools else None
)
result = await self._client.chat( #G
model=self.model,
messages=o_messages,
tools=o_tools,
)
return (
user_message,
ollama_message_to_chat_message(result.message) #H
)
#A 省略 import
#B 引入工具函数
#C 见清单 3.6、3.7、3.8
#D 省略长注释
#E 历史 + 本次输入 转为 Ollama 消息
#F 工具 转为 Ollama 工具
#G 调用 ollama.chat()
#H 将响应转回 ChatMessage
最后实现 continue_chat_with_tool_results()。与 chat() 类似,这里也主要是将我们框架的 ChatMessage 转成 Ollama 的消息类型。但该方法的核心便利性在于:用户可直接传入 ToolCallResult,无需自己转换成 ChatMessage。
要把 ToolCallResult 转成 ChatMessage,我们可用清单 3.2 中提供的构造器 from_tool_call_result()。之后流程与 chat() 类似:用 chat_message_to_ollama_message() 与 tool_to_ollama_tool() 得到 Ollama 消息与工具,调用 ollama.chat() 让 LLM 综合工具结果并回复,再用 ollama_message_to_chat_message() 转回 ChatMessage。方法返回一个二元组:由工具结果转成的 ChatMessage 列表与 LLM 的回复 ChatMessage。流程见图 3.16。
图 3.16 通过 Ollama 的聊天接口实现 continue_chat_with_tool_results():将工具结果与历史转为 Ollama 消息,将工具转为 Ollama 工具,调用 ollama.chat(),再把响应转回 ChatMessage 并返回。
代码清单 3.10 实现 OllamaLLM.continue_chat_with_tool_results()
# llm_agents_from_scratch/llms/ollama/llm.py
… #A
class OllamaLLM(BaseLLM):
"""Ollama LLM class."""
… #B
async def continue_chat_with_tool_results(
self,
tool_call_results: Sequence[ToolCallResult],
chat_history: Sequence[ChatMessage],
tools: Sequence[Tool] | None = None,
**kwargs: Any,
) -> tuple[list[ChatMessage], ChatMessage]:
… #C
# augment chat messages and convert to Ollama messages
tool_messages = [
ChatMessage.from_tool_call_result(tc) #D
for tc in tool_call_results
]
o_messages = [ #E
chat_message_to_ollama_message(cm)
for cm in chat_history
] + [
chat_message_to_ollama_message(tm)
for tm in tool_messages
]
# prepare tools
o_tools = (
[tool_to_ollama_tool(t) for t in tools]
if tools else None
)
# send chat request
o_result = await self._client.chat( #F
model=self.model,
messages=o_messages,
)
return (
tool_messages, #G
ollama_message_to_chat_message(o_result.message) #H
)
#A 省略 import
#B 见清单 3.6、3.7、3.8、3.9
#C 省略长注释
#D 将 ToolCallResult 转为 ChatMessage
#E 历史 + 工具消息 转为 Ollama 消息
#F 调用 ollama.chat()
#G 返回由工具结果生成的 ChatMessage 列表
#H 将响应转回 ChatMessage
呼!实现 OllamaLLM 的四个方法并非易事。但收获也很可观——我们现在可以在框架中使用任何通过 Ollama 可用的开源 LLM 了!为了收尾本章,我们将在下一节重温上一章的 Hailstone 工具,演示 OllamaLLM 如何与它配合完成一次工具调用。
练习 3.1 使用 OllamaLLM 生成结构化输出
为“诗歌”设计一个结构化数据模型,包含诗歌文本与其体裁(如 sonnet、haiku 等)。创建一个 OllamaLLM 实例,使用你的诗歌数据模型生成一首结构化的诗。
3.2.2 使用 OllamaLLM 调用 Hailstone 工具
让我们快速回顾上一章创建的 Hailstone 工具,并演示如何用新实现的 OllamaLLM 跑完整的“工具调用”流程。为此,我们会用到 chat() 与 continue_chat_with_tool_results() 两个方法。
我们仍然使用 qwen2.5:3b 作为 OllamaLLM 的模型,并向其提供上一章创建的 hailstone_tool。
注意
如果你不是在提供的 Jupyter 笔记本中而是自己编码演示,请确保把上一章定义的 hailstone_step_func() 放到当前作用域(例如写在你的 .py 文件里)。
要发起一次工具调用流程,我们先调用 chat(),传入能触发 Hailstone 工具调用请求的输入字符串,如下:
# Included in examples/ch03.ipynb #A
import asyncio
from llm_agents_from_scratch.llms.ollama import OllamaLLM
from llm_agents_from_scratch.data_structures.llm import ChatMessage
from llm_agents_from_scratch.tools import SimpleFunctionTool
llm = OllamaLLM(model="qwen2.5:3b") #B
hailstone_tool = SimpleFunctionTool(hailstone_step_func) #C
async def main():
user_input = (
"What is the result of taking the next step of the " #D
"Hailstone sequence on the number 3?\n\n"
"Be very succinct in your response."
)
return await llm.chat( #E
user_input,
tools=[hailstone_tool], #F
)
user_msg, response_msg = asyncio.run(main())
print(response_msg.tool_calls)
#A 示例 5
#B 创建我们的 OllamaLLM
#C 使用上一章的 hailstone_step_func() 定义 Hailstone 工具
#D 触发 Hailstone 工具调用请求的输入
#E 调用 chat()
#F 给 LLM 装上 hailstone_tool
打印结果应返回一次对 Hailstone 的工具调用,如下所示。如果看到了,恭喜你:OllamaLLM 已成功为我们的 Hailstone 工具发起了工具调用请求。
[ToolCall(id_='6f6d02ca-56db-4872-8c8f-3814e2ceeb19', tool_name='hailstone', arguments={'x': 3})]
不过如前所述,让 LLM 产出“工具调用请求”只是第一步。我们接下来需要用请求中的参数去真正执行该工具,并把得到的 ToolCallResult 再交回给 LLM。
# Included in examples/ch03.ipynb #A
tool_call = response_msg.tool_calls[0]
tool_call_result = hailstone_tool(tool_call) #B
print(tool_call_result)
#A 示例 5 的一部分
#B 用工具调用请求来执行 Hailstone 工具
正如上一章所见,调用一个 BaseTool 会返回 ToolCallResult。因此打印 tool_call_result 应大致如下:
tool_call_id='6f6d02ca-56db-4872-8c8f-3814e2ceeb19', content='10', error=False
拿到 ToolCallResult 后,最后一步就是用 continue_chat_with_tool_results() 把结果提交回 LLM 以便其综合并给出回复。你已经知道,这个方法会返回由工具结果构造的 ChatMessage 列表以及 LLM 的响应。
# Included in examples/ch03.ipynb
async def main():
return await llm.continue_chat_with_tool_results(
tool_call_results=[tool_call_result],
chat_history=[user_msg, response_msg],
)
tools_msg, final_response = asyncio.run(main())
print(final_response)
打印应输出一个 ChatMessage 的字符串表示,类似:
role=<ChatRole.ASSISTANT: 'assistant'> content='The result of taking the next step in the Hailstone sequence on the number 3 is 10.' tool_calls=None
我们完成了!虽然花了点功夫,但最终用 Hailstone 工具与新实现的 OllamaLLM 跑通了完整的工具调用流程。这是一个重要里程碑。事实上,我们已经可以从零实现第一个 LLM Agent 了。下一章我们将实现 LLMAgent 类。
练习 3.2 使用 OllamaLLM 执行一次异步工具调用
创建一个 OllamaLLM 实例,并使用你在练习 2.2 中实现的 异步版 Hailstone 工具完成一次工具调用。
3.3 小结
- 各家 LLM 提供的 API 通常支持两种交互模式:文本补全与对话交互。
- 我们的
BaseLLM通过complete()支持文本补全,通过chat()支持对话交互。 - LLM 的一个重要用例是结构化输出:让 LLM 产出符合指定数据格式(通常是 JSON)的结果。
- 在我们的框架中,可通过
structured_output()执行结构化输出。 - 通过继承
BaseLLM,可以把任意 LLM 提供商及其 Python SDK 集成到框架中。 - 本章实现的
OllamaLLM集成,使我们能在框架中使用 Ollama 所支持的任意开源 LLM。