从 Function Calling 到 MCP:理解 Agent 工具调用的底层通信机制
本文从 Agent 的工具调用出发,向下追溯到操作系统的进程间通信原理,再向上回到 MCP 协议的设计哲学。目标是让你理解 MCP 的本地调用(stdio)和远程调用(HTTP/SSE)到底在做什么,以及为什么这样设计。
一、起点:Function Calling 的本质与局限
1.1 Function Calling 回顾
在学习 Agent 的过程中,我们首先接触的工具调用方式是 Function Calling:
javascript
Agent Loop 中的工具调用流程:
① 开发者定义工具函数 + JSON Schema(名称、描述、参数类型)
② Agent 框架把所有工具的 Schema 塞进 LLM 请求
③ LLM 推理后返回结构化 JSON:"我要调 search 工具,参数是 {query: 'xxx'}"
④ Agent 框架解析 JSON,提取工具名和参数
⑤ 在本地找到对应的函数,直接调用
⑥ 把执行结果回传给 LLM,继续推理
这个流程清晰、高效,但有一个隐含假设 :工具的定义方式、注册方式、调用方式,都是各框架自己定的。
1.2 问题:工具和框架紧耦合
python
tRPC-Agent 的工具定义方式:
@tool(name="search", description="...")
def search(query: str) -> str: ...
LangChain 的工具定义方式:
class SearchTool(BaseTool):
name = "search"
description = "..."
def _run(self, query: str) -> str: ...
OpenAI SDK 的工具定义方式:
tools = [{"type": "function", "function": {"name": "search", ...}}]
同一个"搜索"功能,三个框架要写三遍。
更大的问题是:如果你是一个工具提供方(比如你做了一个数据库查询工具想让所有 Agent 都能用),你需要为每个框架各适配一次。这和 USB 出现之前每种外设都有自己的接口是一样的局面。
1.3 MCP 的定位
ini
Function Calling = "LLM 怎么表达要调工具" 的标准
(LLM → Agent 框架 这段的协议)
MCP = "工具怎么暴露自己的能力" 的标准
(Agent 框架 → 工具服务 这段的协议)
它们是调用链上不同环节的标准化:
LLM ──Function Calling──▶ Agent 框架 ──MCP──▶ 工具服务
"我要调search" "帮我调MCP Server的search"
MCP(Model Context Protocol)就是工具侧的 USB 接口------工具只需实现一次 MCP Server,所有支持 MCP Client 的 Agent 框架都能即插即用。
二、MCP 的架构:Client / Server 分离
2.1 核心模型
scss
┌───────────────────────┐ ┌───────────────────────┐
│ Agent 框架 │ │ MCP Server │
│ (tRPC-Agent/LangChain │ │ (工具提供方) │
│ /Claude Desktop) │ │ │
│ │ │ 实现了具体的工具逻辑 │
│ ┌──────────────┐ │ 通信 │ │
│ │ MCP Client │◄────┼────────┼──▶ 暴露标准 MCP 接口 │
│ └──────────────┘ │ │ │
│ │ │ 工具1: search(query) │
│ Agent Loop 正常跑 │ │ 工具2: read_file(path)│
│ LLM 推理 → 要调工具 │ │ 工具3: query_db(sql) │
│ → 通过 MCP Client 调 │ │ │
└───────────────────────┘ └───────────────────────┘
MCP Server 是一个独立的进程 (或远程服务),它不关心谁在调它。关键的设计决策是:通信协议统一用 JSON-RPC 2.0,传输层可以换。
2.2 两种传输方式
javascript
本地调用 → stdio(标准输入输出,走管道)
远程调用 → HTTP + SSE(走网络)
消息格式完全一样(JSON-RPC),只是底层的"管子"不同。
这就引出了一个底层问题:stdio 通信到底是什么?管道到底是个什么东西?
三、向下探底:操作系统的进程间通信
3.1 每个进程自带的三个通道
每个进程启动时,操作系统自动给它分配三个数据通道:
ini
┌──────────────────────────────────┐
│ 一个进程 │
│ │
│ stdin (标准输入, fd=0) ◄───────── 数据流入(默认接键盘)
│ stdout (标准输出, fd=1) ────────▶ 数据流出(默认接终端)
│ stderr (标准错误, fd=2) ────────▶ 错误信息(默认接终端)
│ │
└──────────────────────────────────┘
fd = file descriptor(文件描述符),就是一个整数编号
Unix 设计哲学"一切皆文件":管道、Socket 都通过 fd 操作
重要澄清 :这三个通道默认不是管道 ,它们默认指向终端设备。只有被重定向到管道时,才变成管道通信。
你每天都在用它们:
bash
# echo 的输出走 stdout → 显示在终端
$ echo "hello"
hello
# 管道符 | 就是把左边进程的 stdout 接到右边进程的 stdin
$ cat file.txt | grep "error" | wc -l
3.2 管道(Pipe)的物理本质
markdown
管道 = 操作系统内核在内存里开辟的一块缓冲区(通常 64KB)
+ 一个写入端
+ 一个读取端
就这么简单。不是文件,不是网络,就是一块内核内存。
css
操作系统内核
┌──────────────────────────────────┐
│ │
│ ┌────────────────────────┐ │
│ │ 管道缓冲区 (64KB) │ │
│ │ │ │
写入端 ──────▶ [数据数据数据数据数据] ──────▶ 读取端
│ │ │ │
│ └────────────────────────┘ │
│ │
└──────────────────────────────────┘
进程A 拿着写入端的 fd 进程B 拿着读取端的 fd
往里塞字节 从里面取字节
生活类比:
css
管道就像一根水管:
┌──────┐ ┌──────┐
│进程 A │ ──── 水管(管道缓冲区)────── │进程 B │
│ 灌水 │ ─────────────────────────▶ │ 接水 │
└──────┘ └──────┘
- 管子有容量(64KB),灌满了 A 就得等(阻塞)
- 管子空了 B 就得等(阻塞)
- 水只能从 A 流向 B(单向)
- A 关掉水龙头,B 读到空就知道"结束了"(EOF)
3.3 管道不是自动分配的,是按需创建的
scss
❌ 错误理解:每个进程启动时自动分配一根管道
✅ 正确理解:管道是你需要的时候,主动调 pipe() 系统调用创建的
创建过程:
css
步骤 1:进程A 调用 pipe() 系统调用
→ 内核在内存里分配 64KB 缓冲区
→ 返回两个 fd:fd[0](读取端) 和 fd[1](写入端)
步骤 2:进程A 调用 fork() 创建子进程B
→ 子进程B 继承了这两个 fd(指向同一块缓冲区)
步骤 3:各关一头
→ 进程A 关掉 fd[0](只写不读)
→ 进程B 关掉 fd[1](只读不写)
最终状态:
进程A ──fd[1]写入──▶ 【缓冲区】 ──fd[0]读取──▶ 进程B
Shell 的 | 符号就是 Shell 帮你做了上面这些事:
bash
$ cat file.txt | grep "error"
Shell 做的事:
1. 看到 | 符号
2. 调 pipe() 创建一根管道
3. fork 子进程1(跑 cat),把它的 stdout 重定向到管道写入端
4. fork 子进程2(跑 grep),把它的 stdin 重定向到管道读取端
5. cat 和 grep 自己根本不知道有管道存在
它们只知道"我从 stdin 读,往 stdout 写"
3.4 管道的生命周期
scss
创建:pipe() 系统调用时 → 内核分配缓冲区
存活:只要还有进程持有这个管道的 fd → 缓冲区一直在
销毁:所有 fd 都关闭(或进程退出)→ 内核自动回收
→ 不需要手动释放,没有泄漏风险
3.5 全景:本地 IPC 方式对比
管道只是本地进程间通信的一种方式。所有本地 IPC 的本质都是共享一块内存,一个写一个读,区别在于"谁来管这块内存"和"接口长什么样":
markdown
方式 本质 特点
───────────────────────────────────────────────────────────────
管道 Pipe 内核管的缓冲区 单向,需要父子关系
数据经过两次拷贝 最简单
(用户态→内核态→用户态)
命名管道 FIFO 内核管的缓冲区 不需要父子关系
通过文件路径寻址 两个独立进程也能用
Unix Socket 内核管的缓冲区 双向通信
通过 socket 文件寻址 功能最丰富
共享内存 两个进程直接映射 零拷贝,最快
Shared Memory 同一块物理内存页 但要自己加锁
不经过内核中转 防止竞态条件
消息队列 内核管的链表 有消息边界
Message Queue 数据经过内核中转 支持优先级
数据搬运路径的差异:
css
【管道 / Unix Socket / 消息队列】------ 内核中转
进程A 用户空间 内核空间 进程B 用户空间
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 数据 │─①─▶│ 缓冲区 │─②─▶│ 数据 │
└──────────┘ └──────────┘ └──────────┘
① write():用户态 → 内核态(拷贝一次)
② read(): 内核态 → 用户态(拷贝一次)
共 2 次数据拷贝
【共享内存】------ 零拷贝
进程A 用户空间 进程B 用户空间
┌──────────┐ ┌──────────┐
│ 虚拟地址 │──┐ ┌──│ 虚拟地址 │
└──────────┘ │ │ └──────────┘
▼ ▼
┌──────────────────────┐
│ 同一块物理内存页 │
└──────────────────────┘
0 次拷贝,但要自己处理并发读写问题
3.6 本地通信 vs 网络通信
scss
本地通信(管道 / 共享内存 / Unix Socket):
数据在内存里搬运,不出本机,不经过网卡
延迟:纳秒 ~ 微秒级
网络通信(TCP / UDP):
数据经过完整的网络协议栈:
应用层 → 传输层(TCP/UDP) → 网络层(IP) → 链路层 → 网卡 → 物理介质
→ 对方网卡 → 链路层 → 网络层 → 传输层 → 应用层
延迟:微秒 ~ 毫秒级
注:即使是本机的 127.0.0.1,也走完整 TCP 协议栈,比管道慢
四、回到 MCP:本地调用(stdio 传输)
有了上面的基础,MCP 的 stdio 模式就彻底透明了。
4.1 完整流程
scss
Agent 框架(父进程)启动 MCP Server(子进程):
代码层面:
proc = spawn("python mcp_server.py", stdin=PIPE, stdout=PIPE)
操作系统做的事:
① 调用 pipe() 创建管道1(父写 → 子的 stdin 读)
② 调用 pipe() 创建管道2(子的 stdout 写 → 父读)
③ fork() 创建子进程
④ 子进程的 fd=0(stdin) 重定向到管道1读取端
⑤ 子进程的 fd=1(stdout) 重定向到管道2写入端
⑥ exec() 加载 mcp_server.py 开始运行
结果:两根管道,一来一回
Agent框架(父进程) MCP Server(子进程)
│ │
│ write(管道1) ──────────▶ stdin 从 stdin 读到请求
│ │ 处理请求...
│ read(管道2) ◀────────── stdout 往 stdout 写响应
│ │
4.2 通信内容:JSON-RPC 消息
管道里跑的数据就是一行行 JSON:
arduino
→ Agent框架 往管道1写(发给 MCP Server):
{"jsonrpc":"2.0","method":"tools/list","id":1}\n
← MCP Server 往管道2写(返回给 Agent框架):
{"jsonrpc":"2.0","result":[{"name":"search","description":"搜索","inputSchema":{"type":"object","properties":{"query":{"type":"string"}}}}],"id":1}\n
→ 调用工具:
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search","arguments":{"query":"MCP协议"}},"id":2}\n
← 返回结果:
{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"MCP是一种..."}]},"id":2}\n
注意 :管道是字节流,没有消息边界。所以 MCP 协议用 \n(换行符)来切分每条 JSON 消息。
4.3 整合进 Agent Loop
Agent Loop 本身没有任何变化,MCP 只改变了工具调用的那一步:
vbscript
【没有 MCP 的 Agent Loop】
LLM 推理
↓
输出: "我要调 search 工具"
↓
Agent 框架在本地查找 search 函数 ← 工具是框架内部注册的
↓
直接调用 search("xxx")
↓
拿到结果,回传 LLM
【有 MCP 的 Agent Loop】
LLM 推理
↓
输出: "我要调 search 工具"
↓
Agent 框架发现这个工具来自 MCP Server
↓
通过管道发 JSON-RPC 请求 ← 标准协议
↓ {"method":"tools/call","params":{"name":"search","arguments":{"query":"xxx"}}}
↓
MCP Server 从 stdin 读到请求,执行工具逻辑
↓
MCP Server 往 stdout 写返回结果
↓ {"content":[{"type":"text","text":"搜索结果..."}]}
↓
Agent 框架从管道读到结果,回传 LLM
五、MCP 的远程调用(HTTP + SSE 传输)
5.1 为什么需要远程模式
stdio 模式要求 MCP Server 作为子进程跑在同一台机器上。但很多场景下工具服务是远程的:
arduino
场景1:公司内部的数据库查询服务跑在服务器集群上
场景2:第三方提供的 SaaS 工具(GitHub API、Jira API 封装)
场景3:需要 GPU 的计算密集型工具跑在专用机器上
场景4:多个 Agent 共享同一个 MCP Server 实例
5.2 远程传输:Streamable HTTP
MCP 最新的远程传输方式是 Streamable HTTP,核心原理:
arduino
Agent框架(MCP Client) MCP Server(远程服务器)
│ │
│ HTTP POST /mcp │
│ Body: JSON-RPC 请求 │
│───────────────────────────────────▶│
│ │
│ 如果是简单请求(如 tools/list): │
│◀─── HTTP Response (JSON-RPC) ─────│ 普通 HTTP 响应
│ │
│ 如果需要流式返回(如长时间的工具执行):│
│◀─── SSE 流 (多条 JSON-RPC) ─────│ Server-Sent Events
│ event: message │
│ data: {"jsonrpc":"2.0",...} │
│ │
│ event: message │
│ data: {"jsonrpc":"2.0",...} │
│ │
5.3 SSE(Server-Sent Events)是什么
csharp
SSE = 服务器单向推送事件流
普通 HTTP:
客户端请求 → 服务端返回一个完整响应 → 连接关闭
一问一答
SSE:
客户端请求 → 服务端返回一个"不关闭的响应"
→ 服务端持续往这个连接里推数据
→ 客户端持续接收
→ 直到服务端主动关闭
格式很简单:
event: message\n
data: {"一条JSON"}\n
\n
event: message\n
data: {"又一条JSON"}\n
\n
SSE 适合 MCP 远程调用的原因:工具执行可能需要时间,服务端可以先推中间进展,最后推最终结果。
5.4 本地 vs 远程对比
c
┌──────────────┬────────────────────────┬──────────────────────┐
│ │ 本地(stdio) │ 远程(HTTP/SSE) │
├──────────────┼────────────────────────┼──────────────────────┤
│ 底层介质 │ 管道(内核缓冲区) │ TCP 网络连接 │
│ 传输协议 │ stdin/stdout 字节流 │ HTTP + SSE │
│ 消息格式 │ JSON-RPC 2.0 │ JSON-RPC 2.0(一样) │
│ Server 位置 │ 本机子进程 │ 任意远程服务器 │
│ 性能 │ 微秒级 │ 毫秒级 │
│ 部署复杂度 │ 零配置 │ 需要 URL、端口、鉴权 │
│ 共享性 │ 父进程独占 │ 多客户端可共享 │
│ 适用场景 │ 开发调试、本地工具 │ 生产部署、远程服务 │
└──────────────┴────────────────────────┴──────────────────────┘
核心设计:传输层和协议层解耦
换传输方式不需要改消息格式
就像 USB 数据线换了材质,USB 协议不变
六、MCP Server 暴露的三类能力
MCP Server 不只是暴露"工具",它定义了三种原语:
scss
┌──────────────┬──────────────┬───────────────┐
│ Tools │ Resources │ Prompts │
│ (工具) │ (资源) │ (提示词模板) │
├──────────────┼──────────────┼───────────────┤
│ Agent 调用 │ Agent 读取 │ Agent 使用 │
│ 有副作用 │ 只读数据 │ 预设指令模板 │
│ │ │ │
│ 例:发邮件 │ 例:读配置 │ 例:代码审查 │
│ 例:写数据库 │ 例:查日志 │ 的标准模板 │
│ 例:调 API │ 例:获取状态 │ │
└──────────────┴──────────────┴───────────────┘
实际使用中 Tools 占了 90%+ 的场景。
Resources 和 Prompts 是"顺便标准化"的补充能力。
七、MCP 的生命周期
一次完整的 MCP 通信过程:
arduino
阶段 1:初始化
Client → Server: initialize(协商协议版本、能力)
Server → Client: 返回支持的能力列表
Client → Server: initialized(确认)
阶段 2:能力发现
Client → Server: tools/list(你有哪些工具?)
Server → Client: 返回所有工具的 Schema
→ Agent 框架拿到 Schema 后塞进 LLM 的请求里
→ LLM 就知道有哪些工具可以调了
阶段 3:工具调用(可重复多次)
Client → Server: tools/call(调具体的工具 + 参数)
Server → Client: 返回执行结果
阶段 4:关闭
Client 关闭连接 / 终止子进程
管道自动销毁 / HTTP 连接关闭
八、完整链路:从用户输入到工具执行
把所有层串起来,一次完整的 MCP 工具调用经过的全部环节:
vbscript
用户输入: "帮我搜索 MCP 协议的最新进展"
│
▼
Agent 框架构造 Prompt(含工具 Schema)
│
▼
LLM 推理,返回 Function Call:
{"tool": "search", "arguments": {"query": "MCP 协议最新进展"}}
│
▼
Agent 框架识别:search 来自 MCP Server
│
├── 本地模式:通过管道(stdin)发 JSON-RPC
│ 管道 = 内核缓冲区,write() 写入,对端 read() 读出
│
└── 远程模式:通过 HTTP POST 发 JSON-RPC
经过 TCP 协议栈 → 网卡 → 网络 → 对端
│
▼
MCP Server 收到请求,执行 search 逻辑
│
▼
MCP Server 返回结果(JSON-RPC Response)
│
├── 本地模式:通过管道(stdout)返回
└── 远程模式:通过 HTTP Response / SSE 返回
│
▼
Agent 框架收到结果,塞回 LLM 上下文
│
▼
LLM 继续推理,生成最终回答
│
▼
返回用户: "根据搜索结果,MCP 协议的最新进展是..."
九、为什么 MCP 本地模式选 stdio / 管道?
c
选项 为什么不选
────────────────────────────────────────────────
TCP Socket 本地通信用 TCP 是杀鸡用牛刀,要选端口、处理端口冲突
共享内存 要处理锁、序列化、内存映射,复杂度爆炸
Unix Socket Windows 兼容性差,要管 socket 文件生命周期
命名管道 需要约定文件路径,多了一层管理
选 stdio / 管道 的理由:
✅ 零配置 --- 不需要端口、不需要文件路径,spawn 就能通信
✅ 跨平台 --- Windows / macOS / Linux 都支持
✅ 安全 --- 父子进程间的管道外部无法窃听
✅ 够用 --- MCP 消息是 JSON 文本(几KB),不需要极致性能
✅ 门槛低 --- 工具开发者只需从 stdin 读、往 stdout 写,任何语言都行
十、总结
bash
层次关系(从上到下):
┌─────────────────────────────────────────┐
│ Agent Loop(应用层) │
│ LLM推理 → 决定调工具 → 拿结果 → 继续推理 │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ MCP 协议(协议层) │
│ JSON-RPC 2.0 消息格式 │
│ initialize → tools/list → tools/call │
└──────────────────┬──────────────────────┘
│
┌───────────┴───────────┐
│ │
┌──────▼───────┐ ┌───────▼──────┐
│ stdio 传输 │ │ HTTP/SSE 传输 │
│(管道通信) │ │(网络通信) │
└──────┬───────┘ └───────┬──────┘
│ │
┌──────▼───────┐ ┌───────▼──────┐
│ 操作系统管道 │ │ TCP 协议栈 │
│ 内核缓冲区 │ │ 网卡 + 网络 │
│(本机内存) │ │(跨机器) │
└──────────────┘ └──────────────┘
核心认知:
- Function Calling 标准化了 LLM 表达工具调用意图的格式
- MCP 标准化了工具暴露能力和被调用的格式
- 管道是操作系统提供的最基础的本地进程间通信方式:内核里的一块缓冲区
- MCP 选 stdio/管道做本地传输是因为零配置、跨平台、安全、够用
- 传输层和协议层解耦:本地用管道,远程用 HTTP/SSE,JSON-RPC 消息格式不变
写于 2026-06-26