从天气查询 Demo 看懂 LLM + MCP:服务端、客户端与工具调用全流程解析
正文
1. 前言:本文要解读哪两份代码?
本文围绕一个 LLM + MCP 天气查询 Demo 展开,重点解读 MCP 服务端和 MCP 客户端如何配合,让大模型能够调用外部天气 API。
参考代码如下:
| 文件 | 作用 | 地址 |
|---|---|---|
weather_server.py |
MCP 服务端代码,负责封装和风天气 API,并向客户端暴露工具 | https://github.com/FlyAIBox/Agent_In_Action/blob/main/01-agent-tool-mcp/mcp-demo/server/weather_server.py |
mcp_client_deepseek.py |
MCP 客户端代码,负责连接 MCP Server,并让 DeepSeek 模型根据用户问题自动调用工具 | https://github.com/FlyAIBox/Agent_In_Action/blob/main/01-agent-tool-mcp/mcp-demo/client/mcp_client_deepseek.py |
| 和风天气控制台 | 用于注册账号、创建项目、获取天气 API Key,具体测试由读者自行完成 | https://console.qweather.com/home?lang=zh |
这篇文章不是简单贴代码,而是围绕一个问题展开:
大模型本身不能实时查天气,那么它如何通过 MCP 调用外部天气服务,并把工具结果整合成自然语言回答?
2. 文章大纲
本文会按照“先看全貌,再拆细节”的方式展开。
- 项目要解决什么问题;
- 项目整体结构是什么;
- MCP 服务端如何把天气 API 封装成工具;
- MCP 客户端如何启动服务端、获取工具列表并连接 LLM;
- LLM 如何根据用户问题决定是否调用工具;
tool_calls是如何被客户端执行并回填给模型的;async/await、dotenv、logging、stdio等知识点在代码中的作用;- MCP Inspector 如何调试服务端工具;
- 这个 Demo 的优势、局限以及后续扩展方向。
3. 先看整体:这个项目的代码结构
这个 Demo 的核心结构可以简化理解为:
mcp-demo/
├── server/
│ └── weather_server.py # MCP 服务端:注册天气工具,调用和风天气 API
├── client/
│ └── mcp_client_deepseek.py # MCP 客户端:连接服务端,调用 DeepSeek,处理工具调用
└── .env # 环境变量:天气 API Key、DeepSeek API Key 等
如果只看职责,可以分成三层:
| 层级 | 组件 | 职责 |
|---|---|---|
| LLM 层 | DeepSeek / 大模型 | 理解用户问题,判断是否需要调用工具,综合工具结果 |
| MCP 协议层 | MCP Client + MCP Server | 工具发现、工具调用、进程通信、结果回传 |
| 外部能力层 | 和风天气 API | 提供实时天气预警、天气预报等数据 |
也就是说,大模型并不是直接访问天气 API,而是通过 MCP Client 调用 MCP Server 暴露出来的工具。
4. 这个 Demo 中需要先想清楚的几个问题
在展开代码前,可以先把几个关键问题列出来。后文会逐一回答。
| 问题 | 对应代码位置 | 核心答案 |
|---|---|---|
| 天气 API Key 放在哪里? | weather_server.py、.env |
放在环境变量中,通过 dotenv 加载 |
| 天气接口如何请求? | make_qweather_request() |
用 httpx.AsyncClient 发送异步 GET 请求 |
| 普通 Python 函数如何变成 MCP 工具? | @mcp.tool() |
通过装饰器注册为 MCP Tool |
| Client 如何知道 Server 有哪些工具? | list_tools() |
通过 MCP 会话动态获取工具 schema |
| MCP 工具如何给 LLM 使用? | Tool.to_openai_format() |
转换成 OpenAI/DeepSeek function calling 格式 |
| LLM 如何触发工具? | process_query() |
模型返回 tool_calls,客户端负责真正执行 |
| 工具结果如何回给模型? | messages.append({"role": "tool", ...}) |
把工具结果作为 tool message 加入上下文 |
| 如何调试 MCP Server? | MCP Inspector | 使用 mcp dev server/weather_server.py 调试 |
5. 背景:为什么需要 MCP?
大模型擅长自然语言理解、推理和生成,但它不擅长直接获取实时信息。
例如用户问:
北京今天有没有天气灾害预警?未来三天适不适合户外活动?
这类问题有两个特点:
- 信息具有实时性,模型训练数据无法保证准确;
- 回答需要外部数据,例如天气预警、温度、风力、降水量等。
传统做法可能是在业务代码中直接写一个天气查询函数,然后把函数调用逻辑和模型调用逻辑写在一起。但这样会导致工具和模型强耦合,工具越多,客户端代码越复杂。
MCP 的思路是:
把外部工具独立封装成 MCP Server,LLM Client 通过统一协议发现工具、调用工具、获取结果。
在这个天气 Demo 中,MCP Server 提供天气工具,MCP Client 负责让 DeepSeek 模型决定是否调用这些工具。
6. MCP 的基本概念:Resource、Tool、Prompt
MCP Server 可以向客户端提供多种能力,常见的有三类:
| 类型 | 含义 | 本 Demo 中的体现 |
|---|---|---|
| Resource | 可被客户端读取的数据资源,例如文件、数据库记录、API 响应 | 本文不重点展开 |
| Tool | 可由 LLM 调用的函数 | get_weather_warning、get_daily_forecast |
| Prompt | 预定义提示词模板,用于特定任务 | 本文不重点展开 |
本文重点关注 Tool。
Tool 可以理解为“模型可调用的外部函数”。不过大模型并不是直接执行 Python 函数,而是先生成工具调用请求,再由客户端执行对应工具。
7. 全流程:从用户提问到模型回答
整体调用链路如下:
用普通文字描述就是:
- 用户输入一个天气相关问题;
- 客户端启动 MCP 服务端;
- 客户端从服务端获取可用工具;
- 客户端把工具转换成 LLM 可识别的 function calling 格式;
- LLM 判断是否需要调用工具;
- 如果需要,LLM 返回
tool_calls; - 客户端解析工具名和参数;
- 客户端调用 MCP Server 执行工具;
- MCP Server 调用和风天气 API;
- 工具结果返回给客户端;
- 客户端把工具结果写回上下文;
- LLM 基于工具结果生成最终回答。
8. 服务端代码解读:weather_server.py
8.1 服务端主要职责
weather_server.py 做了四件事:
| 职责 | 说明 |
|---|---|
| 加载环境变量 | 读取和风天气 API 地址和 API Key |
| 初始化 MCP Server | 使用 FastMCP("weather") 创建服务 |
| 封装天气 API 请求 | 使用 httpx.AsyncClient 异步请求和风天气 |
| 注册 MCP 工具 | 通过 @mcp.tool() 暴露天气预警和天气预报工具 |
8.2 加载 .env 配置
服务端需要从 .env 中读取和风天气配置。
示例代码:
from dotenv import load_dotenv
from pathlib import Path
import os
dotenv_path = Path(__file__).resolve().parents[1] / ".env"
load_dotenv(dotenv_path)
QWEATHER_API_BASE = os.getenv("QWEATHER_API_BASE")
QWEATHER_API_KEY = os.getenv("QWEATHER_API_KEY")
推荐 .env 中写成:
QWEATHER_API_BASE=https://devapi.qweather.com/
QWEATHER_API_KEY=你的和风天气APIKey
DEEPSEEK_API_KEY=你的DeepSeekKey
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
这里要注意:天气 API 的 Key 不建议直接写死在代码里。原因很简单:
| 原因 | 说明 |
|---|---|
| 安全 | 避免 API Key 被提交到 GitHub |
| 灵活 | 测试环境和生产环境可以用不同配置 |
| 可维护 | 更换 API 地址或模型时,不需要改业务代码 |
8.3 初始化 FastMCP 服务
服务端通过 FastMCP 创建一个 MCP Server。
示例代码:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(
"weather",
debug=True,
host="0.0.0.0"
)
参数说明:
| 参数 | 作用 |
|---|---|
"weather" |
MCP 服务名称 |
debug=True |
开启调试模式,方便开发阶段查看日志 |
host="0.0.0.0" |
监听所有网络接口,方便后续扩展远程访问 |
不过这个 Demo 最终使用的是本地 stdio 通信模式:
if __name__ == "__main__":
print("正在启动 MCP 天气服务器...")
print("提供工具: get_weather_warning, get_daily_forecast")
mcp.run(transport="stdio")
stdio 表示客户端和服务端通过标准输入、标准输出通信。它非常适合本地开发和调试,因为客户端可以直接启动服务端进程,不需要额外部署 HTTP 服务。
9. MCP 通信模式:stdio、HTTP、SSE 怎么理解?
MCP 支持不同的通信方式。这个 Demo 使用的是 stdio,但理解其他方式也很重要。
| 通信模式 | 理解方式 | 适用场景 | 特点 |
|---|---|---|---|
stdio |
本地进程通过标准输入/输出通信 | 本地脚本、开发调试、IDE 插件 | 简单、低延迟、容易跑通 |
| HTTP | 客户端通过 HTTP 请求访问服务端 | 远程服务、Web 系统集成 | 通用性强,但涉及服务部署 |
| SSE | Server-Sent Events,服务端持续推送数据 | 流式输出、长连接通知 | 适合流式通信或事件推送 |
当前 Demo 选择 stdio 的原因是:客户端可以直接用 Python 启动服务端文件,然后建立 MCP 会话,比较适合入门学习。
10. 天气 API 请求封装
10.1 URL 规范化
服务端先对 API 基础地址做规范化处理,避免 URL 拼接错误。
示例代码:
def _normalize_base_url(raw_base):
if not raw_base:
raise RuntimeError("未配置 QWEATHER_API_BASE 环境变量")
base = raw_base.strip()
if not base.startswith(("http://", "https://")):
base = f"https://{base.lstrip('/')}"
if not base.endswith("/"):
base = f"{base}/"
return base
这个函数主要解决两个问题:
| 问题 | 处理方式 |
|---|---|
用户在 .env 中没有写 https:// |
自动补上 |
基础 URL 没有以 / 结尾 |
自动补上,避免 urljoin 拼接路径异常 |
可以把 URL 拼接理解成:
其中:
| 符号 | 含义 |
|---|---|
Base |
API 基础地址 |
Endpoint |
API 接口路径,例如 v7/weather/3d |
Params |
查询参数,例如 location、lang |
normalize(Base) |
对基础地址进行协议和斜杠修正 |
10.2 发送异步 GET 请求
天气查询属于网络 I/O 操作,所以服务端使用 httpx.AsyncClient 发送异步请求。
示例代码:
import httpx
from urllib.parse import urljoin
async def make_qweather_request(endpoint, params):
if not _QWEATHER_BASE_URL:
return None
if not QWEATHER_API_KEY:
return None
safe_endpoint = endpoint.lstrip("/")
url = urljoin(_QWEATHER_BASE_URL, safe_endpoint)
headers = {
"X-QW-Api-Key": QWEATHER_API_KEY
}
async with httpx.AsyncClient() as client:
response = await client.get(
url,
params=params,
headers=headers,
timeout=30.0
)
response.raise_for_status()
return response.json()
这里有几个关键点:
| 代码点 | 作用 |
|---|---|
endpoint.lstrip("/") |
避免接口路径前面多余 / 影响拼接 |
urljoin() |
拼接基础 URL 和接口路径 |
headers |
通过请求头传递和风天气 API Key |
await client.get() |
等待异步 HTTP 请求完成 |
response.raise_for_status() |
HTTP 状态码异常时抛出错误 |
response.json() |
将响应体转成 Python 字典 |
11. async/await:为什么这里必须用异步?
服务端和客户端都有大量 async 和 await。
简单说:
async def定义的是异步函数;- 调用异步函数不会立即得到最终结果;
- 必须使用
await等待它执行完成; - 网络请求、进程通信、工具调用都适合使用异步。
可以抽象为:
符号说明:
| 符号 | 含义 |
|---|---|
AsyncFunction |
异步函数,例如请求天气 API 的函数 |
Args |
调用参数 |
await |
等待异步任务完成 |
Result |
异步函数返回结果 |
在这个项目中,异步主要出现在:
| 位置 | 作用 |
|---|---|
| 天气 API 请求 | 等待 HTTP 响应 |
| MCP Server 工具函数 | 等待外部天气接口返回 |
| MCP Client 初始化 | 等待服务端进程和 MCP 会话建立 |
| 工具调用 | 等待 MCP Server 执行工具 |
| 聊天循环 | 支持异步处理用户查询 |
12. 服务端工具一:天气灾害预警
服务端通过 @mcp.tool() 把普通函数注册成 MCP 工具。
示例代码:
@mcp.tool()
async def get_weather_warning(location):
location = str(location)
params = {
"location": location,
"lang": "zh"
}
data = await make_qweather_request("v7/warning/now", params)
if not data:
return "无法获取预警信息或API请求失败。"
if data.get("code") != "200":
return f"API 返回错误: {data.get('code')}"
warnings = data.get("warning", [])
if not warnings:
return f"当前位置 {location} 没有活动预警。"
formatted_warnings = [
format_warning(warning)
for warning in warnings
]
return "\n---\n".join(formatted_warnings)
这个工具的调用流程如下:
接收 location
↓
转换成字符串
↓
组装请求参数 location + lang
↓
请求 v7/warning/now
↓
检查 API 返回 code
↓
提取 warning 列表
↓
格式化为可读文本
参数说明:
| 参数 | 类型 | 说明 | 示例 |
|---|---|---|---|
location |
str 或 int |
城市 ID 或经纬度坐标 | 101010100、116.41,39.92 |
13. 服务端工具二:多日天气预报
第二个工具用于获取多日天气预报。
示例代码:
@mcp.tool()
async def get_daily_forecast(location, days=3):
location = str(location)
valid_days = [3, 7, 10, 15, 30]
if days not in valid_days:
days = 3
params = {
"location": location,
"lang": "zh"
}
endpoint = f"v7/weather/{days}d"
data = await make_qweather_request(endpoint, params)
if not data:
return "无法获取天气预报或API请求失败。"
if data.get("code") != "200":
return f"API 返回错误: {data.get('code')}"
daily_forecasts = data.get("daily", [])
if not daily_forecasts:
return f"无法获取 {location} 的天气预报数据。"
formatted_forecasts = [
format_daily_forecast(daily)
for daily in daily_forecasts
]
return "\n---\n".join(formatted_forecasts)
这个工具有一个比较好的细节:它对 days 做了参数校验。
| 参数 | 说明 |
|---|---|
location |
城市 ID 或经纬度 |
days |
预报天数,可选值为 3、7、10、15、30 |
| 默认值 | 如果传入非法值,回退为 3 天预报 |
这种处理很有必要,因为工具参数是由模型生成的。模型虽然可以生成 JSON 参数,但不保证每次都完全合法。因此服务端应该做基础防御。
14. @mcp.tool():普通函数如何变成 MCP 工具?
@mcp.tool() 是服务端代码中非常关键的一点。
它的作用是:把一个普通 Python 函数注册到 MCP Server 的工具列表中。
可以理解为:
普通 Python 函数
↓
@mcp.tool() 装饰
↓
注册进 MCP Server
↓
客户端可以通过 list_tools 发现它
↓
LLM 可以通过工具 schema 理解它
↓
客户端可以通过 call_tool 调用它
也就是说,@mcp.tool() 不是为了改变函数内部业务逻辑,而是为了让这个函数可以被 MCP 协议发现和调用。
15. 客户端代码解读:mcp_client_deepseek.py
15.1 客户端主要职责
客户端文件比服务端更复杂,因为它要把 MCP Server 和 LLM 连接起来。
主要模块如下:
| 模块 | 作用 |
|---|---|
Configuration |
加载和验证 DeepSeek 配置 |
Tool |
表示 MCP 工具,并转换成 OpenAI/DeepSeek function calling 格式 |
MCPServer |
启动 MCP Server,建立连接,获取工具,执行工具 |
MCPClient |
调用 DeepSeek,处理用户问题和工具调用 |
chat_loop |
命令行交互循环 |
可以用下面的类关系理解:
16. Configuration:统一管理 DeepSeek 配置
客户端首先定义了 Configuration 类。
示例代码:
class Configuration:
def __init__(self):
self.load_env()
self._validate_env()
@staticmethod
def load_env():
load_dotenv()
def _validate_env(self):
required_vars = ["DEEPSEEK_API_KEY"]
missing_vars = [
var for var in required_vars
if not os.getenv(var)
]
if missing_vars:
raise ValueError(
f"缺少必需的环境变量: {', '.join(missing_vars)}"
)
@property
def api_key(self):
return os.getenv("DEEPSEEK_API_KEY", "")
@property
def base_url(self):
return os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
@property
def model(self):
return os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
这里的设计思路很清晰:
| 配置项 | 是否必填 | 默认值 |
|---|---|---|
DEEPSEEK_API_KEY |
是 | 无 |
DEEPSEEK_BASE_URL |
否 | https://api.deepseek.com |
DEEPSEEK_MODEL |
否 | deepseek-chat |
把配置单独封装成类,有利于后续替换模型、调整 API 地址,避免配置逻辑散落在业务代码中。
17. logging:为什么不用 print?
客户端使用了 logging。
示例代码:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
相比 print,logging 更适合真实项目。
| 对比项 | print |
logging |
|---|---|---|
| 时间记录 | 需要手动拼接 | 自动记录 |
| 日志等级 | 不支持 | 支持 INFO、WARNING、ERROR 等 |
| 输出位置 | 控制台为主 | 可输出到控制台、文件、日志系统 |
| 生产可控性 | 弱 | 强 |
| 排错能力 | 一般 | 更适合定位链路问题 |
MCP 调用链路比较长,一次请求可能经过 LLM、Client、Server、外部 API。如果没有日志,很难判断错误发生在哪一层。
18. Tool 类:把 MCP 工具转换成 LLM 可识别的格式
MCP Server 返回的是 MCP 工具结构,而 DeepSeek / OpenAI API 需要 function calling 格式,所以客户端要做一次格式转换。
示例代码:
class Tool:
def __init__(self, name, description, input_schema):
self.name = name
self.description = description
self.input_schema = input_schema
def to_openai_format(self):
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.input_schema
}
}
可以抽象为:
符号说明:
| 符号 | 含义 |
|---|---|
T_{mcp} |
MCP Server 返回的工具描述 |
convert |
客户端中的格式转换过程 |
T_{openai} |
OpenAI/DeepSeek function calling 格式工具 |
这一步很关键,因为 MCP 负责工具发现,但最终模型 API 需要的是自己能识别的工具格式。
19. MCPServer:客户端如何启动并连接服务端?
客户端通过 MCPServer 类管理服务端连接。
核心代码可以简化为:
server_params = StdioServerParameters(
command="python",
args=[self.server_path],
env=None
)
stdio_transport = await self.exit_stack.enter_async_context(
stdio_client(server_params)
)
stdio, write = stdio_transport
self.session = await self.exit_stack.enter_async_context(
ClientSession(stdio, write)
)
await self.session.initialize()
这里发生了几件事:
| 步骤 | 说明 |
|---|---|
StdioServerParameters |
定义如何启动服务端进程 |
command="python" |
使用 Python 启动服务端 |
args=[self.server_path] |
指定服务端脚本路径 |
stdio_client() |
建立 stdio 通信 |
ClientSession() |
创建 MCP 会话 |
session.initialize() |
初始化 MCP 协议连接 |
所以客户端运行时,不需要你单独手动启动 weather_server.py。客户端会自己定位服务端文件并启动它。
20. AsyncExitStack:异步资源管理器
客户端使用了 AsyncExitStack 来管理资源。
它可以理解为一个“异步资源登记表”。
打开 stdio 连接
↓
登记到 AsyncExitStack
↓
创建 ClientSession
↓
登记到 AsyncExitStack
↓
程序退出或异常
↓
统一关闭资源
示例代码:
self.exit_stack = AsyncExitStack()
stdio_transport = await self.exit_stack.enter_async_context(
stdio_client(server_params)
)
self.session = await self.exit_stack.enter_async_context(
ClientSession(stdio, write)
)
为什么需要它?
因为 MCP Client 会启动子进程、建立通信流、创建会话。如果程序异常退出,这些资源必须被释放,否则可能出现进程残留或连接未关闭的问题。
21. list_tools:客户端如何发现服务端工具?
客户端通过 list_tools() 获取 MCP Server 暴露的工具。
示例代码:
async def list_tools(self):
if not self.session:
raise RuntimeError("服务器未初始化")
response = await self.session.list_tools()
return [
Tool(tool.name, tool.description, tool.inputSchema)
for tool in response.tools
]
这里体现了 MCP 的一个重要价值:
客户端不需要提前写死工具列表,而是可以从 MCP Server 动态获取工具 schema。
获取到工具后,客户端再调用:
available_tools = [
tool.to_openai_format()
for tool in tools
]
把 MCP 工具转换成模型可用的 function calling 格式。
22. System Prompt:工具是什么 vs 工具怎么用
工具 schema 只能告诉模型:
- 有哪些工具;
- 工具叫什么;
- 参数是什么;
- 参数类型是什么;
- 工具描述是什么。
但它不能保证模型一定按业务逻辑正确使用工具。
所以客户端在 process_query() 中写了比较详细的 system prompt,用来告诉模型:
- 你是一个天气助手;
- 你可以使用天气预警和天气预报工具;
- 如果用户只问预警,只调用预警工具;
- 如果用户只问预报,只调用预报工具;
- 如果用户问复杂问题,需要综合调用多个工具;
- 如果涉及户外活动,需要结合温度、风力、降水、预警综合判断。
这就是工具调用中容易被忽略的一点:
工具 schema 决定模型“能不能用工具”,system prompt 决定模型“会不会正确用工具”。
23. process_query:多轮工具调用的核心流程
process_query() 是客户端最核心的方法。
它做的事情可以简化为:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query}
]
tools = await self.server.list_tools()
available_tools = [
tool.to_openai_format()
for tool in tools
]
response = self.client.chat.completions.create(
model=self.config.model,
messages=messages,
tools=available_tools,
tool_choice="auto"
)
其中:
| 参数 | 说明 |
|---|---|
messages |
当前对话上下文 |
tools |
LLM 可使用的工具列表 |
tool_choice="auto" |
让模型自行判断是否需要调用工具 |
模型返回后,客户端会判断 finish_reason。
24. finish_reason:模型是要回答,还是要调用工具?
客户端根据 finish_reason 判断下一步动作。
finish_reason |
含义 | 客户端动作 |
|---|---|---|
stop |
模型已经生成最终文本 | 直接返回回答 |
tool_calls |
模型希望调用工具 | 解析工具名和参数,执行工具 |
| 其他 | 非预期状态 | 记录日志并返回错误提示 |
简化代码如下:
finish_reason = response.choices[0].finish_reason
content = response.choices[0].message
if finish_reason == "stop":
return content.content
elif finish_reason == "tool_calls":
messages.append(content.model_dump())
# 后续执行工具
这里要注意:模型不会自己执行工具。它只是告诉客户端:
我想调用某个工具,参数是这些。
真正执行工具的是 MCP Client。
25. execute_tool:客户端真正执行 MCP 工具
当模型返回 tool_calls 后,客户端会解析工具名和参数,然后调用 MCP Server。
示例代码:
for tool_call in content.tool_calls:
tool_name = tool_call.function.name
tool_args = json.loads(tool_call.function.arguments)
result = await self.server.execute_tool(
tool_name,
tool_args
)
tool_outputs.append({
"tool_call_id": tool_call.id,
"output": result.content[0].text
})
execute_tool() 内部最终会调用:
result = await self.session.call_tool(
tool_name,
arguments
)
完整流程如下:
LLM 返回 tool_calls
↓
客户端解析 tool_name
↓
客户端解析 JSON 参数
↓
调用 session.call_tool()
↓
MCP Server 执行对应工具函数
↓
工具函数请求天气 API
↓
工具结果返回 MCP Client
26. 工具结果如何回填给模型?
工具执行完成后,客户端需要把结果作为 tool 消息追加到 messages 中。
示例代码:
for output in tool_outputs:
messages.append({
"role": "tool",
"content": output["output"],
"tool_call_id": output["tool_call_id"]
})
这一步很重要。
如果不把工具结果回填给模型,模型就不知道工具执行结果是什么,也无法生成最终自然语言回答。
可以把多轮工具调用抽象为:
符号说明:
| 符号 | 含义 |
|---|---|
M_t |
第 t 轮对话上下文 |
ToolCall_t |
模型在第 t 轮生成的工具调用 |
ToolResult_t |
工具执行后的返回结果 |
M_{t+1} |
加入工具调用和工具结果后的新上下文 |
也就是说,工具调用不是一次性的函数执行,而是一次多轮对话过程。
27. 为什么要限制最大工具调用轮数?
客户端中设置了:
max_tool_turns = 5
这是为了防止模型反复调用工具,陷入循环。
例如:
模型调用天气预报工具
↓
拿到结果后又继续调用天气预报工具
↓
再次拿到结果后又调用
↓
无限循环
限制最大工具调用轮数是一种必要的安全措施。
| 设计 | 作用 |
|---|---|
max_tool_turns = 5 |
防止无限工具调用 |
| 工具执行失败时返回错误文本 | 让模型知道工具失败,而不是直接中断 |
| 每个 tool_call_id 对应一个 tool message | 满足 OpenAI/DeepSeek 工具调用协议要求 |
28. MCP Tool Server 与 Function Calling 的关系
很多人第一次看 MCP 时,会疑惑:
这和 OpenAI Function Calling 有什么区别?
可以这样理解:
| 对比项 | 普通 Function Calling | MCP Tool Server |
|---|---|---|
| 工具定义位置 | 通常写在客户端应用中 | 写在独立 MCP Server 中 |
| 工具执行位置 | 多数在同一进程中执行 | 可以在独立进程或外部服务中执行 |
| 工具发现方式 | 客户端通常写死 | 客户端通过 list_tools() 动态发现 |
| 扩展方式 | 修改客户端代码 | 新增或修改 MCP Server 工具 |
| 适用场景 | 简单应用、少量工具 | 多工具、跨服务、可复用工具生态 |
Function Calling 更像是“模型调用函数的格式”,MCP 更像是“工具服务的协议和组织方式”。
在这个 Demo 中,二者是配合关系:
MCP Server 暴露工具
↓
MCP Client 获取工具 schema
↓
Client 转换成 function calling 格式
↓
LLM 返回 tool_calls
↓
Client 通过 MCP 执行工具
29. MCP Inspector 调试服务端
在接入 LLM 之前,建议先单独调试 MCP Server。这样可以确认工具是否注册成功、参数是否正确、天气 API 是否能正常返回数据。
29.1 安装 MCP CLI
使用下面命令安装:
pip install "mcp[cli]" -i https://pypi.tuna.tsinghua.edu.cn/simple
29.2 启动 Inspector 调试
在项目目录下运行:
mcp dev server/weather_server.py
运行后,MCP Inspector 会启动调试页面。你可以在页面中查看:
- MCP Server 是否正常启动;
- 当前暴露了哪些工具;
- 每个工具的参数 schema;
- 手动填写参数并执行工具;
- 查看工具返回结果。
建议调试顺序如下:
| 阶段 | 调试目标 | 判断标准 |
|---|---|---|
| 第一步 | 检查 .env |
天气 API Key、Base URL 已配置 |
| 第二步 | 启动 MCP Server | mcp dev server/weather_server.py 能正常运行 |
| 第三步 | 查看工具列表 | 能看到 get_weather_warning 和 get_daily_forecast |
| 第四步 | 测试预警工具 | 输入 location 后能返回预警结果或无预警提示 |
| 第五步 | 测试预报工具 | 输入 location 和 days 后能返回天气预报 |
| 第六步 | 再运行客户端 | 让 LLM 自动判断并调用工具 |
这里的核心原则是:
先验证工具本身,再接入 LLM。否则链路过长,出错时很难定位。
30. 复杂问题测试:为什么要查两个工具?
假设用户问:
北京最近有没有天气预警?未来三天适合户外活动吗?
这个问题包含两个子任务:
| 子任务 | 对应工具 | 原因 |
|---|---|---|
| 查询是否有天气灾害预警 | get_weather_warning |
需要获取实时预警信息 |
| 判断是否适合户外活动 | get_daily_forecast |
需要未来几天温度、天气、风力、降水等信息 |
合理流程是:
先调用 get_weather_warning
↓
判断是否存在灾害预警
↓
再调用 get_daily_forecast
↓
获取未来几天天气趋势
↓
综合温度、降水、风力、预警
↓
给出户外活动建议
这就是 LLM + MCP 的价值:工具负责提供事实数据,大模型负责理解问题、拆分任务和综合判断。
31. 可测试场景整理
当前代码更像是工程 Demo,而不是论文实验。因此这里不编造准确率、延迟、性能提升等指标,而是整理成可运行测试项。
| 测试类型 | 示例问题 | 预期行为 |
|---|---|---|
| 单工具:预警 | “北京现在有天气预警吗?” | 只调用 get_weather_warning |
| 单工具:预报 | “北京未来三天天气怎么样?” | 只调用 get_daily_forecast |
| 多工具综合 | “北京有没有高温预警?未来几天适合户外运动吗?” | 先查预警,再查预报,最后综合分析 |
| 参数容错 | days=5 |
服务端回退到 3 天预报 |
| 配置缺失 | 不配置 QWEATHER_API_KEY |
服务端返回无法获取数据 |
| 工具调试 | 使用 MCP Inspector 手动调用工具 | 能看到工具列表和执行结果 |
32. 方法优势
| 优势 | 说明 |
|---|---|
| 工具和模型解耦 | 天气 API 封装在 MCP Server 中,LLM Client 不需要直接写天气请求逻辑 |
| 支持动态工具发现 | 客户端通过 list_tools() 获取工具,而不是写死工具列表 |
| 便于扩展 | 新增工具时主要修改服务端 |
| 适合复杂任务 | LLM 可以根据问题自动决定调用一个或多个工具 |
| 调试链路清晰 | 可以先用 Inspector 调试 Server,再接入 Client 和 LLM |
| 更接近 Agent 架构 | 模型负责决策,工具负责执行,客户端负责调度 |
33. 当前局限与改进方向
| 局限 | 说明 | 改进方向 |
|---|---|---|
| 城市名到城市 ID 的转换不够完整 | 工具参数主要依赖城市 ID 或经纬度 | 增加城市查询工具 |
| 依赖外部天气 API | API Key、接口权限、网络状态都会影响结果 | 增加错误处理和缓存 |
stdio 更适合本地调试 |
不适合直接作为远程服务暴露 | 后续可扩展 HTTP 或 SSE |
| 工具越多,模型越可能误选 | 大量工具会增加选择难度 | 做工具分组、工具路由或更强 system prompt |
| 结果格式是纯文本 | 后续处理不够结构化 | 返回 JSON,再由客户端统一格式化 |
| system prompt 依赖较强 | 工具使用策略主要靠提示词 | 加入更明确的任务规划逻辑 |
34. 对实际应用的启发
虽然这个 Demo 是天气查询,但它的模式可以迁移到很多场景。
| 应用场景 | MCP 工具示例 |
|---|---|
| 企业知识库 | 查询文档、检索 FAQ、读取内部规范 |
| 运维系统 | 查询服务器状态、分析日志、重启服务 |
| 数据分析 | 查询数据库、运行 SQL、生成报表 |
| 电商客服 | 查询订单、物流、售后状态 |
| 教育系统 | 查询课程、作业、考试安排 |
| 金融助手 | 查询行情、生成风险提示、读取研报摘要 |
迁移时可以遵循这个模式:
外部能力
↓
封装成 Python 函数
↓
注册为 MCP Tool
↓
Client 通过 list_tools 发现工具
↓
转换成 LLM function calling 格式
↓
模型根据用户问题自动调用
↓
工具结果回填给模型
↓
模型生成最终回答
35. 总结
这个 LLM + MCP 天气查询 Demo 的核心价值,不在于“查天气”本身,而在于它完整展示了一个大模型调用外部工具的工程链路。
服务端 weather_server.py 做了三件关键事情:
- 读取和风天气 API 配置;
- 请求天气预警和天气预报接口;
- 用
@mcp.tool()把函数注册成 MCP 工具。
客户端 mcp_client_deepseek.py 做了五件关键事情:
- 读取 DeepSeek 配置;
- 启动 MCP Server;
- 通过
list_tools()获取工具列表; - 把 MCP 工具转换成 function calling 格式;
- 处理模型返回的
tool_calls,执行工具并把结果回填给模型。
整体可以概括为一句话:
MCP Server 负责提供工具,MCP Client 负责调度工具,LLM 负责理解问题和综合回答。
如果要继续完善这个项目,最值得做的几个方向是:
- 增加城市名称到城市 ID 的查询工具;
- 把工具返回结果从纯文本改成结构化 JSON;
- 增强异常处理和缓存;
- 增加 HTTP 或 SSE 部署方式;
- 加入更多天气生活指数类工具,例如穿衣、运动、出行建议等。
36. 需要进一步确认的问题
- 当前代码主要使用城市 ID 或经纬度作为
location,如果希望用户直接输入“北京”“上海”等城市名,需要确认是否要增加城市查询工具。 get_daily_forecast支持 3、7、10、15、30 天预报,但不同天数接口是否都能使用,取决于和风天气账号权限,需要用户自行注册测试。- 当前 Demo 使用
stdio通信,如果要部署为远程服务,需要进一步补充 HTTP 或 SSE 版本。 - 当前实验部分没有真实运行截图和性能数据,因此本文只整理测试思路,不编写具体性能提升结论。
- 如果后续想写成更标准的“论文解读类”文章,还需要补充对应论文的研究问题、方法图、实验表格和评价指标。
浙公网安备 33010602011771号