news 2026/10/22 5:08:46

从Function Call到MCP到Agent:大模型工具调用开发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从Function Call到MCP到Agent:大模型工具调用开发实战

刚接一个内部项目的时候,我遇到过这么个情况:某团队的文档越攒越多,负责人想让大模型“看懂”这些文档,顺便还能把里面提到的最新系统状态拉到对话里来。最初的想法很简单——写一段提示词,把文件路径塞给模型,让它自己读。结果真上手才发现,模型只会对着路径“信口开河”,压根不具备访问文件系统的能力。后来折腾了一圈,才理清楚真正缺的是三样东西:模型能不能“说人话”地表达自己需要调哪个接口,接口怎么以一种通用的、低成本的姿势暴露给模型,以及谁来拆解任务、循环调度、判断结果到底行不行。这三样东西,就是大家天天挂在嘴边的Function Call、MCP Server和Agent。

这篇文章我想用自己实战过的一条完整链路,把这三样东西挨个拆开。不需要你有很深的算法背景,我会从“这东西到底是干嘛的”讲到“最小可用代码长什么样”,再补上我在真实项目里踩过的坑。无论你是刚准备接触大模型应用开发,还是已经在写一些带工具调用的脚本,这篇都适合拿来当一份能直接照着操作的入门笔记。

1. Function Call:先让大模型学会“伸手要工具”

1.1 为什么只有聊天能力远远不够

大模型本质上是一个“接上文、猜下文”的机器。你给它一句“今天天气怎么样”,它能把句子接得通顺,甚至接得很有条理,但如果它不会查天气接口,那它就是在一本正经地“编”。区别在哪?区别在于它的知识停在训练完成的那一刻,它对你电脑里的文件、数据库里的记录、某个内部系统的实时状态,一概不知。

所以要让模型真正“干实事”,第一步不是让它变得更聪明,而是让它能明确表达“我现在需要调用某个工具”。比如它应该说“我需要调用一个叫做query_weather的工具,参数是city=Beijing”,而不是说“我觉得今天北京天气不错”。前者是一个可以被程序捕获并执行的结构化指令,后者只是一句自然语言。

这件事就是Function Call(也有人叫Function Calling、工具调用)。模型在跟你对话的过程中,基于用户的提问和它自身的能力边界,判断“该调用工具了”,然后在回复里输出一个高度结构化的调用请求。程序拿到这个请求之后,去真实环境里调用对应的方法,再把结果塞回给模型,让模型基于真实数据继续生成最终答案。

1.2 它的输出到底长什么样

我举个例子。假设我定义了一个工具函数,用来查询商品库存:

{ "name": "check_stock", "description": "查询指定商品的实时库存数量", "parameters": { "type": "object", "properties": { "product_id": { "type": "string", "description": "商品ID" }, "warehouse": { "type": "string", "enum": ["east", "west", "south"], "description": "仓库编码" } }, "required": ["product_id"] } }

当用户问“东仓的A001还剩多少”时,模型不会直接回答,而是先输出类似这样的结构化结果:

{ "name": "check_stock", "arguments": { "product_id": "A001", "warehouse": "east" } }

你的程序拿到这个结果,检查一下它存在且合法,然后去数据库里执行查询,最后把查询结果以“工具返回消息”的身份追加进对话历史里,再交给模型做二次生成。整个链路的关键判断就是“是否该调用工具”,以及“参数传得对不对”。

我见过不少刚上手的人困惑于“模型是不是自己把函数跑了”。不是的。模型只是“提议”调用,真正执行是在你的程序进程里。你可以选择执行、拒绝执行、甚至篡改参数再执行。这是Function Call和“模型内置插件”这类方案一个本质的区别:你拥有最终控制权。

1.3 最小可复现的伪代码流程

我不想把代码堆得太复杂,用一个完全可运行的Python伪流程来展示核心逻辑:

user_input = "东仓的A001还剩多少?" messages = [ {"role": "user", "content": user_input} ] # 第一次请求:把可用工具声明传给模型 response = chat_completion( messages=messages, tools=[check_stock_schema] # 就是上面那段JSON ) # 如果模型认为应该调用工具 if response.tool_calls: fn_call = response.tool_calls[0] # 这里才真正进入你的业务代码 result = check_stock( product_id=fn_call.arguments["product_id"], warehouse=fn_call.arguments["warehouse"] ) # 把工具结果回传给模型 messages.append({ "role": "tool", "tool_call_id": fn_call.id, "content": json.dumps(result) }) # 第二次请求:让模型基于真实数据组织最终回复 final_response = chat_completion(messages=messages) print(final_response.content) else: # 模型觉得不需要工具,直接回答 print(response.content)

这段流程看起来简单,但它点出了Function Call最核心的循环:声明工具 → 模型决策 → 程序执行 → 结果回流 → 模型总结。后面讲到Agent的时候你会发现,Agent的本质就是在更高维度上反复做这件事。

1.4 小白最容易踩的坑

  • 参数幻觉:模型偶尔会编造不存在的仓库编码,或者把“A001”误传为“A01”。解决办法是严格约束枚举值和字段格式,做好入参校验。我的经验是,任何工具的入参都必须做一遍业务校验,不能默认模型一定传对。
  • 工具描述不清晰:如果description写得含糊,模型会选择困难,甚至同时调用多个工具。建议给每个工具写清楚“什么时候该用、什么时候不该用”,比如check_stock和check_price都接收product_id,但一个用于库存、一个用于价格,描述里就要明确区分。
  • 返回格式不可控:少数情况下模型不按标准JSON返回,尤其是自部署的小模型。可以强制开启响应格式约束,或者在代码里做一次“如果解析失败就重试一次”的保护逻辑。

2. MCP Server:给每个工具装上“通用插座”

2.1 工具越来越多,接入越来越乱

搞定了Function Call,下一个问题立刻冒出来:每个外部能力都有自己的调用方式。有的走HTTP接口,有的要读本地文件,有的需要先登录拿Token,有的返回XML,有的返回JSON。如果每接一个工具就写一套适配代码,那项目迟早变成“胶水代码大杂烩”。

类比一下:以前给手机充电,每个品牌一个接口,出门要带一堆线。后来行业渐渐统一,大部分设备都用同一个充电口,出差带一根线就够了。MCP做的是同一件事——它是给“模型 ↔ 工具”之间的连接定一个统一标准。

MCP的全称是Model Context Protocol。它规范了模型侧(或者说客户端侧)如何发现工具、如何调用工具、工具结果如何返回,约定了传输格式和交互流程。工具提供方只要按照MCP协议实现一个Server,任何支持MCP的客户端就能直接发现并调用它,不需要额外写对接代码。

2.2 MCP的架构和一次调用的完整过程

一个标准的MCP系统包含几个角色:

角色作用
MCP Client运行在应用或框架里,负责和模型交互,代表用户发起工具调用
MCP Server通过协议暴露一个或多个工具,屏蔽底层实现细节
Transport消息传输层,常见的有stdio和streamable HTTP
Tool实际执行具体能力的子模块,例如“搜索文件”“查数据库”

一次完整调用是这样的:用户在客户端发起请求 → 客户端带着用户问题、可用工具列表一起发给模型 → 模型决定调用某个工具并输出结构化调用意图 → 客户端把调用意图翻译成MCP协议消息,通过Transport发给对应Server → Server找到对应的Tool并执行 → 执行结果以协议格式返回客户端 → 客户端把结果作为工具消息追加进上下文 → 模型基于结果生成最终回复。

整个过程对模型来说是透明的:模型不需要知道工具的实现语言、部署位置,它只需要知道“这个工具叫什么、是干什么的、参数是什么”。MCP Server把这些统一暴露出来。这也是为什么越来越多团队选择把“内部工具包”改造成MCP Server——一套标准应对所有上层应用。

2.3 用一个最小“文件搜索”Server快速上手

说再多不如动手写一个。我现在要做的是一个提供“搜索指定目录下所有匹配文件名”能力的MCP Server。这个场景很常见:内部知识库文件散落多处,想让模型帮忙定位某个文档。

我用官方SDK来实现。下面是一个简化但能运行的最小框架:

import asyncio from mcp.server import Server, stdio_server async def search_files(keyword: str, dir_path: str) -> list: """核心业务逻辑:遍历目录,匹配文件名""" ... server = Server("file-search-server") @server.list_tools() async def list_tools(): return [ { "name": "search_files", "description": "在指定目录下根据关键字搜索文件名", "inputSchema": { "type": "object", "properties": { "keyword": {"type": "string"}, "dir_path": {"type": "string"} }, "required": ["keyword", "dir_path"] } } ] @server.call_tool() async def call_tool(name: str, arguments: dict): if name == "search_files": result = await search_files( keyword=arguments["keyword"], dir_path=arguments["dir_path"] ) return {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]} raise ValueError(f"未知工具: {name}") async def main(): async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream) if __name__ == "__main__": asyncio.run(main())

server.py的核心其实就三件事:声明工具清单、实现工具函数、启动stdio传输。你不需要关心客户端怎么连、消息怎么解析,SDK都封装好了。写完之后用MCP客户端连接一下本地stdio,就能在对话框里直接让它“搜索包含xx关键字的文件”。

2.4 配置端接入的方式

写完了Server,怎么让客户端认到它?以最常见的桌面客户端配置为例,通常是在配置文件里声明一个mcpServers节点:

{ "mcpServers": { "file-search": { "command": "python", "args": ["/path/to/server.py"], "env": { "LOG_LEVEL": "INFO" } } } }

配置文件里指定了用哪个命令启动、传什么参数、设置什么环境变量。客户端启动时会根据这段配置自动拉起Server进程,然后走MCP协议握手、工具列表拉取。整个过程你基本无感,只要配置正确,工具列表里就会多出那个Server暴露的几个工具。

如果你打算把Server部署到远程,供多个客户端共用,那就把transport从stdio换成streamable HTTP,配置里改成url地址就行:

{ "mcpServers": { "remote-file-search": { "url": "http://your-host:8000/mcp" } } }

2.5 实战选型心得

  • 本地优先用stdio:简单、稳定、没有端口和鉴权问题。缺点是Server只能被本机进程使用,没法做成公共服务。
  • 远程用streamable HTTP:部署灵活,但要做好鉴权、限流、超时处理。我是建议在Server前面加一层网关,不要直接把原始端口暴露出去。
  • 安全边界要想清楚:MCP Server暴露的工具就是“能被模型调用的攻击面”。像文件搜索这种工具,如果不限制目录范围,模型可能被诱导读取本不该读的路径。我做的项目里,所有文件操作都会强制加上“白名单根目录”,路径做了归一化校验,防止相对路径穿越。

3. Agent:从“回答问题”到“拆任务、调工具、看结果”

3.1 Agent和普通对话的本质区别

如果没有Agent,前面说的Function Call和MCP Server都只能解决“单次工具调用”的场景:用户问一个具体问题,模型判断该调哪个工具,调完就结束。但现实需求很少这么简单。

拿“帮我整理上个月的销售周报”这个需求来说,它至少包含:拉取各区域销售数据 → 汇总计算同比环比 → 发现问题点 → 生成报告文字。任何一个模型都无法一步到位,它需要连续、多轮的“选择工具 → 执行 → 看结果 → 再选择工具”。这就是Agent要做的事。

Agent的本质是一个“带循环的决策执行器”。它不再只是被动地接一句话回一句话,它会自己拆解目标,然后按照“当前缺什么信息→调用什么工具→拿到结果→判断够不够→再决定下一步”的节奏推进。你可以把Agent理解成一个“会自己点外卖的人”:先想好要吃什么,打开外卖App,搜店铺,看评价,下单,等送达,发现少送了一双筷子,打电话反馈,最后开吃。

3.2 两种主流的实现模式

我在项目里用过两种Agent实现思路,一种是“流程硬编码”,一种是“模型自主规划”。

  • 流程硬编码:人先把任务拆成固定步骤,每一步规定调用哪个工具,模型只负责其中一部分判断。优点是可控、稳定、易排查;缺点是泛化能力差,换个任务形态就不适用。
  • ReAct模式:模型自主进行“推理 → 行动 → 观察”的循环。每一轮模型先想“我现在需要什么信息”,然后输出工具调用指令,程序执行并返回结果,模型再基于结果继续推理,直到它认为任务已完成。优点是灵活,能适应开放性任务;缺点是容易失控,需要限制最大轮数。

目前实际落地中,ReAct模式是主流。因为它最接近人在解决复杂问题时的思考方式,而且现在模型能力足够强,多轮推理的成功率已经明显提升。如果任务流程非常固定,硬编码反而更省心。我是建议从小任务开始用ReAct,跑通后再逐步收紧限制。

3.3 一个完整可用的ReAct循环代码

下面这个示例解决的是“统计某目录下所有代码文件的行数并汇总”:

MAX_ITERATIONS = 8 # Agent记忆:所有对话内容都保存在这里 messages = [ {"role": "user", "content": "统计/data/projects下所有.py文件的总行数"} ] for step in range(MAX_ITERATIONS): # 1. 模型根据当前记忆决定下一步动作 response = chat_completion( messages=messages, tools=[list_files_schema, count_lines_schema] ) # 2. 如果模型认为任务已完成 if not response.tool_calls: print("Agent最终回答:", response.content) break # 3. 如果有工具调用,逐个执行 for fn_call in response.tool_calls: tool_name = fn_call.name args = fn_call.arguments if tool_name == "list_files": tool_result = list_files(args["path"]) elif tool_name == "count_lines": tool_result = count_lines(args["file_path"]) else: tool_result = {"error": "未知工具"} # 4. 结果追加进记忆 messages.append({ "role": "tool", "tool_call_id": fn_call.id, "content": json.dumps(tool_result, ensure_ascii=False) }) else: print("已达最大迭代次数,停止")

这段代码最关键的是步骤1和步骤4:模型要能看到“上一步结果”,才能决定“下一步做什么”。很多人写Agent失败,就是只把模型生成的内容追加进上下文,忘了把工具执行结果追加回去,导致模型每次都在重复同样的推理。

迭代轮数必须设上限。我见过模型在一个简单任务里反复调用同一个工具十几轮的情况,要不是设了上限,程序就跑飞了。设置轮数要结合任务复杂度来定,3到10轮是比较常见的区间。

3.4 Agent模式的关键控制点

  • 结果校验不能省:工具返回的数据格式千奇百怪,模型不一定能理解。建议每个关键工具的输出都做一次标准化,输出统一JSON结构,并附上简短的文字说明,让模型更容易消化。
  • 记忆管理:整体对话记录塞得越多,模型响应越慢、越容易“忘掉”最初目标。长任务超过一定轮数后,我会做摘要压缩:把前面的关键结论抽出来放回上下文,丢弃重复细节。
  • 安全护栏:给Agent绑定的工具权限不要超过“解决问题所需的最小范围”。尤其在能执行代码、能写文件、能发请求的场景,必须加“人类确认”环节,或者强制走审批流程。

4. 三者协同作战:拆解一条真实需求

4.1 用一张分工表理清整体边界

在动手编码之前,建议先用一张表理清三个概念的分工:

组件实现层次核心产物解决的核心问题
Function Call模型调用接口层结构化的工具调用指令让模型能表达“我需要什么工具、传什么参数”
MCP Server工具接入协议层统一暴露的工具集合让不同来源的工具能低成本接入任意客户端
Agent任务调度决策层多轮“推理-行动-观察”循环让模型能自主拆解目标、组合调用多个工具

三个东西不是互斥方案,而是互相嵌套的关系。Agent在决策时依赖Function Call来输出清晰的调用意图,这些调用经过MCP协议路由到对应Server,执行结果再被Agent拿来做下一步推断。

4.2 用一个实际场景串起来

我模拟一个常见需求:用户说“对比一下上个月和这个月的销售额,看看哪个品类掉得最明显”。

这个需求落在Agent里是这么跑的:

  • 第一轮:Agent判断需要“查询月度销售额”,于是通过Function Call输出调用意图,参数写明月份。程序把调用意图翻译成MCP消息发给销售数据Server,Server执行SQL查询,返回各品类销售汇总。
  • 第二轮:Agent拿到上个月数据后,继续调用同样工具查本月数据。
  • 第三轮:Agent对比两次返回的数据,判断哪个品类降幅最大。它不调用工具,直接基于已拿到的数据生成最终回答:“日用品下降34%,是跌幅最大的品类”。

整个过程中,用户只最初说了那句话,之后一路都是Agent自己驱动。Function Call负责让模型能输出每步的工具调用指令,MCP负责让销售数据能力以统一协议暴露给Agent使用,Agent负责把大目标拆成三步、逐步完成。三者缺一不可。

4.3 小团队的上手路径建议

如果你在小团队里,想把三件套落地到现有产品里,我建议别一上来就搞大而全的Agent框架,按这个顺序来:

  1. 先只做Function Call:选一两个高频工具,接入模型调用,跑通“模型发现工具→程序执行→结果回流”的最短闭环。目标是验证模型的调用准确率和你自己的解析逻辑是否稳。
  2. 再把工具改造成MCP Server:工具从两三个增加到十几个的时候,立刻上MCP,统一暴露方式。目标是把“接入新工具”的成本降到最低。
  3. 最后才上Agent:在工具稳定、调用准确率靠谱的前提下,用ReAct模式粘合多个工具去解决“用户一句话、多步操作”的需求。这一步的核心是控制迭代轮数和结果校验,不要让模型放飞自我。

每一步跑稳了再进入下一步,比一次性拼装完整的Agent架子要靠谱得多。

5. 常见问题与排查实录

5.1 Function Call环节的典型故障

模型一直不肯调用工具。最常见的原因是工具描述写得不够明确,模型觉得“直接回答也行”。或者说,你给的工具跟用户问题压根不匹配。排查时先确认:用户问题里要求的实体在你的某个工具参数里能对应上吗?描述里写清楚“当用户询问XX时使用此工具”了吗?

模型调用了工具,但参数明显不合法。例如查询商品库存时传了一个不存在的商品ID。这个问题我遇到过几次,根因多是参数枚举没设、描述里没有说明合法取值范围。解决办法:给所有参数加上格式约束和校验,对枚举值尽量用enum而不是让模型自由填。

工具刚接入时调用准确率高,过了几天下降。这种情况通常是模型上游更新导致行为变化。把工具描述写得更详细、增加few-shot示例能减少波动。我的习惯是为每个工具附上一个“正例”和“反例”,模型参考着学比抽象描述稳得多。

5.2 MCP Server环节的典型故障

配置了但工具列表不显示。先确认Server进程有没有被启动,日志里有没有报错。如果是stdio模式,注意不要在主进程里print额外内容,否则会污染协议流。很多新人把调试日志print到标准输出,结果协议解析直接乱掉。

远程Server频繁超时。优先排查网络和鉴权。MCP握手阶段会拉取工具列表,如果这个过程太慢,客户端就会超时。建议工具声明保持精简,不要塞大段无关描述。

5.3 Agent环节的典型故障

无限循环观察。模型反复调用同一个工具却不向前推进。原因是工具返回结果没有给模型“新信息”,它只能重复同样的选择。解决办法:迭代轮数上限,同时要求模型每轮输出“这一步想得到什么”,收敛它的思路。

任务完成了,但最终回答没有总结。模型在多轮工具调用后,手里已经有齐全的数据,但受最大输出长度限制,最终答案被截断。降低中间轮次的冗余内容、把上下文早点压缩,能明显缓解这个问题。

Agent行为不可复现,同样的输入两次结果不同。模型带有随机性。对于需要稳定结果的场景,把温度调低,或者在关键分支上让模型先输出结构化的“决策原因”,再执行工具调用。这样即使结果不同,也能看出模型是依据什么做出了不同判断。

5.4 一套问题速查表

现象排查方向推荐解法
模型不调用工具工具描述不清、工具与问题不匹配加few-shot示例,重写description
工具参数非法缺少枚举、缺少格式约束严格JSON Schema,代码再校验一次
MCP工具不显示Server没启动、stdio被污染查进程和日志,清理print
MCP连接超时握手慢、网络不稳精简工具列表,检查网络和鉴权
Agent死循环缺少新信息、上下文膨胀设最大轮数,压缩旧记忆
最终回答不完整输出超长、被截断压缩中间轮次,缩短记忆

这一套排查心得都是我自己在项目里一条条试出来的。我见过不少开发者在最初阶段把大量精力花在“把Agent调得更聪明”上,结果发现越往后瓶颈越不在“聪明”,而在“稳定”。工具调用指令能不能稳定解析、Server能不能稳定连接、Agent记忆能不能稳定管理,这些基础环节稳了,上层才能跑得顺。

如果非要给一条最值得分享的体会,我会说:别急着搭大Agent框架。先用Function Call把一个工具调到百发百中,再用MCP把十几个工具统一接好,最后才把决策逻辑交还给模型。我见过太多团队一上来就铺了一大套Agent体系,结果底下单点工具调用都是飘的,问题层出不穷。顺序对了,后面全是水到渠成。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 5:08:45

蓝桥杯Day07零基础抢分攻略:最后一天必背模板与考场捞分技巧

蓝桥杯这种比赛,对零基础小白来说,最刺激的就是“临时突击”这几个字。别管网上怎么说“算法需要长期积累”,真到了只剩一周、甚至只剩最后两三天的时候,你需要的不是焦虑,而是一套能直接落地的“抢分策略”。Day07这个…

作者头像 李华
网站建设 2026/10/22 5:08:44

基于深度学习的草莓腐烂识别:图像分类到迁移学习实战与踩坑记录

做毕业设计选了个AI识别草莓是否腐烂的项目,很多人第一反应是“这不就是个图像分类吗,能有多难”。等真上手你会发现,数据、标注、训练、调参、评估,每一步都有坑,而且坑和坑还不一样。这篇文章把我做这个基于Python的…

作者头像 李华
网站建设 2026/10/22 5:08:06

语义翻译层:把口语指令变成可执行JSON的完整方案

帮我把客厅灯调暗一点,然后放点轻音乐”——就这么一句话,放在十年前,得拆成“打开智能音箱、调暗灯光、播放歌单”三步手动操作;放在今天,大部分人想到的是“用语音助手”,但真动手做过的人都知道&#xf…

作者头像 李华
网站建设 2026/10/22 5:07:34

Java静态调用链分析:基于JavaParser的AST方法追溯技术

简介:这是一套基于JavaParser实现的Java代码方法调用链静态分析工具,面向Java中高级开发者、代码质量工程师及编译器原理学习者,用于快速识别类间方法调用关系、构建调用图谱并辅助重构或依赖分析。资源包共105个文件,含64个核心J…

作者头像 李华
网站建设 2026/10/22 5:07:20

Android+Spring Boot仿闲鱼毕设工程:全链路交易闭环实现

简介:这是一套完整的仿闲鱼二手交易平台安卓端后端源码项目,面向计算机、人工智能、软件工程等专业的在校学生、教师及初学者,适用于课程设计、毕业设计、项目实训与移动开发入门实践。资源包含174个文件,以44个Java核心业务类、5…

作者头像 李华
网站建设 2026/10/22 5:07:16

AI替代浪潮下,哪些岗位最危险?普通人如何找到职业避风港

最近这半年,我密集地接到一类咨询,不是问技术、问工具,而是问前途。问的人有干了十年的会计,有刚毕业的翻译专业学生,有带团队的客服主管,甚至还有初级程序员。问题大同小异:我这个岗位&#xf…

作者头像 李华