跳转至

第二章: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 后,做了以下事情:

  1. 解析出函数名 get_weather 和参数 {"city": "北京"}
  2. 在本地的函数注册表里找到对应的 Python 函数
  3. 调用 get_weather("北京"),拿到返回值
  4. 把返回值包成 role: "tool" 的消息塞回对话历史
  5. 再调一次模型 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
  }
}

逐字段看:

contentnull。模型这一轮没有生成文本回答,它选择了调工具。

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_callstool_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 就能造成灾难。

所以命令行工具必须配合安全措施:

  • 命令白名单:只允许 lscatgreppython 等安全命令
  • 沙箱隔离:在 Docker 容器或 microVM 里执行,限制文件系统和网络(详见附录 2A
  • 超时控制:每个命令设置执行时间上限
  • 人工确认:写入类命令(rmmvgit 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 数组怎么维护。

延伸阅读: