从零讲透 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 白名单校验,或simpleeval、numexpr、sympy等更稳妥的方案,而不是直接照搬示例里的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 决定调工具时,
content为null(它没有直接生成文字回复) 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 基础设施。但实际工程中有两个常见痛点:
- 不是所有 LLM 都支持 Function Call——很多开源模型、本地部署的模型没有这个能力
- 工具越多,上下文越膨胀——如果你有 50 个工具的 JSON Schema 全部塞进每次请求,Token 消耗非常大,而且大部分工具在当前对话中根本用不上
Skills 用一种更接近"人类认知"的方式解决这两个问题:把工具能力封装成一份份自然语言编写的"技能包"(Skill),每个 Skill 有 name 和 description 作为索引,LLM 根据这些元数据判断需要哪个 Skill,再按需加载完整的使用说明。
核心机制分三层:
- 元数据常驻:每个 Skill 只有
name(名称)和description(描述)常驻在 System Prompt 中,Token 开销极小 - 按需加载:当 LLM 判断用户意图匹配某个 Skill 的 description 时,触发加载——完整的工具说明、参数定义、使用规则、示例才被注入上下文
- 上下文指导执行:加载后的 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 Call | MCP | Skills |
|---|---|---|---|
| 模型要求 | 需要模型支持 | 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 Call | MCP |
|---|---|---|
| 管什么 | 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分钟前),然后给我一个处理建议。"
系统需要:
- 查客户信息,调用 query_customer 工具(从 CRM 查询合同级别、历史工单、联系人)
- 算 SLA,调用 calculator 工具(计算剩余响应时间、判断是否即将超期)
- 给出处理建议,并根据 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 轮 | Skills | LLM 读到 ticket-triage 的 description,识别出“工单 / SLA”意图,触发加载完整策略文档,包括 SLA 紧急程度判定规则、客户分级处理矩阵和回复格式要求 |
| 第 2 轮 | Function Call + MCP | LLM 按照 Skill 文档的指引,通过 tool_calls 结构化字段请求调用 query_customer 和 calculator;应用再通过 MCP 协议把请求转发给远程 Server 执行 |
| 第 3 轮 | Skills | LLM 根据 Skill 文档中的分级矩阵(白金客户 + 警告状态 = P1)和固定回复结构,把工具返回的原始数据组织成结构化处理建议 |
缺少任何一层都不行:
- 没有 Skills:LLM 不知道 SLA 紧急程度怎么判定,不知道白金客户应该走什么升级流程,回复格式也无法统一
- 没有 Function Call:LLM 输出的工具调用格式不可靠,
query_customer的参数可能拼错字段名,calculator的表达式也可能格式不对 - 没有 MCP:CRM 查询和计算器都要硬编码在 Client 里,换一个 CRM 系统就得改代码,无法复用
十、动手练习建议
如果你想进一步理解这三种方案,推荐以下练习:
- Function Call 入门:跑通上面的工单处理代码,然后给 MCP Server 加一个新工具(比如
assign_engineer——自动指派工程师),看看 LLM 能否根据 Skill 策略自动选择正确的工具 - MCP 实践:把
ticket_mcp_server.py部署到远程服务器,Client 通过 Streamable HTTP 方式连接,体验 Client-Server 的真正解耦;如果你查的是旧资料,也可以顺手对比一下早期的 HTTP+SSE / SSE 兼容方案 - Skills 调优:故意把工单分级策略写得模糊一些(比如去掉紧急程度的百分比阈值),观察 LLM 的判断会出什么偏差;然后逐步完善,体会 Prompt Engineering 的重要性
- 组合扩展:为工单系统添加第二个 Skill(比如
incident-postmortem——故障复盘),让 LLM 根据用户意图自动选择加载哪个 Skill
十一、总结
如果你只记住三句话,请记住:
- Function Call 解决的是:LLM 如何可靠地表达“我要调用哪个工具、参数是什么”。
- MCP 解决的是:应用如何用统一协议发现和调用工具。
- Skills 解决的是:LLM 在什么场景下该用什么工具,以及按什么策略来用。
所以,这三种方案不是竞争关系,而是分层配合:
- Skills 在上层,负责策略和上下文
- Function Call 在中间,负责结构化输出
- MCP 在下层,负责工具通信
真正的工程选择也很简单:
- 做 Demo、工具不多时,通常先从 Function Call 开始
- 工具生态复杂、需要统一接入时,引入 MCP
- 需要领域策略,或者模型不支持 Function Call 时,使用 Skills
- 企业级系统里,三者经常一起出现
当你把“认知层、接口层、协议层”这三层分清之后,Function Call、MCP 和 Skills 就不再是三个混在一起的新名词,而是一套可以组合使用的工程方法。