从Function Calling到MCP协议:金融Agent工具调用链路完整梳理

金融场景里的Agent,工具调用不是"能不能调通"的问题,而是"敢不敢让它调"的问题。在做一个投研助手Agent的过程中,我经历了从最朴素的Function Calling到引入MCP协议重构工具链路的完整过程。这篇文章把这条链路从头到尾理一遍,包括踩过的坑和当时的判断逻辑。
为什么Function Calling不够用了
最开始我们的投研Agent工具数量不多,就五六个:查行情、读财报摘要、算估值指标、搜公告、查宏观数据。直接用OpenAI风格的Function Calling,在系统提示词里声明函数定义,模型返回要调哪个函数、参数是什么,我们这边执行完把结果塞回对话上下文。这套东西跑通很快,两天就搞了个能演示的版本。
问题出现在工具数量涨到二十多个之后。金融工具的特点是长得像但不一样------比如"查行情"和"查历史行情"就差一个时间参数,"读财报摘要"和"读财报原文"返回的结构完全不同。所有工具平铺在系统提示词里,模型开始频繁选错工具,有时候该调A却调了B,参数还填得有模有样。更麻烦的是,我们同时接了两家不同的模型供应商,Function Calling的声明格式和返回格式有细微差异,每接一家就要写一层适配。
还有个更现实的问题:工具实现分散在各个服务里,有的用Python写的有的用Java写的,函数签名改了之后要手动同步到Agent侧的声明,漏一次就出一次事故。那段时间我经常半夜被叫起来,一查就是"工具声明和实际实现不一致"。这些问题的核心不是某个函数写得不好,而是工具调用缺乏一个统一的、带类型的、可发现的抽象层。
为什么选了MCP而不是继续缝补
坦白说我们一开始没想上MCP,觉得那是给大模型和外部工具之间做标准化的,离我们的业务场景有点远。我们先尝试了两个"轻量"方案:一个是把工具声明集中到一个配置文件里统一生成,一个是给Function Calling加了一层路由逻辑按领域分组。这两个方案都缓解了问题,但都没解决本质------工具的实现和声明仍然是分离的,模型侧的"工具视图"和实际的"工具能力"之间靠人工保证一致性。
后来看了MCP协议的草案,觉得它的几个设计恰好打在痛点上。MCP把工具抽象成客户端-服务端结构,工具提供方注册成一个MCP Server,用统一的schema描述每个工具的输入输出,Agent侧作为MCP Client去发现和调用。这样工具声明和实现天然绑定在一起,Server端工具改了参数,Client端发现时拿到的就是最新定义,不会出现"声明滞后"的问题。传输层支持stdio和SSE,我们内部用HTTP SSE就够,部署上灵活。
选MCP还有一个附带好处:我们团队里做数据平台的同事可以独立开发MCP Server,不用管Agent侧怎么用,他们只负责把数据能力包装成符合规范的MCP工具。这种"工具提供方只管提供、Agent侧只管消费"的分工,让我们从工具调用的泥潭里抽身出来,开始认真考虑工具链路的整体设计。
工具调用链路的实现过程
整个链路分三层:MCP Server层(工具的实际提供方)、MCP Client层(Agent侧的工具发现与调用封装)、编排层(模型决策与执行循环)。我们用Python实现了一套精简的MCP Client,核心逻辑简化后长这样:
python
# mcp_client.py - MCP客户端核心逻辑(简化版)
import json
from typing import Any
class MCPToolClient:
def __init__(self, server_endpoint: str):
self.endpoint = server_endpoint
self.tools_cache = None
def list_tools(self) -> list[dict]:
"""从MCP Server发现可用工具,返回标准化的工具schema列表"""
if self.tools_cache is not None:
return self.tools_cache
response = self._http_get(f"{self.endpoint}/tools/list")
tools = response.get("tools", [])
# 标准化成模型可理解的函数声明格式
normalized = []
for tool in tools:
normalized.append({
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": tool.get("inputSchema", {})
})
self.tools_cache = normalized
return normalized
def call_tool(self, name: str, arguments: dict) -> Any:
"""调用指定的MCP工具并返回结果"""
payload = {"name": name, "arguments": arguments}
response = self._http_post(f"{self.endpoint}/tools/call", payload)
return response.get("result")
这段代码位于Agent侧的MCP Client层,负责两件事:把MCP Server提供的工具schema转换成模型能理解的函数声明格式,以及把模型的工具调用请求转发给MCP Server。它解决的核心问题是让Agent感知到的工具定义永远和实际实现保持一致,因为发现和调用走的是同一个源头。
在编排层,工具调用循环从"模型返回函数名→我们查表执行"变成了"模型返回函数名→MCP Client执行",表面看变化不大,但工具发现的时机变了。以前是启动时把工具列表塞进系统提示词,现在可以在对话过程中动态发现。这让我们可以按需加载工具,比如用户问到财务数据时才挂载财务领域的MCP Server,而不是一上来把几十个工具全堆给模型。
另一个关键代码在MCP Server端,工具注册的部分:
python
# mcp_server.py - 工具注册与schema生成(简化版)
from typing import Callable, get_type_hints
import inspect
class MCPServer:
def __init__(self):
self.tools = {}
def register_tool(self, func: Callable):
"""将Python函数注册为MCP工具,自动从类型注解生成inputSchema"""
hints = get_type_hints(func)
# 从函数签名和docstring生成工具schema
schema = {
"name": func.__name__,
"description": inspect.getdoc(func) or "",
"inputSchema": {
"type": "object",
"properties": {
k: self._type_to_schema(v)
for k, v in hints.items()
if k != "return"
}
}
}
self.tools[func.__name__] = {"func": func, "schema": schema}
def _type_to_schema(self, type_hint) -> dict:
"""将Python类型映射为JSON Schema类型(简化版)"""
mapping = {str: {"type": "string"}, int: {"type": "integer"},
float: {"type": "number"}, bool: {"type": "boolean"}}
return mapping.get(type_hint, {"type": "string"})
这段代码在MCP Server端,让工具开发者用普通的Python函数加上类型注解就能注册工具,schema自动生成。位置在数据平台同事写的服务里,他们不需要理解Agent侧如何消费这些工具,只需要把函数写清楚、docstring写明白。它解决了工具定义和实现分离的问题------工具的定义不是别处维护的一份配置文件,而是函数签名本身。
这两段代码合起来,构成了工具调用链路的"统一抽象层"。MCP Server负责提供带schema的工具,MCP Client负责发现和执行,编排层只管决策。
过程中遇到的实际问题
第一个问题是工具描述写的质量直接决定模型选工具的准确率。MCP协议本身不解决"工具描述写得好不好",它只是把描述传递过去。我们有几个工具名字起得太像,描述写得太笼统,模型在它们之间反复横跳。后来花了不少时间重写工具描述,把适用场景、参数约束、返回结构都写清楚,体感上工具选择的准确度有明显改善。这个经验很朴素:协议再好,工具的元数据质量才是上限。
第二个问题更隐蔽。MCP协议支持工具返回结构化数据,但模型对超长返回内容的处理会退化。查财报原文那个工具,返回的文本动辄上万字,塞回上下文之后模型开始"忘事",前面问的问题后半段就不管了。我们的处理方式是在MCP Server端加了一层返回压缩,工具内部先做摘要或截断,把核心信息控制在合理长度内再返回给Agent。这严格来说超出了MCP协议的范畴,但在金融场景里不做不行。
还有一个问题是MCP Server的可用性。工具调用从"本地函数调用"变成了"跨进程甚至跨服务的远程调用",网络超时、服务重启这些运维问题全来了。我们在MCP Client里加了超时和重试逻辑,但说实话,比起Function Calling时代的"调用一定成功",MCP的分布式特性带来的不稳定性是真实存在的代价。这些问题的共同点是它们不在协议规范覆盖范围内,但对实际使用的影响远大于协议本身的对错。
总结与下一步
从Function Calling到MCP,本质是把工具调用从"模型和一个函数列表的对话"变成了"模型和一组可发现服务的对话"。对金融Agent来说,这个变化让工具管理从手工作坊变成了有标准的流水线,团队协作的边界也更清晰了。代价是链路多了一层网络开销,以及要自己处理分布式调用带来的各种状况。
下一步想探索的是工具之间的自动组合------让模型根据用户意图自动编排多个MCP工具的调用序列,而不是每次都靠我们预设流程。初步的想法是基于MCP工具schema做依赖分析,看看能不能在编排层做自动规划。这条路不好走,但方向应该是对的。