从零讲透 LLM 工具调用:Function Call、MCP 与 Skills 如何分工与配合

LLM 会解释、会总结、会写代码,但它不会真的计算、不会直接查数据库,也不会自己调用 API。要让它真正干活,就得把它和外部工具连接起来。围绕这件事,今天最常被讨论的三套思路就是 Function Call、MCP 和 Skills。它们经常被混着说,很多人学完还是分不清。本文用同一组工具和一套渐进式示例,把它们各自解决什么问题、工作在哪一层、真实项目里怎么组合,讲清楚。


一、问题的起点:LLM 为什么需要工具?

试试让 ChatGPT 算一道题:

你:请精确计算 7923 × 4618 = ?

你可能得到一个看起来像模像样、但其实不对的数字。这不是 bug,而是 LLM 的工作原理决定的——它通过逐个预测下一个 token 来生成文本,而不是真的在做数学运算。它"编"出来的数字,只是在训练数据中见过类似模式后的一种概率猜测。

核心矛盾: LLM 擅长理解意图和组织语言,但不擅长精确执行。计算、查询、文件操作这些事,必须交给专门的程序来做。

所以问题变成了:怎样建立 LLM 和外部工具之间的桥梁?

目前主流的三种方案,分别从不同角度解决这个问题:

方案一句话说明谁提出的
Function Call让 LLM 通过 API 的结构化字段说出"我要调什么工具"OpenAI(2023年6月)
MCP用一套标准协议统一工具的注册、发现和通信Anthropic(2024年11月)
Skills用自然语言编写"能力包",让 LLM 按需加载领域知识和工具使用规则社区实践,无单一提出者

先记住下面这三句话,后面会轻松很多:

  • Skills 解决的是“LLM 怎么思考和决策”,属于认知层
  • Function Call 解决的是“LLM 怎么结构化表达调用意图”,属于接口层
  • MCP 解决的是“应用怎么发现和调用工具”,属于协议层

如果你先把这三层分清,后面就不会再把它们误认为是“三选一”的竞争关系。

下面我们通过同一个例子来拆解这三种方案。


二、实验准备:统一的工具和任务

为了公平对比,我们固定两个简单的工具和一个统一的用户提问:

工具定义:

# tools.py —— 三种方案共享的工具实现

import json

def calculator(expression: str) -> str:
    """
    演示版:计算数学表达式。
    这里用字符白名单 + eval 做简化实现,便于聚焦后文三种工具调用方案。
    """
    allowed = set("0123456789+-*/.() ")
    if not all(c in allowed for c in expression):
        return json.dumps({"error": f"包含不允许的字符"}, ensure_ascii=False)
    try:
        result = eval(expression)
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": str(e)})


def get_weather(city: str) -> str:
    """
    查询城市天气(这里用模拟数据,实际项目应调用真实 API)。
    """
    mock_db = {
        "北京": {"temperature": 18, "condition": "晴", "humidity": "35%"},
        "上海": {"temperature": 22, "condition": "多云", "humidity": "65%"},
        "广州": {"temperature": 28, "condition": "小雨", "humidity": "80%"},
    }
    info = mock_db.get(city, {"temperature": "未知", "condition": "未知", "humidity": "未知"})
    return json.dumps({"city": city, **info}, ensure_ascii=False)


# 工具名到函数的映射,后面会用到
TOOL_MAP = {
    "calculator": calculator,
    "get_weather": get_weather,
}

说明:为了把注意力放在 Function Call、MCP 和 Skills 的差异上,文中的 calculator 都采用了演示级简化实现。生产环境请使用 AST 白名单校验,或 simpleevalnumexprsympy 等更稳妥的方案,而不是直接照搬示例里的 eval

统一任务:

用户:"北京今天多少度?另外帮我算一下 42 × 38"

这个任务需要同时调用两个不同的工具,能很好地展示各方案的差异。


三、Function Call:让 LLM “说"出工具调用

3.1 它解决什么问题?

在 Function Call 出现之前,如果你想让 LLM 调工具,只能靠 Prompt 引导它输出特定格式的文本(这就是后面说的 Skills 方案)。问题是:LLM 的文本输出不总是符合格式,解析可能失败。

Function Call 的思路是:在 API 层面增加结构化的字段,让 LLM 不再通过自由文本表达调用意图,而是通过专门的 JSON 字段输出。这样格式就是有保障的。

3.2 工作流程

┌─────────────────────────────────────────────────────┐
│                     你的应用代码                       │
│                                                       │
│  ① 构造请求:用户消息 + 工具的 JSON Schema 定义        │
│          ↓                                            │
│  ② 调用 LLM API ─────────────→ LLM                   │
│          ↓                         │                  │
│  ③ LLM 返回 tool_calls 字段 ←─────┘                  │
│     (结构化 JSON,包含函数名和参数)                    │
│          ↓                                            │
│  ④ 你的代码执行对应函数,获取结果                       │
│          ↓                                            │
│  ⑤ 把函数结果以 role="tool" 传回 LLM                  │
│          ↓                                            │
│  ⑥ LLM 根据结果生成自然语言回复                        │
└─────────────────────────────────────────────────────┘

这里最容易搞混的一点是: LLM 自己不会执行任何函数。它只是通过 tool_calls 字段告诉你"我觉得应该调用 calculator,参数是 42 * 38”。真正执行函数的是你的应用代码。LLM 只是一个"决策者",不是"执行者"。

3.3 完整代码

"""
方案一:Function Call
依赖:pip install openai
环境变量:OPENAI_API_KEY
"""

import json
from openai import OpenAI
from tools import TOOL_MAP  # 导入前面定义的共享工具

# ======== 第一步:用 JSON Schema 描述工具 ========
# 这些定义会随 API 请求发给 LLM,让它知道有哪些工具可用

TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "计算数学表达式。当需要做加减乘除等数学运算时使用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "要计算的数学表达式,例如 '(3 + 5) * 2'"
                    }
                },
                "required": ["expression"],
                "additionalProperties": False
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的当前天气信息。",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "要查询天气的城市名称,例如 '北京'"
                    }
                },
                "required": ["city"],
                "additionalProperties": False
            }
        }
    }
]


def run():
    client = OpenAI()  # 自动读取环境变量 OPENAI_API_KEY

    messages = [
        {"role": "system", "content": "你是一个助手,可以查天气和做数学计算。请使用工具来完成任务。"},
        {"role": "user", "content": "北京今天多少度?另外帮我算一下 42 × 38"}
    ]

    # ======== 第二步:第一次 API 调用 —— LLM 决定调用哪些工具 ========
    print("发送请求(用户消息 + 工具定义)...")
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        tools=TOOLS_SCHEMA,
        tool_choice="auto",  # "auto" 表示让 LLM 自己判断要不要调工具
    )

    msg = response.choices[0].message

    # 检查 LLM 是否决定调用工具
    if not msg.tool_calls:
        print(f"LLM 直接回复了:{msg.content}")
        return

    print(f"LLM 决定调用 {len(msg.tool_calls)} 个工具")

    # ======== 第三步:执行工具,收集结果 ========
    # 先把 LLM 的回复(含 tool_calls)加入对话历史
    messages.append(msg.model_dump())

    for call in msg.tool_calls:
        name = call.function.name
        args = json.loads(call.function.arguments)

        print(f"  执行 {name}({args})")
        result = TOOL_MAP[name](**args)
        print(f"  结果:{result}")

        # 把每个工具的结果以 role="tool" 加入对话
        # tool_call_id 必须与对应的 call.id 匹配
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": result,
        })

    # ======== 第四步:第二次 API 调用 —— LLM 根据结果生成回复 ========
    print("发送工具结果,请求最终回复...")
    final = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
    )

    print(f"\n最终回复:{final.choices[0].message.content}")


if __name__ == "__main__":
    run()

3.4 API 请求和响应长什么样?

理解 Function Call 的关键在于理解 API 交互的数据结构。

你发给 LLM 的请求(简化):

{
  "model": "gpt-4o",
  "messages": [
    {"role": "user", "content": "北京今天多少度?另外帮我算一下 42 × 38"}
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "查询指定城市的当前天气信息。",
        "parameters": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "calculator",
        "description": "计算数学表达式。",
        "parameters": { "type": "object", "properties": { "expression": { "type": "string" } }, "required": ["expression"] }
      }
    }
  ]
}

LLM 返回的响应(简化):

{
  "choices": [{
    "message": {
      "role": "assistant",
      "content": null,
      "tool_calls": [
        {
          "id": "call_abc123",
          "type": "function",
          "function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" }
        },
        {
          "id": "call_def456",
          "type": "function",
          "function": { "name": "calculator", "arguments": "{\"expression\":\"42 * 38\"}" }
        }
      ]
    },
    "finish_reason": "tool_calls"
  }]
}

注意几个关键点:

  • 当 LLM 决定调工具时,contentnull(它没有直接生成文字回复)
  • tool_calls 是一个数组,LLM 可以一次返回多个调用(并行调用)
  • finish_reason"tool_calls" 而不是 "stop"
  • arguments 是一个 JSON 字符串(注意不是 JSON 对象),需要你 json.loads 解析

3.5 关于 Strict Mode

OpenAI 提供了 strict: true 选项。开启后,LLM 生成的参数 JSON 会100% 符合你定义的 Schema。这是通过模型的受限解码(constrained decoding)在生成阶段就保证的,不是事后校验。

# 在工具定义中开启 strict mode
{
    "type": "function",
    "function": {
        "name": "calculator",
        "strict": True,  # ← 加上这个
        "parameters": {
            "type": "object",
            "properties": { "expression": { "type": "string" } },
            "required": ["expression"],
            "additionalProperties": False  # ← strict mode 要求此项为 False
        }
    }
}

生产环境强烈建议开启,可以彻底消除参数格式不对导致的运行时错误。


四、MCP:工具世界的"USB 接口"

4.1 它解决什么问题?

Function Call 解决了 LLM"怎么表达调用意图"的问题,但在真实世界中还有一个更大的挑战:工具生态的碎片化

假设你在开发 AI 应用,需要接入 GitHub、Slack、数据库、日历……每个工具都有自己的 API 风格、认证方式、数据格式。你的代码里会出现大量的适配代码。如果换一个工具提供商,就要重写一遍。

MCP(Model Context Protocol)想做的事情,类似于 USB 接口对硬件世界的意义:

在 USB 出现之前,打印机用并口,键盘用 PS/2,鼠标用串口——每种设备一种接口。 USB 出现之后,所有设备都用同一种接口。

MCP 就是想给 AI 工具生态建立这样一个统一的"接口协议"。

4.2 架构:Client 和 Server

MCP 采用 Client-Server 架构

┌──────────────┐       MCP 协议        ┌──────────────┐
│  MCP Client  │ ◄──────────────────► │  MCP Server  │
│ (你的 AI 应用)│   JSON-RPC 2.0 消息   │ (工具提供方)  │
└──────┬───────┘                       └──────────────┘
       │                                      
       │ 与 LLM 通信                  工具提供方只需实现一次 Server,
       │ (可以用 Function Call)       所有支持 MCP 的 Client 都能接入
       ▼
    ┌─────┐
    │ LLM │
    └─────┘
  • MCP Server:工具提供方开发。把工具能力暴露出来——注册工具、声明参数、执行调用。
  • MCP Client:AI 应用开发者实现。连接一个或多个 Server,发现有哪些工具可用,把用户请求路由到正确的 Server。
  • 传输方式:支持 stdio(标准输入输出,适合本地进程通信)和 Streamable HTTP(新版规范中的远程传输方式)。如果你看到一些旧资料提到 HTTP+SSE / SSE,那通常是在描述旧版协议或兼容实现。

4.3 MCP 和 Function Call 是什么关系?

这是最容易混淆的地方,必须搞清楚:

MCP 和 Function Call 不是替代关系,而是工作在不同层面。

               ┌────────────────────────────────────┐
               │         你的 AI 应用(MCP Client)    │
               │                                      │
  用户消息 ──→ │  ① Client 连接 Server,发现可用工具    │
               │  ② 把工具列表转成 Function Call 格式   │
               │  ③ 连同用户消息一起发给 LLM            │
               │  ④ LLM 返回 tool_calls(Function Call)│
               │  ⑤ Client 通过 MCP 协议调用 Server     │
               │  ⑥ Server 执行工具,返回结果           │
               │  ⑦ 结果传回 LLM,生成最终回复          │
               └────────────────────────────────────┘

看到了吗?LLM 和你的应用之间的通信,用的还是 Function Call。 MCP 管的是你的应用和工具之间的通信。它们是两个不同层面的协议:

  • Function Call = LLM 和应用之间的接口(“LLM 怎么告诉你它想调什么”)
  • MCP = 应用和工具之间的接口(“应用怎么发现和调用工具”)

4.4 完整代码

Server 端(mcp_server.py):

"""
MCP Server —— 提供 calculator 和 get_weather 工具
依赖:pip install "mcp[cli]"
运行:python mcp_server.py(会以 stdio 模式启动,等待 Client 连接)
"""

import json
from mcp.server.fastmcp import FastMCP

# 创建 Server 实例,名称用于标识
server = FastMCP("demo-tools")


# 用 @server.tool() 装饰器注册工具
# MCP SDK 会自动从函数签名和 docstring 提取工具的名称、描述、参数 Schema
@server.tool()
def calculator(expression: str) -> str:
    """
    计算数学表达式,支持加减乘除和括号。

    Args:
        expression: 要计算的数学表达式,例如 "(3 + 5) * 2"
    """
    allowed = set("0123456789+-*/.() ")
    if not all(c in allowed for c in expression):
        return json.dumps({"error": "包含不允许的字符"}, ensure_ascii=False)
    try:
        result = eval(expression)
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": str(e)})


@server.tool()
def get_weather(city: str) -> str:
    """
    查询指定城市的当前天气。

    Args:
        city: 城市名称,例如 "北京"
    """
    mock_db = {
        "北京": {"temperature": 18, "condition": "晴", "humidity": "35%"},
        "上海": {"temperature": 22, "condition": "多云", "humidity": "65%"},
        "广州": {"temperature": 28, "condition": "小雨", "humidity": "80%"},
    }
    info = mock_db.get(city, {"temperature": "未知", "condition": "未知", "humidity": "未知"})
    return json.dumps({"city": city, **info}, ensure_ascii=False)


if __name__ == "__main__":
    # stdio 传输:通过标准输入/输出和 Client 通信
    # 适合 Client 和 Server 在同一台机器上的场景
    server.run(transport="stdio")

Client 端(mcp_client.py):

"""
MCP Client —— 连接 Server,发现工具,配合 LLM 完成任务
依赖:pip install "mcp[cli]" openai
"""

import asyncio
import json
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from openai import OpenAI


async def run():
    # ======== 第一步:连接 MCP Server ========
    server_params = StdioServerParameters(
        command="python",
        args=["mcp_server.py"],  # 启动 Server 的命令
    )

    # stdio_client 会启动一个子进程运行 Server,并通过 stdin/stdout 通信
    async with stdio_client(server_params) as (read_stream, write_stream):
        async with ClientSession(read_stream, write_stream) as session:
            # 握手初始化(交换协议版本、能力声明等)
            await session.initialize()

            # ======== 第二步:发现可用工具 ========
            tools_result = await session.list_tools()
            print(f"Server 提供了 {len(tools_result.tools)} 个工具:")
            for t in tools_result.tools:
                print(f"   - {t.name}: {t.description}")

            # 把 MCP 工具格式转换为 OpenAI Function Call 格式
            # 这一步是 MCP Client 的核心职责之一
            openai_tools = []
            for t in tools_result.tools:
                openai_tools.append({
                    "type": "function",
                    "function": {
                        "name": t.name,
                        "description": t.description or "",
                        "parameters": t.inputSchema,  # MCP 工具的参数已经是 JSON Schema
                    }
                })

            # ======== 第三步:与 LLM 交互 ========
            llm = OpenAI()
            messages = [
                {"role": "system", "content": "你是一个助手,可以查天气和做数学计算。"},
                {"role": "user", "content": "北京今天多少度?另外帮我算一下 42 × 38"}
            ]

            print("\n发送请求到 LLM...")
            response = llm.chat.completions.create(
                model="gpt-4o",
                messages=messages,
                tools=openai_tools,
                tool_choice="auto",
            )

            msg = response.choices[0].message
            if not msg.tool_calls:
                print(f"LLM 直接回复:{msg.content}")
                return

            print(f"LLM 要求调用 {len(msg.tool_calls)} 个工具")
            messages.append(msg.model_dump())

            # ======== 第四步:通过 MCP 协议调用工具 ========
            for call in msg.tool_calls:
                name = call.function.name
                args = json.loads(call.function.arguments)

                print(f"  通过 MCP 调用 {name}({args})")

                # 核心区别在这里:不是直接调本地函数,
                # 而是通过 MCP 协议发给 Server 执行
                #
                # session.call_tool() 是 MCP SDK 中 ClientSession 提供的方法,
                # 它会通过底层传输层(这里是 stdio)向 Server 发送一条
                # JSON-RPC 请求:{"method": "tools/call", "params": {"name": ..., "arguments": ...}}
                # Server 收到后执行对应的 @server.tool() 函数,把结果返回。
                # 我们不需要自己定义这个方法,SDK 已经封装好了。
                result = await session.call_tool(name, arguments=args)

                # MCP 返回的结果是 CallToolResult 对象,
                # 其中 content 是一个 Content 对象列表(通常是 TextContent)
                result_text = result.content[0].text
                print(f"  Server 返回:{result_text}")

                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": result_text,
                })

            # ======== 第五步:LLM 根据结果生成回复 ========
            print("\n发送工具结果到 LLM...")
            final = llm.chat.completions.create(model="gpt-4o", messages=messages)
            print(f"\n最终回复:{final.choices[0].message.content}")


if __name__ == "__main__":
    asyncio.run(run())

4.5 MCP SDK 的关键 API 说明

上面代码中用到了 MCP Python SDK(mcp 包)的几个核心方法,它们都是 SDK 内置的,不需要我们自己实现:

方法所属作用
session.initialize()ClientSession与 Server 握手,交换协议版本和能力声明
session.list_tools()ClientSession向 Server 查询所有可用工具的名称、描述、参数 Schema
session.call_tool(name, arguments)ClientSession向 Server 发送工具调用请求,等待执行结果返回
@server.tool()FastMCP装饰器,将一个 Python 函数注册为 MCP 工具
server.run(transport="stdio")FastMCP启动 Server,监听指定传输通道

其中 call_tool 的底层是一条 JSON-RPC 2.0 消息:

// Client → Server
{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "calculator", "arguments": {"expression": "42 * 38"}}, "id": 1}

// Server → Client
{"jsonrpc": "2.0", "result": {"content": [{"type": "text", "text": "{\"result\": 1596}"}]}, "id": 1}

SDK 帮我们封装了序列化、传输、响应匹配这些底层细节,我们只需要一行 await session.call_tool(...) 就完成了整个过程。

4.6 MCP 的真正价值在哪里?

看完代码你可能会想:这不就是多了一层 Server 的包装吗?代码量反而更多了。

对于两三个工具的小项目,确实没必要用 MCP。它的价值体现在规模化场景

没有 MCP 时(每个应用单独对接每个工具):

AI App 1 ──→ 自己写代码对接 GitHub API
AI App 1 ──→ 自己写代码对接 Slack API
AI App 1 ──→ 自己写代码对接数据库

AI App 2 ──→ 自己再写一遍 GitHub 对接
AI App 2 ──→ 自己再写一遍 Slack 对接
... 每个 App 都要重复造轮子

有了 MCP 之后:

AI App 1 ─┐                    ┌─ GitHub MCP Server(官方维护一次)
AI App 2 ─┤── MCP 协议通信 ──┤─ Slack MCP Server(官方维护一次)
AI App 3 ─┘                    └─ Database MCP Server(官方维护一次)

每个 App 只需实现 MCP Client,
每个工具只需实现 MCP Server。

此外,MCP 协议还内置了:

  • 工具发现:Client 可以动态查询 Server 有哪些工具可用
  • 能力协商:Client 和 Server 握手时声明各自支持的功能
  • 结构化错误:统一的错误报告格式

五、Skills:用"说明书"教 LLM 使用工具

5.1 它解决什么问题?

Function Call 需要模型原生支持这个特性,MCP 需要配套的 SDK 和 Server 基础设施。但实际工程中有两个常见痛点:

  1. 不是所有 LLM 都支持 Function Call——很多开源模型、本地部署的模型没有这个能力
  2. 工具越多,上下文越膨胀——如果你有 50 个工具的 JSON Schema 全部塞进每次请求,Token 消耗非常大,而且大部分工具在当前对话中根本用不上

Skills 用一种更接近"人类认知"的方式解决这两个问题:把工具能力封装成一份份自然语言编写的"技能包"(Skill),每个 Skill 有 name 和 description 作为索引,LLM 根据这些元数据判断需要哪个 Skill,再按需加载完整的使用说明。

核心机制分三层:

  1. 元数据常驻:每个 Skill 只有 name(名称)和 description(描述)常驻在 System Prompt 中,Token 开销极小
  2. 按需加载:当 LLM 判断用户意图匹配某个 Skill 的 description 时,触发加载——完整的工具说明、参数定义、使用规则、示例才被注入上下文
  3. 上下文指导执行:加载后的 Skill 文档指导 LLM 按约定格式输出调用请求,你的代码解析并执行

可以这样类比:如果 Function Call 的 JSON Schema 是"给 LLM 一份结构化的工具清单",那 Skills 就是"给 LLM 一本操作手册,但目录页始终翻开着,只有需要时才翻到具体章节"。

当然,在小型项目中(工具少、不需要延迟加载),Skills 也可以简化为直接把全部说明书放进 System Prompt——这就是我们下面示例代码的做法。理解了按需加载的核心思想后,简化版本的逻辑更容易看懂。

5.2 工作流程

┌─────────────────────────────────────────────────────────────┐
│                        你的应用代码                           │
│                                                               │
│  ① System Prompt = 基础人设 + 各 Skill 的 name/description   │
│          ↓                                                    │
│  ② LLM 根据用户意图,匹配需要的 Skill                        │
│          ↓                                                    │
│  ③ 加载对应 Skill 的完整文档(注入上下文)                    │
│     (小型项目可跳过②③,直接全量注入)                        │
│          ↓                                                    │
│  ④ 调用 LLM API(普通 Chat Completion,无 tools 参数)       │
│          ↓                                                    │
│  ⑤ LLM 在回复文本中输出约定格式的"action 块"                 │
│     ```action                                                 │
│     {"tool": "calculator", "params": {"expression": ...}}     │
│     ```                                                       │
│          ↓                                                    │
│  ⑥ 你的代码用正则表达式解析出 action 块                       │
│          ↓                                                    │
│  ⑦ 执行对应函数,把结果放回对话历史                           │
│          ↓                                                    │
│  ⑧ 循环 ④~⑦,直到 LLM 不再输出 action 块                   │
└─────────────────────────────────────────────────────────────┘

5.3 完整代码

Skills 说明书(skills_doc.md):

下面的 Markdown 文件就是一个完整的 Skill。注意开头的标题和描述构成了 Skill 的元数据(name + description),LLM 通过它们判断是否需要激活这个 Skill。在我们的简化示例中,整个文档直接注入 System Prompt;在生产环境中,只有 name 和 description 常驻,正文按需加载。

# 可用工具

> **Skill name**: tool-assistant
> **Skill description**: 提供数学计算和天气查询能力。当用户需要做数学运算或查询天气时激活此技能。

你可以使用以下工具来完成任务。需要使用工具时,在回复中输出 action 代码块。
每次只输出一个 action 块,等待结果后再决定下一步。

---

## calculator - 数学计算器

**功能:** 计算数学表达式。当需要做任何数学运算时必须使用此工具,不要自己心算。

**调用格式:**

```action
{"tool": "calculator", "params": {"expression": "数学表达式"}}
```

**参数说明:**
- expression(必填):数学表达式字符串,使用标准符号 + - * / ( )

**示例:**
用户问"3乘以7加2等于多少",输出:

```action
{"tool": "calculator", "params": {"expression": "3 * 7 + 2"}}
```

---

## get_weather - 天气查询

**功能:** 查询城市的实时天气信息。

**调用格式:**

```action
{"tool": "get_weather", "params": {"city": "城市名"}}
```

**参数说明:**
- city(必填):要查询的城市名称

**示例:**
用户问"上海天气怎么样",输出:

```action
{"tool": "get_weather", "params": {"city": "上海"}}
```

---

## 重要规则

1. 需要计算时**必须**使用 calculator,不要自己算
2. 每次回复中**最多输出一个** action 块
3. 输出 action 块后**立即停止**,等待工具结果
4. 收到工具结果后,继续处理剩余任务或给出最终回答
5. 如果不需要使用工具,直接回复即可,不要输出 action 块

主程序(skills_demo.py):

注意:下面代码中 load_skills_doc() 返回的字符串里包含 ```action 标记,这些就是注入到 System Prompt 中的约定格式。

"""
方案三:Skills
依赖:pip install openai
特点:不需要模型支持 Function Call,任何 LLM 都能用
"""

import json
import re
from openai import OpenAI
from tools import TOOL_MAP


def load_skills_doc() -> str:
    """
    加载 Skills 说明书。
    生产环境应该从 skills_doc.md 文件读取:
        with open("skills_doc.md") as f:
            return f.read()
    这里为了示例自包含,直接写在代码中。
    注意:返回内容与前面的 skills_doc.md 模板保持一致,
    包含 Skill name 和 description 元数据。
    """
    return '''
# 可用工具

> **Skill name**: tool-assistant
> **Skill description**: 提供数学计算和天气查询能力。当用户需要做数学运算或查询天气时激活此技能。

你可以使用以下工具来完成任务。需要使用工具时,在回复中输出 action 代码块。
每次只输出一个 action 块,等待结果后再决定下一步。

---

## calculator - 数学计算器

**功能:** 计算数学表达式。当需要做任何数学运算时必须使用此工具,不要自己心算。

**调用格式:**

```action
{"tool": "calculator", "params": {"expression": "数学表达式"}}
```

**参数说明:**
- expression(必填):数学表达式字符串,使用标准符号 + - * / ( )

**示例:**
用户问"3乘以7加2等于多少",输出:

```action
{"tool": "calculator", "params": {"expression": "3 * 7 + 2"}}
```

---

## get_weather - 天气查询

**功能:** 查询城市的实时天气信息。

**调用格式:**

```action
{"tool": "get_weather", "params": {"city": "城市名"}}
```

**参数说明:**
- city(必填):要查询的城市名称

**示例:**
用户问"上海天气怎么样",输出:

```action
{"tool": "get_weather", "params": {"city": "上海"}}
```

---

## 重要规则

1. 需要计算时**必须**使用 calculator,不要自己算
2. 每次回复中**最多输出一个** action 块
3. 输出 action 块后**立即停止**,等待工具结果
4. 收到工具结果后,继续处理剩余任务或给出最终回答
5. 如果不需要使用工具,直接回复即可,不要输出 action 块
'''


def parse_action(text: str) -> dict | None:
    """
    从 LLM 的回复文本中解析 action 代码块。
    返回解析出的 dict,如果没有 action 块则返回 None。
    """
    # 匹配 ```action ... ``` 代码块
    pattern = r"```action\s*\n?(.*?)\n?```"
    match = re.search(pattern, text, re.DOTALL)
    if not match:
        return None

    raw = match.group(1).strip()
    try:
        action = json.loads(raw)
        # 基本校验:必须有 tool 和 params 字段
        if "tool" not in action or "params" not in action:
            print(f"  action 块缺少必要字段: {action}")
            return None
        return action
    except json.JSONDecodeError as e:
        print(f"  action 块 JSON 解析失败: {e}")
        print(f"  原始内容: {raw}")
        return None


def run():
    client = OpenAI()

    system_prompt = f"""你是一个助手,可以通过工具查天气和做数学计算。

{load_skills_doc()}
"""

    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": "北京今天多少度?另外帮我算一下 42 × 38"}
    ]

    MAX_ROUNDS = 10  # 安全限制,防止无限循环

    for round_num in range(1, MAX_ROUNDS + 1):
        print(f"\n{'='*50}")
        print(f"第 {round_num} 轮请求")

        # 普通的 Chat Completion 调用,注意没有 tools 参数
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
        )

        reply = response.choices[0].message.content
        print("LLM 回复:")
        print(f"   {reply[:300]}{'...' if len(reply) > 300 else ''}")

        # 把回复加入对话历史
        messages.append({"role": "assistant", "content": reply})

        # 解析 action 块
        action = parse_action(reply)

        if action is None:
            # 没有 action 块 = LLM 认为任务已完成
            print(f"\n最终回复:{reply}")
            break

        # 执行工具
        tool_name = action["tool"]
        tool_params = action["params"]

        if tool_name not in TOOL_MAP:
            error_msg = f"未知工具: {tool_name}"
            print(f"  {error_msg}")
            messages.append({"role": "user", "content": f"工具调用错误:{error_msg}"})
            continue

        print(f"  执行 {tool_name}({tool_params})")
        result = TOOL_MAP[tool_name](**tool_params)
        print(f"  结果:{result}")

        # 将结果以 user 消息传回(Skills 模式没有专门的 tool 角色)
        messages.append({
            "role": "user",
            "content": f"工具 {tool_name} 执行结果:{result}\n\n请根据已有信息继续处理。"
        })

    print(f"\n共经历 {round_num} 轮对话")


if __name__ == "__main__":
    run()

5.4 Skills 模式的权衡

优势:

  • 零门槛:不需要模型支持 Function Call,任何能对话的 LLM 都能用,包括开源模型
  • 极低的工具接入成本:加一个新 Skill 只需写一份 Markdown 文档——name、description 和使用说明
  • 延迟加载省 Token:只有 name 和 description 常驻上下文,完整文档按需注入,工具多时优势明显
  • 可以表达复杂策略:你可以在 Skill 文档中写"如果用户没指定城市,先问清楚再查天气"——这种决策逻辑在 JSON Schema 里无法表达
  • 领域知识封装:可以把团队的 SOP、代码规范、排查流程等"隐性知识"固化为 Skill

劣势:

  • 格式不保证可靠:LLM 可能输出格式错误的 JSON、遗漏引号、多输出 action 块等
  • 匹配依赖 description 质量:description 写得不好会导致 Skill 被错误激活或遗漏
  • 不支持并行调用:通常需要一轮一轮地执行,每轮只处理一个工具
  • 需要自己写解析逻辑:正则解析 + 错误处理的代码需要你自己维护

六、三种方案全面对比

6.1 调用流程对比

【Function Call —— 2轮 API 调用,最高效】

Round 1: 用户消息 + 工具 Schema ──→ LLM ──→ tool_calls(可并行多个)
         你的代码执行所有工具
Round 2: 工具结果 ──→ LLM ──→ 最终回复


【MCP —— 2轮 API 调用 + MCP 协议开销】

初始化:  Client ←→ Server 握手,获取工具列表
Round 1: 用户消息 + 工具 Schema ──→ LLM ──→ tool_calls
         Client 通过 MCP 协议调用 Server
Round 2: 工具结果 ──→ LLM ──→ 最终回复


【Skills —— 可能需要 N+1 轮 API 调用(N=工具调用次数)】

准备:  LLM 读取各 Skill 的 name + description(常驻上下文)
匹配:  LLM 判断需要哪个 Skill,加载其完整文档
Round 1: 用户消息 + Skill 文档 ──→ LLM ──→ action 块(1个工具)
         你的代码执行
Round 2: 工具结果 ──→ LLM ──→ action 块(下一个工具)
         你的代码执行
Round 3: 工具结果 ──→ LLM ──→ 最终回复

6.2 核心指标对比

维度Function CallMCPSkills
模型要求需要模型支持Client 侧通常仍需要模型支持 Function Call任何模型都可以
格式可靠性高,尤其适合 strict mode高,本质上复用 Function Call一般,取决于提示词和解析逻辑
并行调用可以一次返回多个可以通常逐个执行
Token 效率较高,Schema 相对紧凑中等,额外有协议和工具发现开销取决于是否延迟加载
添加新工具改代码,补 Schema改代码,补 Server写 Markdown 说明书
跨模型兼容一般,各家格式不同高,协议层与模型无关高,纯文本方案
生态标准化厂商特定开放协议没有统一标准

6.3 它们到底是什么关系?

这三种方案不是同一个问题的三种替代方案,而是在不同层面解决不同问题:

┌──────────────────────────────────────────┐
│          Skills(认知层)                  │
│  "教 LLM 理解工具的场景和策略"             │
│  → name/description 索引 + 按需加载文档   │
│  → 通过自然语言引导 LLM 的决策和执行       │
├──────────────────────────────────────────┤
│       Function Call(接口层)              │
│  "LLM 如何结构化地表达调用意图"             │
│  → API 层面的结构化输出                    │
├──────────────────────────────────────────┤
│           MCP(协议层)                    │
│  "应用如何发现和调用远程工具"               │
│  → 应用与工具之间的通信标准                 │
└──────────────────────────────────────────┘

在实际工程中,它们经常组合使用

# 实际项目中可能是这样的:

# 1. Skills 说明书告诉 LLM:"处理金融数据时要特别小心,先查余额再转账"
#    (认知层:引导决策策略)

# 2. LLM 通过 Function Call 输出结构化的调用意图
#    (接口层:可靠的输出格式)

# 3. 应用通过 MCP 协议把请求发给远程的银行 API Server
#    (协议层:标准化的工具通信)

七、如何选择?

你只有两三个工具、做个 Demo

直接用 Function Call。 最简单,最高效,格式最可靠。

你在做一个要接入大量第三方工具的产品

MCP + Function Call。 用 MCP 管理工具生态(发现、注册、调用),用 Function Call 和 LLM 通信。

你在用不支持 Function Call 的开源模型

Skills。 这是唯一不依赖模型特殊能力的方案。

你需要复杂的工具使用策略

Skills + Function Call。 用 Skills 文档引导 LLM 的决策(什么时候该用什么工具、什么场景不能用),用 Function Call 保证输出格式。

你在做企业级产品

三者组合。 Skills 管策略,Function Call 管格式,MCP 管通信。


八、常见误解

下面列出四个最常见的认知误区。每条先说误解,再给出正确理解。


误解一:“Function Call 是 LLM 在执行函数”

误解: LLM 收到用户请求后,会自己去调用函数、执行代码。

正确理解: LLM 不执行任何东西tool_calls 只是一段结构化数据,告诉你“我认为应该调用这个函数,参数是这些”。真正执行的是你的应用代码,你始终是最终决定者。

举个极端例子,如果 LLM 返回了:

{"name": "delete_database", "arguments": {"confirm": true}}

你完全可以在代码里拦截掉,不执行。LLM 只是“建议者”,不是“执行者”。

记住: Function Call 的 tool_calls 是“调用建议”,不是“调用命令”。


误解二:“MCP 要取代 Function Call”

误解: MCP 是 Function Call 的替代品,用了 MCP 就不需要 Function Call 了。

正确理解: 它们工作在完全不同的层面,不是竞争关系。MCP Client 和 LLM 通信时,底层通常还是在用 Function Call。

对照来看:

维度Function CallMCP
管什么LLM 怎么表达“我想调什么”应用怎么发现和调用工具
工作层面LLM 与应用之间应用与工具之间
类比你告诉快递员“寄这个包裹”快递公司的统一接单系统

记住: Function Call 管“说”,MCP 管“做”,两者是上下游关系。


误解三:“Skills 是过时的方案”

误解: Skills 只是没有 Function Call 时的临时替代品,现在有了 Function Call 就不需要了。

正确理解: Skills 的价值不只是替代 Function Call,而是提供了三个独特能力:

能力Function Call 是否擅长
延迟加载:name + description 做索引,按需注入完整文档不擅长,Schema 往往全量注入
领域知识封装:把 SOP、决策流程、排查步骤固化为文档不擅长,JSON Schema 很难表达
复杂策略表达:如“用户连续三次输入错误就停止重试”不擅长,通常要额外写代码

很多生产级 AI 产品会同时使用 Function Call 和 Skills:前者保证格式,后者引导策略。

记住: Skills 是“教 LLM 怎么思考”,Function Call 是“让 LLM 怎么说话”,不是替代关系。


误解四:“这三种方案只能选一种”

误解: Function Call、MCP、Skills 是三选一的关系,只能用其中一种。

正确理解: 在实际工程中,它们经常是三层叠加、各司其职

你的企业级 AI 应用 = Skills(引导决策) + Function Call(结构化输出) + MCP(工具通信)

回忆一下前面的三层架构图:Skills 在认知层,Function Call 在接口层,MCP 在协议层。它们本来就是设计给不同层用的,组合才是正确姿势。

记住: 不是“三选一”,而是“三层配合”。


九、实战项目:智能工单处理系统(三种方案融合)

前面我们分别学习了三种方案的原理和代码。但在真实项目中,它们经常同时出现在一个系统里。下面用一个更贴近企业场景的「智能工单处理系统」来展示三者如何配合工作。

9.1 项目背景与需求

假设你在一家 SaaS 公司负责客服系统。客服每天收到大量工单,需要人工判断优先级、查询客户信息、计算 SLA 剩余时间。你要做一个 AI 助手来帮客服半自动处理工单。

客服输入一句话,比如:

"客户 ACME Corp 报了一个生产环境数据库连接超时问题,合同是白金级别,
帮我查一下这个客户的信息,算一下 SLA 还剩多少时间
(4小时响应要求,工单创建于2小时15分钟前),然后给我一个处理建议。"

系统需要:

  1. 查客户信息,调用 query_customer 工具(从 CRM 查询合同级别、历史工单、联系人)
  2. 算 SLA,调用 calculator 工具(计算剩余响应时间、判断是否即将超期)
  3. 给出处理建议,并根据 Skills 中的工单分级策略来组织回复(升级 / 转派 / 自行处理)

9.2 三种方案各负责什么

┌──────────────────────────────────────────────────────────┐
│           智能工单处理系统(MCP Client)                    │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Skills 层(认知层)                               │     │
│  │  • ticket-triage Skill: name + description 常驻   │     │
│  │  • 匹配"工单/故障/SLA"意图后加载完整策略文档       │     │
│  │  • 策略内容:客户分级 → SLA 计算 → 处理建议       │     │
│  └────────────────────┬─────────────────────────────┘     │
│                       ↓                                     │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Function Call 层(接口层)                        │     │
│  │  • LLM 通过 tool_calls 字段输出结构化调用          │     │
│  │  • strict mode 保证参数格式 100% 正确             │     │
│  └────────────────────┬─────────────────────────────┘     │
│                       ↓                                     │
│  ┌──────────────────────────────────────────────────┐     │
│  │  MCP 层(协议层)                                  │     │
│  │  • CRM 工具 → Customer MCP Server                 │     │
│  │  • 计算工具 → Calculator MCP Server               │     │
│  └──────────────────────────────────────────────────┘     │
└──────────────────────────────────────────────────────────┘

9.3 第一步:MCP Server(工具层)

这次我们写一个更贴近企业场景的 MCP Server,提供 query_customer(客户查询)和 calculator(计算器)两个工具:

"""
工单处理 MCP Server —— 提供 CRM 查询和计算工具
运行:python ticket_mcp_server.py
"""

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("ticket-tools")


# 模拟 CRM 数据库
CUSTOMER_DB = {
    "ACME Corp": {
        "contract_level": "白金",
        "sla_response_hours": 4,
        "sla_resolve_hours": 24,
        "account_manager": "张伟",
        "open_tickets": 3,
        "monthly_revenue": 500000,
        "history_note": "上月有过2次数据库相关工单,均已解决"
    },
    "Beta Inc": {
        "contract_level": "黄金",
        "sla_response_hours": 8,
        "sla_resolve_hours": 48,
        "account_manager": "李娜",
        "open_tickets": 1,
        "monthly_revenue": 120000,
        "history_note": "历史工单较少,服务满意度高"
    },
}


@mcp.tool()
def query_customer(customer_name: str) -> str:
    """
    查询客户信息,返回合同级别、SLA 标准、客户经理、历史工单等。
    参数:customer_name - 客户名称
    """
    import json
    customer = CUSTOMER_DB.get(customer_name)
    if customer:
        return json.dumps(customer, ensure_ascii=False, indent=2)
    return json.dumps({"error": f"未找到客户: {customer_name}"}, ensure_ascii=False)


@mcp.tool()
def calculator(expression: str) -> str:
    """
    演示用:在受限环境中计算数学表达式,返回结果。
    参数:expression - 数学表达式,如 "4 * 60 - 135"
    """
    import json
    allowed = set("0123456789+-*/.() ")
    if not all(c in allowed for c in expression):
        return json.dumps({"error": "包含不允许的字符"}, ensure_ascii=False)
    try:
        # 这里只是为了演示流程而做的简化实现。
        # 生产环境仍应使用 AST 白名单或专门的安全求值器。
        result = eval(expression, {"__builtins__": {}}, {})
        return json.dumps({"expression": expression, "result": result}, ensure_ascii=False)
    except Exception as e:
        return json.dumps({"error": str(e)}, ensure_ascii=False)


if __name__ == "__main__":
    mcp.run(transport="stdio")

9.4 第二步:Skills 文档(认知层)

创建一个 ticket_triage_skill.md ——这不是代码,而是给 LLM 看的「决策手册」:

# 工单分级处理策略

> **Skill name**: ticket-triage
> **Skill description**: 智能工单分级处理助手。当用户提到"工单"、"故障"、"SLA"、"客户报障"等意图时激活。提供客户查询、SLA 计算和处理建议的一站式服务。

## 工作流程

收到工单处理需求后,严格按以下顺序执行:

### 步骤 1:查询客户信息
- 使用 `query_customer` 工具查询客户的合同级别、SLA 标准、历史工单
- 如果用户没有给出客户名称,先询问

### 步骤 2:计算 SLA 剩余时间
- 使用 `calculator` 工具计算
- 公式:SLA 要求时间(分钟) - 已用时间(分钟)
- 判断紧急程度:
  - **紧急**:剩余时间 < SLA 总时间的 25%(即将超期)
  - **警告**:剩余时间 < SLA 总时间的 50%
  - **正常**:剩余时间 >= SLA 总时间的 50%

### 步骤 3:生成处理建议
根据客户级别 + SLA 紧急程度,给出分级建议:

| 客户级别 | 紧急程度 | 建议 |
|---------|---------|------|
| 白金 | 紧急 | 立即升级至 P0,通知客户经理 + 技术总监,15分钟内响应 |
| 白金 | 警告 | 升级至 P1,通知客户经理,30分钟内响应 |
| 白金 | 正常 | P2 正常处理,但优先排队 |
| 黄金/白银 | 紧急 | 升级至 P1,通知客户经理 |
| 黄金/白银 | 警告 / 正常 | 按正常流程处理 |

### 回复格式
最终回复必须包含四个部分,用清晰的标题分隔:
1. **客户概况**(合同级别、月营收、历史情况)
2. **SLA 分析**(剩余时间、紧急程度判定)
3. **处理建议**(建议优先级、通知谁、响应时限)
4. **风险提示**(如有历史类似问题、客户情绪等)

9.5 第三步:主程序(三层融合)

"""
智能工单处理系统 —— 融合 Skills + Function Call + MCP
依赖:pip install "mcp[cli]" openai
"""

import asyncio
import json
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from openai import OpenAI


# ======== Skills 管理:name/description 索引 + 按需加载 ========

SKILLS_REGISTRY = [
    {
        "name": "ticket-triage",
        "description": "智能工单分级处理助手。当用户提到工单、故障、SLA、客户报障等意图时激活。",
        "doc_path": "ticket_triage_skill.md",
    },
    # 未来可以加更多 Skill,比如:
    # {"name": "incident-postmortem", "description": "故障复盘助手...", "doc_path": "..."},
    # {"name": "capacity-planner", "description": "容量规划助手...", "doc_path": "..."},
]


def build_skills_index() -> str:
    """
    构建 Skills 索引文本(只有 name + description),常驻 System Prompt。
    这就是 LLM 的"目录页"——Token 开销很小。
    """
    lines = ["## 可用技能(Skills)", ""]
    for skill in SKILLS_REGISTRY:
        lines.append(f"- **{skill['name']}**: {skill['description']}")
    lines.append("")
    lines.append("当你判断用户意图匹配某个技能时,回复 `[LOAD_SKILL: 技能名]` 来加载它。")
    return "\n".join(lines)


def load_skill(skill_name: str) -> str | None:
    """
    按需加载 Skill 的完整文档。
    只有 LLM 判断需要时才调用,避免每次都注入所有文档。
    """
    for skill in SKILLS_REGISTRY:
        if skill["name"] == skill_name:
            path = Path(skill["doc_path"])
            if path.exists():
                return path.read_text(encoding="utf-8")
            else:
                return _get_embedded_skill(skill_name)
    return None


def _get_embedded_skill(name: str) -> str:
    """内嵌的 Skill 文档(示例用,生产环境应从文件读取)"""
    if name == "ticket-triage":
        return """
收到工单处理需求后,严格按以下顺序执行:
1. 使用 query_customer 工具查询客户合同级别、SLA 标准、历史工单
2. 使用 calculator 工具计算 SLA 剩余时间(分钟)
3. 根据客户级别 + SLA 紧急程度给出分级处理建议

紧急程度判定:
- 紧急:剩余 < 25% SLA 时间
- 警告:剩余 < 50% SLA 时间
- 正常:剩余 >= 50% SLA 时间

回复格式:客户概况、SLA 分析、处理建议、风险提示
"""
    return None


async def run():
    # ======== MCP 层:连接工具 Server ========
    server_params = StdioServerParameters(
        command="python", args=["ticket_mcp_server.py"]
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # 从 MCP Server 发现可用工具
            tools_result = await session.list_tools()
            print(f"MCP: 发现 {len(tools_result.tools)} 个工具")

            # ======== Function Call 层:把 MCP 工具转为 OpenAI 格式 ========
            openai_tools = []
            for t in tools_result.tools:
                openai_tools.append({
                    "type": "function",
                    "function": {
                        "name": t.name,
                        "description": t.description or "",
                        "parameters": t.inputSchema,
                        "strict": True,
                    }
                })

            # ======== Skills 层:构建 System Prompt ========
            skills_index = build_skills_index()
            system_prompt = f"""你是企业客服系统的智能助手,拥有多种专业技能。

{skills_index}

当前已加载技能:(暂无)
"""

            llm = OpenAI()
            messages = [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": (
                    "客户 ACME Corp 报了一个生产环境数据库连接超时问题,"
                    "合同是白金级别,帮我查一下这个客户的信息,"
                    "算一下 SLA 还剩多少时间(4小时响应要求,工单创建于2小时15分钟前),"
                    "然后给我一个处理建议。"
                )}
            ]

            # ======== 主循环:Skills 匹配、Function Call 调用、MCP 执行 ========
            MAX_ROUNDS = 15
            skill_loaded = False

            for round_num in range(1, MAX_ROUNDS + 1):
                print(f"\n{'='*50}")
                print(f"第 {round_num} 轮")

                response = llm.chat.completions.create(
                    model="gpt-4o",
                    messages=messages,
                    tools=openai_tools if skill_loaded else None,
                    tool_choice="auto" if skill_loaded else None,
                )

                msg = response.choices[0].message

                # 情况 A:LLM 要求加载 Skill(Skills 层触发)
                if msg.content and "[LOAD_SKILL:" in msg.content:
                    import re
                    match = re.search(r"\[LOAD_SKILL:\s*(\S+?)\]", msg.content)
                    if match:
                        skill_name = match.group(1)
                        print(f"加载 Skill: {skill_name}")

                        skill_doc = load_skill(skill_name)
                        if skill_doc:
                            messages.append({"role": "assistant", "content": msg.content})
                            messages.append({
                                "role": "user",
                                "content": f"[SKILL_LOADED: {skill_name}]\n\n{skill_doc}\n\n请根据以上技能文档处理用户的请求。"
                            })
                            messages[0]["content"] = system_prompt.replace(
                                "当前已加载技能:(暂无)",
                                f"当前已加载技能:{skill_name}"
                            )
                            skill_loaded = True
                            continue

                # 情况 B:LLM 通过 Function Call 调用工具(从接口层进入协议层)
                if msg.tool_calls:
                    print(f"Function Call: {len(msg.tool_calls)} 个工具调用")
                    messages.append(msg.model_dump())

                    for call in msg.tool_calls:
                        name = call.function.name
                        args = json.loads(call.function.arguments)
                        print(f"   MCP 调用 {name}({args})")

                        result = await session.call_tool(name, arguments=args)
                        result_text = result.content[0].text
                        print(f"   结果:{result_text}")

                        messages.append({
                            "role": "tool",
                            "tool_call_id": call.id,
                            "content": result_text,
                        })
                    continue

                # 情况 C:LLM 给出最终回复
                if msg.content:
                    print(f"\n最终回复:\n{msg.content}")
                    break

            print(f"\n{round_num} 轮,三层协作完成任务")


if __name__ == "__main__":
    asyncio.run(run())

9.6 运行效果(模拟)

MCP: 发现 2 个工具

==================================================
第 1 轮
加载 Skill: ticket-triage

==================================================
第 2 轮
Function Call: 2 个工具调用
   MCP 调用 query_customer({"customer_name": "ACME Corp"})
   结果:{"contract_level": "白金", "sla_response_hours": 4, "sla_resolve_hours": 24,
          "account_manager": "张伟", "open_tickets": 3, "monthly_revenue": 500000,
          "history_note": "上月有过2次数据库相关工单,均已解决"}
   MCP 调用 calculator({"expression": "4 * 60 - 135"})
   结果:{"expression": "4 * 60 - 135", "result": 105}

==================================================
第 3 轮

最终回复:

**客户概况**
- 客户:ACME Corp(白金级别)
- 月营收贡献:¥500,000
- 客户经理:张伟
- 当前待处理工单:3 个
- 历史记录:上月有过 2 次数据库相关工单,均已解决

**SLA 分析**
- SLA 响应要求:4 小时(240 分钟)
- 已用时间:2 小时 15 分钟(135 分钟)
- **剩余时间:105 分钟(1 小时 45 分钟)**
- 紧急程度:**警告**(剩余 43.75%,低于 50% 阈值)

**处理建议**
- 建议优先级:**P1**
- 立即通知客户经理张伟
- **30 分钟内**必须完成首次响应
- 建议指派有数据库连接池排查经验的工程师

**风险提示**
1. 该客户上月已有 2 次数据库相关工单,可能存在**系统性问题**,建议本次解决后安排根因分析
2. 白金客户且已用超过一半 SLA 时间,**升级风险较高**,建议主动联系客户同步进展
3. 当前该客户有 3 个待处理工单,需关注客户整体满意度

共 3 轮,三层协作完成任务

9.7 这个项目里三层分别在干什么?

回顾一下这次执行过程中,三种方案各自的贡献:

阶段谁在工作做了什么
第 1 轮SkillsLLM 读到 ticket-triage 的 description,识别出“工单 / SLA”意图,触发加载完整策略文档,包括 SLA 紧急程度判定规则、客户分级处理矩阵和回复格式要求
第 2 轮Function Call + MCPLLM 按照 Skill 文档的指引,通过 tool_calls 结构化字段请求调用 query_customercalculator;应用再通过 MCP 协议把请求转发给远程 Server 执行
第 3 轮SkillsLLM 根据 Skill 文档中的分级矩阵(白金客户 + 警告状态 = P1)和固定回复结构,把工具返回的原始数据组织成结构化处理建议

缺少任何一层都不行:

  • 没有 Skills:LLM 不知道 SLA 紧急程度怎么判定,不知道白金客户应该走什么升级流程,回复格式也无法统一
  • 没有 Function Call:LLM 输出的工具调用格式不可靠,query_customer 的参数可能拼错字段名,calculator 的表达式也可能格式不对
  • 没有 MCP:CRM 查询和计算器都要硬编码在 Client 里,换一个 CRM 系统就得改代码,无法复用

十、动手练习建议

如果你想进一步理解这三种方案,推荐以下练习:

  1. Function Call 入门:跑通上面的工单处理代码,然后给 MCP Server 加一个新工具(比如 assign_engineer——自动指派工程师),看看 LLM 能否根据 Skill 策略自动选择正确的工具
  2. MCP 实践:把 ticket_mcp_server.py 部署到远程服务器,Client 通过 Streamable HTTP 方式连接,体验 Client-Server 的真正解耦;如果你查的是旧资料,也可以顺手对比一下早期的 HTTP+SSE / SSE 兼容方案
  3. Skills 调优:故意把工单分级策略写得模糊一些(比如去掉紧急程度的百分比阈值),观察 LLM 的判断会出什么偏差;然后逐步完善,体会 Prompt Engineering 的重要性
  4. 组合扩展:为工单系统添加第二个 Skill(比如 incident-postmortem——故障复盘),让 LLM 根据用户意图自动选择加载哪个 Skill

十一、总结

如果你只记住三句话,请记住:

  1. Function Call 解决的是:LLM 如何可靠地表达“我要调用哪个工具、参数是什么”。
  2. MCP 解决的是:应用如何用统一协议发现和调用工具。
  3. Skills 解决的是:LLM 在什么场景下该用什么工具,以及按什么策略来用。

所以,这三种方案不是竞争关系,而是分层配合:

  • Skills 在上层,负责策略和上下文
  • Function Call 在中间,负责结构化输出
  • MCP 在下层,负责工具通信

真正的工程选择也很简单:

  • 做 Demo、工具不多时,通常先从 Function Call 开始
  • 工具生态复杂、需要统一接入时,引入 MCP
  • 需要领域策略,或者模型不支持 Function Call 时,使用 Skills
  • 企业级系统里,三者经常一起出现

当你把“认知层、接口层、协议层”这三层分清之后,Function Call、MCP 和 Skills 就不再是三个混在一起的新名词,而是一套可以组合使用的工程方法。