Function Calling与MCP实战:大模型工具接入原理、选型与避坑指南

发布时间:2026/10/19 3:36:59
Function Calling与MCP实战:大模型工具接入原理、选型与避坑指南 大模型的工具调用Function Calling和MCPModel Context Protocol是这两年做AI应用绕不开的两个词。很多人花了不少时间把API文档翻了个遍可真到自己写代码的时候还是会卡在几个关键问题上工具描述到底怎么写模型才不犯迷糊MCP服务器和普通工具注册到底有什么区别两套方案到底什么时候选哪个这篇文章我打算把这些年实际踩过的坑、验证过的方法连同原理一起拆开讲清楚。这篇文章适合正在做智能体Agent、AI助手、自动化流程的同学参考也适合那些刚把大模型API调通、准备给模型装上“手脚”的开发者。里面不会只丢概念而是给出能直接抄作业的结构、代码思路和排查方案。1. 工具调用的整体思路为什么模型需要“手脚”1.1 模型不是只会聊天但它确实做不了“动作”先想一个基本问题大模型本身能做什么它能理解语义、生成文本、总结信息、改写代码但本质上它只是一个“预测下一个词”的机器。你说“帮我查一下这周的天气顺便订个会议室”模型即使理解这句话也没办法自己去访问天气接口更不可能去操作你的会议系统。要让模型具备行动能力就得在模型外面挂一层“工具层”。我们定义好一批函数或接口然后让模型在需要的时候主动选择调用哪个、传什么参数再把工具返回的结果交给模型继续处理。这整个链路就是大家常说的工具调用。最早的做法非常简单粗暴把工具的描述连同一个“输出JSON格式”的指令硬塞进提示词里让模型把“想调用的函数名参数”以JSON文本吐出来然后我们自己去解析。这套方案最大的问题是模型经常输出格式不稳定的JSON函数名偶尔会凭空捏造参数类型也可能对不上。后来主流大模型API陆续推出了结构化的函数调用能力也就是Function Calling模型不再输出“看起来像JSON的字符串”而是直接返回结构化的工具调用对象从协议层面解决了格式问题。1.2 MCP想解决的是另一个问题Function Calling解决的是“模型和工具之间的通信格式”但它没解决“工具怎么接入”的问题。每个工具都要单独适配每个应用都要自己写一套工具注册、鉴权、参数校验的代码。做过三四个Agent项目就会发现光是把不同的内部系统、外部API一个个接进来就够写几千行胶水代码。MCP的定位就是一个标准化的“工具接入层”。它定义了应用Host、客户端Client和工具服务端Server之间的通信协议。你把一个数据源或工具封装成一个MCP Server任何支持MCP的客户端都能直接发现它、调用它不需要为每个应用重复改代码。类比一下如果Function Calling是“统一了打电话的接线术语”MCP就是“统一了电话线接口、拨号规则和电话号码簿格式还制定了电话亭的尺寸”。两者解决的问题层次不同。从实际选型来看目前比较合理的思路是这样的方案解决的问题适配场景接入成本提示词JSON解析模型输出函数调用意图临时演示、快速验证、不方便改API的旧系统最低但稳定性差Function Calling结构化返回工具调用指令需要稳定、可靠地调用少量工具的应用低主流API都支持MCP多工具、多数据源统一接入与复用复杂Agent、企业数据中台、需要生态复用的场景偏高需要封装服务2. Function Calling实战拆解从原理到可落地的调用流程2.1 核心三要素tools参数、工具描述、两轮往返Function Calling的整个机制可以浓缩成三件事让模型知道有哪些工具、让模型决定调哪个、把结果反馈给模型。每个环节都有对应的参数和代码结构。第一件事叫“工具注册”。在API请求体里用tools参数列出所有可用的工具每个工具要包含type一般是function、function.name、function.description和function.parameters。这个parameters用的是JSON Schema格式相当于你把工具的“说明书”喂给模型看。一个典型的工具定义长这样{ type: function, function: { name: get_weather, description: 查询指定城市的实时天气和未来三天的预报参数city为城市名称例如北京、上海、广州, parameters: { type: object, properties: { city: { type: string, description: 要查询的城市名称 } }, required: [city] } } }第二件事是模型“做决定”。你把用户消息和tools列表一起发给模型模型如果判断需要调用工具返回的响应里会带一个tool_calls数组里面包含函数名name和参数arguments。注意这个arguments虽然是字符串但格式是可控的结构化JSON不像早期提示词方案那样需要在一大段对话文本里“大海捞针”。第三件事是“结果回传”。工具执行完以后你要把结果以role: tool的消息返回给模型并附上对应的tool_call_id。模型拿到工具结果后才能生成最终的自然语言回答。整个过程需要两次或更多次请求通常叫“两轮往返”。2.2 一个完整的调用流程示例我用一段接近真实项目的代码来展示这个流程。为了不依赖某个特定SDK这里用最通用的HTTP请求结构说明import json import requests # 第一步构造请求带上工具定义和用户问题 payload { model: your-model-name, messages: [ {role: user, content: 北京今天适合出门吗我想知道明天会不会下雨} ], tools: [ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气和未来三天的预报, parameters: { type: object, properties: { city: {type: string, description: 城市名称} }, required: [city] } } } ], tool_choice: auto } resp requests.post(https://api.example.com/v1/chat/completions, headers{Authorization: Bearer YOUR_API_KEY}, jsonpayload) data resp.json() assistant_message data[choices][0][message] # 第二步判断模型是否需要调用工具 if assistant_message.get(tool_calls): tool_calls assistant_message[tool_calls] # 这里假设只处理一个工具调用实际场景需要循环执行 call tool_calls[0] fn_name call[function][name] fn_args json.loads(call[function][arguments]) if fn_name get_weather: # 执行真实工具调用天气接口 result real_weather_api(fn_args[city]) # 第三步把工具结果回传给模型继续生成回答 messages payload[messages] messages.append({ role: assistant, content: None, tool_calls: tool_calls }) messages.append({ role: tool, tool_call_id: call[id], content: json.dumps(result, ensure_asciiFalse) }) final_payload { model: your-model-name, messages: messages, tools: payload[tools] } final_resp requests.post(https://api.example.com/v1/chat/completions, headers{Authorization: Bearer YOUR_API_KEY}, jsonfinal_payload) final_data final_resp.json() answer final_data[choices][0][message][content] print(answer)这段代码看起来简单但实际项目里需要处理的细节比这个多得多多个工具并行调用、工具执行异常、超时、结果太长、参数校验失败……我后面汇到问题清单里统一讲。2.3 工具描述怎么写模型才不会乱调这是Function Calling里最容易出问题的地方。很多同学把description写得特别简单类似“查询天气”结果模型经常搞不清楚要填什么参数、返回什么格式。我总结出了一套描述写法核心是“四个说清”说清工具用途不要只写“获取库存”要写“查询指定SKU在当前仓位的实时库存数量返回在库数和可售数”说清每个参数的含义city这个字段要说明格式比如“城市中文名例如杭州、成都”说清参数的限制条件比如“日期格式为YYYY-MM-DD不得早于今天”说清返回结果的格式比如“返回JSON包含字段temperature和humidity温度单位为摄氏度”打个比方工具描述就是给模型看的“产品说明书”说明书写得越清楚模型这个“用户”就越不会用错。而且描述文字本身也会参与模型token计算太长浪费太短误事需要反复打磨到一个平衡点。2.4 工具调用的几个关键参数和进阶玩法在实际项目里有下面几个参数值得专门研究。tool_choice参数控制模型调用工具的“自由度”。默认auto表示模型自己决定调不调设为required可以强制模型必须调用至少一个工具适合那种“这一步必须操作某个系统”的场景直接指定某个函数名{type: function, function: {name: xxx}}则相当于强制走某一条工具路径。parallel_tool_calls参数控制并行调用。模型在一次回答中可以返回多个工具调用比如“查天气设置提醒”这类没有依赖关系的动作可以一次性返回两个tool_calls。对于延迟敏感的Agent应用这个能力非常有用。需要强调的是并行调用不代表结果可以乱给每个tool_call要对应一个tool_call_id返回的时候需要一一匹配。还有一个很多人忽略的点工具返回的content最好是结构化的JSON字符串而不是一段人话描述。比如工具做完了“发起退款”你返回{status: success, refund_id: R12345}模型就能基于这个结果生成流畅的回复。如果你返回“退款成功了哈单号是R12345”模型虽然也能读但遇到复杂的多步推理任务结构化结果让它做后续判断更准确。3. MCP实战拆解把工具变成标准化的服务3.1 MCP架构速览Host、Client、Server各司其职MCP的架构里主要有三个角色Host是用户正在使用的AI应用比如一个桌面客户端Client在Host内部负责与Server建立连接Server是提供工具、数据源的一方可以运行在本地也可以部署在远程。每次工具调用的大致流程是Client启动时向Server发送initialize请求完成握手Server返回自己支持的协议版本和服务器信息接着Client调用tools/list方法拿到一批工具的定义当Agent需要执行某个动作时Client调用tools/call方法传入工具名和参数Server执行完返回结果。这套机制和Function Calling很像区别在于它的发现、调用、参数传递全是标准化协议而且不绑定具体模型厂商。对于没有接触过MCP的人来说可以把它想象成一个“USB接口标准”以前每个设备都要用自己的充电线现在有了统一接口任何支持这个标准的设备插上就能用。MCP Server就是那个“设备”AI应用就是那个“电脑”。3.2 用FastMCP实现一个最小可用的MCP服务器目前最顺手的方式是用官方Python SDK里的FastMCP类。下面是一个包含两个工具的迷你服务器能在五分钟内跑起来from mcp.server.fastmcp import FastMCP import json # 创建一个MCP服务器实例名称会显示在客户端列表里 mcp FastMCP(namedemo-tools-server) mcp.tool() def get_stock_price(symbol: str) - str: 查询指定股票代码的最新价格。 Args: symbol: 股票代码如 AAPL、MSFT、600519 # 实际项目里这里替换成真实的行情接口 data {symbol: symbol, price: 123.45, currency: CNY} return json.dumps(data, ensure_asciiFalse) mcp.tool() def send_message(phone_number: str, content: str) - str: 发送一条短信通知。 Args: phone_number: 接收号码11位手机号 content: 短信内容长度不超过70字 # 实际项目里这里替换成真实的短信服务商接口 return json.dumps({status: ok, message_id: MSG123456}) if __name__ __main__: mcp.run()注意这里有个现在很多新手还不熟悉的点mcp.tool()装饰器会自动从函数的类型注解和函数文档里生成JSON Schema。也就是说函数签名里的symbol: str和文档字符串的Args说明最终会变成tools/list返回的inputSchema。这意味着写好一个函数就相当于定义好了一个工具不需要像Function Calling那样单独维护一份JSON Schema。不过这也带来一个隐藏问题很多Python函数用Union、Literal、List这些类型时FastMCP能智能转化但如果函数里有复杂自定义类型它会自动把类型转成object参数描述就丢失了。遇到这种情况可以用Pydantic的Field来补充字段描述保持工具定义的质量。3.3 传输方式的选择stdio还是Streamable HTTPMCP支持不同的传输通道最常见的两种是本地场景的stdio和跨服务的Streamable HTTP。mcp.run()默认走stdio模式。在这种方式下Client启动一个子进程来运行Server脚本通过标准输入输出传递JSON-RPC消息。这种方式的好处是零网络配置、启动快很适合桌面端应用和本地调试。缺点是Server和Client必须部署在同一台机器上跨进程通信不好做权限隔离。如果MCP Server要提供给多个远程客户端使用就得走Streamable HTTP模式。通常做法是用一个Web框架比如FastAPI包一层或者直接用SDK提供的StreamableHTTPTransport。这种模式需要额外处理鉴权、跨域和并发问题但好处是服务可以复用、集中部署、统一升级。我自己在项目里一般按这个规则选只在本地给某个单一工具用选stdio如果Server要给多个不同应用共享或者要部署到服务器上直接上HTTP模式。3.4 客户端接入MCP Server的完整步骤接入MCP的客户端端目前主流做法是用不同语言的SDK。以Python为例连接一个stdio模式的Server大致需要四步创建session、初始化连接、列出工具、调用工具。import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): # 1. 启动MCP Server进程 server_params StdioServerParameters( commandpython, args[demo_tools_server.py], envNone ) # 2. 建立stdio通信管道并创建会话 async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: # 3. 完成握手初始化 init_result await session.initialize() print(fConnected to: {init_result.serverInfo}) # 4. 列出所有工具 tools await session.list_tools() print(Available tools:, [tool.name for tool in tools.tools]) # 5. 调用指定工具 result await session.call_tool( nameget_stock_price, arguments{symbol: 600519} ) print(result.content) asyncio.run(main())在这个例子里initialize()对应协议里的初始化握手list_tools()拿到的是工具元数据call_tool()则是真正执行。你如果把这些方法名和Function Calling的三段式流程对照会发现思路完全一致区别只在于MCP把发现工具的过程标准化了而且Client不需要提前知道Server定义了哪些工具。3.5 MCP调试中的安全与性能注意点MCP服务器不能只图能用就行接入真实业务的时候还有两件事务必考虑权限和效率。权限方面MCP协议本身不负责鉴权。HTTP模式下Server暴露在网络上任何人都可以尝试调用tools/list和tools/call因此必须在Server层自己实现鉴权比如用API Key、OAuth令牌或服务间调用的签名机制。即便本地stdio模式也要思考一个问题如果你在MCP Server里封装了一个“执行SQL”的工具任何链接到这个Server的客户端都等于拿到了数据库执行权限。我见过一些开发者在本地MCP服务器里放了删除数据的工具当时的场景是自用调试但如果哪天被其他应用连进来风险非常大。效率方面一个常见的坑是Server在tools/list里注册了大量工具而每次请求模型都需要处理全部工具描述。MCP本身的工具发现机制会让任何支持MCP的客户端自动获取所有工具这对Agent的上下文窗口是个不小的负担。后面我可以给个小建议不要把几十个工具一股脑塞进一个Server按业务域拆成多个Server让Agent按需连接。4. Function Calling与MCP的对比取舍到底该选哪条路4.1 两条路线的能力边界对比我经常被问到一句话既然Function Calling这么好用为什么还要折腾MCP反过来也有人问MCP这么标准是不是以后不用写Function Calling了我的看法是这两个东西不是同一个层次的问题与其纠结谁取代谁不如看它们在项目里的落点。从工具定义方式看Function Calling的工具是“注册”到每次API请求里的你调一次Model API就要把工具定义带一次。MCP的工具是“挂”在一个独立Server上的客户端通过协议发现工具。这意味着MCP的工具天然可以被多个应用复用而Function Calling的工具更像是单次会话内的临时配置。从生态兼容看Function Calling强依赖模型厂商对协议的支持你换一家模型可能要检查参数细节是否一致。MCP则偏模型无关客户端只面向标准的MCP协议模型怎么接进去是客户端内部的事。从灵活程度看Function Calling的处理链条更短流程控制更容易。MCP多了一层Server进程或远程服务多一个环节就多一分故障可能性排查链路更长。我在实际项目里通常按这样决策如果项目是单模型应用工具数量少少于10个而且不打算给其他应用复用直接用Function Calling最省事。如果工具数量大、有多个前端应用共用同一套数据工具、或者需要让Agent动态探索外部系统的能力上MCP。还有一种情况也会选MCP某团队内部已经封装好了一批数据服务用MCP包一层避开了每个项目重复写服务对接代码。4.2 两者不是非此即彼组合拳往往更顺手真正成熟的Agent架构里两种方式经常同时出现。比如外层用Function Calling驱动模型的基础动作内层再把那些复杂的、重复的工具封装成一个MCP Server然后在Function Calling的工具定义里注册一个名叫mcp_call_tool的工具参数是server_name和tool_name让模型通过这个“通用工具”去间接使用MCP里的工具。这种“网关模式”有一种很直观的好处模型每次只需要看一个工具定义不需要把几十个MCP工具全塞进上下文省token。同时MCP Server端新增工具时不用改模型侧的tools配置因为模型只需要知道“通过通用调用器去访问任何MCP工具”这一个入口。但模式也有明显的副作用模型可能不知道某个具体工具的存在和使用规则因为MCP里的工具描述没有被模型看到。解决的办法是在MCP Server的工具命名和描述上下狠功夫尽量让工具名自解释同时在mcp_call_tool的描述里把常用工具体验写成“使用指南”。简单说这种方案的成败取决于你写工具描述的能力比单纯的Function Calling更依赖文案功底。4.3 从“能用”到“好用”还要补齐三块配置不管选了哪条路想跑出一个稳定可靠的Agent光有工具调用还不够。以下三块配置是经验之谈缺了某一块后面很容易出问题。第一块是工具的统一错误码规范。工具执行失败时返回给模型的信息不能是一堆Python异常堆栈而应该是一段结构化的错误描述包括错误类型、可修复建议、重试次数限制。比如查不到数据返回{error: NOT_FOUND, message: 未查询到该城市的天气数据}模型能理解并产生合适的回复。如果直接把KeyError堆栈丢过去模型也只能干瞪眼。第二块是全局的“最大重试和最大步数”控制。Agent调用工具的时候很容易陷入死循环模型不断调用同一个失败的工具或者来回调两个相互依赖的工具。建议在Agent循环外面加一个计数器超过5轮后强制停止并触发一个“请直接回复用户当前拿到的信息不要再调用工具”的系统指令。第三块是完整请求日志。每次入参、工具调用、返回结果、token计数全部记录成结构化日志。我见过太多项目模型行为异常时只能靠猜。有了完整链路日志你能快速定位是模型选错工具还是工具返回数据异常还是上下文被撑爆。5. 常见问题与排查技巧实录写到这里我把实际开发中踩过的高频问题整理成一张速查表按症状、原因、解法三条线来描述。症状原因解法模型反复调用同一个工具陷入死循环工具返回的结果让模型觉得没拿到信息检查工具返回结构是否清晰错误信息是否包含建议动作模型完全不调用工具直接编答案工具描述不具体模型没意识到该调用重写description明确“不调用工具无法回答”的场景工具返回JSON模型却理解错返回内容不是JSON或字段含义模糊统一返回JSON并在工具描述中说明字段单位与取值多工具并行调用时结果对不上忽略了tool_call_id的一一匹配逐个遍历tool_calls按tool_call_id回传MCP客户端连不上stdio ServerServer启动报错或依赖包没装先把Server脚本单独跑一遍确认能正常启动再连客户端MCP工具列表为空Server加载了模块但没注册任何tool检查是否用mcp.tool()装饰确认装饰器作用范围上下文塞满导致后续回答错乱每次请求都带完整工具描述和历史消息用“网关模式”精简工具描述或对历史消息做裁剪总结模型调用了不存在的工具名工具定义与模型内置知识冲突在描述里加上“如无此工具请不要假设其存在”的说明或检查工具命名是否有歧义这里面有两个问题我想特别展开因为它们太容易被忽略。第一个是“模型调用了一个你能提供的工具但参数总是不规范”。比如你定义了get_weather要求参数是city但模型偶尔会传location。排查思路不是改工具而是改描述。把description改成“city是唯一合法参数其他字段一律不接受”然后让工具端做参数校验不合法参数返回统一错误。这样可以倒逼模型按规范传参也方便日志追踪。第二个是“工具返回数据特别大”。比如你让模型查一个数据库表的所有记录如果是海量数据直接把原始内容喂回给模型光token费用就足够吓人。正确做法是在工具端做“结果精简”只返回模型判断所需要的摘要信息。比如要查询用户订单工具端可以先做分页或只返回最近五条。这个必须在工具设计阶段想好否则上线后要返工。6. 从工具调用到Agent再往后走一步的思考6.1 工具调用是能力基础Agent才是上层建筑聊了很多工具调用的细节最后把视线往上拉一层。工具调用只是让模型“能用”工具Agent则是让模型“会用”工具。两者之间还夹着规划能力模型需要分解任务、确定调用顺序、观察中间结果、动态调整计划。比如“帮我规划下周三北京到上海的行程天气不好就改订高铁”这类任务会涉及到一系列工具调用先查天气、再查高铁票、再查酒店、再决定是否调整时间。这个流程里模型需要自己判断调用顺序甚至要回退重试。目前的实现方式主要靠“ReAct式循环”思考Thought→调用工具Action→观察结果Observation→再思考直到完成。6.2 给Agent增加记忆和状态管理的建议工具调用是无状态的但Agent必须是有状态的。建议在工具调用之外增加一个“状态槽”机制每次模型完成一步操作就把结果摘要写入状态槽后续步骤直接读取状态槽而不是重新把全部历史消息一股脑带回模型。这个方法有两个好处。一是省token长对话场景下效果非常明显二是隔离噪声早期环节的信息如果对当前环节没有帮助就不用再浪费模型注意力。最简单的实现是用一个全局字典存状态字段复杂一点则可以用向量数据库做记忆检索。6.3 最后分享一个让我少踩很多坑的习惯从一开始做工具调用到现在我最大的感受是不要试图一次性把工具调用链路调通再全部上线这样做太容易炸。我的习惯是永远先跑通“一个工具的最小闭环”用最简单的查询工具从用户提问到模型返回结果整个过程只调用一次工具全部打通、打点记录完整再往外面扩展第二个、第三个工具。每加一个工具都回归一遍这个最小闭环。虽然慢但稳定性是普通项目很难比的。等工具数量多了再按这个思路去沉淀成通用的Agent框架后面的项目就能直接用这套地基去建房子了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询