从天气查询 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. 文章大纲

本文会按照“先看全貌,再拆细节”的方式展开。

  1. 项目要解决什么问题;
  2. 项目整体结构是什么;
  3. MCP 服务端如何把天气 API 封装成工具;
  4. MCP 客户端如何启动服务端、获取工具列表并连接 LLM;
  5. LLM 如何根据用户问题决定是否调用工具;
  6. tool_calls 是如何被客户端执行并回填给模型的;
  7. async/awaitdotenvloggingstdio 等知识点在代码中的作用;
  8. MCP Inspector 如何调试服务端工具;
  9. 这个 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?

大模型擅长自然语言理解、推理和生成,但它不擅长直接获取实时信息。

例如用户问:

北京今天有没有天气灾害预警?未来三天适不适合户外活动?

这类问题有两个特点:

  1. 信息具有实时性,模型训练数据无法保证准确;
  2. 回答需要外部数据,例如天气预警、温度、风力、降水量等。

传统做法可能是在业务代码中直接写一个天气查询函数,然后把函数调用逻辑和模型调用逻辑写在一起。但这样会导致工具和模型强耦合,工具越多,客户端代码越复杂。

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_warningget_daily_forecast
Prompt 预定义提示词模板,用于特定任务 本文不重点展开

本文重点关注 Tool

Tool 可以理解为“模型可调用的外部函数”。不过大模型并不是直接执行 Python 函数,而是先生成工具调用请求,再由客户端执行对应工具。


7. 全流程:从用户提问到模型回答

整体调用链路如下:

sequenceDiagram participant U as User participant C as MCP Client participant L as LLM / DeepSeek participant S as MCP Server participant A as QWeather API U->>C: 输入天气问题 C->>S: 启动并连接 MCP Server C->>S: list_tools() S-->>C: 返回工具列表和 schema C->>L: 发送 messages + tools L-->>C: 返回 tool_calls C->>S: call_tool(tool_name, arguments) S->>A: 请求和风天气 API A-->>S: 返回天气数据 S-->>C: 返回工具结果 C->>L: 把工具结果追加到 messages L-->>C: 生成最终回答 C-->>U: 输出自然语言结果

用普通文字描述就是:

  1. 用户输入一个天气相关问题;
  2. 客户端启动 MCP 服务端;
  3. 客户端从服务端获取可用工具;
  4. 客户端把工具转换成 LLM 可识别的 function calling 格式;
  5. LLM 判断是否需要调用工具;
  6. 如果需要,LLM 返回 tool_calls
  7. 客户端解析工具名和参数;
  8. 客户端调用 MCP Server 执行工具;
  9. MCP Server 调用和风天气 API;
  10. 工具结果返回给客户端;
  11. 客户端把工具结果写回上下文;
  12. 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 查询参数,例如 locationlang
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:为什么这里必须用异步?

服务端和客户端都有大量 asyncawait

简单说:

  • 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 strint 城市 ID 或经纬度坐标 101010100116.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 命令行交互循环

可以用下面的类关系理解:

classDiagram class Configuration { +api_key +base_url +model } class Tool { +name +description +input_schema +to_openai_format() } class MCPServer { +initialize() +list_tools() +execute_tool() +cleanup() } class MCPClient { +initialize() +process_query() +chat_loop() +cleanup() } Configuration --> MCPClient MCPClient --> MCPServer MCPServer --> Tool MCPClient --> Tool

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__)

相比 printlogging 更适合真实项目。

对比项 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,用来告诉模型:

  1. 你是一个天气助手;
  2. 你可以使用天气预警和天气预报工具;
  3. 如果用户只问预警,只调用预警工具;
  4. 如果用户只问预报,只调用预报工具;
  5. 如果用户问复杂问题,需要综合调用多个工具;
  6. 如果涉及户外活动,需要结合温度、风力、降水、预警综合判断。

这就是工具调用中容易被忽略的一点:

工具 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 会启动调试页面。你可以在页面中查看:

  1. MCP Server 是否正常启动;
  2. 当前暴露了哪些工具;
  3. 每个工具的参数 schema;
  4. 手动填写参数并执行工具;
  5. 查看工具返回结果。

建议调试顺序如下:

阶段 调试目标 判断标准
第一步 检查 .env 天气 API Key、Base URL 已配置
第二步 启动 MCP Server mcp dev server/weather_server.py 能正常运行
第三步 查看工具列表 能看到 get_weather_warningget_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 做了三件关键事情:

  1. 读取和风天气 API 配置;
  2. 请求天气预警和天气预报接口;
  3. @mcp.tool() 把函数注册成 MCP 工具。

客户端 mcp_client_deepseek.py 做了五件关键事情:

  1. 读取 DeepSeek 配置;
  2. 启动 MCP Server;
  3. 通过 list_tools() 获取工具列表;
  4. 把 MCP 工具转换成 function calling 格式;
  5. 处理模型返回的 tool_calls,执行工具并把结果回填给模型。

整体可以概括为一句话:

MCP Server 负责提供工具,MCP Client 负责调度工具,LLM 负责理解问题和综合回答。

如果要继续完善这个项目,最值得做的几个方向是:

  1. 增加城市名称到城市 ID 的查询工具;
  2. 把工具返回结果从纯文本改成结构化 JSON;
  3. 增强异常处理和缓存;
  4. 增加 HTTP 或 SSE 部署方式;
  5. 加入更多天气生活指数类工具,例如穿衣、运动、出行建议等。

36. 需要进一步确认的问题

  1. 当前代码主要使用城市 ID 或经纬度作为 location,如果希望用户直接输入“北京”“上海”等城市名,需要确认是否要增加城市查询工具。
  2. get_daily_forecast 支持 3、7、10、15、30 天预报,但不同天数接口是否都能使用,取决于和风天气账号权限,需要用户自行注册测试。
  3. 当前 Demo 使用 stdio 通信,如果要部署为远程服务,需要进一步补充 HTTP 或 SSE 版本。
  4. 当前实验部分没有真实运行截图和性能数据,因此本文只整理测试思路,不编写具体性能提升结论。
  5. 如果后续想写成更标准的“论文解读类”文章,还需要补充对应论文的研究问题、方法图、实验表格和评价指标。
posted @ 2026-06-23 12:15  yong_2333  阅读(27)  评论(0)    收藏  举报