1. Function Calling 的三堵墙:没有协议、没有边界、没有生命周期
如果你和我一样,是从 Function Calling 开始接触 AI 工具链的,大概率会在某个项目做到三分之二时感到不对劲:模型确实能输出结构化的 JSON 参数,你在代码里写了一个分支去执行对应函数,再把结果塞回对话,让它继续决策。看起来是一条顺畅的调用链,但等你要把这套能力搬到第二个应用、第三个终端,或者只是换一个模型供应商,就会发现在 Function Calling 里积累的东西很难带走。
MCP(Model Context Protocol)刚出现时,很多人把它解释成“AI 界的 USB-C”,这个类比不算错,但真正动手做集成时又会发现它的价值不止于“统一接口”。它重新划定了工具的位置:工具不再是调用方内存里的一个普通函数,而是独立运行、自我描述、可被发现的服务。今天这篇我想把这两者放在同一张表里,从协议设计而不是框架功能的角度,拆一拆 Function Calling 为何不够用,以及 MCP 用哪些决定补上了缺口。
1.1 先退一步:Function Calling 到底是什么
Function Calling 是模型供应商提供的一种结构化输出能力。你在请求里附带一组工具描述,模型生成回答时不再说“我需要查下库存”,而是输出一个{"name": "query_stock", "arguments": "{...}"}结构。调用方拿到这个结构之后,在本地代码里路由到真实函数,执行完再把它作为消息传回去,模型再根据执行结果给出最终答复。这个过程对开发者友好,调试也直观,适合把一些小工具快速暴露给模型。
但麻烦也藏在这种便利里。工具列表依附在“某一次模型请求”上,真正的执行发生在你的应用进程里。这意味着同样一个函数,如果旁边另一个服务也想调用,它不能通过一次 API 请求里的 tool 列表就单独访问,必须把整个执行环境搬过去:复制代码、引入共享库,或者另包一个 HTTP 服务。走到最后一步,你就会发现自己其实在一遍又一遍地造轮子,而且每个轮子的接口风格还都不一样。
1.2 三堵墙的具体含义
第一堵墙是“没有协议,只有厂商样例”。不同模型供应商的 Function Calling 字段并不兼容。OpenAI 的调用格式、Anthropic 的工具描述、Google 的函数声明,底层都会用 JSON Schema 描述参数,但外层封装各有各的说法。今天你按 A 家的字段写了工具层,明天想换 B 家,就得写一层转换逻辑。小项目里这是成本,大项目里这是负担。MCP 没打算定义一个“模型该怎么理解工具”,而是定义了一套独立于模型的报文规范,让工具在模型层之外也能被同一个客户端复用。
第二堵墙是“执行没有边界”。Function Calling 的每个函数都跑在调用方进程内,模型拿到的是函数返回结果,而调用方必须自己处理密钥、环境变量、运行上下文。更现实的问题是权限:原函数如果直连数据库或生产环境,它所用的凭证往往就暴露在应用代码里。调用方进程权限过大,工具本身又没有独立的执行沙箱,这会成为隐性的操作风险。
第三堵墙是“生命周期不存在”。多数 Function Calling 场景是一次请求、一次调用、拿到结果就结束。但如果工具本身有状态呢?比如一个持续监听文件变化的工具,一个需要分步执行的长任务,或者一个要跟外部系统建立会话的查询,Function Calling 都没有合适的承载形态。它适合“即调即走”的短操作,却无法表达一个长期存在的服务。
1.3 为什么简单场景下它仍然够用
说了这么多,并不是要让所有项目立刻抛弃 Function Calling。如果你的场景是单体应用、工具数量少、调用频率不高,而且没有第二个消费端,那么 Function Calling 依然是性价比最高的选择。它不需要额外部署服务,不需要考虑进程间通信,一个函数文件就能跑通。MCP 的价值点是“当工具开始被多个应用、多个模型、多个场景复用时,需要一层独立于应用的服务边界”,这时候再上协议才划算。
| 维度 | Function Calling | MCP |
|---|---|---|
| 工具定义格式 | 随模型厂商变化 | 统一 JSON Schema 描述 |
| 执行位置 | 调用方进程内 | 独立 server 进程 |
| 凭证粒度 | 集中在调用方 | 各 server 本地持有 |
| 跨应用复用 | 需要重复接入或改造 | 一次接入多处消费 |
| 生命周期 | 随请求结束 | 可建立长连接会话 |
2. MCP 的设计出发点:把工具调用从“参数协议”升维成“服务协议”
Function Calling 解决的是“模型如何吐出一段参数”,MCP 解决的是“工具如何作为独立服务存在”。这个区别是整个设计的分水岭。
2.1 一次连接,两端都要亮出能力
MCP 的消息基座是 JSON-RPC 2.0。你可能已经熟悉 REST API:用不同的 URL 表达不同资源,用 GET/POST 表达操作。MCP 走的不是这条路,它更像一个方法总线——无论你是列工具、读资源、发起调用,还是推送日志,都通过同一套method和params结构完成。
客户端连接一个 MCP server 时,通常先发一条初始化消息:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "clientInfo": { "name": "demo-client", "version": "1.0.0" }, "capabilities": {} } }服务端返回自己的协议版本和能力声明后,客户端再发送 initialized 通知,随后才进入正常的工具发现与调用阶段。这套流程比 Function Calling 重,但它带来了一个重要能力:双方可以协商。客户端告诉服务端自己支持哪些能力,服务端也可以只暴露自己愿意开放的能力。这是一种“能力协商”,而不是假设所有端都一定会长成同一套模型。
2.2 服务端成为独立的执行边界
MCP 里最值得琢磨的一个决策是:工具不再以一个纯函数存在,而是由一个服务器进程对外暴露。这个服务器可以跑在本地,也可以跑在远端;可以由客户端拉起,也可以由部署平台托管。调用方拿到的永远是一个接口描述和一次调用结果,而不是一段可以直接嵌入自己进程的代码。
这带来一个很实际的改变:工具的运行责任被分离了。原来工具跑在应用里,应用挂了工具就没了;现在工具跑在自己的 server 进程或者远端节点里,调用方只是一个消费方。对于工具持有方来说,它可以单独发布、单独升级、单独鉴权;对于调用方来说,它不需要看到工具背后的数据库连接串、凭证路径和操作系统依赖。
2.3 传输可以变,报文必须一致
MCP 允许不同的传输方式:本地开发常用 stdio,也就是客户端直接拉起一个 server 进程,通过标准输入输出传递 JSON-RPC 消息;跨进程或者远端场景则可以用 HTTP、SSE 这类传输。为什么强调“传输可以变”?因为协议层真正的资产不是网络通道,而是消息格式本身。
同样一条tools/call请求,不管 server 是本地进程还是远程服务,报文结构是一致的。开发者调试本地工具时用的客户端,到生产环境连接远端 MCP server 时依然适用。这也是它和“厂商 API 字段”最大的区别:字段规则随厂商走,消息规则随协议走,后者才具备跨模型迁移的可能。
2.4 从语言实现解耦
Function Calling 里你写的是一个语言函数,调用方和实现方的依赖关系紧密到难以拆分。MCP 里你写的是一个可描述的服务,客户端只关心你暴露的接口。于是当你想把某个工具迁移给另一个团队,或者把同一个工具同时接入 IDE、网页端、命令行客户端时,不需要重新处理原函数所在项目的依赖链。这就是“参数协议”和“服务协议”的分界线。
3. 拆开 MCP 骨架:Resources、Tools、Prompts 三个原语各司其职
MCP 并没有简单地把 Function Calling 复制一遍,而是定义了三个互相补充的原语。理解它们,就能理解 MCP 为什么值得单独成为一个协议。
3.1 Resources:把上下文接到对话里
Resources 在语义上类似于“只读的数据面”。一个 MCP server 可以把本地文件、数据库 schema、项目文档、监控指标都暴露成带 URI 的资源。客户端可以主动列举资源、读取资源,也可以由服务端在合适的时机把资源变化推送出来。
我之前做过的项目里,有一个内部知识库工具就直接被包装成了一个 Resources 源。它的工作不是“执行某个操作”,而是“提供当前版本的关键上下文”。模型在回答时不需要调用复杂工具,只要把资源内容作为背景信息注入即可。从工程角度看,这比把大段文档拼进 prompt 更可控:服务端决定数据的生产与更新,客户端决定什么时机读取,而不是每次漫无目的地全部塞给模型。
3.2 Tools:可以被调用的执行能力
Tools 是离 Function Calling 最近的组件。它的名字、描述、参数 schema 都可以通过tools/list暴露给客户端,客户端再根据模型的需要发起tools/call。与 Function Calling 不同的地方在于,MCP 对工具返回的结构做了规范化,要求返回一组 content block,而不是一个裸字符串。
这样做的好处是渲染解耦。普通文本、图片、结构化数据都能在 content block 里表达,客户端拿到后可以按照自己的界面能力渲染,而不是只把字符串拼进对话。比如一个截图分析工具,可以返回一张 base64 图片和一个描述文本块,前端就能同时展示图片与结论。这个小设计看似不起眼,却让工具的结果具备了跨端复用能力。
3.3 Prompts:把流程模板沉淀成资产
Prompts 这个原语最有想象力。它不是一个“提示词字符串”,而是一段可复用的多步对话模板。服务端可以定义 “代码审查” 这个 prompt,客户端触发后,服务端返回一组消息序列,引导模型按照既定流程执行。
这对团队协作价值很大。过去提示词散落在各个客户端、各个工程师的配置里,版本更新靠复制粘贴。MCP 把流程模板放到 server 端统一维护,客户端只是触发和执行,模板本身成为可管理的资产。项目的操作规范、分析流程、报告模板都能以 Prompts 形式沉淀下来。
3.4 最小 server 长什么样
这里用 Python 的 FastMCP 库写一个极简示例,方便你直观感受:
from mcp.server.fastmcp import FastMCP mcp = FastMCP("demo-server") @mcp.tool() def add(left: int, right: int) -> int: """两个整数相加""" return left + right @mcp.resource("note://today") def get_today_note() -> str: return "今日重点是迁移 MCP" if __name__ == "__main__": mcp.run(transport="stdio")启动这个脚本后,它便成为一个可以通过 stdio 通信的 MCP server。用兼容 MCP 的客户端连接后,客户端会看到add这个 tool 和note://today这个 resource。整个过程里模型厂商并不感知,它只是从一个符合协议的端点获取能力。这个最小模型足以说明:MCP 的核心不是某个宏大平台,而是一套把“能力”打包成标准服务的方法。
4. 被大多数教程忽略的设计哲学:上下文聚合、会话生命周期与权限边界
讲 MCP 的教程很多,但真正值得深入玩味的是它背后的设计取舍。我理解下来,有三个点最容易被低估。
4.1 上下文聚合:连接多个数据源,而不是只连接多个函数
Function Calling 一次调用通常只解决一个具体动作。MCP 的设计却把“连接多个数据源”放在更优先的位置:一个 MCP 客户端可以同时连接多个 server,每个 server 提供自己的 resources 和 tools。客户端在拿到这些能力列表后,由模型判断应该使用哪些。
我在实际项目里同时挂了一个数据库 server 和一个浏览器自动化 server。数据库 server 提供只读的查询工具,浏览器 server 负责打开页面并获取渲染结果。模型在一个对话里既查询了数据库,又收集了页面实时数据,最后把两边的结果合在一起给出结论。这种多源上下文聚合是 Function Calling 难以做到的,因为你很难在一套工具定义里既承载数据读取,又承载跨进程编排。
4.2 会话生命周期:从 initialize 到 shutdown
MCP 引入了明确的会话生命周期。连接建立时先初始化协商,连接存活期间可以持续交互,关闭时通过特定报文结束。这套机制让 server 能够在会话内维持状态,比如记录客户端偏好、暂存中间结果、连续推送进度。
这看起来是协议细节,实际上直接影响工程实现。一个长耗时任务在 MCP 场景里可以和客户端保持连接,服务端用进度通知持续汇报状态;而在 Function Calling 场景里,你只能阻塞等待函数返回,或者自己改造一套异步机制。MCP 把异步和状态变成协议的一部分,让制造复杂工具的团队不需要重新发明轮子。
4.3 权限边界:从函数级走向服务级
MCP 的另一个哲学是“让工具拥有自己的运行边界”。server 可以持有自己的密钥、读取自己的配置、访问自己需要的外部系统,而客户端只看到接口和返回值。这种隔离在治理层面很关键,因为它让权限粒度从一个函数缩小到一个服务单元。
举例来说,如果一个银行应用的工具需要访问用户账户数据,Function Calling 模式下密钥要么存放在应用环境里,要么被注入到工具执行的进程里,边界模糊。MCP 模式下密钥可以放在独立的账户服务进程里,调用方只发送参数,服务端在自身环境内完成鉴权和访问。你需要控制的节点从一个巨大的应用进程,变成一个可独立审计的 server。
4.4 关于它做不到的事
MCP 不是安全层,也不是万能胶水。它规范了工具之间、工具与客户端之间的对话格式,却不改变工具本身的推理能力。你的工具返回的内容含糊,MCP 不会自动把它变清晰;你的 server 存在漏洞,MCP 同样不会替你挡住。它的价值是“让工具更容易被组合、替换和治理”,而不是“让工具变得更聪明”。如果一开始就把这个边界画清楚,后续不会对它产生不切实际的期待。
5. 从热搜里翻出三个高频翻车点:超时、日志、幂等
最近社区里关于 MCP 的问题越来越多,除了“MCP 是什么”这种入门问题,真正让开发者卡住的多半是实际运行时的小环节。我翻了翻手头碰到过的案例,三个高频翻车点值得单独拿出来讲。
5.1 超时:客户端默认快速失败
很多 MCP 客户端为了避免请求悬挂,会设置一个较短的超时时间,常见的是 30 秒。对于大多数查询类工具,30 秒够用;但一旦你的 server 要做 Blender 渲染、Unity 场景导入、浏览器大规模页面抓取这种事,30 秒根本不够。
我见过一个把几何计算封装成 MCP 工具的项目,模型请求一次后客户端直接超时,日志里什么都没留下。排查后发现不是工具逻辑问题,而是客户端默认超时太短。解决办法有两个方向:如果任务确实需要长时间计算,最稳的方式是让工具立即返回一个任务 ID,再提供另一个状态查询工具;如果任务本身具备分阶段能力,就用 MCP 的进度通知机制持续上报。单纯把客户端超时调大只能临时缓解,并不能解决长任务的管理问题。
5.2 日志:别污染 stdio 通道
如果你用 stdio 方式运行本地 MCP server,那么 stdout 被协议消息占用。这时候你如果图省事在代码里console.log打印调试信息,这些文本会被客户端当成 JSON-RPC 报文来解析,轻则日志错乱,重则直接中断通信。
正确做法是把日志写到独立通道。本地调试时至少要用标准错误输出,而不是标准输出;更规范的方式是使用 MCP SDK 自带的日志能力,通过协议层的logging/setLevel和消息通知上报,让客户端统一收集和展示。自定义日志管理也是一样的原则:你选任何日志框架都可以,但必须保证日志不混入 stdout 的协议流。
import logging from mcp.server.fastmcp import FastMCP logger = logging.getLogger("demo-server") logging.basicConfig(level=logging.INFO) mcp = FastMCP("demo-server") @mcp.tool() def ping() -> str: logger.info("someone called ping") return "pong" if __name__ == "__main__": mcp.run(transport="stdio")这样运行后,日志会出现在标准错误或独立日志系统里,不会干扰协议通信。很多新上手 MCP 的人第一周就栽在这个点上,检查代码逻辑找不出问题,最后发现是日志把 stdout 挤爆了。
5.3 幂等:客户端重试,服务端不能重复生产副作用
网络请求存在不确定性,MCP 客户端在超时或连接异常时可能会重试。如果你的工具不是只读操作,而是“生成报告”“发送通知”“创建资源”这类有副作用的动作,就必须考虑幂等。
我踩过的坑是一个报表生成工具。客户端第一次调用时服务端已经生成了文件,但响应在网络上丢失,客户端重试后又生成了一份重复报表。解决办法是在工具层支持幂等键:客户端传一个请求 ID,服务端用 ID 判断是否已处理,已处理就直接返回之前的结果。这跟普通微服务设计的思路一致,但放到 MCP 里容易被忽略,因为很多人还停留在“函数调用不会重复”的直觉里。
5.4 生态为什么热闹:Blender、Playwright、BurpSuite 都不缺
从热搜词里可以看到,Blender MCP、Playwright MCP、Chrome DevTools MCP、BurpSuite MCP、Unity MCP 这些名词在快速出现。你会发现这些工具差异很大:有的是设计软件,有的是浏览器自动化,有的是安全测试工具。但它们的共同点是被封装成标准 MCP server 之后,客户端只需要一套接入逻辑,就能消费这些完全不同的领域能力。
这其实是协议价值最直接的体现。Figma、Blender、Unity 这些软件各有自己的脚本接口和本地生态,如果没有 MCP,你每接入一个工具,都得写一套专门的适配代码。MCP 出现后,工具作者只要写一个 bridge server,把软件的脚本接口翻译成标准报文,任何支持 MCP 的客户端便都能使用。生态热闹不是因为某个模型特别好用,而是标准协议降低了所有工具的接入成本。
6. 从 Function Calling 迁移到 MCP 的落地路径,以及不做迁移的理由
如果你已经有一定 Function Calling 基础,迁移到 MCP 并不需要推翻重写。真正需要的是一次思路转换:从“写函数”变成“写服务”。
6.1 第一周:把现有函数包成标准 server
最稳妥的迁移方式是不要一上来就动核心业务,而是挑一个不敏感、调用频率低的工具先试水。我的建议分四步:
- 列出当前用 Function Calling 实现的工具清单,明确输入输出结构。
- 为每个工具用 JSON Schema 描述参数,尽量沿用原有定义。
- 用 MCP SDK 写一个最小的 server,把这些函数注册成
@mcp.tool()。 - 用 MCP Inspector 或任意兼容客户端连接,验证调用链路跑通。
这一步的目的不是马上替换生产环境,而是让团队熟悉“工具作为一个独立进程”的心智模型。你可能会发现有些函数里引用了全局变量、隐式依赖调用方环境,这些问题在 Function Calling 里不明显,一旦拆成独立 server 就暴露了。早暴露反而是好事。
6.2 第二步:把输入输出从“面向模型”改成“面向协议”
Function Calling 的参数定义往往以模型理解为优先,而 MCP 的参数定义要兼顾客户端展示和其他消费端。写出更准确的描述、为返回内容增加结构化字段、区分只读工具和写操作,这些都会提升迁移后的质量。
我通常建议在工具命名上就把副作用写清楚。比如get_user_profile可以只读,update_user_profile就要标注为写操作。客户端的权限策略可能基于工具名称或能力分组做判断,清晰的命名比后面加一堆文档更有效。
6.3 什么情况不做迁移
不是所有场景都应该迁移。如果工具只在一个应用内使用、没有跨端复用诉求,而且要求极低延迟、极低资源开销,那么 MCP server 的额外部署和通信开销确实没必要。MCP 的价值建立在复用和组合上,单点使用的工具直接留在 Function Calling 里反而更高效。
另一个不该迁移的场景是“临时脚本”。如果你只是在一个 Jupyter Notebook 里让模型帮忙调个函数,增加一个 server 进程纯属多余。把握好边界:它被几个消费端需要?未来会不会被其他模型调用?答案是“不确定”时,可以先不上 MCP。
6.4 我踩过一圈之后留的三个判断
第一,MCP 最适合作为“团队级基础设施”来建设,而不是每个人各自加一个 server。给 server、凭证、资源这三类东西找一个集中管理的位置,否则会从一个编程问题演变成配置管理问题。
第二,协议层要尽早把日志、幂等、鉴权当一等公民。很多项目先用 demo 跑通,上线后才发现日志采集不了、重试导致重复操作、密钥散落在多个环境里。MCP 的协议给了一半的支持,另一半需要工程纪律来补。
第三,不要把所有工具都做成一个 server。我更习惯按领域拆分:数据库一组、浏览器一组、设计软件一组。表面上多部署几个进程,实际上每个 server 的权限边界更清晰,出问题时影响面也更小。这个决策在项目第一天看不出区别,等工具数量增长到两位数时,你会感谢当初的克制。
MCP 和 Function Calling 的争议,本质上是“一次性调用”和“基础设施化”的之争。如果你正在构建的不只是一次对话,而是一套会持续演化的 AI 能力平台,那么与其继续在 Function Calling 里补协议、补边界、补生命周期,不如早点按服务协议的方式来设计。把这些边界想清楚,你的工具才经得起多次消费端的轮番调用。