大模型天生只有一张嘴------吐 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 侧加权限校验和人审节点,把「模型想调」和「系统真执行」之间的一堵墙砌实。
第三个坑是忽略工具返回值的结构。模型拿到一段非结构化的长文本,很难从中抽取它要的字段,下一步推理就容易跑偏。工具返回尽量给结构化、带字段名的数据,而不是一段散文。
- 硬编码 工具揉进主进程,耦合重、难复用、语言锁死
- 无闸门 高危副作用直接执行,缺权限与确认,误操作难挽回
- 弱返回 返回非结构化长文本,模型二次解析成本高、易出错
