工具与 MCP:让 Agent 长出手脚

大模型天生只有一张嘴------吐 token。它能写诗、能推导,却摸不到数据库、点不了按钮、发不出邮件。问题不在模型笨,在于它和外部世界之间没有接口。工具(Tool)与 MCP 就是给 agent 接上的手脚:前者定义模型能调什么,后者定义这些能力跑在哪、怎么被发现。这篇文章把这两层机制拆到底,让你看清 function calling 究竟怎么发生,以及为什么把工具做对,比把模型调大更重要。

工具的本质:模型只看到签名,执行发生在别处

很多人以为「给模型接个工具」就是在代码里塞个 if 判断去分流。这是最根本的误解。工具的本质,是给模型一份结构化函数签名加一块独立的执行环境。

模型这一侧,永远只接收到 schema:函数的名字、一句话描述、以及参数用 JSON Schema 写的类型和约束。它基于这些信息决定「调还是不调」「传什么参数」,然后产出一个调用意图------一段包含函数名和参数的结构化数据。到这里为止,模型一行工具代码都没碰过。

真正的执行发生在 agent 进程之外的某台机器、某个服务里。查数据库、调第三方 API、读写文件系统,这些副作用全在外部进程完成,跑完把结果回填给模型。模型是「大脑与嘴」,执行环境是「手与脚」,二者通过 schema 这份契约隔离。

  • 隔离 模型不读函数体,看不见实现,只能依据 schema 文字下判断
  • 委托 真正干活的是外部进程,模型只负责产生调用意图
  • 契约 schema 是双方唯一接口,定义了能做什么、不暴露怎么做

@tool 装饰器:让 Python 函数自动编译成 JSON Schema

在 LangChain 里,把函数变成工具只要一个装饰器。你写一个普通 Python 函数,带上类型注解和 docstring,@tool 会在加载时把它自动编译成模型能消费的 JSON Schema------你不必手写一份重复的接口描述。

python 复制代码
from langchain_core.tools import tool

@tool
def search_flights(
    origin: str,
    destination: str,
    date: str,
) -> list:
    """查询指定日期从出发城市到目的城市的航班列表。

    Args:
        origin: 出发城市,如 "上海"
        destination: 目的城市,如 "北京"
        date: 出发日期,格式 YYYY-MM-DD,如 "2024-05-30"
    """
    # 真实代码运行在外部进程,模型看不到这里
    return external_api.query(origin, destination, date)

装饰器扫一遍函数,吐出的 schema 长这样:

python 复制代码
print(search_flights.to_json())
# {
#   "name": "search_flights",
#   "description": "查询指定日期从出发城市到目的城市的航班列表。",
#   "parameters": {
#     "type": "object",
#     "properties": {
#       "origin": {"type": "string", "description": "出发城市,如 \"上海\""},
#       "destination": {"type": "string", "description": "目的城市,如 \"北京\""},
#       "date": {"type": "string", "description": "出发日期,格式 YYYY-MM-DD"}
#     },
#     "required": ["origin", "destination", "date"]
#   }
# }

注意三个关键点。第一,参数类型直接来自函数的类型注解(str 映射 string、int 映射 integer),少写一处类型,schema 就少一份约束。第二,docstring 的第一句话会成为 description,模型就是靠这行字理解「这个工具干嘛用」的。第三,参数说明也来自 docstring 里的 Args 段,写得细,模型传参越准。

schema 不是文档,是契约。它从代码派生,所以代码改了,schema 跟着改,不会出现文档和接口两张皮。

schema 质量即调用准确率:描述写不清,模型就调错

这里有个反直觉的事实:模型不会去读你的源码。它唯一的判断依据就是 schema 里的文字。所以 description 写得多含糊,模型就调得多离谱。

举同一个函数的两种写法。反例里 description 只写「处理数据」,date 参数没有任何格式说明,类型还标成 any。模型面对这样的 schema,会把 date 传成时间戳、把城市传成缩写,接口十次有六次报错。正例里 description 写清「查询指定日期从出发城市到目的城市的航班列表」,date 标注为 string 且注明 YYYY-MM-DD 格式,模型传参一次到位。

  • 准确率绑定描述精度 schema 是模型唯一信息源,含糊等于高误调率
  • 类型约束不能省 该 string 就 string,标 any 等于把判断推给模型瞎猜
  • 示例值放进去 在 description 里给「如 2024-05-30」,比单纯说格式更有效

MCP:把工具执行从 agent 进程拆成独立服务

工具写好了,下一个问题是:它该跑在哪。最朴素的做法是函数直接 import 进 agent 进程。但这样工具就被语言和进程锁死了,换个项目、换个语言,全部重写。MCP(Model Context Protocol)要解决的正是这件事。

MCP 把「工具的执行」从 agent 进程里抽出来,变成独立的 server 进程,彼此用一套统一协议通信。agent 作为 Host 内嵌一个 MCP Client,通过 stdio 或 SSE 这类传输层,用 JSON-RPC 和 server 对话:先 list 对端暴露了哪些工具,再 call 其中某一个。

python 复制代码
from langchain_mcp_adapters.client import MultiServerMCPClient

async def build_tools():
    client = MultiServerMCPClient({
        "filesystem": {
            "command": "python",
            "args": ["mcp_server_filesystem.py", "/data"],
            "transport": "stdio",
        },
        "github": {
            "url": "http://localhost:8000/mcp",
            "transport": "streamable_http",
        },
    })
    # 协议层自动发现对端暴露的工具
    # agent 进程内无需写任何工具实现
    tools = await client.get_tools()
    return tools

这套机制带来三个实打实的好处。其一是跨语言:server 用 Python、Go、Node 随便写,只要说 MCP 的协议,agent 照单全收。其二是跨进程:server 可以跑在另一台机器上,它崩了不影响 agent。其三是可复用:一个写好的 filesystem server,全公司的 agent 都能接,不用每人重写一遍。

重点在于:agent 这边不需要进程内写任何工具实现,工具的发现与调用全走协议。解耦之后,工具的生死、升级、权限,都收敛到 server 一侧。

反例与反模式:硬编码工具与不设防的副作用

讲完正路,说几个我见过的真坑。

第一个坑是把工具实现硬编码进 agent 主进程。函数直接 import,和业务逻辑揉在一起。改一个工具的描述要重发整个 agent;想用另一种语言重写某个能力,没法单拆;工具之间的依赖关系变成一坨意大利面。正确做法是把工具外推成 server(见上节),agent 只管编排。

第二个坑更危险:把复杂副作用当普通工具,不设权限、不做确认。看这段反例------

python 复制代码
# 反例:把发邮件写成一个普通工具,没有权限闸门
@tool
def send_email(to: str, subject: str, body: str) -> str:
    """发邮件"""
    smtp.send(to, subject, body)   # 模型一句话,邮件就真发出去了
    return "sent"

模型一旦产生调用意图,这封邮件就真的从服务器飞出去了。没有二次确认、没有白名单、没有审批闸门。在自动化 agent 里,这等于把炸药和最灵巧的手放在一个房间。正确做法:写库、发邮件、删资源这类高危操作,必须在 MCP server 侧加权限校验和人审节点,把「模型想调」和「系统真执行」之间的一堵墙砌实。

第三个坑是忽略工具返回值的结构。模型拿到一段非结构化的长文本,很难从中抽取它要的字段,下一步推理就容易跑偏。工具返回尽量给结构化、带字段名的数据,而不是一段散文。

  • 硬编码 工具揉进主进程,耦合重、难复用、语言锁死
  • 无闸门 高危副作用直接执行,缺权限与确认,误操作难挽回
  • 弱返回 返回非结构化长文本,模型二次解析成本高、易出错
相关推荐
阿里云云原生44 分钟前
Agent as a Service:驱动 Personal Agent 与企业智能体的服务架构
agent
武子康1 小时前
撤回发生在重建途中,旧索引还能切回来吗
人工智能·后端·agent
%KT%2 小时前
Agent开发过程中的会话管理
agent
訾博ZiBo2 小时前
我从"删掉核心代码"的开源项目里,学到了一堂 713 行的 Agent 工程课
agent
jerrywus3 小时前
给 Pi 装上生图插件:从登录报错到画出一只 200×200 的猫
前端·agent·claude
小金的研发日志4 小时前
ASP.NET 手搓 MCP 服务(四):刷新令牌的轮换与重放检测
mcp
灵动的艺术4 小时前
如一 Agent 架构解读(二):上下文工程——为模型构造它唯一的现实
人工智能·架构·agent
Dumbness5 小时前
写 MCP 服务器踩的坑:stdout 是协议通道,一行不带换行的日志就能让客户端超时
node.js·mcp
老周聊架构6 小时前
create_agent 实战:一个 Agent 是怎么被组装出来的
agent