我最近两个月一直在折腾一件事:让 DeepSeek 从一个只会"回答问题"的模型,变成能自己决定"接下来该调用什么工具"的智能体。最开始我用的是常见的 Function Calling 方案,被不稳定输出折磨得不轻;后来切到 MCP 协议,反而不到一天就通了。这篇内容就讲讲 MCP 和 DeepSeek 如何融合:协议的核心概念怎么理解、最小可跑工程怎么搭、接入真实项目之前要定清楚哪些设计点,以及我实际踩过的坑。想用 DeepSeek 做 Agent 的开发者,可以直接从第三章的代码开始抄。
MCP 这个词最近在圈子里出现频率越来越高,但很多人对它到底解决了什么问题还是一头雾水。我自己也是一边看协议文档一边改代码,才慢慢把整个链路吃透。这篇文章不打算堆概念,我会先讲清楚"为什么需要 MCP",再把协议里最关键的几个点翻译成人话,最后给出一套能跑的最小工程和完整的排错记录。
1. 为什么我在 DeepSeek 上做"工具调用"时,会折腾到 MCP 协议上来
1.1 一次失败的 Function Calling 经历
我在一个模拟项目里做过这样一个需求:用户问"北京今天适合穿什么衣服",系统需要先查天气接口,再基于天气结果让模型生成穿衣建议。最初的实现方式就是现在很多人还在用的 Function Calling 流程:把天气查询函数的名字、参数格式、描述写进请求里,让模型在需要时返回一个结构化的 JSON 给我,我再解析这个 JSON,去调用真实的天气接口。
听起来顺理成章,但实际跑起来问题多得让我头疼。第一个问题是模型返回不稳定。同一个问题问十次,可能有两次返回的 JSON 里字段名是对的,有一次把日期格式从字符串传成了时间戳,还有一次直接在arguments里塞了函数定义里根本不存在的字段。我不得不在解析层写大量容错代码,处理各种"看似合理但实际不合法"的输入。
第二个问题是多轮对话中的上下文维护。用户可能先问"北京天气怎么样",再问"那上海呢",这里面隐含了一层"这轮如果没提城市,就沿用上一轮的城市"的意图。靠 Function Calling 返回的原始 JSON 很难承载这种会话状态,我必须自己在系统里维护一个"当前城市"变量,再把状态拼进下一轮 prompt,逻辑一多就非常容易乱。
1.2 MCP 要解决的是"连接标准化",而不是"模型更聪明"
折腾完那套方案,我最大的感受是:模型本身足够聪明,真正难搞的是模型和工具之间的连接方式。每个模型服务商有自己私有的 tool 调用格式,每个工具实现方又要分别兼容不同模型。今天想换一个模型服务商,所有工具调用代码都要改一遍;明天想加一个工具,又要重新设计 JSON Schema 并在模型 prompt 里反复调试。
MCP 做的事情很朴素:它把"工具提供方"和"工具使用方"彻底分开了。工具写得再复杂,只要实现一套 MCP Server,统一暴露成标准的工具接口;模型或者应用侧只要实现了 MCP Client,就能自动发现这些工具、调用这些工具、拿到标准化的结果。这种标准化的感觉很像 USB-C 接口,之前每个设备都有自己的充电口,现在大家约定同一个口,插上就能用。
对我这种只用 DeepSeek 做应用层的开发者来说,MCP 带来的最大价值不是模型能力变强了,而是我不用再为"怎么把工具喂给模型"写一堆胶水代码了。我把工具写成 MCP Server,模型侧只要能连 MCP,就天然能使用这些工具,换模型、换工具、增删工具都变得很轻。
1.3 换到 MCP 之后,我的工作方式发生了什么变化
从 Function Calling 切到 MCP 之后,最直观的变化是:我的业务代码里不再出现"根据函数名 if-else 分派"这种逻辑了。以前每加一个工具,主流程里就要加一个分支来处理它的参数和结果;现在工具都挂在 MCP Server 上,模型通过工具名自动发现和调用,主流程只剩一个统一的循环:模型提想法,Client 执行工具,结果回填,模型再继续。
另一个变化是调试方式。Function Calling 方案里,工具调用是"藏"在模型返回里的,出了问题很难定位是模型理解错了,还是我的解析代码错了。MCP 方案里,工具调用变成了明确的、可观测的环节,每次调用什么工具、传了什么参数、返回了什么结果,都有清晰记录,排错成本明显低了一个数量级。
2. 把 MCP 的三个核心概念翻译成人话:架构、原语、传输
2.1 Host、Client、Server:三个角色的分工
MCP 的架构里永远站着三个角色。用户的应用程序是 Host,比如我写的 DeepSeek Agent 主程序;Host 内部跑着一个 MCP Client,它负责和 MCP Server 建立连接、协商能力、发起工具调用;MCP Server 是真正干活的一方,它负责暴露工具、接收调用请求、返回结果。
我习惯用一个点餐的例子来记:Host 是坐在餐桌前的客人,Client 是服务员,Server 是后厨。客人不会直接闯进后厨,他只需要对着服务员报菜名;服务员把需求转述给后厨,再把做好的菜端回来。客人不需要知道后厨到底用什么灶、什么锅,也不需要知道食材仓库在哪里,他只需要和"服务员"这一个固定窗口打交道。
这个分工决定了 MCP 的扩展性。一个 Host 可以同时连多个 Server,就像一桌客人可以同时点多个档口的菜;一个 Server 可以被多个 Client 连接,就像一家后厨可以同时服务多桌客人。我在实际工程里会把不同领域的工具拆成不同的 Server,由部署方各自演进和升级,互不干扰。
2.2 三个原语:Resources、Tools、Prompts
MCP 协议里定义了三个基础原语,分别是 Resources、Tools 和 Prompts。我花了一些时间才彻底分清它们:
- Resources:用于读取数据的内容。它解决的是"模型需要读到背景材料"的问题,典型场景是让 Agent 读取一份数据库表结构、一份项目文档、一张图片信息。Resources 是模型视角下的"可读文件"。
- Tools:用于执行动作的函数。它解决的是"模型要让系统帮它做一件事"的问题,典型场景是调用天气接口、执行一段代码、写一条数据库记录。Tools 是模型可以主动触发的"操作按钮"。
- Prompts:用于复用的提示词模板。它解决的是"某些任务有固定流程"的问题,比如"例行周报生成""代码审查"这类场景,Server 可以预定义好 Prompt 模板,模型需要时直接加载。
三者的关系我总结成一句话:Resources 是读,Tools 是写和做,Prompts 是套模板。对于一个智能体应用,Tools 用得最多,Resources 在需要读取外部系统资料时很重要,Prompts 则适合把高频任务标准化。下面的表格可以帮你快速对照:
| 原语 | 语义 | 数据流向 | 典型场景 |
|---|---|---|---|
| Resources | 数据读取 | Server 到 Client | Agent 读取系统文档、表结构 |
| Tools | 动作执行 | Client 到 Server,结果返回 | 查天气、写数据库、跑脚本 |
| Prompts | 模板复用 | Server 到 Client | 例行任务模板、审查清单 |
2.3 传输层:stdio 与 HTTP/SSE,以及 JSON-RPC
MCP 消息的底层传输方式主要有两种:stdio 和 HTTP/SSE。stdio 模式适合本地场景,Host 通过启动一个子进程来运行 Server,两者通过标准输入输出流传递消息。它的好处是隔离好、部署简单,进程挂掉也不会影响宿主环境;坏处是只能在本机使用,不方便做远程调用。
HTTP/SSE 模式适合远程场景,Server 作为一个独立的 HTTP 服务运行,Client 通过 HTTP 建立 SSE 连接来接收服务端推送的消息。需要注意,SSE 是单向的,所以实际双向通信时,Client 往 Server 发消息通常走普通的 HTTP POST,Server 往 Client 推消息走 SSE 通道。我第一次调试时就因为搞反了方向,折腾了很久才发现消息根本没推过去。
无论哪种传输方式,MCP 的协议消息统一走 JSON-RPC 2.0 格式。这意味着每条消息都有jsonrpc、method、params、id这些固定字段。比如tools/list是获取工具列表,tools/call是调用某个工具。我在写代码时最需要注意的是id字段,它用来把请求和对应响应关联起来,异步场景下如果丢了id,响应就不知道往哪里送了。
3. 动手搭最小工程:DeepSeek 当大脑,MCP Server 当手脚
3.1 环境准备和目录结构
我建议直接用 Python 3.10 以上的版本,搭配虚拟环境来跑。需要安装的基础依赖只有两个:mcp库用来实现 MCP 的 Server 和 Client 端,requests库用来调用 DeepSeek 的 API 接口。安装命令很简单:
pip install mcp requests整个工程我建议拆成两个文件,职责分开:一个文件写 MCP Server,里面只放工具实现;另一个文件写 Agent 主程序,里面放 DeepSeek 对话循环和 MCP Client。目录结构大概是这样:
deepseek_mcp_demo/ ├── weather_mcp_server.py ├── deepseek_agent.py └── .env # 存放模型服务相关的环境变量为什么要把 Server 和 Agent 分开?因为在 MCP 的架构里,Server 是"工具端",Agent 是"使用端",两边是可以独立演进的。我本地调试的时候,甚至会把 Server 单独跑起来,先用官方调试工具看一下工具暴露是否正常,再启动 Agent 主程序做端到端测试,这样定位问题会快很多。
3.2 最小 MCP Server 代码:把"查天气"变成一个标准工具
下面这个文件就是最小的 MCP Server,它暴露一个get_city_weather工具,输入城市名,输出简单的模拟天气数据。实际项目中,你只需要把这里的模拟数据替换成真实天气接口的返回即可,其余逻辑不用动:
# weather_mcp_server.py import json from mcp.server import Server # 创建 MCP Server 实例 weather_server = Server("weather-server") @weather_server.list_tools() async def list_tools(): return [ { "name": "get_city_weather", "description": "查询指定城市当天的天气情况,返回温度、天气状况和穿衣建议", "inputSchema": { "type": "object", "properties": { "city": { "type": "string", "description": "城市中文名称,例如:北京" } }, "required": ["city"] } } ] @weather_server.call_tool() async def call_tool(name: str, arguments: dict): if name == "get_city_weather": city = arguments.get("city", "") weather_data = { "city": city, "temperature_celsius": 26, "condition": "晴", "advice": "适合穿短袖,白天注意防晒" } return [{"type": "text", "text": json.dumps(weather_data, ensure_ascii=False)}] raise ValueError(f"未知工具: {name}")这段代码里最核心的是两个回调:list_tools告诉客户端我这个 Server 能做什么,call_tool真正执行工具并返回结果。不同版本的mcpSDK 在启动方式上可能会略微调整,但这两个回调的注册方式基本稳定。工具描述里的description字段非常关键,因为模型是"读描述猜功能"的,描述写得越清楚,模型用错的概率越低。
3.3 Agent 主程序代码:让 DeepSeek 决定何时调用工具
接下来是 Agent 主程序。它会启动一个 MCP Client 去连接上面的 Server,拉取工具列表,然后进入一个循环:把工具列表和对话历史一起发给 DeepSeek,如果 DeepSeek 认为需要调用工具,就通过 MCP 执行工具,把结果回填后继续下一次模型调用;如果 DeepSeek 觉得问题已经回答完毕,就直接输出最终文本:
# deepseek_agent.py import asyncio import json import os import requests from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client MODEL_BASE_URL = os.getenv("MODEL_BASE_URL") # 模型服务商提供的兼容接口地址 MODEL_API_KEY = os.getenv("MODEL_API_KEY") # 模型服务商提供的 API Key MODEL_NAME = os.getenv("MODEL_NAME", "deepseek-chat") # 模型名称 def call_model(messages, tools): payload = { "model": MODEL_NAME, "messages": messages, "tools": tools, "tool_choice": "auto", } resp = requests.post( MODEL_BASE_URL.rstrip("/") + "/chat/completions", headers={"Authorization": f"Bearer {MODEL_API_KEY}"}, json=payload, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"] async def main(): server_params = StdioServerParameters(command="python", args=["weather_mcp_server.py"]) async with stdio_client(server_params) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() tools_result = await session.list_tools() tool_schemas = [tool.model_dump(exclude_none=True) for tool in tools_result.tools] messages = [{"role": "user", "content": "北京今天适合穿什么衣服?"}] for _ in range(5): message = call_model(messages, tool_schemas) messages.append(message) if message.get("tool_calls"): for tool_call in message["tool_calls"]: fn_name = tool_call.function.name fn_args = json.loads(tool_call.function.arguments) call_result = await session.call_tool(fn_name, fn_args) tool_text = "".join( item.text for item in call_result.content if item.type == "text" ) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": tool_text, }) else: if message.get("content"): print(message["content"]) break if __name__ == "__main__": asyncio.run(main())这段代码的核心循环我把它叫做"模型决策-工具执行-结果回填"三角循环。它把 DeepSeek 的推理能力和 MCP 的工具能力真正串了起来:模型负责判断是否需要工具、需要什么工具、传什么参数,MCP 负责把工具能力标准化暴露给模型。循环最多跑五轮,这是为了防止模型陷入"反复调用同一个工具拿不到结论"的死循环,属于一个很实用的兜底保护。
3.4 跑通后的链路长什么样
启动 Agent 后,实际发生的链路大致如下。DeepSeek 收到用户问题后,并不会直接回答,而是先判断"这个问题需要查天气才能回答",于是返回一个结构化的工具调用指令,里面写着get_city_weather和参数{"city": "北京"}。Agent 主程序拿到这个指令后,通过 MCP 调用天气 Server 拿到模拟天气数据,再把数据作为工具结果回填给 DeepSeek。最后 DeepSeek 结合天气数据生成穿衣建议,输出给用户。
我实测跑通的输出大致是这样:模型最终回复"北京今天 26 度、晴,适合穿短袖,白天注意防晒"。整个过程中我一行"工具分发逻辑"都没写,模型通过 MCP 自动发现了工具、自动构造了参数、自动处理了返回结果。对于我这种习惯把精力花在业务逻辑上的人来说,这种"声明式工具接入"的方式非常省心。
4. 接入真实项目前,先把"工具发现、工具描述、多工具串联"这三个设计点定下来
4.1 从本地函数列表迁移到 MCP 工具发现
很多现有项目的工具调用代码是写死在主流程里的:一个函数列表、一堆 if-else 分支、一套手动拼接 prompt 的逻辑。迁到 MCP 时,不建议一次性重构所有内容。我比较推荐的做法是:先把最常用的两三个高频工具包成 MCP Server,Agent 主流程改成从 MCP 动态拉取工具列表,然后观察模型调用效果,再逐步把其他工具迁移进去。
从"本地函数列表"到"MCP 工具发现"的转变,本质上是把"工具清单由代码维护"改成了"工具清单由 Server 主动暴露"。好处是以后加工具、改参数、换实现,都不需要改主流程代码,只需要更新对应的 Server 并重新部署即可。对 DeepSeek 侧的 Agent 来说,它每次对话前都会拿到最新的工具列表,不会因为代码更新出现"模型还在调一个已经删掉的函数"这种问题。
4.2 多工具串联:一个含搜索、计算、存储的示例设计
我在做实操时经常用一个"信息整理助手"的案例:用户要求查一个技术名词的资料,整理成一张卡片,并统计字数。这个任务天然需要三个工具协同,我给它们的命名和职责分别是:
web_search:接收查询词,返回相关的搜索结果摘要,对应"搜索"能力。calc_word_count:接收一段文本,返回字数统计,对应"计算"能力。save_card:接收卡片标题和正文,保存到本地文件,对应"存储"能力。
三个工具都放在同一个 MCP Server 里,Agent 主程序不需要为任何一个工具写分派逻辑。DeepSeek 拿到用户需求后,会自主决定调用顺序:先调web_search拿到资料,然后调calc_word_count统计字数,最后调save_card保存卡片。我在日志里看到模型自行编排步骤的时候,说实话是有点惊讶的,它真的像一个小员工在按流程办事。
不过多工具串联也带来了一个新的问题:工具之间如果存在依赖关系,模型必须把前一个工具的结果放进后一个工具的参数里。这要求我写工具描述时把"前置依赖"写清楚,比如calc_word_count的描述里要明确"text 参数应该填入前一步搜索到的摘要内容",否则模型很容易在第二步就编一个不存在的文本进去。
4.3 写好工具描述,比调模型参数更能提升成功率
这是我实验下来最值钱的一条经验:在 DeepSeek 接入 MCP 的场景里,工具描述的编写质量,对调用成功率的影响远大于反复调整模型参数。模型本质上是在"读说明书"决定怎么用工具,说明书写得含糊,模型就只能靠猜。
同一个查询天气工具,我试过两种描述。糟糕的描述只写"查询天气,参数 city";效果是模型偶尔把城市名传成拼音,偶尔把日期塞进参数里。经过优化的描述会写成"查询指定城市当天的天气情况,返回温度、天气状况和穿衣建议。city 为城市中文名称,例如:北京;不支持英文名和拼音;不要额外传入日期参数"。改完之后,连续测试二十次,参数错误率明显下降,模型基本都能规规矩矩地传一个中文城市名。
工具描述里建议包含以下几个信息:工具能做什么、每个参数的取值范围和格式示例、什么情况不要调用这个工具、参数之间的先后依赖。描述要像给一个不熟悉业务的新同事写交接文档,而不是给程序员写 API 手册。
5. 我踩过的四类坑:从连接失败到上下文被撑爆
5.1 stdio 连接失败:环境变量和工作目录的坑
最早期我遇到的一个问题,是 Agent 主程序启动时说 MCP Server 连接失败,程序直接退出。当时第一反应是代码写错了,后来排查发现根本不是调用逻辑的问题,而是 stdio 模式下子进程的环境问题。因为 Agent 是通过"启动一个子进程"来运行 Server 的,这个子进程的工作目录、环境变量都必须正确设置,否则 Server 内部引用的配置或文件路径就找不到。
我的排查链路是这样的:先在命令行里单独手动启动 Server 进程,看它能不能正常起来、有没有报错日志;然后确认 Server 文件路径是正确的绝对值,而不是相对路径;最后检查环境变量,比如MODEL_API_KEY、MODEL_BASE_URL在 Agent 进程里是否能被子进程继承。排查完之后,我把所有路径都改成了os.path.abspath形式,环境变量通过进程参数显式传入,连接问题就消失了。
5.2 工具返回结果太大:上下文被撑爆的典型症状
第二个坑出现在工具返回大量数据的时候。有一次我让一个工具去读取某个系统里的日志摘要,返回结果里有几千行原始数据,MCP 把这一大坨文本原样回填给了 DeepSeek。结果就是模型压力骤增,又或者开始"忘记"用户最初的问题,回复的内容东拉西扯。这个症状很典型:不是工具调用失败,而是工具结果"污染"了上下文。
解决方案是在 Server 端做结果治理。一是限制返回量,只返回前 N 条或前 N 个字段;二是做摘要,把原始数据在 Server 端先压缩成简短结论再返回;三是设置超长拦截,超过阈值直接截断并提示模型"结果过大,已截断,需要细化查询条件再试"。这三条我后来直接做成了一个通用的"结果规整层",放在所有工具的前面。
5.3 模型乱传参数:schema 校验之外的兜底方案
我前面提到过模型有时会生成 schema 里不存在的字段,这在复杂场景下出现的概率会更高。比如工具里定义了city和date两个参数,模型有时候会额外塞一个country进去,某些客户端 SDK 遇到未声明参数会直接报错,导致整次调用失败。
我给这类问题设计的兜底方案有三层。第一层,MCP Server 端对入参做宽松解析,只取代码里真正需要的字段,其他一律忽略,不报错;第二层,在 Agent 主循环里捕获工具调用异常,把错误信息按固定格式回填给模型,让它尝试修正参数后重试;第三层,重试次数不超过两次,超过就直接结束循环并提示用户"工具调用多次失败"。这三层逻辑加完之后,整套系统的稳定性提升非常明显。
5.4 权限边界:MCP Server 不是安全边界
还有一个值得强调的点:工具一旦暴露给模型,就成了模型的"能力",所以 MCP Server 本身的安全边界一定要提前设计。我在本地实验时,曾写过一个带文件读取功能的 Server,工具描述里明确说"只能读取 /demo 目录下的文件",但模型完全可能传一个路径试试水,比如传一个上层目录的路径。
不要指望模型天然守规矩,防护逻辑必须在 Server 端做死。文件类工具做目录白名单和路径归一化校验,数据库工具用只读账号,联网工具做域名白名单,远程调用场景还要做来源 IP 校验。个人项目也不例外,我自己的做法是给每个工具都加一条日志记录:谁调的、传了什么参数、返回了什么结果、耗时多少。这些日志在出问题时比什么都有用。
6. 从单工具到复杂智能体:一条我验证过的进阶路线
6.1 按领域拆 Server,而不是一个 Server 堆几十个工具
做多工具串联的时候,我一开始图省事,把所有工具都塞进同一个 MCP Server 里。工具一多,问题就来了:list_tools返回的工具列表越来越长,模型在几十个工具里选出正确目标的难度明显增大,调用错误率上升;而且每改一个工具都要重新部署整个 Server,影响面太大。
后来我把工具按领域拆成了几个 Server:一个专门负责文件读写,一个专门负责网络请求,一个专门负责数据处理。Agent 主程序按需连接不同的 Server,模型每次面对的工具列表都短而清晰,决策准确率明显提升。这个经验让我意识到,MCP Server 的粒度应该按照"职责单一"来划分,而不是按照"项目"来划分。
6.2 用工具化的方式做记忆与状态管理
智能体应用绕不开记忆问题。DeepSeek 本身没有持久记忆,多轮对话之间如果需要保留用户偏好或历史结论,必须由应用层自己管理。我的做法是把记忆做成一个 MCP 工具:save_memory(key, value)负责写入,get_memory(key)负责读取,必要时再加一个search_memory(keyword)做检索。
这样做的好处是模型的记忆读写变得非常自然。比如用户说"以后查天气都优先看空气质量",模型可以在工具调用环节主动调用save_memory("weather_preference", "air_quality_first"),后续对话需要时再通过get_memory取回。记忆不再是一段被硬塞进 prompt 的长文本,而是一个可以被模型主动操作的数据源,上下文压力小很多。
6.3 上线前给智能体加好评估和护栏
最后一条进阶建议,和代码无关,但我觉得比代码还重要:不要直接拿生产环境当测试环境。我给这套 DeepSeek + MCP 系统补了两样东西之后,才敢让它长期跑。第一样是一组固定的评估用例,比如"查天气并给穿衣建议""搜资料并保存卡片"这类典型任务,每次改完代码都跑一遍,对比结果是否稳定;第二样是上线护栏:工具调用轮次上限、单次请求超时、重要写操作前的二次确认提示。
个人项目最容易忽略的是限流和审计,我一开始也觉得没必要。但有一次我在调试时不小心让 Agent 陷入了一个死循环,连续调了几十次网络接口,把服务商的额度和对方系统的压力都惊动了,从那以后我就老老实实把轮次上限、请求间隔、失败熔断全部加上了。做 Agent 和写普通脚本不一样的地方就在于,模型的行为是有随机性的,你必须用机制去兜住这种随机性。
最后聊点个人体会。做完这套最小系统之后,我最大的变化是再也不在一个代码文件里堆"if 工具名 == xxx"这种分派逻辑了。MCP 的价值在于把模型能力和工具实现解耦,DeepSeek 的推理能力负责"判断",MCP Server 负责"执行",两边各自演进,互不拖累。想上车的同学,我的建议是先挑一个高频小工具,比如查天气、算时间,用一天时间把链路跑通,再慢慢加复杂度。再来一个小技巧:在 Agent 循环里把工具结果统一包上<tool_result>标签,DeepSeek 会更容易理解这段内容来自工具调用,你调试时也能一眼分清哪一步是模型说话、哪一步是工具输出。整个链路跑通之后,后续加工具就真的只是"写一个 Server 并描述清楚"这件事了。