CClaude 中文站
核心机制

Function Calling:模型是怎么用工具的

22 分钟阅读·学习笔记 · 梅奥整理

模型不执行代码,它只表达调用意图。搞懂 tools、tool_calls、tool 消息三件套,跑通一次完整的工具调用闭环。

这篇笔记解决什么问题

看到 Agent 自动查数据、读文件、发请求时,很容易产生一种错觉:大模型好像"长了手",能自己跑代码干活。这篇笔记要做的第一件事就是拆掉这个错觉——模型从头到尾一行代码都没执行过。第二件事,是把 Function Calling 的完整链路走一遍:模型怎么知道有哪些工具、它用什么格式表达"我想调用某个工具"、执行结果怎么送回去、为什么一次工具调用天然需要两次 API 请求。读完你可以自己写出一个最小可跑的工具调用脚本,并且知道 description 该怎么写才能让模型选对工具。

先摆正一个认知:模型只输出意图,不执行任何东西

大模型本质上是一个"文本进、文本出"的函数。它没有运行时、没有网络访问、没有文件系统——你把消息数组发过去,它算出一段输出,仅此而已。

那所谓"模型调用工具"是怎么回事?答案是:模型输出的不是执行结果,而是一段结构化的调用意图——"我建议调用某个函数,参数是这些"。这段意图本身还是文本(一段 JSON),真正拿着它去执行函数的,是你写的那段 Python 程序。

这个分工有个非常重要的推论:执行权始终在程序手里。模型说想调工具,你的代码可以照办,也可以先弹窗让用户确认,可以记日志审计,甚至可以直接拒绝。所有关于安全、权限、限流的控制点,都在程序这一层,而不在模型那一层。理解了这一点,后面所有 Agent 安全设计(工具白名单、危险操作二次确认)才有落脚点。

一次工具调用 = 两次请求组成的闭环

以"用户问汇率"为例,完整流程是这样的:

用户: "500 欧元能换多少人民币?"
  │
  ▼
[请求 1] 程序把 messages + tools 一起发给模型
  │
  ▼
模型返回 tool_calls: "请调用 get_fx_rate(base=EUR, quote=CNY)"
  │
  ▼
程序自己执行 get_fx_rate() → 得到 7.82
  │
  ▼
[请求 2] 程序把执行结果以 role=tool 消息追加进 messages,再发一次
  │
  ▼
模型基于真实数据回答: "500 欧元约合 3910 元人民币"

注意两个关键点:

  1. 两次请求缺一不可。第一次请求模型只能表达意图,因为它拿不到数据;第二次请求它才见到工具的真实输出,才能组织出靠谱的回答。
  2. 中间那步执行是纯本地行为。API 服务器完全不知道你在两次请求之间干了什么——你可以真调外部接口,也可以返回 mock 数据,模型无从分辨。

tools 参数:向模型"广播"工具清单

模型怎么知道你有哪些工具?靠请求里的 tools 参数。这是一个 JSON Schema 格式的数组,每个元素声明一个工具的三要素:

TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "get_fx_rate",
            "description": (
                "查询两种货币之间的当前参考汇率。"
                "仅支持 USD、EUR、JPY 兑换 CNY,"
                "不提供历史汇率和走势预测。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "base": {
                        "type": "string",
                        "description": "源货币的三位代码,如 USD、EUR",
                    },
                    "quote": {
                        "type": "string",
                        "description": "目标货币的三位代码,目前只接受 CNY",
                    },
                },
                "required": ["base", "quote"],
            },
        },
    }
]

三个字段各司其职:

  • name:工具的唯一标识。模型决定调用时,会在返回里原样写出这个名字,程序据此路由到对应函数。
  • description:模型判断"该不该用这个工具"的唯一依据,直接决定选工具的准确率(后面单独展开)。
  • parameters:标准 JSON Schema,声明每个参数的类型、含义、是否必填。模型会照着它生成参数。

要强调的是:tools 里写的只是声明,函数本体在你本地,模型永远看不到实现代码。声明和实现是否一致,完全靠你自己保证。

tool_calls:模型返回的调用意图长什么样

当模型判断需要工具时,返回的 assistant 消息里会多出一个 tool_calls 字段:

{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_9fj2Kx7q",
      "type": "function",
      "function": {
        "name": "get_fx_rate",
        "arguments": "{\"base\": \"EUR\", \"quote\": \"CNY\"}"
      }
    }
  ]
}

解析这段结构有四个必须注意的细节,每一个都是新手常踩的坑:

  1. content 可能是 null,也可能有值。有的模型会顺带说一句"我来帮你查一下",有的什么都不说。程序不能假定二者只出现其一。
  2. tool_calls 是数组,可能一次包含多个调用。比如用户一句话问了两种货币,模型可能并行申请两次 get_fx_rate。处理逻辑必须写成循环,而不是只取第一个元素。
  3. arguments 是字符串,不是对象。模型生成的是一段 JSON 文本,你需要 json.loads() 把它解析成字典才能传参。忘记这一步,或者模型偶尔生成了残缺 JSON,都会在这里爆异常,所以生产代码要包 try/except。
  4. id 必须保存下来。回传执行结果时要靠它对应"这是哪一次调用的结果",尤其在一次返回多个 tool_calls 时,id 是唯一的对应关系。

用 role=tool 把结果交还给模型

工具执行完,结果作为一条新消息追加进 messages,角色是第四种角色 tool(前三种是 system / user / assistant):

messages = [
    {"role": "system", "content": "你是一个货币兑换助手"},
    {"role": "user", "content": "500 欧元能换多少人民币?"},
    # 第一次响应的 assistant 消息,必须原样放回来
    {
        "role": "assistant",
        "tool_calls": [
            {
                "id": "call_9fj2Kx7q",
                "type": "function",
                "function": {
                    "name": "get_fx_rate",
                    "arguments": "{\"base\": \"EUR\", \"quote\": \"CNY\"}",
                },
            }
        ],
    },
    # 工具执行结果,tool_call_id 与上面的 id 一一对应
    {
        "role": "tool",
        "tool_call_id": "call_9fj2Kx7q",
        "content": "{\"base\": \"EUR\", \"quote\": \"CNY\", \"rate\": 7.82}",
    },
]

有两条硬性规则:

  • tool_calls 的 assistant 消息不能省略。模型是无状态的,它不记得上一次请求发生过什么;只有把"我曾申请调用工具"这条消息放回上下文,它才能理解后面的 tool 消息是在回应什么。
  • 每个 tool_calls 里的 id 都必须有一条对应的 tool 消息。少回一条、多回一条、id 写错,API 都会直接报错。

把这个数组再发一次请求,模型看到的完整故事是:"用户提问 → 我申请了工具 → 工具给了数据",于是第二次响应就是一段正常的自然语言 content,不再有 tool_calls。闭环完成。

完整可跑的最小示例

下面是一个不依赖任何框架的完整脚本。接口用 OpenAI 兼容格式,换 base_url 和模型名就能跑在 DeepSeek、Kimi、GLM、Qwen 等任意兼容服务上:

# fx_agent_demo.py — Function Calling 最小闭环
import json
import os

from openai import OpenAI

# 任何 OpenAI 兼容服务均可,通过环境变量切换厂商
client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)
MODEL_NAME = os.environ.get("LLM_MODEL", "deepseek-chat")


# ---------- 本地工具实现(模型看不到这部分代码) ----------
MOCK_RATES = {
    ("USD", "CNY"): 7.16,
    ("EUR", "CNY"): 7.82,
    ("JPY", "CNY"): 0.048,
}


def get_fx_rate(base: str, quote: str) -> str:
    """返回 JSON 字符串,方便模型稳定读取字段。"""
    rate = MOCK_RATES.get((base.upper(), quote.upper()))
    if rate is None:
        return json.dumps(
            {"error": f"不支持的货币对 {base}/{quote}"}, ensure_ascii=False
        )
    return json.dumps(
        {"base": base.upper(), "quote": quote.upper(), "rate": rate},
        ensure_ascii=False,
    )


# 分发表:工具名 -> 本地函数。新增工具只需在这里注册
TOOL_IMPLS = {
    "get_fx_rate": get_fx_rate,
}

TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "get_fx_rate",
            "description": (
                "查询两种货币之间的当前参考汇率。"
                "仅支持 USD、EUR、JPY 兑换 CNY,"
                "不提供历史汇率和走势预测。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "base": {
                        "type": "string",
                        "description": "源货币三位代码,如 USD、EUR",
                    },
                    "quote": {
                        "type": "string",
                        "description": "目标货币三位代码,目前只接受 CNY",
                    },
                },
                "required": ["base", "quote"],
            },
        },
    }
]


def run_once(question: str) -> None:
    messages = [
        {"role": "system", "content": "你是一个严谨的货币兑换助手。"},
        {"role": "user", "content": question},
    ]

    # ---- 第一次请求:模型决定是否需要工具 ----
    first = client.chat.completions.create(
        model=MODEL_NAME,
        messages=messages,
        tools=TOOL_SCHEMAS,
    )
    reply = first.choices[0].message
    print("[第一次响应] content =", reply.content)
    print("[第一次响应] tool_calls =", reply.tool_calls)

    if not reply.tool_calls:
        # 模型认为不需要工具,直接回答,流程到此结束
        return

    # SDK 返回的是对象,转成 dict 才能塞回 messages;
    # exclude_none 去掉 content=None 之类的空字段
    messages.append(reply.model_dump(exclude_none=True))

    # ---- 本地执行:可能有多个调用,必须循环处理 ----
    for call in reply.tool_calls:
        impl = TOOL_IMPLS.get(call.function.name)
        try:
            kwargs = json.loads(call.function.arguments)
        except json.JSONDecodeError:
            result = json.dumps({"error": "参数不是合法 JSON"}, ensure_ascii=False)
        else:
            result = (
                impl(**kwargs)
                if impl
                else json.dumps(
                    {"error": f"未注册的工具 {call.function.name}"},
                    ensure_ascii=False,
                )
            )
        print(f"[本地执行] {call.function.name}({call.function.arguments}) -> {result}")

        messages.append(
            {
                "role": "tool",
                "tool_call_id": call.id,  # 与申请时的 id 对齐
                "content": result,
            }
        )

    # ---- 第二次请求:模型基于工具结果组织回答 ----
    second = client.chat.completions.create(
        model=MODEL_NAME,
        messages=messages,
        tools=TOOL_SCHEMAS,
    )
    print("[最终回答]", second.choices[0].message.content)


if __name__ == "__main__":
    run_once("手里有 500 欧元,现在大概能换多少人民币?")

几处值得留意的设计:

  • 工具结果统一返回 JSON 字符串而不是随意的中文句子,模型读取字段更稳定,出错时也能通过 error 字段明确告知。
  • TOOL_IMPLS 字典做分发,而不是 if/else 链——加第二个、第三个工具时主流程一行都不用改,这也是后续演化成"工具注册器"的雏形。
  • json.loads 包了异常处理:模型偶尔会生成残缺 JSON(流式输出中断时尤其常见),解析失败时把错误当成工具结果回传,让模型自己重试或改口,比程序直接崩掉好得多。

另外可以做个对照实验:把问题换成"你是谁",模型会发现和汇率无关,直接返回普通 contenttool_calls 为空——调不调工具是模型自己的判断,tools 参数只是给它选项,不是强制指令。

description 是选工具准确率的第一杠杆

工具清单交出去之后,模型选不选、选哪个、参数怎么填,几乎全靠 description 里那几句话。这是整个机制里少有的"纯写作活",也是最容易被敷衍的地方。

坏的写法只报个名字:

{ "description": "汇率工具" }

这样模型只知道"跟汇率沾边",用户问"过去一年美元走势如何",它也可能兴冲冲地调用,然后拿到一个驴唇不对马嘴的结果。

好的 description 要覆盖四件事:

  1. 能做什么——查询两种货币的当前参考汇率;
  2. 什么时候用——用户想知道换汇比价、金额折算时;
  3. 什么时候不该用——不提供历史汇率、不做走势预测;
  4. 边界与限制——仅支持 USD/EUR/JPY 兑 CNY。

参数的 description 同样要写具体:"源货币三位代码,如 USD、EUR"比"货币"有用得多——它直接约束了模型生成参数的格式,避免它填出"美元"或"US Dollar"这种实现函数认不出来的值。

一个实用的自检标准:把 description 单独拿给一个没看过实现代码的人读,如果他能准确回答"这个工具什么情况下该用、什么情况下不该用、参数怎么填",这段描述才算合格。模型的处境和这个人是一样的。

实践要点与常见坑

  • 模型死活不调工具:先打印确认 tools 真的传进了请求;再检查 description 是不是太模糊,模型看不出工具和问题的关联。也可能是模型判断确实不需要——这不是 bug。
  • json.loads 报错:非流式调用下少见,但不能不设防。流式获取 tool_calls 时参数是分片拼接的,处理不当极易拿到半截 JSON,这个话题本系列后面讲流式输出时会展开。
  • API 报消息不匹配:九成是 tool_call_id 对不上,或者忘了把带 tool_calls 的 assistant 消息原样放回 messages。记住配对规则:一个 tool_call id,对应且仅对应一条 tool 消息。
  • 模型幻觉参数:description 没写边界时,模型可能给 quote 填 "KRW" 这种不支持的值。工具实现必须自己做校验并返回明确错误,永远不要假设模型传参一定合法。
  • 本篇脚本的局限:它只处理一轮工具调用。真实任务往往是"查完 A 再根据结果查 B"的多轮循环,那就是 Agent Loop 的领域了,本系列会单独拆解。

动手清单

  1. 跑通上面的脚本,然后把用户问题换成"100 美元和 10000 日元分别能换多少人民币",观察模型是否在一次响应里返回了两个 tool_calls,验证你的循环处理是否正确。
  2. 注册第二个工具 get_bank_branch(city: str),返回某城市可办理换汇的网点(mock 数据即可),问模型"我在杭州,去哪换汇、现在汇率多少",看它能否正确地各调一次两个工具。
  3. 做一个 description 对照实验:把汇率工具的描述改成干巴巴的"汇率工具",问"过去一年欧元走势怎么样",记录模型行为;再换回带边界的完整描述重问一遍,对比它是否学会了拒绝。
  4. 故意把回传消息里的 tool_call_id 改错一个字符,观察 API 返回的错误信息,把它记进你的排错笔记。
  5. 把工具返回值从 JSON 字符串改成一句随意的中文(如"大概七块多吧"),对比模型最终回答的严谨程度,体会为什么工具输出要结构化。

*本文为学习笔记,知识框架参考自叶小钗「生产级 Agent」系列课程;文字与代码示例为本站原创整理。*

#Function Calling#tools#JSON Schema