我最近两个月一直在折腾一件事让 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、PromptsMCP 协议里定义了三个基础原语分别是 Resources、Tools 和 Prompts。我花了一些时间才彻底分清它们Resources用于读取数据的内容。它解决的是模型需要读到背景材料的问题典型场景是让 Agent 读取一份数据库表结构、一份项目文档、一张图片信息。Resources 是模型视角下的可读文件。Tools用于执行动作的函数。它解决的是模型要让系统帮它做一件事的问题典型场景是调用天气接口、执行一段代码、写一条数据库记录。Tools 是模型可以主动触发的操作按钮。Prompts用于复用的提示词模板。它解决的是某些任务有固定流程的问题比如例行周报生成代码审查这类场景Server 可以预定义好 Prompt 模板模型需要时直接加载。三者的关系我总结成一句话Resources 是读Tools 是写和做Prompts 是套模板。对于一个智能体应用Tools 用得最多Resources 在需要读取外部系统资料时很重要Prompts 则适合把高频任务标准化。下面的表格可以帮你快速对照原语语义数据流向典型场景Resources数据读取Server 到 ClientAgent 读取系统文档、表结构Tools动作执行Client 到 Server结果返回查天气、写数据库、跑脚本Prompts模板复用Server 到 Client例行任务模板、审查清单2.3 传输层stdio 与 HTTP/SSE以及 JSON-RPCMCP 消息的底层传输方式主要有两种stdio 和 HTTP/SSE。stdio 模式适合本地场景Host 通过启动一个子进程来运行 Server两者通过标准输入输出流传递消息。它的好处是隔离好、部署简单进程挂掉也不会影响宿主环境坏处是只能在本机使用不方便做远程调用。HTTP/SSE 模式适合远程场景Server 作为一个独立的 HTTP 服务运行Client 通过 HTTP 建立 SSE 连接来接收服务端推送的消息。需要注意SSE 是单向的所以实际双向通信时Client 往 Server 发消息通常走普通的 HTTP POSTServer 往 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_asciiFalse)}] 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: fBearer {MODEL_API_KEY}}, jsonpayload, timeout60, ) resp.raise_for_status() return resp.json()[choices][0][message] async def main(): server_params StdioServerParameters(commandpython, 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_noneTrue) 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 ServerAgent 主流程改成从 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 并描述清楚这件事了。