好,我对文章风格已经清楚了。现在给你生成一篇完整的博客文章。由于当前是 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 的部分,随时说。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐