第二章:Function Calling — 模型不执行任何东西
上一章我们完成了原始 API 调用和流式输出。模型能聊天了,但它说的全是编的——"北京明天 26°C、晴",它在猜,不是在查。
这一章只解决一个问题:让模型使用外部工具获取真实信息。
核心事实先放在这里:模型从头到尾只做一件事——生成文本。Function Calling 也不例外,模型生成的只是一段 JSON,告诉你"我想调这个函数,参数是这些"。真正执行函数的是你的 Python 代码。
2.1 Function Calling 基础
2.1.1 模型做了什么 vs 你的代码做了什么
很多人第一次听到"Function Calling",会以为模型真的去调了函数。没有。
模型输出的是一段结构化文本:
{
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
你的代码拿到这段 JSON 后,做了以下事情:
- 解析出函数名
get_weather和参数{"city": "北京"} - 在本地的函数注册表里找到对应的 Python 函数
- 调用
get_weather("北京"),拿到返回值 - 把返回值包成
role: "tool"的消息塞回对话历史 - 再调一次模型 API
模型从来没有接触过你的函数、你的网络、你的文件系统。它只做了两件事:第一次生成 tool_calls JSON,第二次根据工具结果生成回答。
画成时序图:
你的代码 OpenAI API 模型
│ │ │
│── messages + tools schema ───►│ │
│ │─── 推理 ───────────────────►│
│ │◄── tool_calls JSON ─────────│
│◄── response ──────────────────│ │
│ (finish_reason="tool_calls") │ │
│ │
│ *** 你的代码执行函数 *** │
│ result = get_weather("北京") │
│ │
│── messages + tool result ────►│ │
│ │─── 推理 ───────────────────►│
│ │◄── 最终回答 ────────────────│
│◄── response ──────────────────│ │
│ (finish_reason="stop") │ │
左侧(你的代码)做了:组装请求、解析 tool_calls、执行函数、组装结果、再次请求、展示回答。
右侧(模型)做了:根据 schema 和对话历史生成 tool_calls JSON,根据工具结果生成自然语言回答。
这个职责划分贯穿后面所有章节。Agent 循环、权限控制、沙箱隔离,全部建立在一个事实上:执行权在你的代码手里,不在模型手里。
2.1.2 完整 Function Calling 流程
用一个最简单的例子走四步:用户问"北京今天天气怎么样",模型通过 get_weather 工具获取天气。每一步都贴出完整请求体和响应体。
第一步:定义工具 schema
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,例如 北京、上海"
}
},
"required": ["city"]
}
}
}
]
这个 schema 告诉模型三件事:有一个函数叫 get_weather,它的作用是查天气,它接受一个必填参数 city。模型读的是这个 schema,看不到你的 Python 函数代码。
parameters 用的是标准 JSON Schema 格式。description 越准确,模型判断"什么时候该调这个工具"就越靠谱。
第二步:第一次 API 调用 — 模型返回 tool_calls
发送请求:
import os
import json
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"), # 可选,兼容其他 provider
)
messages = [
{"role": "system", "content": "你是一个旅游助手。用户问天气时,调用 get_weather 工具获取真实数据,不要编造。"},
{"role": "user", "content": "北京今天天气怎么样?"}
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
完整响应体:
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"model": "gpt-4o-mini-2025-07-18",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xyz789",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 82,
"completion_tokens": 17,
"total_tokens": 99
}
}
逐字段看:
content 是 null。模型这一轮没有生成文本回答,它选择了调工具。
tool_calls 是一个数组。这次只调了一个函数,但模型可以在一次响应里请求调用多个函数。
function.arguments 是一个 JSON 字符串,不是对象。你需要 json.loads() 把它解析成 Python 字典。
finish_reason 是 "tool_calls",不是 "stop"。这是你的代码判断"模型想调工具"还是"模型想直接回答"的唯一依据。
id 是 "call_xyz789"。后面把函数结果塞回对话历史时,必须带上这个 id,模型才能把结果和调用配对。
用 Python 读取关键字段:
choice = response.choices[0]
print(choice.finish_reason) # "tool_calls"
print(choice.message.content) # None
tool_call = choice.message.tool_calls[0]
print(tool_call.id) # "call_xyz789"
print(tool_call.function.name) # "get_weather"
print(tool_call.function.arguments) # '{"city": "北京"}'
输出:
tool_calls
None
call_xyz789
get_weather
{"city": "北京"}
第三步:你的代码执行函数
模型说"我想调 get_weather,参数是 {"city": "北京"}"。现在轮到你的代码了。
def get_weather(city: str) -> dict:
"""模拟天气查询。生产环境替换为真实 API。"""
weather_data = {
"北京": {"temperature": "28°C", "condition": "晴", "humidity": "45%", "wind": "北风2级"},
"上海": {"temperature": "25°C", "condition": "多云", "humidity": "72%", "wind": "东南风3级"},
"三亚": {"temperature": "33°C", "condition": "晴", "humidity": "68%", "wind": "东南风3级"},
}
return weather_data.get(city, {"error": f"暂无 {city} 的天气数据"})
# 解析参数并调用
args = json.loads(tool_call.function.arguments) # {"city": "北京"}
result = get_weather(**args)
print(json.dumps(result, ensure_ascii=False))
输出:
{"temperature": "28°C", "condition": "晴", "humidity": "45%", "wind": "北风2级"}
json.loads 把模型给的参数字符串解析成字典,**args 解包传给 get_weather。这个函数是你写的普通 Python 函数,它不知道模型的存在。
第四步:第二次 API 调用 — 带上工具结果
把 assistant 的 tool_calls 消息和函数执行结果放回 messages,再调一次模型:
# assistant 的 tool_calls 消息原样放回
messages.append(choice.message)
# 函数执行结果作为 tool 消息放回
messages.append({
"role": "tool",
"tool_call_id": tool_call.id, # 必须匹配 "call_xyz789"
"content": json.dumps(result, ensure_ascii=False),
})
# 第二次调用
response2 = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
print(response2.choices[0].message.content)
此时 messages 数组的完整内容:
[
{"role": "system", "content": "你是一个旅游助手。用户问天气时,调用 get_weather 工具获取真实数据,不要编造。"},
{"role": "user", "content": "北京今天天气怎么样?"},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xyz789",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_xyz789",
"content": "{\"temperature\": \"28°C\", \"condition\": \"晴\", \"humidity\": \"45%\", \"wind\": \"北风2级\"}"
}
]
role: "tool" 这条消息的 tool_call_id 必须和 assistant 消息里 tool_calls[0].id 一致。API 靠这个 id 配对。
第二次响应体:
{
"id": "chatcmpl-def456",
"object": "chat.completion",
"model": "gpt-4o-mini-2025-07-18",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "北京今天天气晴朗,气温28°C,湿度45%,北风2级。很适合户外活动,记得做好防晒。"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 145,
"completion_tokens": 38,
"total_tokens": 183
}
}
finish_reason 变成了 "stop"。content 不再是 null,模型用真实天气数据生成了回答。
最终输出:
北京今天天气晴朗,气温28°C,湿度45%,北风2级。很适合户外活动,记得做好防晒。
整个流程:两次 API 调用,中间夹着你的代码执行函数。模型负责"决定调什么"和"用结果写回答",你的代码负责"真正去调"和"把结果传回去"。
2.1.3 Travel Agent: get_weather 工具
把上面四步合成一个完整可运行的脚本。
"""travel_agent_weather.py — Travel Agent 第一个工具:天气查询"""
import os
import json
from openai import OpenAI
# ── 工具函数 ───────────────────────────────────────────
def get_weather(city: str) -> dict:
"""模拟天气查询。生产环境替换为真实天气 API。"""
weather_data = {
"北京": {
"city": "北京", "temperature": "28°C",
"condition": "晴", "humidity": "45%",
"wind": "北风2级", "suggestion": "适合户外,注意防晒"
},
"三亚": {
"city": "三亚", "temperature": "33°C",
"condition": "晴,午后有短时雷阵雨",
"humidity": "78%", "wind": "东南风3级",
"suggestion": "带伞、防晒霜,避开午后暴晒"
},
"成都": {
"city": "成都", "temperature": "22°C",
"condition": "阴转多云", "humidity": "82%",
"wind": "微风", "suggestion": "带薄外套,不需要伞"
},
}
return weather_data.get(city, {"city": city, "error": f"暂无 {city} 的天气数据"})
# ── 工具 schema ────────────────────────────────────────
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气,返回气温、天气状况、湿度、风力和出行建议",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,例如 北京、三亚、成都"
}
},
"required": ["city"]
}
}
}
]
AVAILABLE_FUNCTIONS = {"get_weather": get_weather}
# ── 主流程 ─────────────────────────────────────────────
def run():
client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
)
messages = [
{
"role": "system",
"content": "你是一个旅游助手。用户问天气时,调用 get_weather 工具获取真实数据,不要编造。根据天气数据给出实用的旅行建议。"
},
{
"role": "user",
"content": "我下周想去三亚,那边天气怎么样?需要准备什么?"
}
]
# ---- 第一次调用 ----
print("=" * 60)
print(">>> 第一次请求: 用户提问")
print("=" * 60)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
assistant_msg = response.choices[0].message
finish_reason = response.choices[0].finish_reason
print(f"finish_reason: {finish_reason}")
print(f"content: {assistant_msg.content}")
print(f"tool_calls: {assistant_msg.tool_calls}")
# ---- 执行工具 ----
if finish_reason == "tool_calls":
messages.append(assistant_msg)
for tool_call in assistant_msg.tool_calls:
fn_name = tool_call.function.name
fn_args = json.loads(tool_call.function.arguments)
print(f"\n--- 执行工具: {fn_name}({fn_args}) ---")
result = AVAILABLE_FUNCTIONS[fn_name](**fn_args)
result_str = json.dumps(result, ensure_ascii=False, indent=2)
print(f"--- 返回结果 ---\n{result_str}")
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=False),
})
# ---- 第二次调用 ----
print("\n" + "=" * 60)
print(">>> 第二次请求: 带上工具结果")
print("=" * 60)
response2 = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
final = response2.choices[0]
print(f"finish_reason: {final.finish_reason}")
print(f"\n旅游助手:\n{final.message.content}")
if __name__ == "__main__":
run()
完整运行输出:
============================================================
>>> 第一次请求: 用户提问
============================================================
finish_reason: tool_calls
content: None
tool_calls: [ChatCompletionMessageToolCall(id='call_sanya01', function=Function(arguments='{"city":"三亚"}', name='get_weather'), type='function')]
--- 执行工具: get_weather({'city': '三亚'}) ---
--- 返回结果 ---
{
"city": "三亚",
"temperature": "33°C",
"condition": "晴,午后有短时雷阵雨",
"humidity": "78%",
"wind": "东南风3级",
"suggestion": "带伞、防晒霜,避开午后暴晒"
}
============================================================
>>> 第二次请求: 带上工具结果
============================================================
finish_reason: stop
旅游助手:
三亚目前气温 33°C,天气以晴为主,但午后可能有短时雷阵雨,湿度较高(78%),东南风3级。
下周出行建议:
- 防晒:SPF50+ 防晒霜、墨镜、遮阳帽,紫外线很强
- 带伞:午后可能有短时阵雨,折叠伞随身带
- 穿着:轻薄透气的衣服,备一件薄外套(商场和车上空调冷)
- 时间安排:海边活动放在上午或傍晚,中午 12-15 点避开暴晒
- 补水:随身带水壶,湿度高容易出汗
这就是 Travel Agent 的第一个工具。模型判断、你的代码执行、模型总结——三步完成。
2.2 Agent 雏形
上面的代码只处理了"调一次工具"的场景。如果用户问"帮我比较三亚和成都,查天气、机票和酒店",模型可能需要调很多次工具,而且第二轮调什么取决于第一轮的结果。
硬写"第一次调用、第二次调用、第三次调用"会变成一堆 if-else。更好的做法:while 循环——每轮检查 finish_reason,如果是 "tool_calls" 就执行并继续,如果是 "stop" 就退出。
"""travel_agent_loop.py — Agent 雏形:while 循环 + 多工具"""
import os
import json
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
)
# ── 三个工具 ───────────────────────────────────────────
def get_weather(city: str) -> dict:
"""查询城市天气"""
data = {
"三亚": {"city": "三亚", "temp": "33°C", "condition": "晴,午后雷阵雨", "suggestion": "带伞防晒"},
"成都": {"city": "成都", "temp": "22°C", "condition": "阴转多云", "suggestion": "带薄外套"},
"北京": {"city": "北京", "temp": "28°C", "condition": "晴", "suggestion": "注意防晒"},
}
return data.get(city, {"city": city, "error": f"暂无 {city} 天气数据"})
def get_flight_price(origin: str, destination: str) -> dict:
"""查询机票价格"""
routes = {
("北京", "三亚"): {"route": "北京→三亚", "price": "¥1,280", "duration": "4h", "type": "直飞经济舱"},
("北京", "成都"): {"route": "北京→成都", "price": "¥980", "duration": "2.5h", "type": "直飞经济舱"},
("上海", "三亚"): {"route": "上海→三亚", "price": "¥1,150", "duration": "3h", "type": "直飞经济舱"},
}
return routes.get(
(origin, destination),
{"route": f"{origin}→{destination}", "error": "暂无航班数据"}
)
def get_hotel_price(city: str, stars: int = 4) -> dict:
"""查询酒店价格"""
hotels = {
("三亚", 4): {"city": "三亚", "stars": 4, "name": "三亚湾海景酒店", "price": "¥680/晚", "breakfast": True},
("三亚", 5): {"city": "三亚", "stars": 5, "name": "亚龙湾度假村", "price": "¥1,500/晚", "breakfast": True},
("成都", 4): {"city": "成都", "stars": 4, "name": "春熙路商务酒店", "price": "¥420/晚", "breakfast": True},
}
return hotels.get(
(city, stars),
{"city": city, "stars": stars, "error": f"暂无 {city} {stars}星酒店数据"}
)
# ── 工具注册 ───────────────────────────────────────────
TOOL_MAP = {
"get_weather": get_weather,
"get_flight_price": get_flight_price,
"get_hotel_price": get_hotel_price,
}
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "get_flight_price",
"description": "查询两个城市之间的机票价格",
"parameters": {
"type": "object",
"properties": {
"origin": {"type": "string", "description": "出发城市"},
"destination": {"type": "string", "description": "目的地城市"}
},
"required": ["origin", "destination"]
}
}
},
{
"type": "function",
"function": {
"name": "get_hotel_price",
"description": "查询目的地的酒店价格",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"},
"stars": {"type": "integer", "description": "酒店星级,默认4", "default": 4}
},
"required": ["city"]
}
}
},
]
# ── Agent 循环 ─────────────────────────────────────────
def run_agent():
messages = [
{
"role": "system",
"content": (
"你是一个旅游助手。根据用户需求,使用工具查询天气、机票和酒店,"
"综合所有信息给出旅行建议。可以分多步调用工具。"
)
},
{
"role": "user",
"content": "我在北京,下周想去三亚玩三天,帮我查一下天气、机票和酒店"
}
]
MAX_TURNS = 10
turn = 0
while turn < MAX_TURNS:
turn += 1
print(f"\n{'='*60}")
print(f">>> 第 {turn} 轮调用")
print(f"{'='*60}")
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
choice = response.choices[0]
assistant_msg = choice.message
finish_reason = choice.finish_reason
print(f"finish_reason: {finish_reason}")
# ---- 模型说完了,退出循环 ----
if finish_reason != "tool_calls":
print(f"\n旅游助手:\n{assistant_msg.content}")
break
# ---- 模型请求工具调用 ----
messages.append(assistant_msg)
for tc in assistant_msg.tool_calls:
fn_name = tc.function.name
fn_args = json.loads(tc.function.arguments)
print(f" 调用: {fn_name}({json.dumps(fn_args, ensure_ascii=False)})")
fn = TOOL_MAP.get(fn_name)
if fn:
result = fn(**fn_args)
else:
result = {"error": f"未知工具: {fn_name}"}
result_str = json.dumps(result, ensure_ascii=False)
print(f" 返回: {result_str}")
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result_str,
})
else:
print(f"\n达到最大轮次 {MAX_TURNS},强制停止")
if __name__ == "__main__":
run_agent()
完整运行输出(3 次工具调用,2 轮 API 调用):
============================================================
>>> 第 1 轮调用
============================================================
finish_reason: tool_calls
调用: get_weather({"city": "三亚"})
返回: {"city": "三亚", "temp": "33°C", "condition": "晴,午后雷阵雨", "suggestion": "带伞防晒"}
调用: get_flight_price({"origin": "北京", "destination": "三亚"})
返回: {"route": "北京→三亚", "price": "¥1,280", "duration": "4h", "type": "直飞经济舱"}
调用: get_hotel_price({"city": "三亚", "stars": 4})
返回: {"city": "三亚", "stars": 4, "name": "三亚湾海景酒店", "price": "¥680/晚", "breakfast": true}
============================================================
>>> 第 2 轮调用
============================================================
finish_reason: stop
旅游助手:
帮你查好了,三亚三天行程的基本信息如下:
天气:33°C,晴天为主,午后偶有短时雷阵雨。带好防晒霜和折叠伞。
机票:北京直飞三亚,经济舱 ¥1,280,飞行约4小时。
酒店:三亚湾海景酒店(四星),¥680/晚,含早餐。三晚合计 ¥2,040。
预估总费用:
- 往返机票:约 ¥2,560
- 酒店三晚:¥2,040
- 合计:约 ¥4,600(不含餐饮、门票和交通)
建议海边活动安排在上午或傍晚,中午避开暴晒时段。
看第 1 轮:模型同时发出了三个 tool_calls(天气、机票、酒店),你的代码逐个执行并塞回结果。第 2 轮:模型看到所有结果,finish_reason 变成 "stop",循环退出。
如果用户的问题更复杂(比如"三亚和成都都帮我查一下,我再选"),模型可能需要更多轮——先查两个城市的天气、机票、酒店,再综合对比。while 循环自动处理,不需要你提前知道轮数。
MAX_TURNS = 10 是硬性保护。没有它,如果模型陷入"调工具 → 不满意 → 再调工具"的循环,token 账单会失控。生产环境通常设 5-20,取决于任务复杂度。
这个 while 循环就是 Agent 的骨架。后面所有更复杂的 Agent 模式——ReAct、Plan-and-Execute、Multi-Agent——都是在这个循环里加东西:加状态管理、加计划、加记忆、加多个模型。但骨架不变。
2.3 工具协议演化
Function Calling 是模型使用工具的第一种方式,但不是唯一的。过去两年,工具协议从"每家 provider 各写一套 JSON schema"演化出了 MCP、命令行工具、Agent Skills 等多种形态。
这一节把它们排在一起看,搞清楚各自解决什么问题。
2.3.1 原生 Function Calling
不同 provider 的 Function Calling 格式不一样。下面并排放 OpenAI 和 Anthropic 的工具定义和响应,看区别在哪里。
工具定义对比
OpenAI 格式——外面多套一层 function 包装:
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名"}
},
"required": ["city"]
}
}
}
Anthropic 格式——直接平铺,参数字段叫 input_schema:
{
"name": "get_weather",
"description": "查询天气",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名"}
},
"required": ["city"]
}
}
参数定义部分都是标准 JSON Schema,没有区别。差别在外层包装和字段命名。
响应格式对比
OpenAI 的 tool_calls——在 message 顶层,arguments 是 JSON 字符串:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xyz789",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
}
]
}
Anthropic 的 tool_use——在 content 数组里,input 是 JSON 对象(不需要 json.loads):
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_abc123",
"name": "get_weather",
"input": {"city": "北京"}
}
]
}
回传工具结果对比
OpenAI 用独立的 role: "tool" 消息:
{
"role": "tool",
"tool_call_id": "call_xyz789",
"content": "28°C,晴"
}
Anthropic 用 role: "user" 消息,里面嵌套 tool_result block:
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_abc123",
"content": "28°C,晴"
}
]
}
关键差异汇总
| 对比点 | OpenAI | Anthropic |
|---|---|---|
| 参数定义字段 | parameters |
input_schema |
| 参数返回格式 | JSON 字符串(要 json.loads) |
JSON 对象(直接可用) |
| 停止原因 | finish_reason: "tool_calls" |
stop_reason: "tool_use" |
| 结果回传角色 | role: "tool" |
role: "user" 内嵌 tool_result |
| 调用 ID 前缀 | call_ |
toolu_ |
如果你的 Agent 要同时支持多个 provider,写一个 adapter 层抹平这些差异是值得的。也可以用 LiteLLM 之类的库。核心逻辑不应该关心 tool_calls 和 tool_use 的拼写区别。
这些格式差异也是 MCP 出现的动机之一:能不能定一个标准协议,让工具只写一次?
2.3.2 MCP(Model Context Protocol)
MCP 要解决的问题很具体:每个工具都要为 OpenAI、Anthropic、Google 各写一遍 schema 和集成代码,太浪费了。
MCP 的做法是把工具包装成独立的 Server,通过标准协议暴露工具列表和调用接口。Client(你的 Agent 代码或 IDE)通过这个协议发现工具、调用工具、拿结果。
┌──────────────┐ JSON-RPC ┌──────────────┐
│ MCP Client │ ◄───────────────────► │ MCP Server │
│ (Agent 代码) │ stdio / HTTP+SSE │ (工具集合) │
└──────────────┘ └──────────────┘
│ │
启动时: tools/list → 获取工具列表 │
运行时: tools/call → 调用工具并返回结果 │
一个最小的 MCP Server:
"""weather_mcp_server.py — 最小 MCP Server"""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("travel-tools")
@mcp.tool()
def get_weather(city: str) -> str:
"""查询指定城市的当前天气。
Args:
city: 城市名称,例如 北京、上海
"""
data = {
"北京": "28°C,晴,北风2级",
"三亚": "33°C,晴,午后短时雷阵雨",
"成都": "22°C,阴转多云,微风",
}
return data.get(city, f"暂无 {city} 的天气数据")
@mcp.tool()
def get_flight_price(origin: str, destination: str) -> str:
"""查询两个城市之间的机票价格。
Args:
origin: 出发城市
destination: 目的地城市
"""
routes = {
("北京", "三亚"): "直飞经济舱 ¥1,280,约4小时",
("北京", "成都"): "直飞经济舱 ¥980,约2.5小时",
}
return routes.get((origin, destination), f"暂无 {origin}→{destination} 航班数据")
if __name__ == "__main__":
mcp.run(transport="stdio")
运行 python weather_mcp_server.py,MCP Client 通过 stdio 连接后,自动拿到工具列表。Claude Desktop、Cursor、你自己写的 Agent 都可以连。
MCP 和 Function Calling 的关系
MCP 不替代 Function Calling,它是 Function Calling 的上游标准化层:
MCP Server MCP Client 模型 API
────────── ────────── ──────────
暴露工具 schema ───────► 拿到 schema
转成 provider 格式
塞进 tools 参数 ──────► 模型推理
返回 tool_calls
解析 tool_calls ◄──────
调用 tools/call ◄────── 转发调用请求
返回执行结果 ───────► 塞回 messages
再次请求 ──────► 生成最终回答
Client 从 MCP Server 拿到工具 schema 后,仍然要转成 OpenAI 或 Anthropic 的格式才能调模型 API。模型返回 tool_calls 后,Client 再通过 MCP 协议让 Server 执行。
MCP 解决的是工具侧的标准化(写一次,到处用),不是模型侧的格式统一。
2.3.3 命令行工具
还有一种更直接的方式让 Agent 操作世界:把 shell 命令当工具。
Claude Code 就是这样做的。模型想读文件,输出 cat /path/to/file;想搜代码,输出 grep -r "pattern" .;想跑测试,输出 npm test。你的 runtime 在子进程里执行命令,把 stdout/stderr 收集回来当工具返回值。
"""cli_tool.py — Shell 命令作为工具"""
import subprocess
def execute_shell(command: str, timeout: int = 30) -> str:
"""在受限环境中执行 shell 命令。
生产环境必须配合沙箱、命令白名单和人工确认。
"""
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
)
output = result.stdout
if result.returncode != 0:
output += f"\nSTDERR: {result.stderr}"
output += f"\nEXIT CODE: {result.returncode}"
return output.strip()
except subprocess.TimeoutExpired:
return f"命令超时({timeout}秒)"
工具 schema 和前面的 get_weather 没有结构上的区别:
{
"type": "function",
"function": {
"name": "execute_shell",
"description": "在沙箱环境中执行 shell 命令",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "要执行的 shell 命令"
}
},
"required": ["command"]
}
}
}
命令行工具的覆盖面极广——操作系统能做的事,Agent 都能做。但风险也最大。一个 rm -rf / 或 curl | bash 就能造成灾难。
所以命令行工具必须配合安全措施:
- 命令白名单:只允许
ls、cat、grep、python等安全命令 - 沙箱隔离:在 Docker 容器或 microVM 里执行,限制文件系统和网络(详见附录 2A)
- 超时控制:每个命令设置执行时间上限
- 人工确认:写入类命令(
rm、mv、git push)执行前让用户确认(详见附录 2B)
2.3.4 Agent Skills
当 Agent 的工具变多了,一个新问题出现:光有工具不够,还需要"怎么组合使用这些工具"的知识。
Skill = 一段专用提示词 + 一组工具 + 一套行为约定,打包成比单个工具更高一层的抽象。
"查天气"是一个工具。"搜索并总结旅行目的地攻略"是一个 Skill——它需要先搜索、再阅读、再提取关键信息、再组织成结构化输出,涉及多个工具的协调使用。
一个 Skill 通常用一个 SKILL.md 文件描述:
# search_and_summarize
## 目标
根据用户的旅行目的地,搜索相关攻略,提取关键信息,输出结构化的目的地指南。
## 可用工具
- web_search: 搜索网页
- read_page: 读取网页全文
- get_weather: 查询天气
## 行为步骤
1. 用 web_search 搜索 "{目的地} 旅游攻略"
2. 从搜索结果选前 3 个链接,用 read_page 读取
3. 用 get_weather 查询当地天气
4. 综合所有信息,输出结构化指南
## 输出格式
- 天气概况
- 必去景点(3-5个)
- 美食推荐(3-5个)
- 交通建议
- 预算估算
## 限制
- 最多搜索 5 次
- 最多读取 3 个页面
- 总 token 预算 < 10,000
在代码层面,Skill 的激活通常就是把 SKILL.md 的内容注入到 system prompt:
def activate_skill(skill_name: str, messages: list) -> list:
"""激活 Skill:读取 SKILL.md 注入为 system message。"""
skill_path = f"skills/{skill_name}/SKILL.md"
with open(skill_path) as f:
skill_prompt = f.read()
skill_message = {
"role": "system",
"content": f"当前激活的 Skill:\n\n{skill_prompt}"
}
# 插入到原有 system message 之后
return [messages[0], skill_message] + messages[1:]
模型读到 Skill 描述后,知道该按什么步骤、用哪些工具、输出什么格式。你的 while 循环代码不需要改——仍然是"调模型 → 执行工具 → 塞回结果"的标准流程。Skill 改变的是模型的行为方式,不是执行机制。
Skill 和单个工具的区别:工具是"能做什么"(原子操作),Skill 是"怎么完成一件事"(工具的组合用法加行为指导)。
2.3.5 对比总结
四种工具协议,从底层到高层:
| 维度 | 原生 Function Calling | MCP | 命令行工具 | Agent Skills |
|---|---|---|---|---|
| 是什么 | Provider 各自定义的工具调用格式 | 工具发现与调用的标准协议 | Shell 命令作为工具 | 提示词 + 工具 + 行为约定 |
| 工具在哪 | 硬编码在 API 请求的 tools 参数 | 独立 Server 进程 | 操作系统 | SKILL.md + 关联工具 |
| 发现方式 | 写死在代码里 | Client 启动时自动发现 | 白名单或按规则扫描 | 按任务名加载 |
| 跨 Provider | 每家格式不同,要 adapter | 写一次,所有 Client 可用 | 与 Provider 无关 | 与 Provider 无关 |
| 安全控制 | 代码层面自己实现 | Server 层 + Client 层 | 沙箱 + 白名单 + HITL | 工具级 + Skill 级限制 |
| 典型场景 | 直接调 API 做原型 | IDE 插件、通用工具集 | 编程 Agent(Claude Code) | 复杂多步任务 |
| 复杂度 | 低 | 中 | 中(安全要求高) | 高 |
抽象阶梯:
Function Calling → 模型能调一个函数
↓
MCP → 工具能被标准化地发现和复用
↓
命令行工具 → 操作系统的能力都能当工具
↓
Agent Skills → 一组工具加行为指导,完成复杂任务
这四种不互相替代。一个真实的 Agent 系统很可能同时使用多种:MCP 接入标准化工具,命令行做文件操作,Skills 定义复杂任务流程,底层全部通过 Function Calling 和模型通信。
本章小结
这一章只讲了一件事:模型怎么"使用"工具。
核心事实是:模型不执行任何东西。它输出 tool_calls JSON,你的代码解析、执行、把结果塞回去。这个"你的代码做中间人"的结构,贯穿后面所有章节。
我们从一次工具调用走完了四步流程(定义 schema → 模型返回 tool_calls → 执行函数 → 带结果再调模型),然后把两次调用变成 while 循环——这就是 Agent 的最小骨架。
最后梳理了四种工具协议:原生 Function Calling、MCP、命令行工具、Agent Skills。它们是不同抽象层次的工具接入方式,逐级叠加,不互相替代。
下一章讲对话历史和上下文管理:模型怎么记住前面的对话,messages 数组怎么维护。
延伸阅读:
- 附录 2A:Agent Sandbox — 工具执行的沙箱隔离方案
- 附录 2B:权限控制 — 工具分级与人工确认机制