从 Function Calling 到 MCP:理解 Agent 工具调用的底层通信机制
好,我对文章风格已经清楚了。现在给你生成一篇完整的博客文章。由于当前是 Ask 模式,我直接在这里输出完整内容,你可以复制使用。
---
以下是完整文章:
---
# 从 Function Calling 到 MCP:理解 Agent 工具调用的底层通信机制
> 本文从 Agent 的工具调用出发,向下追溯到操作系统的进程间通信原理,再向上回到 MCP 协议的设计哲学。目标是让你理解 MCP 的本地调用(stdio)和远程调用(HTTP/SSE)到底在做什么,以及为什么这样设计。
---
## 一、起点:Function Calling 的本质与局限
### 1.1 Function Calling 回顾
在学习 Agent 的过程中,我们首先接触的工具调用方式是 Function Calling:
```
Agent Loop 中的工具调用流程:
① 开发者定义工具函数 + JSON Schema(名称、描述、参数类型)
② Agent 框架把所有工具的 Schema 塞进 LLM 请求
③ LLM 推理后返回结构化 JSON:"我要调 search 工具,参数是 {query: 'xxx'}"
④ Agent 框架解析 JSON,提取工具名和参数
⑤ 在本地找到对应的函数,直接调用
⑥ 把执行结果回传给 LLM,继续推理
```
这个流程清晰、高效,**但有一个隐含假设**:工具的定义方式、注册方式、调用方式,都是**各框架自己定的**。
### 1.2 问题:工具和框架紧耦合
```
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 的定位
```
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 核心模型
```
┌───────────────────────┐ ┌───────────────────────┐
│ 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 两种传输方式
```
本地调用 → stdio(标准输入输出,走管道)
远程调用 → HTTP + SSE(走网络)
消息格式完全一样(JSON-RPC),只是底层的"管子"不同。
```
这就引出了一个底层问题:**stdio 通信到底是什么?管道到底是个什么东西?**
---
## 三、向下探底:操作系统的进程间通信
### 3.1 每个进程自带的三个通道
每个进程启动时,操作系统**自动**给它分配三个数据通道:
```
┌──────────────────────────────────┐
│ 一个进程 │
│ │
│ 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)的物理本质
```
管道 = 操作系统内核在内存里开辟的一块缓冲区(通常 64KB)
+ 一个写入端
+ 一个读取端
就这么简单。不是文件,不是网络,就是一块内核内存。
```
```
操作系统内核
┌──────────────────────────────────┐
│ │
│ ┌────────────────────────┐ │
│ │ 管道缓冲区 (64KB) │ │
│ │ │ │
写入端 ──────▶ [数据数据数据数据数据] ──────▶ 读取端
│ │ │ │
│ └────────────────────────┘ │
│ │
└──────────────────────────────────┘
进程A 拿着写入端的 fd 进程B 拿着读取端的 fd
往里塞字节 从里面取字节
```
生活类比:
```
管道就像一根水管:
┌──────┐ ┌──────┐
│进程 A │ ──── 水管(管道缓冲区)────── │进程 B │
│ 灌水 │ ─────────────────────────▶ │ 接水 │
└──────┘ └──────┘
- 管子有容量(64KB),灌满了 A 就得等(阻塞)
- 管子空了 B 就得等(阻塞)
- 水只能从 A 流向 B(单向)
- A 关掉水龙头,B 读到空就知道"结束了"(EOF)
```
### 3.3 管道不是自动分配的,是按需创建的
```
❌ 错误理解:每个进程启动时自动分配一根管道
✅ 正确理解:管道是你需要的时候,主动调 pipe() 系统调用创建的
```
创建过程:
```
步骤 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 管道的生命周期
```
创建:pipe() 系统调用时 → 内核分配缓冲区
存活:只要还有进程持有这个管道的 fd → 缓冲区一直在
销毁:所有 fd 都关闭(或进程退出)→ 内核自动回收
→ 不需要手动释放,没有泄漏风险
```
### 3.5 全景:本地 IPC 方式对比
管道只是本地进程间通信的一种方式。所有本地 IPC 的本质都是**共享一块内存,一个写一个读**,区别在于"谁来管这块内存"和"接口长什么样":
```
方式 本质 特点
───────────────────────────────────────────────────────────────
管道 Pipe 内核管的缓冲区 单向,需要父子关系
数据经过两次拷贝 最简单
(用户态→内核态→用户态)
命名管道 FIFO 内核管的缓冲区 不需要父子关系
通过文件路径寻址 两个独立进程也能用
Unix Socket 内核管的缓冲区 双向通信
通过 socket 文件寻址 功能最丰富
共享内存 两个进程直接映射 零拷贝,最快
Shared Memory 同一块物理内存页 但要自己加锁
不经过内核中转 防止竞态条件
消息队列 内核管的链表 有消息边界
Message Queue 数据经过内核中转 支持优先级
```
数据搬运路径的差异:
```
【管道 / Unix Socket / 消息队列】—— 内核中转
进程A 用户空间 内核空间 进程B 用户空间
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 数据 │─①─▶│ 缓冲区 │─②─▶│ 数据 │
└──────────┘ └──────────┘ └──────────┘
① write():用户态 → 内核态(拷贝一次)
② read(): 内核态 → 用户态(拷贝一次)
共 2 次数据拷贝
【共享内存】—— 零拷贝
进程A 用户空间 进程B 用户空间
┌──────────┐ ┌──────────┐
│ 虚拟地址 │──┐ ┌──│ 虚拟地址 │
└──────────┘ │ │ └──────────┘
▼ ▼
┌──────────────────────┐
│ 同一块物理内存页 │
└──────────────────────┘
0 次拷贝,但要自己处理并发读写问题
```
### 3.6 本地通信 vs 网络通信
```
本地通信(管道 / 共享内存 / Unix Socket):
数据在内存里搬运,不出本机,不经过网卡
延迟:纳秒 ~ 微秒级
网络通信(TCP / UDP):
数据经过完整的网络协议栈:
应用层 → 传输层(TCP/UDP) → 网络层(IP) → 链路层 → 网卡 → 物理介质
→ 对方网卡 → 链路层 → 网络层 → 传输层 → 应用层
延迟:微秒 ~ 毫秒级
注:即使是本机的 127.0.0.1,也走完整 TCP 协议栈,比管道慢
```
---
## 四、回到 MCP:本地调用(stdio 传输)
有了上面的基础,MCP 的 stdio 模式就彻底透明了。
### 4.1 完整流程
```
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:
```
→ 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 只改变了工具调用的那一步:
```
【没有 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 作为子进程跑在同一台机器上。但很多场景下工具服务是远程的:
```
场景1:公司内部的数据库查询服务跑在服务器集群上
场景2:第三方提供的 SaaS 工具(GitHub API、Jira API 封装)
场景3:需要 GPU 的计算密集型工具跑在专用机器上
场景4:多个 Agent 共享同一个 MCP Server 实例
```
### 5.2 远程传输:Streamable HTTP
MCP 最新的远程传输方式是 **Streamable HTTP**,核心原理:
```
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)是什么
```
SSE = 服务器单向推送事件流
普通 HTTP:
客户端请求 → 服务端返回一个完整响应 → 连接关闭
一问一答
SSE:
客户端请求 → 服务端返回一个"不关闭的响应"
→ 服务端持续往这个连接里推数据
→ 客户端持续接收
→ 直到服务端主动关闭
格式很简单:
event: message\n
data: {"一条JSON"}\n
\n
event: message\n
data: {"又一条JSON"}\n
\n
```
SSE 适合 MCP 远程调用的原因:工具执行可能需要时间,服务端可以先推中间进展,最后推最终结果。
### 5.4 本地 vs 远程对比
```
┌──────────────┬────────────────────────┬──────────────────────┐
│ │ 本地(stdio) │ 远程(HTTP/SSE) │
├──────────────┼────────────────────────┼──────────────────────┤
│ 底层介质 │ 管道(内核缓冲区) │ TCP 网络连接 │
│ 传输协议 │ stdin/stdout 字节流 │ HTTP + SSE │
│ 消息格式 │ JSON-RPC 2.0 │ JSON-RPC 2.0(一样) │
│ Server 位置 │ 本机子进程 │ 任意远程服务器 │
│ 性能 │ 微秒级 │ 毫秒级 │
│ 部署复杂度 │ 零配置 │ 需要 URL、端口、鉴权 │
│ 共享性 │ 父进程独占 │ 多客户端可共享 │
│ 适用场景 │ 开发调试、本地工具 │ 生产部署、远程服务 │
└──────────────┴────────────────────────┴──────────────────────┘
核心设计:传输层和协议层解耦
换传输方式不需要改消息格式
就像 USB 数据线换了材质,USB 协议不变
```
---
## 六、MCP Server 暴露的三类能力
MCP Server 不只是暴露"工具",它定义了三种原语:
```
┌──────────────┬──────────────┬───────────────┐
│ Tools │ Resources │ Prompts │
│ (工具) │ (资源) │ (提示词模板) │
├──────────────┼──────────────┼───────────────┤
│ Agent 调用 │ Agent 读取 │ Agent 使用 │
│ 有副作用 │ 只读数据 │ 预设指令模板 │
│ │ │ │
│ 例:发邮件 │ 例:读配置 │ 例:代码审查 │
│ 例:写数据库 │ 例:查日志 │ 的标准模板 │
│ 例:调 API │ 例:获取状态 │ │
└──────────────┴──────────────┴───────────────┘
实际使用中 Tools 占了 90%+ 的场景。
Resources 和 Prompts 是"顺便标准化"的补充能力。
```
---
## 七、MCP 的生命周期
一次完整的 MCP 通信过程:
```
阶段 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 工具调用经过的全部环节:
```
用户输入: "帮我搜索 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 / 管道?
```
选项 为什么不选
────────────────────────────────────────────────
TCP Socket 本地通信用 TCP 是杀鸡用牛刀,要选端口、处理端口冲突
共享内存 要处理锁、序列化、内存映射,复杂度爆炸
Unix Socket Windows 兼容性差,要管 socket 文件生命周期
命名管道 需要约定文件路径,多了一层管理
选 stdio / 管道 的理由:
✅ 零配置 — 不需要端口、不需要文件路径,spawn 就能通信
✅ 跨平台 — Windows / macOS / Linux 都支持
✅ 安全 — 父子进程间的管道外部无法窃听
✅ 够用 — MCP 消息是 JSON 文本(几KB),不需要极致性能
✅ 门槛低 — 工具开发者只需从 stdin 读、往 stdout 写,任何语言都行
```
---
## 十、总结
```
层次关系(从上到下):
┌─────────────────────────────────────────┐
│ 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*
---
这篇文章从 Agent 工具调用一路向下挖到操作系统管道原理,再回到 MCP 的协议设计,形成一个完整的认知闭环。你可以直接复制发博客。如果想要我调整某个章节的深度或者补充 A2A/AG-UI 的部分,随时说。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)