前言
学习一个东西,有必要先弄清楚它的应用场景是什么,它为什么会出现,究竟能解决什么问题,具体是如何解决的,这样才能避免盲目,也能深入理解本质。
MCP主要应用在LLM场景,我们先搭建一个LLM环境用于测试验证和DEMO开发。然后,为探究MCP原理,从LLM的一个固有短板:知识茧房问题着手展开讨论。
针对MCP,目前大量资料介绍了基本概念,也有一些范例,但不少资料写的神乎其玄,在原理这块却有些模糊不清,调用流程、与LLM互动这块通常是讲如何集成在Claude、Cursor这些系统,隐藏了其细节原理,本文将从0手搓client、Server代码,详解底层原理,使读者能知其然并知其所以然。最后给出一个应用示例:LLM通过MCP联动数据库查询,来实现涉及私域知识(私域信息存放在数据库中)的问答。
此外,对机器学习、深度学习、神经网络、LLM知识库等感兴趣的AI技术爱好者,此前编写了一本AI启蒙书籍,有需要的可以免费下载:https://developer.aliyun.com/ebook/8435
环境准备及QwQ-32B简介
因为最终需要实现MCP+LLM的完整示例,所以在准备环境时就需要考虑LLM安装对GPU的配置要求。本例选择QwQ-32B量化模型为示例。
2025.3.6日阿里云发布了QwQ-32B LLM,支持在消费级显卡部署大模型,再次惊艳了行业。业界一方面在不断做大模型,提高基准大模型的泛化能力,另一方面以中国Deepseek、阿里通义等厂商,正在进一步发展模型的小型化低成本推理部署,卷出了新方向新赛道,今年或有望成为大模型大规模应用的普惠年。低成本部署后,很多产品都可以便捷独立集成大模型了,线下场景的私域大模型、RAG等系列方案推广和落地起来不再有阻力。此后,各产品与大模型的联动能力或将成为基本要求,使得MCP等LLM工具对接生态发展也迎来了一拨热潮。
QwQ-32B模型数学代码等核心指标(AIME 24/25、livecodebench)以及部分通用标(IFEval、LiveBench等)达到DeepSeek-R1 满血版水平,各指标均显著超过同样基于 Qwen2.5-32B 的 DeepSeek-R1-Distill-Qwen-32B。
QwQ-32B开源链接:
魔搭开源链接:https://modelscope.cn/models/Qwen/QwQ-32B
huggingface开源链接:https://huggingface.co/Qwen/QwQ-32B
ecs要求:CPU 16核以上,内存64GB+,硬盘50GB+,显卡24GB+显存。本例选择ecs.gn7i-c16g1.4xlarge:16核(vCPU) 60 GiB GPU:NVIDIA A10,显存24GB(注意:显存过小,将会导致LLM无法正常启动)。
以在Ubuntu 22.04 64位位系统(该系统有对应的cuda驱动,使用较方便)为例:
- 创建GPU实例。设置用户名及登录密码,同时选择安装CUDA。
- 设置安全组配置,配置出方向端口22,并在源IP中加入本机IP。
- 本机ssh到云ECS:sudo apt-get update。如果是root登录,系统会提示正在安装CUDA。待安装完毕,查看GPU详情(若命令无法正常运行,则说明CUDA没安装好):nvidia-smi
下载QwQ-32B
- pip 换源加速下载并安装依赖包:
sudo python3 -m pip install --upgrade pip
pip3 config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
pip3 install modelscope==1.22.3
pip3 install transformers==4.48.2
pip3 install accelerate
pip3 install tqdm==4.67.1
pip3 install autoawq
- 模型下载(本例部署量化版QwQ-32B-AWQ):modelscope download --model=Qwen/QwQ-32B-AWQ --local_dir ./QwQ-32B
此外,推荐使用 modelscope 中的 snapshot_download 函数下载模型。新建 model_download.py 文件,输入以下内容:
from modelscope import snapshot_download
model_dir = snapshot_download('Qwen/QwQ-32B-AWQ', cache_dir='/home/ecs-user/QwQ-32B', revision='master')
说明:第一个参数为模型名称(也可以部署QwQ-32B,需要留足空间),参数 cache_dir 为模型的下载路径(修改为自己模型下载的路径)。
然后在终端中输入: nohup python3 model_download.py &
执行下载(建议使用nohup方式执行,文件下载会大量占用带宽,可能导致ssh断连),需要等待约40分钟,直到模型下载完毕。
部署兼容 OpenAI API 接口的服务
LLM联动tools常用做法是采用OpenAI的Chat Completion API,OpenAI的API中提供了函数调用流程和规范(function specifications)。vLLM、ollama、SgLang等均支持OpenAI API的tool参数,本例使用vLLM进行模型的OpenAI API服务部署,结合参数配置,实现基于OpenAI规范的Function call接口,默认会在http://localhost:8000 启动服务器(端口号支持在服务创建时自定义)。服务器当前一次托管一个模型,并实现completions和chat completions端口。
- completions:是基本的文本生成任务,模型根据输入的提示词生成一段文本,常用于生成文章、文案、写作、故事、邮件等。
- chat completions:是面向对话的任务,模型需要理解和生成对话,常用于构建聊天机器人或者对话系统。
在创建服务器时,可以指定模型名称、模型路径、聊天模板、服务端口等参数。
- --host 和 –port:地址和服务端口。
- --model:模型名称。
- --chat-template:聊天模板。
- --served-model-name:服务模型的名称。
- --max-model-len:模型的最大长度。
服务部署:
- 安装vLLM:pip3 install vllm
- vLLM服务部署命令:python3 -m vllm.entrypoints.openai.api_server --model /home/ecs-user/QwQ-32B/Qwen/QwQ-32B-AWQ --served-model-name QwQ-32B --max-model-len 2048 --gpu_memory_utilization 0.9 --max_num_seqs 32 --enable-auto-tool-choice --tool-call-parser hermes
系统会打印详细的启动过程和日志,以及系列参数配置,可以拷贝一份保存下来,以备后用。此外,服务一旦测试能正常启动后,可以使用nohup方式在后台运行:
nohup python3 -m vllm.entrypoints.openai.api_server --model /home/ecs-user/QwQ-32B/Qwen/QwQ-32B-AWQ --served-model-name QwQ-32B --max-model-len 2048 --gpu_memory_utilization 0.9 --max_num_seqs 32 --enable-auto-tool-choice --tool-call-parser hermes &
- 编写OpenAI格式的测试程序:新建一个Python文件,如qwqOpenAI.py,复制如下示例代码:
from openai import OpenAI # 设置 OpenAI 的 API 密钥和 API 基础 URL 使用 vLLM 的 API 服务器。 openai_api_key = "EMPTY" openai_api_base = "http://localhost:8000/v1" client = OpenAI( api_key=openai_api_key, base_url=openai_api_base, ) # 使用流式输出(stream=True) chat_response = client.chat.completions.create( model="QwQ-32B", # vLLM启动时,LLM的命名。注意:不是填写模型所在的路径 messages=[{"role": "user", "content": "请介绍你自己"}], stream=True# 启用流式响应 ) # 处理流式输出 contents = [] for e in chat_response: # print(e.choices[0].delta.content,end="") contents.append(e.choices[0].delta.content) print("".join(contents))
- 运行测试程序:python3 qwqOpenAI.py
在vLLM服务的打印日志中,也可以看到服务器已经接收到了测试程序的请求,说明已工作正常。
LLM“知识茧房”问题
为探究MCP为什么会爆火,我们需要先理解LLM的“知识茧房”问题。ChatGPT 等LLM,只需要输入提示语句和问题描述(prompt),模型就能给出类人智能的答复,表现惊艳,在智能问答等场景迅速迎来火爆应用。但随着应用的不断深入,大家发现不论是哪家的LLM,给出的答案,时常出现胡说八道的问题。主要原因之一:回到AI的数据本质,LLM也是采用大量数据样本做训练得出的,模型一旦训练完成,对于数据样本外的知识一无所知,我们给这个现象取个非常形象的名字:知识茧房。LLM的知识局限在所训练的数据样本中,类似被包裹在一个茧房里面一样。由于缺乏茧房之外的知识,所以面对需要额外知识的问题,则只能根据模型已有知识盲目的推测一个答案,问答效果自然很难保障,甚至驴唇不对马嘴。
以企业构建智能问答系统为例,企业的私域知识因涉密、商业价值等原因,不对外开放,无论哪家的LLM,通常都缺乏企业私域知识。在涉及私域知识的问答中,通用LLM很难取得很好的效果。比如,咨询通用大模型公司有几个部门,每个部门有多少人,类似这种涉及公司内私密信息的问题,LLM是无法给出正确答案的。
此外,LLM的知识茧房问题还表现在,LLM的知识是有时效性缺陷的,比如在四月份完成了LLM训练,到六月份,这两个月产生了大量新信息、新数据、新知识,无论LLM的模型参数做多大,哪怕有一天训练了一个超级LLM,把已有的知识都纳入了,这个问题依然是存在的。因为随着时间的流逝,总是不断有新知识新信息产生,知识和信息本身是难以穷尽的。
比如,在2025.4.15日咨询QwQ-32B大模型,问深圳2025年4月14日的天气怎么样,此时,4月14日的深圳天气、气温等其实已经可以查到准确的数据。但QwQ大模型是2025.3.6日发布的,其知识也仅限于2025.3.6日之前(从后面的回答可以看到,QwQ相关数据停留在2023年)。2025年4月10日对于QwQ大模型已经是未来的时间,看到QwQ只能靠推断,无法给出准确的信息,类似如下的答复:
好的,用户问的是2025年4月14日深圳的天气。首先,我需要确认当前时间,现在是2023年,用户想知道两年后的天气情况。显然,这个时间跨度有点长,常规的天气预报通常只能提供几天到一周的准确预测,最多几个月前的长期预报也可能有较大误差。
。。。。。。<为节省篇幅,此处略去思考过程>
关于2025年4月14日深圳的天气,目前无法提供准确的预测。天气预报的准确性通常在**7-10天**内较高,而超过两周的预测误差会显著增加,更不用说两年后的具体日期了。
不过,可以参考深圳**4月的气候特征**(基于历史数据)为您提供大致的参考信息:
### 深圳4月气候特点:
1. **气温**:
- 平均气温约在 **22℃-28℃** 之间,白天温暖,早晚可能稍凉。
- 四月是春季向夏季过渡的月份,气温逐渐上升。
2. **降水**:
- 4月属于**雨季初期**,可能有阵雨或雷雨,但强度通常不如夏季台风季。
- 历史数据显示,4月平均降水量约为 **80-100毫米**,但具体分布不均。
3. **天气模式**:
- 常见多云或晴天交替,偶有短时降雨。
- 可能受**华南前汛期**影响,但具体天气需结合当年气候模式(如厄尔尼诺/拉尼娜等)。
。。。。。。<为节省篇幅,此处略去建议,读者可自行测试>
从QwQ给出的答复可以看出,QwQ的相关知识停留在2023年,和实际使用时的时间无法对上,对未来只能靠盲猜。实际上,深圳在4.14日全天天晴,气温是19-27℃,盲猜测信息是很难精准的。
针对时效性缺陷,我们可以再举一个更直观的例子,比如2025年4月份美国发起的震荡全球的关税大战。QwQ是2025年3月份发布的,而美国关税政策的重大调整是发生在4月份,故QwQ是没有这块知识内容的。我们可以咨询QwQ看具体答复,如输入问题:请问2025年4月美国的关税政策变化,以及对全球经济的影响?QwQ的答复如下:
嗯,用户问的是2025年4月美国的关税政策变化以及对全球经济的影响。首先,我需要确认自己是否了解当前的美国关税政策,以及未来 的可能变化。不过,2025年现在还没到,所以只能进行预测和分析。用户可能是在做长期规划,比如企业战略或者投资决策,需要了解未来的关税趋势。
首先,美国的关税政策通常受政治、经济因素影响,比如当前的贸易保护主义倾向,或者经济压力下的调整。比如特朗普时期加征的关税,拜登政府可能有调整,但未来可能又有新的变化。需要考虑到2024年的总统选举结果,如果共和党上台,可能会更倾向于保护主义,继续或加强关税;而民主党可能更倾向于多边贸易协定,减少关税。
另外,2025年全球经济形势可能如何?比如通胀情况、供应链稳定性、地缘政治冲突如俄乌战争是否缓解,中美关系是否改善。这些都会影响关税政策。如果美国经济面临衰退,可能通过关税保护国内产业;如果经济向好,可能更开放。
。。。。。。<为节省篇幅,此处略去建议,读者可自行测试>
从QwQ的答复可以看出,面对这种全球都众所周知的公开信息,依然只能靠盲猜,无法准确的作答。核心原因是LLM在训练时这部分知识语料就是缺乏的,所以也就无法给出准确的答案。
LLM“知识茧房”破解方法
目前业界的解题思路可以归结为两种:1、缺知识就补知识,常见做法是采用行业私域知识直接做训练,或微调训练,不断更新模型的知识。2、调用LLM之外的工具,获取相关的知识信息,并将信息以prompt方式输入给LLM做最终的内容加工和整理。比如,将私域知识加工成知识库,结合大模型构建RAG系统。或者工具提供接口和LLM对接,当LLM判断该问题自身知识不足以作答时,则调用对应接口获取LLM之外的知识做补充,并结合获取的外部知识做进一步的加工整理,最终返回给用户,而MCP就是这种方式。
MCP简介
简介可查看官网:https://mcpcn.com/docs/introduction/
MCP(Model Context Protocol) 由 Anthropic 于 2024 年底开源,其目标是为大模型与外部工具 / 数据源之间建立起标准化的调用规范。它定义了统一的通信标准,使得大模型能够通过标准化接口连接任意工具,各工具按照MCP规范,编写server程序,就能便捷对接大模型。
1、MCP Server简介:
根据MCP协议定义,Server可以提供三种类型的标准能力,Resources、Tools、Prompts,每个Server可同时提供者三种类型能力或其中一种。
- **Resources:**资源,类似于文件数据读取,可以是文件资源或是API返回的内容。
- **Tools:**工具,第三方服务、功能函数,通过此可控制LLM可调用哪些函数。
- **Prompts:**提示词,为用户预先定义好的,完成特定任务的模板。
2、MCP Server通讯机制
MCP支持两种传输方式:标准输入输出(stdio)和基于 HTTP 的服务器推送事件(SSE)。
其中标准输入输出(stdio)模式,是一种用于本地通信的传输方式。在这种模式下,MCP 客户端会将服务器程序作为子进程启动,双方通过标准输入(stdin)和标准输出(stdout)进行数据交换。这种方式适用于客户端和服务器在同一台机器上运行的场景,确保了高效、低延迟的通信。本例DEMO程序,也将采用这种方式。
极简Client示例
官方范例参考:https://mcpcn.com/docs/quickstart/client/
Client示例代码参考(以Python为例):https://github.com/modelcontextprotocol/quickstart-resources/blob/main/mcp-client-python/client.py
- MCP要求借助uv进行虚拟环境创建和依赖管理。uv 是一个Python 依赖管理工具,类似于 pip 和 conda,但它更高效。安装UV:pip3 install uv
2、创建 MCP 客户端项目及目录:
uv init mcp-client
cd mcp-client
3、创建MCP客户端虚拟环境,并激活
创建虚拟环境:uv venv
激活虚拟环境:source .venv/bin/activate
注意:下次启动时,无需重复创建项目目录和虚拟环境,直接激活已有虚拟环境即可。
4、通过add方法在虚拟环境中安装MCP SDK
uv add mcp
5、编写 MCP 客户端。在当前项目主目录中创建 client1.py,并写入以下代码:
import asyncio from mcp import ClientSession from contextlib import AsyncExitStack class MCPClient: def __init__(self): """初始化 MCP client""" self.session = None self.exit_stack = AsyncExitStack() async def connect_to_server(self): """用于后续扩展MCP server连接,当前暂不连接真实server""" print("MCP client已初始化,但未连接server") async def chat_loop(self): """交互式聊天循环""" print("\nMCP client已启动!输入 'quit' 退出") while True: try: query = input("\nQuery: ").strip() if query.lower() == 'quit': break print(f"\n[Response] 你说的是:{query}") except Exception as e: print(f"\n发生错误: {str(e)}") async def cleanup(self): """清理资源""" await self.exit_stack.aclose() async def main(): client = MCPClient() try: await client.connect_to_server() await client.chat_loop() finally: await client.cleanup() if __name__ == "__main__": asyncio.run(main())
----------------代码走读----------------
MCP Client代码由MCPClient类和main函数构成,几个关键函数作用如下:
函数 |
说明 |
__init__ |
初始化客户端。 |
connect_to_ser |
用于连接MCP server,下一章节重点讲解。 |
chat_loop |
连上server后,开启对话循环,可以多轮输入。在对话中可联动LLM,调用server做处理,实现智能问答。 |
cleanup |
用于程序退出时,释放资源。 |
此外,值得注意的是采用了异步编程,asyncio:Python 内置的异步编程库,让 MCP 可以非阻塞地执行任务(比如对话、查询等)。因为采用了异步编程,函数定义时需要用 async 关键字。
main() 函数执行的主要步骤:
- client = MCPClient():实例化一个 MCP client类。
- await client.connect_to_server():初始化 MCP client,连接server(本例暂未连接server)。
- await client.chat_loop():启动对话循环。
- finally: 确保程序退出时,释放资源。
6、运行 MCP 客户端
uv run client1.py
极简Server端+Client调用Server tool
范例参考:https://mcpcn.com/docs/quickstart/server/
官网以及目前一些开发者社区的资料上,常以查询天气为范例。实现一个调用天气查询的tool。用于示例,我们并不关注天气网站的具体接口规范,其实可以进一步简化,就在server程序中模拟一个天气查询tool即可,模拟一个查询天气的返回。关键是理解client和server端的调用和交互流程。
本示例用于演示client调用server中tool的过程。实现一个模拟查询当前天气的tool工具,也即client在对话中,输入一个城市名称,然后调用server端查询天气,server端的tool函数收到客户端的输入后,返回天气信息给客户端。如果该流程走通了,则说明client准确调用到了server端的tool,实现了server端的联动处理。其他更为复杂丰富的应用场景,只是进一步开发server端相应的tool函数即可。
- 编写server程序:
from mcp.server.fastmcp import FastMCP # create an MCP server mcp = FastMCP("Demo") # add an weather tool @mcp.tool() def get_current_weather(city:str) -> str: """ get city weather.""" print(f"\n Hello {city}, it is server tool") response = "%s:白天气温23℃,夜间18℃。" % city return response if __name__ == "__main__": mcp.run(transport='stdio')
- 修改client程序:
- 完善连接Server的函数connect_to_server,client通过调用该函数和server建立连接:
self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
初始化连接后,调用list_tools函数列出server支持的tool:self.session.list_tools()
- 新增process_query函数,在该函数中封装调用server的tool做处理:
self.session.call_tool(tool_name, tool_arguments)
- 修改chat_loop函数,在client的每轮对话中,调用process_query,实现server中tool的调用:self.process_query(query)
- main()函数的处理几乎没变化,只是增加了server连接时需要的参数传入(server程序所在的路径和名称),完整程序:
import asyncio import os from typing import Optional from contextlib import AsyncExitStack from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client class MCPClient: def __init__(self): """初始化 MCP client""" self.session: Optional[ClientSession] = None self.exit_stack = AsyncExitStack() async def connect_to_server(self, server_script_path: str): """连接到 MCP server并列出可用工具""" is_python = server_script_path.endswith('.py') is_js = server_script_path.endswith('.js') if not (is_python or is_js): raise ValueError("server脚本必须是 .py 或 .js 文件") command = "python" if is_python else "node" server_params = StdioServerParameters( command=command, args=[server_script_path], env=None ) # 启动 MCP server并建立通信 stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) self.stdio, self.write = stdio_transport self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write)) await self.session.initialize() # 列出 MCP server上的工具 response = await self.session.list_tools() tools = response.tools print("\n已连接到server,支持以下工具:", [tool.name for tool in tools]) async def process_query(self, query: str) -> str: """ 调用MCP server工具处理输入,得到输出 """ response = await self.session.list_tools() tools = response.tools #print(f"\n session.list_tools: {response}, \n tools:{tools}") tool_name = tools[0].name tool_arguments = {"city": query} # 注意:入参名称需要和tool中匹配,否则会调用失败 #print(f"\n tool_name: {tool_name} ; tool_arguments: {tool_arguments}.") # 执行工具 result = await self.session.call_tool(tool_name, tool_arguments) #tool name->str,tool arguments-> {\"a\": 3,\"b\": 5} #print(f"\n\n[Calling tool {tool_name} with args {query}, result is:{result}]\n\n") return result.content[0].text async def chat_loop(self): """交互式聊天循环""" print("\nMCP client已启动!输入 'quit' 退出") while True: try: query = input("\nQuery: ").strip() if query.lower() == 'quit': break response = await self.process_query(query) # 发送用户输入到server,并得到处理后的结果 print(f"\n MCP Server response: {response}") except Exception as e: print(f"\n发生错误: {str(e)}") async def cleanup(self): """清理资源""" await self.exit_stack.aclose() async def main(): if len(sys.argv) < 2: print("Usage: python client.py <path_of_the_server_script>") sys.exit(1) client = MCPClient() try: await client.connect_to_server(sys.argv[1]) await client.chat_loop() finally: await client.cleanup() if __name__ == "__main__": import sys asyncio.run(main())
- 运行client和server程序:uv run client2.py server2.py
扩展:上述示例仅演示了一个最简示例,实际上,调用链路通了后,可以做更多事情,可以在server中封装各种tool,实现丰富的功能。也可以开发各种不同用途的server,由client实现统一的调用。
问题:如果一个server中有很多tools,client如何知道什么时候需要调用tool,需要调用哪些tool?并且准确传入需要的参数呢?
解法:结合LLM的function call能力,基于用户输入的问题做智能化判断。
Step1:Client先调用LLM,LLM根据用户输入的问题和传入的tools列表信息,做智能化判断是否需要调用server中的tool,以及需要调用哪些tool。
Step2:Client基于LLM返回的需要调用的具体tool列表,就可以进一步调用server中的tool做处理,返回结果。
Step3:基于Step2 server返回的结果,可以再一次以prompt方式输入给LLM做内容整理加工,将LLM加工后的最终内容返回给用户。
这就是MCP和LLM相结合的点,这也是MCP诞生的初衷。接下来将进一步探索Client和LLM,以及和Server端tools之间的交互。实现完整应用示例。
Client+LLM+Server完整应用示例
- 修改vLLM启动QwQ的启动参数(增加enable-auto-tool-choice、tool-call-parser hermes),重新启动QwQ,以支持QwQ函数调用。
python3 -m vllm.entrypoints.openai.api_server --model /home/ecs-user/QwQ-32B/Qwen/QwQ-32B-AWQ --served-model-name QwQ-32B --max-model-len 2048 --gpu_memory_utilization 0.9 --max_num_seqs 32 --enable-auto-tool-choice --tool-call-parser hermes
注意:可以使用nvidia-smi命令查看显存使用情况,如果发现停止vLLM的服务后,显存依然没有释放,就需要重启ECS后再使用vLLM新的启动参数启动QwQ。
- 为了支持以OpenAI方式调用模型,且需要在程序中读取API-KEY等信息,需要安装如下依赖:uv add mcp openai python-dotenv
- 在MCP client所在项目目录下,创建.env文件,用于保存LLM的相关信息。本例是在本地安装了QwQ,并使用vLLM启动了OpenAI服务,大模型在启动时命名为QwQ-32B,故对应的配置信息如下(读者可以回顾下前文QwQ的测试程序中的对应配置,若是其他LLM则需要相应修改为其对应的配置):
OPENAI_API_KEY = "EMPTY"
BASE_URL = "http://localhost:8000/v1"
MODEL = "QwQ-32B"
- 修改client代码,在client中加载LLM配置,联动LLM。Server代码则无需修改。
import asyncio import os from typing import Optional from contextlib import AsyncExitStack from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client import json from openai import OpenAI from dotenv import load_dotenv # 加载 .env 文件,API Key等私密信息存放在.env 文件中 load_dotenv() class MCPClient: def __init__(self): """初始化 MCP client""" self.exit_stack = AsyncExitStack() self.openai_api_key = os.getenv("OPENAI_API_KEY") # 读取API Key self.base_url = os.getenv("BASE_URL") # 读取BASE URL self.model = os.getenv("MODEL") # 读取model if not self.openai_api_key: raise ValueError("缺失OpenAI API Key,请在 .env 文件中设置 OPENAI_API_KEY") self.client = OpenAI(api_key=self.openai_api_key, base_url=self.base_url) # 创建OpenAI client,用于连接LLM self.session: Optional[ClientSession] = None self.exit_stack = AsyncExitStack() async def connect_to_server(self, server_script_path: str): """连接到 MCP server并列出可用工具""" is_python = server_script_path.endswith('.py') is_js = server_script_path.endswith('.js') if not (is_python or is_js): raise ValueError("server脚本必须是 .py 或 .js 文件") command = "python" if is_python else "node" server_params = StdioServerParameters( command=command, args=[server_script_path], env=None ) # 启动 MCP server并建立通信 stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) self.stdio, self.write = stdio_transport self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write)) await self.session.initialize() # 列出 MCP server上的工具 response = await self.session.list_tools() tools = response.tools print("\n已连接到server,支持以下工具:", [tool.name for tool in tools]) async def process_query(self, query: str) -> str: """ 调用MCP server,列出全部工具,并加工成OpenAI调用工具的格式 """ response = await self.session.list_tools() available_tools = [{ "type": "function", "function": { "name": tool.name, "description": tool.description, "input_schema": tool.inputSchema } } for tool in response.tools] # print(available_tools) """ 第一次调用LLM:输入用户的问题和server tools列表。由LLM判断该用户问题需要使用哪个或哪些tools """ messages = [{"role": "user", "content": query}] response = self.client.chat.completions.create( model=self.model, messages=messages, tools=available_tools ) # 解析LLM的返回,确认需要哪个或哪些tools,以及tool需要传入的参数 content = response.choices[0] if content.finish_reason == "tool_calls": tool_call = content.message.tool_calls[0] tool_name = tool_call.function.name tool_args = json.loads(tool_call.function.arguments) # 在client调用server,执行tool。 result = await self.session.call_tool(tool_name, tool_args) print(f"\n\n[Calling tool {tool_name} with args {tool_args}]\n\n") # 再次调用LLM,将server tool处理后返回的结果及工具信息追加到prompt,传入LLM,进一步做内容整理后作为最终结果 messages.append(content.message.model_dump()) messages.append({ "role": "tool", "content": result.content[0].text, "tool_call_id": tool_call.id, }) response = self.client.chat.completions.create( model=self.model, messages=messages, ) return response.choices[0].message.content return content.message.content async def chat_loop(self): """交互式聊天循环""" print("\nMCP client已启动!输入 'quit' 退出") while True: try: query = input("\nQuery: ").strip() if query.lower() == 'quit': break response = await self.process_query(query) # 处理用户输入的问题,该函数会结合LLM和server tools做信息处理 print(f"\n MCP Server response: {response}") except Exception as e: print(f"\n发生错误: {str(e)}") async def cleanup(self): """清理资源""" await self.exit_stack.aclose() async def main(): if len(sys.argv) < 2: print("Usage: python client.py <path_of_the_server_script>") sys.exit(1) client = MCPClient() try: await client.connect_to_server(sys.argv[1]) await client.chat_loop() finally: await client.cleanup() if __name__ == "__main__": import sys asyncio.run(main())
-----------关键代码走读,原理解析----------
在MCPClient类的init函数中完成了LLM的配置信息加载,并创建了OpenAI client,用于连接和调用LLM。
LLM、client和server tools间的主要调用流程:
Step1:在process_query函数中将获取到的server tools信息,按照OpenAI function call的格式,填好了available_tools,并结合用户问题封装好prompt模板。然后进行第一次LLM的问答调用:
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=available_tools
)
Step2:解析第一次LLM调用的返回信息(判断是否需要调用tools,涉及哪些tools,以及tool需要传入的参数),确认是否需要调用tools才能作答,如果不需要则直接作答,并返回。
Step3:如果该问题,需要调用tools才能作答(content.finish_reason == "tool_calls"表示需要调用tools),则根据LLM返回的server tools信息,调用tools(注意此调用不是LLM发起的,而是在client直接调用server对应的tools),result = await self.session.call_tool(tool_name, tool_args)
Step4:根据server tools的返回信息追加到prompt,再次调用LLM问答,由LLM进一步做内容加工整理后,作为最终结果返回。这种使用LLM做信息加工整理的处理方法在RAG系统中也经常使用到。
- 启动client和server:uv run client3.py server3.py
程序启动后,只输入地名,如本例输入:shenzhen,发现QwQ会自动识别当前输入是地名,而且会自动调用天气接口返回该地的接口。
如果用户输入其他问题,则不会调用天气接口:
如果用户的问题,明确说了需要查询某地天气,则QwQ会准确识别该问题,并返回该地天气(本例是模拟了一个天气查询的接口,写死了天气信息,故返回的值是固定的)。
从上述几个对话测试,可以看出当用户问题涉及天气问题时,则QwQ会自动调用server提供的天气相关的tool返回天气信息。若不涉及天气问题,则QwQ不会调用server的天气tool接口。DEMO程序正常运行。基于此,可以进一步丰富和扩展server的各自tools来实现其他功能,网上也有大量的MCP server可供使用,如:https://github.com/punkpeye/awesome-mcp-servers
小结:交互流程整理如下(蓝色字体部分,尤其值得关注和理解):
Client+LLM+数据库Server
再回到数据库场景,也可以编写数据库的MCP server tools,并接入到LLM中,当用户的问题涉及需要查询数据库中的信息是,LLM就可以通过数据库的server tools来查询数据库,进而完成问题的准确回答。
在server端将数据库连接和查询的代码扩展进去,就能实现数据库的MCP server tools了。此外,也有一些开源的MCP Server实现了数据库连接和查询,可供参考和使用,如:https://github.com/designcomputer/mysql_mcp_server
准备工作
- 以MySQL为例,先安装一个数据库。本例直接选择在ECS所在的可用区,在阿里云上开通了一个RDS MySQL实例。VPC、vswitch网络配置和ECS选成一样(相当于构建了一个内网可达的数据库)。
- 将ECS的IP加入到RDS MySQL数据库的白名单(只有加入到白名单的IP才能访问数据库)。说明:因为本例ECS和RDS MySQL选择了相同的az和vpc网络,所以ECS可以直接使用内网访问MySQL数据库,白名单中的IP填写ECS内网IP即可。
- 在RDS MySQL中创建用于测试的用户名及库表等基本信息:可以直接使用云上的DMS管理工具进行可视化的开发。
- 在数据库表中写入测试数据,以班级、老师做一个精简示例,创建两张表:teacher、class,并分别插入几条数据用于测试。
CREATE TABLE teacher (
teacher_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
age INT,
gender ENUM('Male', 'Female', 'Other'),
phone_number VARCHAR(20)
);
insert into `teacher`(`teacher_id`,`name`,`age`,`gender`,`phone_number`) values
('1001','张三','30','Male','13700008888'),
('1002','李四','25','Male','13700008888'),
('1003','王五','30','Female','13700008888'),
('1004','赵六','29','Male','13700008888'),
('1005','刘七','30','Male','13700008888'),
('1006','林八','26','Female','13700008888');
CREATE TABLE class (
class_id INT AUTO_INCREMENT PRIMARY KEY,
class_name VARCHAR(100) NOT NULL,
male_num INT,
female_num INT,
teacher_id INT,
FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id)
);
insert into `class`(`class_id`,`class_name`,`male_num`,`female_num`,`teacher_id`) values
('31','三一班','30','25','1001'),
('32','三二班','32','26','1002'),
('33','三三班','29','27','1003'),
('34','三四班','29','28','1004');
- 安装MySQL连接器模块:pip3 install mysql-connector-python>=9.1.0
- 将连接数据库所需的相关配置变量,填入.env文件中:
MYSQL_HOST = localhost # Database host
MYSQL_PORT = 3306 # Optional: Database port (defaults to 3306 if not specified)
MYSQL_USER = your_username
MYSQL_PASSWORD = your_password
MYSQL_DATABASE = your_database
MYSQL_TABLE = "class"
- 编写数据库测试脚本pySQL.py,见如下示例代码
#!/bin/python3 # -*- coding: utf-8 -*- import os from mysql.connector import connect, Error import logging from dotenv import load_dotenv # 加载 .env 文件,API Key等私密信息存放在.env 文件中 load_dotenv() # global env:如下配置正式使用时存放在配置文件中,如:.env # MYSQL_HOST = "rm-xxx.mysql.rds.aliyuncs.com" # Database host # MYSQL_PORT = 3306 # Optional: Database port (defaults to 3306 if not specified) # MYSQL_USER = "xxx" # User name # MYSQL_PASSWORD = "xxx" # User password # MYSQL_DATABASE = "xxx" # MySQL database # MYSQL_TABLE = "xxx" # MySQL table def get_db_config(): """Get database configuration from environment variables.""" config = { "host": os.getenv("MYSQL_HOST", "localhost"), "port": int(os.getenv("MYSQL_PORT", "3306")), "user": os.getenv("MYSQL_USER"), "password": os.getenv("MYSQL_PASSWORD"), "database": os.getenv("MYSQL_DATABASE") } if not all([config["user"], config["password"], config["database"]]): logger.error("Missing required database configuration. Please check environment variables:") logger.error("MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE are required") raise ValueError("Missing required database configuration") return config # 连接数据库,执行SQL查询 config = get_db_config() try: with connect(**config) as conn: with conn.cursor() as cursor: MYSQL_TABLE = os.getenv("MYSQL_TABLE") # 读取MYSQL_TABLE变量 sql = "SELECT male_num FROM %s WHERE class_name='三一班'" % MYSQL_TABLE cursor.execute(sql) for result in cursor: male_num = result[0] print("male_num:", male_num) finally: cursor.close() conn.close()
脚本如果运行成功,则会返回查询结果(示例为查询三一班男生人数):
手写MySQL server DEMO程序
有了前面的准备工作,MySQL数据库已经安装好,并且在ECS上的python测试程序能正常连接并查询数据库了。那么接下来可以把这个查询数据库的功能扩展到server程序中,作为server的tools功能。
- 修改server3.py代码,增加数据库查询的tool功能。示例代码:
from mcp.server.fastmcp import FastMCP import os from mysql.connector import connect, Error import logging from dotenv import load_dotenv # 加载 .env 文件,API Key等私密信息存放在.env 文件中 load_dotenv() # global env:如下配置正式使用时存放在配置文件中,如:.env # MYSQL_HOST = "rm-xxx.mysql.rds.aliyuncs.com"# Database host # MYSQL_PORT = 3306 # Optional: Database port (defaults to 3306 if not specified) # MYSQL_USER = "xxx" # User name # MYSQL_PASSWORD = "xxx" # User password # MYSQL_DATABASE = "xxx" # MySQL database # MYSQL_TABLE = "xxx" # MySQL table # create an MCP server mcp = FastMCP("Demo") # add an weather tool @mcp.tool() def get_current_weather(city:str) -> str: """get city weather.""" print(f"\n Hello {city}, it is server tool") response = "你好 %s, 白天气温23℃,夜间18℃。" % city return response def get_db_config(): """Get database configuration from environment variables.""" config = { "host": os.getenv("MYSQL_HOST", "localhost"), "port": int(os.getenv("MYSQL_PORT", "3306")), "user": os.getenv("MYSQL_USER"), "password": os.getenv("MYSQL_PASSWORD"), "database": os.getenv("MYSQL_DATABASE") } if not all([config["user"], config["password"], config["database"]]): logger.error("Missing required database configuration. Please check environment variables:") logger.error("MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE are required") raise ValueError("Missing required database configuration") return config # add an query database tool @mcp.tool() def query_database(class_name:str) -> int: """query the number of male students in the class from database.""" # 连接数据库,执行SQL查询 config = get_db_config() try: with connect(**config) as conn: with conn.cursor() as cursor: MYSQL_TABLE = os.getenv("MYSQL_TABLE") # 读取MYSQL_TABLE变量 sql = "SELECT male_num FROM %s WHERE class_name='%s'" % (MYSQL_TABLE , class_name) cursor.execute(sql) for result in cursor: male_num = int(result[0]) print("male_num:", male_num) finally: cursor.close() conn.close() return male_num if __name__ == "__main__": mcp.run(transport='stdio')
- client代码中已支持传入一个参数的tools调用,本例查询数据库也仅需要传入班级这个入参,所以无需修改。
- 安装客户端连接器模块:uv add mysql-connector-python
- 启动client和server:uv run client3.py server3.py
如果涉及的问题需要查询数据库,则LLM会判断出该问题需要查询数据库,并识别出需要调用的tools名称及其需要传入的参数。见下:
扩展:由LLM生成查询SQL
思考:上述示例只是个最简示例,在调用查询数据库的tools时,仅需要传入一个参数:班级。功能上也只能查班级男生人数。实际使用时,数据库内的表是非常多的,一个问题可能需要关联多张表的查询,而且每个表内支持的查询变量也非常多,不同问题对应不同的查询变量。所以查询的SQL语句也必将是五花八门的。
解法:众所周知,LLM其实也具备SQL编写能力,如果把数据库内的库表结构信息输入给LLM,则LLM能自动生成对应的SQL查询语句。所以,可以看到,目前业界的不少MySQL MCP server就是直接提供了SQL查询的tools能力,SQL由LLM根据问题自行生成。下面,我们继续顺着这个思路往下拓展。直接使用开源的MCP Server,分析该程序可以看到其本质就是实现了一个简洁的SQL查询:https://github.com/designcomputer/mysql_mcp_server
方法:
- 从前面的原理可以看到,server端程序的关键代码就是server.py这个文件,所以我们可以直接把开源项目中的server.py文件下载下来,拷贝到演示环境中。调测中,会发现报MySQL相关的配置变量无法获取,是因为.env文件没有加载。在server.py中增加如下代码,即可加载:
from dotenv import load_dotenv
# 加载 .env 文件,API Key等私密信息存放在.env 文件中
load_dotenv()
- 直接运行client3.py和新加入的MySQL开源server,发现是可以正常启动的。但从调用日志中可以看出,LLM生成的SQL语句中的列名关键字是错误的。原因是,我们并没有把数据库的表结构信息输入给LLM,所以LLM就盲猜了表中的列名称,故通常就很难和实际情况匹配。也可以看到后续的SQL执行时必然会失败。
- 从报错中,可以看出,LLM盲猜的表名称和表结构都是错误的。有个笨办法:直接建表时就建成LLM盲猜的表结构,这样就能和LLM生成的SQL语句匹配上,当然这个实际应用时极少用,因为如果用户换了问题,LLM又盲猜了其他的表结构,就又要修改。
比较靠谱的解法是,将表结构这些必要的信息填入prompt提示词中,传入给LLM,这样LLM就能生成正确的SQL语句了。解决方法也很多,比如可以直接修改首次调用LLM的prompt模板,加入表结构信息。也可以等首次调用完成,LLM返回需要调用SQL查询tools的时候,把表结构信息放在prompt模板,调用LLM生成SQL语句等。
- 按上述思路,尝试修改修改client.py中的process_query函数中,首次调用LLM的prompt模板,加入表结构信息,看LLM能否一次到位,生成准确的SQL语句:
async def process_query(self, query: str) -> str: """ 调用MCP server,列出全部工具,并加工成OpenAI调用工具的格式 """ response = await self.session.list_tools() available_tools = [{ "type": "function", "function": { "name": tool.name, "description": tool.description, "input_schema": tool.inputSchema } } for tool in response.tools] print(available_tools) """ 第一次调用LLM:输入用户的问题和server tools列表。由LLM判断该用户问题需要使用哪个或哪些tools """ prompt_template = '''任务描述:解答用户问题。如果能直接作答,则返回答案。如果用户问题需要查询数据库,则生成的SQL查询语句需要符合补充信息中的数据库表结构。 用户问题:'%s' 补充信息(数据库表结构):" 表名: `teacher` - `teacher_id` (INT, 主键): 老师的ID - `name` (VARCHAR(100)): 老师的名字 - `age` (INT): 老师的年龄 - `gender` (ENUM('Male', 'Female', 'Other')): 老师的性别 - `phone_number` (VARCHAR(20)): 老师的手机号 表名: `class` - `class_id` (INT, 主键): 班级ID - `teacher_id` (INT, 外键, 引用 `teacher.teacher_id`): 老师的ID - `class_name` (VARCHAR(100)): 班级名称 - `male_num` (INT)): 男生数量 - `female_num`(INT)): 女生数量" ''' % query messages = [{"role": "user", "content": prompt_template}] response = self.client.chat.completions.create( model=self.model, messages=messages, tools=available_tools ) # 解析LLM的返回,确认需要哪个或哪些tools,以及tool需要传入的参数 content = response.choices[0] if content.finish_reason == "tool_calls": tool_call = content.message.tool_calls[0] tool_name = tool_call.function.name tool_args = json.loads(tool_call.function.arguments) # 在client调用server,执行tool。 result = await self.session.call_tool(tool_name, tool_args) print(f"\n\n[Calling tool {tool_name} with args {tool_args}]\n\n") # 再次调用LLM,将server tool处理后返回的结果及工具信息追加到prompt,传入LLM,进一步做内容整理后作为最终结果 messages.append(content.message.model_dump()) messages.append({ "role": "tool", "content": result.content[0].text, "tool_call_id": tool_call.id, }) response = self.client.chat.completions.create( model=self.model, messages=messages, ) return response.choices[0].message.content return content.message.content
- 运行程序,实测看效果:uv run client.py server.py
从测试结果可以看到,LLM一步到位生成了准确的SQL查询语句。再试一个需要两张表关联查询的例子,发现LLM也能精准的给出对应的SQL查询语句,返回准确结果:
当然,本例只是用作DEMO示例,简化了大量功能。实际应用的client和server程序可以设计的非常丰富,比如本例是把表结构人为填入到prompt模板中,实际上表结构也可能会发生修改而变化,在程序中可以直接通过查询数据库而获得实时准确的表结构。再比如,本例是把client和server放在了同一个ECS中,实际应用时,client和server间也可以使用SSE方式实现远程调用等等。
总结:万物可连、万事归一入口到LLM
MCP的特点:
- MCP采用了client-server结构,制定了一套标准接口规范,开发者按照MCP的编程规范,开发设计自身的client和server程序。就能实现client和server间的访问。
- Client可理解为服务调用方、使用方。
- Server可理解为服务提供方,开发者可将自身各项功能以tools函数方式封装到server端。喜欢动脑筋的同学可能会思考,当存在大量server及tools时,LLM是怎么判断用户输入的问题和哪个或哪些tools相关呢,根据传入LLM的tools相关信息可以看出,LLM主要是根据tools函数里面的函数注释以及函数名称来理解和识别的,所以编写tools函数时,需要把函数注释和函数名称写清楚。
- 有了这套机制后,相当于所有的功能、对外的服务接口都有了一个标准规范。可方便的供应用开发者使用。
- Client、Server及LLM间的大致交互如下(蓝色字体部分尤其值得理解):
MCP开创性的提出了一套通用的AI agent应用开发规范:各个不同厂商、不同开发者开发的server,都可以用统一的client调用,这就相当于实现了一套统一且通用的功能开发和接口调用规范,可以屏蔽各厂商、各产品的底层接口差异。这也是MCP目前越来越火爆的关键原因,MCP实际上定义了一个全新的通用的LLM和应用工具之间的开发生态。
此外,从MCP的程序开发中可以看到,和LLM相关的交互代码大量使用了OpenAI接口方式,并且主要是使用了LLM的function call功能。国内的LLM建议也早日原生支持OpenAI API及function call能力,这样开发者开发起来就更便捷了。
MCP带来的无限想象和新的可能性:
LLM并非无所不知,天生具有“知识茧房”局限,注定对“茧房”外的知识是不甚明了的,所以在涉及私域知识、模型训练后新发生的事情等的知识问答上,经常答非所问。而MCP定义了一套LLM和上下游生态工具、系统的对接规范,构建了万物都可与LLM联动的可能性。这样就解决了LLM的“知识茧房”短板,当一个问题LLM发现自身知识难以精确回答时,就可以调用MCP Server内相关的tools,并结合LLM自身所擅长的文案整理、内容提取等能力,实现精确且文案流畅的回答。这使得,万物皆可通过MCP与LLM实现互联,LLM未来可以作为万事的统一入口,万事不知皆可问LLM,LLM自身知识可解则直接解答,如果自身知识不足以解,则联动server tools解答。万物均可通过MCP接入LLM,成为LLM的知识库补充。
回到数据库领域,在私域知识库场景,RAG系统将不再是知识库的唯一方案,通用数据库的结构化和非结构化数据也可成为LLM的外挂知识库,LLM可生成问题对应的SQL,由Client直接查数据库获取信息,而且还能实现写数据库。
常见问题处理
- 大模型下载速度慢,经常中断。
解决方法:使用国内的源做加速,推荐使用modelscope 中的 snapshot_download 函数下载模型。并且使用nohup方式在系统后台下载。详见模型下载章节。
- vLLM启动大模型服务时,经常遇到CUDA out of memory的报错,尤其是显存不是特别大的时候。而实际上,显存是够用的。
问题描述:启动命令:python3 -m vllm.entrypoints.openai.api_server --model /home/ecs-user/QwQ-32B/Qwen/QwQ-32B-AWQ--served-model-name QwQ-32B--max-model-len=4096
报错:RuntimeError: CUDA out of memory occurred when warming up sampler with 1024 dummy requests. Please try lowering `max_num_seqs` or `gpu_memory_utilization` when initializing the engine.
原因:通常并不是因为显存不足,比如QwQ-32B-AWQ在运行时,可以看到只需要约18G显存,但24G显存的卡依然会报显存不够的错。此问题,是因为vLLM的启动参数配置不合理,通常报错信息中有对应提示,需要手工根据提示做不同参数配置的尝试。
解决方法:根据每次报错日志中的原因提示,尝试修改启动命令的对应参数值(亲测max-model-len、gpu_memory_utilization、max_num_seqs这三个参数调整有效),此过程,可能得耐心反复多试试各种参数值的不同配置,比如:
python3 -m vllm.entrypoints.openai.api_server --model /home/ecs-user/QwQ-32B/Qwen/QwQ-32B-AWQ --served-model-name QwQ-32B --max-model-len 2048 --gpu_memory_utilization 0.9 --max_num_seqs 32
- 使用LLM调用tool时报错
发生错误: Error code: 400 - {'object': 'error', 'message': '"auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser to be set', 'type': 'BadRequestError', 'param': None, 'code': 400}
解决方法:根据错误提示可以看出是LLM启动时,需要设置相关参数。
python3 -m vllm.entrypoints.openai.api_server --model /home/ecs-user/QwQ-32B/Qwen/QwQ-32B-AWQ --served-model-name QwQ-32B --max-model-len 2048 --gpu_memory_utilization 0.9 --max_num_seqs 32 --enable-auto-tool-choice --tool-call-parser hermes
附录:参考资料
https://github.com/modelcontextprotocol/servers
https://github.com/bytebase/dbhub/
https://mp.weixin.qq.com/s/Dcfg32sWnAmQGTEth53OYg
https://mp.weixin.qq.com/s/iHlH7zXKeRGHEPi8pSiUrg
https://cookbook.openai.com/examples/how_to_call_functions_with_chat_models
https://datawhaler.feishu.cn/docx/Wz2NdqSx1oEZsuxB9zHcEQ20nNe