编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

我如何用自定义MCP服务器构建了一个工具调用的Llama代理

wxchong 2025-05-24 17:44:33 开源技术 2 ℃ 0 评论

1. 引言

本文将详细介绍如何开发一个本地AI代理,该代理通过与先前构建的MCP(模型上下文协议)服务器通信,利用工具调用来生成具有上下文感知能力的响应。

我为什么启动这个项目

本文是对我前一篇文章的后续,在那篇文章中我介绍了一个连接我个人Obsidian知识库的自定义MCP服务器。我没有选择使用具有文件系统访问权限的官方MCP服务器,而是决定自行构建,原因如下:

更多详情,请参阅我之前的文章

利用MCP将Obsidian与本地AI工具连接,实现更智能、上下文感知的辅助功能

升级.gitconnected.com

构建好MCP服务器后,我想解决一个新挑战:对外部AI模型的依赖。虽然Claude展现了出色的推理能力,但除非购买付费方案,否则使用会受到限制。更重要的是,依赖外部AI服务意味着我的私人知识笔记内容仍需传输到本地环境之外。

本文涵盖了构建完全本地化、私有化代理的后续步骤:

为此,我选择不使用LangChain等框架,这样整个流程透明且易于理解。

2. 支持工具调用的sLLM集成

2.1. 本地使用的小型语言模型

在智能体开发中,最核心的是大脑——大语言模型(LLM)。生成回复的质量很大程度上取决于模型的推理能力。但由于目标是实现本地化运行,使用庞大的LLM并不可行,我们必须依赖能在本地GPU或CPU环境运行的小型语言模型(sLLM)。

但并非所有小型语言模型都适用。如果模型的响应质量过低或缺乏遵循工具调用指令的能力,它就无法适用于这类代理架构。

此前,我尝试了Llama 3.1 8B-Instruct模型,其表现令人印象深刻。我在一个项目中使用了它,该项目让多个模型各自携带不同的系统提示(角色)就选定主题展开讨论,以生成合成(人工)文本数据。若您想了解详情,请参阅下文。

利用Llama 3.1的AI对话生成合成文本数据

利用Llama生成合成数据,而非收集难以找到的文本数据。

medium.com

虽然Llama 3.1 8B-Instruct模型也支持工具调用,但本项目我选择了Llama 3.2版本模型。Llama 3.2中的1B和3B模型是专为设备端智能应用设计的轻量级模型,能确保所有数据本地处理,有效保护用户隐私。

根据Meta的基准测试结果,Llama 3.2模型在规模和性能之间取得了良好平衡。尽管体积较小,它们仍能提供合理的响应质量并支持工具调用功能,非常适合本项目。

正如Meta官方博客所述,Llama 3.2模型是通过对Llama 3.1 8B模型实施单次结构化剪枝而创建的。为恢复剪枝后的性能表现,Meta采用了多组Llama 3.1模型进行知识蒸馏,具体流程如下图所示。

这里我就不深入技术细节了。如果你感兴趣,建议你阅读Meta的官方博客文章。

Llama 3.2:以开放可定制模型革新边缘AI与视觉技术

今天我们发布Llama 3.2,包含中小型视觉大语言模型及轻量级纯文本模型……

ai.meta.com

2.1. 大语言模型的工具调用流程

LLM如何调用工具并生成响应?整个工具调用流程如下图所示。

当提供有关可用工具的信息时——无论是通过系统提示还是用户提示——大语言模型会判断是否应调用某个工具。若需调用,它将生成一个函数调用定义作为响应。

LLM应用随后解析函数调用,执行相应工具,并将结果反馈给模型。根据工具的输出,模型可以生成综合响应。

在此项目中,图中的工具组件被替换为MCP客户端,由其负责调用工具。

以下说明基于官方Llama 3.1文档。若您已熟悉相关内容,可直接跳转至下一章节。

Llama 3.1 | 模型卡片与提示格式

Llama 3.1 - 最强大的开源模型。

www.llama.com

让我们简要回顾构成提示格式核心的特殊标记与角色结构。

特殊标记

支持的角色

让我们来看看LLM如何决定何时调用以及如何生成响应。

1) 系统提示与工具定义

系统提示中包含JSON格式的工具定义,详细说明了可用工具及其参数。虽然这一定义也可包含在用户提示中,但为了清晰起见,通常更推荐将其置于系统提示内。

2) 用户提示至LLM

系统提示(包含工具定义)与用户提示(包含实际查询)相结合。为了让LLM通过补全句子生成响应,消息以助手标头结尾。

3) 工具调用响应

在此步骤中,LLM判定回答用户查询需调用函数。它通过生成一个符合系统提示指定格式的函数调用表达式来响应。

4) 原始提示词 + 工具响应

应用程序执行请求的功能并将结果附加回提示中。在将工具结果传回模型时,使用角色ipython来标记此工具结果。

5) 综合回应

最终,模型利用工具输出生成完整的响应:

即使是像Llama 3.2 1B和3B这样的轻量级模型也能执行工具调用功能。不过根据Meta官方文档建议,若要构建稳定的工具感知型对话应用,推荐使用70B-Instruct或405B-Instruct模型。

尽管8B-Instruct模型支持零样本工具调用,但Meta的博客指出,当提示中包含工具定义时,它无法可靠地维持对话。因此,在处理较小模型时,通常需要从提示中移除工具指令,以确保用户与AI模型之间更流畅的交互。

这是生成高质量回应的关键考量,也是你必须牢记于心的一点。

注:对于结合对话和工具调用的应用场景,我们推荐使用Llama 70B-instruct或Llama 405B-instruct模型。Llama 8B-Instruct无法在保持工具调用定义的同时稳定维持对话流程。该模型可用于零样本工具调用场景,但在用户与模型进行常规对话时应当移除工具指令说明。——摘自Meta AI技术文档

3. 构建LLM智能体

现在,让我们来看看我构建的智能体架构。它严格遵循上述工具调用流程,并添加了若干组件以实现与MCP服务器的通信交互。

核心组件包括:MCP客户端与管理器、LLM和代理

由于本文包含大量代码,为清晰起见,仅展示关键部分。完整源代码请访问下方GitHub代码库。

GitHub - hjlee94/mcp-knowledge-base: 面向私有知识库的MCP代理/客户端/服务器实现

私有知识库的MCP代理/客户端/服务器实现 - hjlee94/mcp-knowledge-base

github.com

3.1. MCP客户端与管理器

A. MCP客户端

首先,我们需要一个能够与MCP服务器建立1:1连接的MCP客户端。这是通过使用Python MCP SDK实现的,遵循了官方MCP文档的指导:

面向客户端开发者的模型上下文协议

开始构建您自己的客户端,可与所有MCP服务器集成。

模型
上下文
协议.io

以下是MCPClient类,负责处理与服务器的连接。由于我搭建的自定义MCP服务器通过标准输入输出(stdio)进行通信,客户端会启动服务器进程,并通过其读写流与之建立连接。

创建客户端会话后,客户端遵循MCP连接生命周期。它首先向MCP服务器发送初始化请求,等待响应,然后通过发送初始化通知作为确认来完成握手。

初始化请求的结果是,客户端接收到关于服务器的信息,其结构如下所示。

建立连接后,MCP客户端可获取服务器名称及版本信息。若服务器采用FastMCP类实现且未明确指定版本,则默认版本号为MCP SDK的封装版本。

MCP客户端包含诸如list_tools()和list_resources()等基本方法,用于枚举可用工具和资源,以及call_tool(name, args)来调用特定工具。它们的实现如下所示。

B. MCP经理

由于每个MCP客户端与MCP服务器保持一对一连接,支持多服务器需要管理多个客户端实例。

为此,我定义了一个MCP客户端管理器,负责为每个已注册的MCP服务器路径初始化和清理客户端。

该类的另一项重要职责是从相应的已注册MCP客户端获取资源和工具信息。为此,管理器维护了一个映射关系,用于追踪每个工具或资源对应的MCP客户端。

3.2. LLM代理

A. 大语言模型

对于代理的语言模型,我使用了Llama,通过Llama.cpp在我的MacBook上本地运行。

GitHub - ggml-org/llama.cpp: 基于C/C++的LLM推理实现

在C/C++中进行LLM推理。通过创建GitHub账户为ggml-org/llama.cpp开发做贡献。

github.com

使用Llama.cpp模型的第一步是从Hugging Face下载模型权重。您可以通过Hugging Face工具获取模型仓库的快照,具体操作如下所示。

Llama.cpp要求语言模型必须采用GGUF格式。从Hugging Face下载模型后,您可以使用Llama.cpp提供的转换脚本将模型转为GGUF格式,操作如下:

通过Llama.cpp的Python绑定实现了一个自定义封装类,支持以编程方式传入提示并生成响应。

B. 提示

Llama.cpp提供了一个高级API函数create_chat_completion(),允许您通过传入简单格式的结构化消息来生成响应,如下所示。

然而,为了更灵活地控制提示构建与处理流程,我实现了两个辅助类:LlamaMessage与LlamaPrompt。

LlamaMessage类负责按照预期的Llama提示结构格式化消息。它处理分配的角色、内容以及可选的tool_scheme(取决于是否设置了tool_enabled——我将在后续章节讨论tool_enabled的作用)。

Prompt类管理模型与用户之间的对话历史,负责构建多轮对话提示。该类由智能体直接调用,提供按角色(如用户或助手)添加消息的接口。

最终,它会生成传递给大语言模型(LLM)用于生成响应的最终输入提示(也称为生成提示)。

目前,提示词格式遵循Llama提示模板,因为该智能体底层使用的是Llama模型。但整体设计采用模块化架构——通过子类化BaseMessage和BaseModel接口并实现相应逻辑,即可支持其他AI模型。

历史类由提示类使用,负责维护过往消息的记录。它被设计为可根据上下文或应用需求,选择性地仅返回最新的k条消息。

C. 代理

现在,让我们将之前定义的类整合起来,实现代理的三大核心功能:

在深入实施之前,我们首先为Llama模型提供一个提示,定义工具调用格式和可用工具。

该工具指令遵循官方Llama文档示例中定义的格式。

功能方案从MCP服务器获取,解析为JSON格式,并用于定义提示中的可用工具,如下所示。

MCP客户端连接与初始化

代理使用先前实现的MCPManager注册MCP服务器路径,并为每个已注册的服务器初始化客户端会话。

一旦MCP客户端会话建立,它会发送tools/list和resources/list请求以获取服务器上可用的工具和资源。

随后这些响应会被转换为JSON字符串并存储,以供系统提示使用。

匹配工具调用模式并调用相应工具

该代理部分负责判断LLM的响应是否需要调用工具,若需要则通过相应的MCP客户端发送tools/call请求以获取结果。

为匹配诸如[func_1(param1=value1, param2=value2), func_2()]这样的工具调用模式,定义了一个正则表达式。

_is_tool_required(str) 方法用于检查LLM的响应是否包含任何工具调用。get_func_props(str) 方法是一个生成器,它会遍历所有匹配的函数并生成函数名称和解析后的参数。

最后,提取出的函数名和参数将用于向对应的MCP客户端发送tools/call请求。工具返回的结果是一个字典,随后会被转换为JSON字符串传递回AI模型进行下一步处理。

通过工具调用合成响应

代理的最后一步是利用工具调用的结果生成响应。这一过程遵循与第2.1节(综合响应)所述的相同流程。

chat(str)方法返回一个AgentResponse对象列表,每个对象按类型分类——例如工具调用、工具结果和文本——这样用户的回答和相关的工具信息都能清晰地呈现出来。

以上就是该智能体截至目前的发展历程。现在,作为最后一步,让我们赋予它交互能力——使其能够在真实对话流中与用户互动。

交互式界面

通过实例化Agent对象并反复调用chat()方法,您可以直接通过终端与代理交互,如下所示。

然而,由于代理还会输出工具调用结果,终端输出可能变得非常冗长——随着对话增多会难以跟踪。

为了提高可用性,我使用Streamlit构建了一个网页用户界面,该界面不仅提供了交互式聊天功能,还能动态调整LLM生成响应的参数,便于开展进一步的实验。

脚本的具体细节不在本文讨论范围内。如有兴趣,请查看GitHub仓库中的实现代码。

4. 结果

引导AI模型决定何时执行工具调用主要有两种方式:

还记得关于Llama模型零样本工具调用的重要说明吗?

对于小于8B的模型,提示中包含工具模式常会导致对话不稳定。为保持与用户对话的一致性和连贯性,使用小模型时应省略工具指令。

现在我们来探究工具指令注入位置不同时,生成回答的质量会有何差异。

4.1. 系统提示中的工具指令

我们将首先在系统提示中定义工具指令,如下所示,并观察生成的响应。

虽然AI模型在识别和调用所需工具方面总体准确,但在合成步骤(即将工具结果整合到最终答案中)往往无法生成理想回复。生成的响应通常分为两类。

第一种情况是空响应,如下所示:

第二个案例涉及模型过度专注于工具调用,频繁发出不必要甚至格式错误的工具调用请求。

例如,在如下所示的情况中,用户明确要求模型检索有关特定知识项的信息。

尽管模型成功访问并提取了所需知识,但它不必要地尝试调用与任务无关的list_knowledges()工具。

4.2. 用户提示中的工具指令(仅在请求时)

为了解决之前观察到的问题,我调整了方法,不再在每次生成时展示工具指令。

工具指令不再始终包含在系统提示中,而是仅在模型需要根据用户请求决定是否调用工具时提供。

以下代码与第3节中介绍的完全相同。

关键在于调用Prompt对象的get_generation_prompt()方法时tool_enabled参数的作用。

根据tool_enabled的取值,通过Prompt对象构建提示时,工具方案会被包含或省略。

在构建发送给AI模型的提示时,代理会根据上下文有条件地公开工具指令。

指令仅在初始响应生成时包含,当模型需要决定是否应调用工具时。

在最终合成步骤——模型根据工具结果和用户查询生成响应时——有意省略了指令,以保持输出的专注和连贯。

如下所示结果,该方法显著提升了响应质量。

即便在询问特定知识项内容时,该模型产生的回答也比系统提示中包含工具指令时更相关且重点突出。

当工具调用因URI中的拼写错误而失败时,Llama模型仍尝试基于其内部知识提供有用答案,展现了优雅的降级处理能力。

4.3. 实际应用案例

现在让我们看看代理执行最初推动MCP服务器开发的三个核心功能的表现如何。

在第一次测试中,我要求代理检索一个特定的知识项,并以Markdown格式进行总结。

尽管工具调用存在小错误,模型仍能根据内容大小将笔记格式化为结构化表格。总体而言,结果相当不错。

接下来,我要求列出有标题但无内容的笔记清单。

工具调用再次略有偏差,但模型通过检查字节大小成功识别出几乎没有内容的条目。

然而,结果仅部分完成,遗漏了一些相关条目。

最后,我让模型根据特定知识笔记的内容生成简答题形式的复习问题。

尽管工具调用和之前有着相同的限制,但最终回复结构清晰且贴合语境。

虽然该智能体的表现尚未达到Claude AI的水平,但它仍输出了令人印象深刻的实用结果——尤其是考虑到它完全运行在轻量级的30亿参数模型上。

话虽如此,当前版本并非总能持续生成完美回应,实际应用仍需进一步改进。

5. 挑战与我的思考

在多个应用场景中构建并测试该智能体后,我总结出几个您需要考虑的关键挑战:

最根本的限制在于小型语言模型(sLLMs)的性能。尽管该智能体设计为模型无关,可与更大模型协同工作,但其主要定位仍是搭配轻量级sLLMs使用。

当然,我们不应期望其通用推理能力与大型模型比肩。相反,小规模语言模型更擅长处理约束明确的专业任务。

2. 过度关注工具调用

当工具指令被注入轻量级模型的提示中时,模型往往会过度专注于调用工具,即使并无必要。例如,即便在前一步已检索到知识条目列表,模型仍经常忽略该上下文,发出冗余的工具调用请求。

这表明需要动态提示控制,即仅根据查询包含工具指令。

3. 迭代工具使用的弱点

与大型模型相比,轻量级Llama模型在迭代工具使用方面表现出有限能力。我早期使用Claude进行实验时,该模型会为每条知识笔记发起工具调用以搜索空白内容。相比之下,Llama-3.2–3B-Instruct模型通常在一两次调用后就会停止,即便实际需要更多次调用。

虽然这可能因提示结构而异,但它突显了较小模型在执行多步推理与工具反馈循环能力上的局限性。

尽管存在这些限制,Meta的轻量级Llama模型在推理速度和响应质量方面仍展现出与其规模相称的出色表现。

虽然它们可能不适合作为通用代理,但对于需求受限的特定领域应用,sLLM仍然是强有力的选择。

欢迎对本文或源代码提出任何反馈。若对后续文章感兴趣,请关注我。如需深入讨论更多话题,欢迎通过LinkedIn与我联系。

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表