Featured image of post 第二话|Function Calling:谁把“调用工具”做成了一套协议?

第二话|Function Calling:谁把“调用工具”做成了一套协议?

用一次天气查询跑通 Function Calling:请求里传 tools 菜单,响应里收 tool_calls 申请单,真正执行函数的始终是你自己的代码。

系列 GYA(Get Your Agent) 第 2 / 12 篇
主题 AgentLLMFunction-CallingPython

上一话我们搞懂了两件事:模型只会补全文字;想让它干活,得让它把“想干什么”说成某种格式,再由外面的代码去执行。

但上一话那个办法很脆——用提示词逼模型输出 JSON,模型经常给你残缺的 JSON、在前后多写几句废话、或者干脆忘了格式。于是就有了 Function Calling:它把“让模型提出一次工具调用”这件事,从碰运气的提示词技巧,做成了一套规规矩矩的协议。

这一话就讲这套协议:它长什么样,谁先做出来的,以及为什么它能让模型“说清楚要干什么”。

一、协议要解决的,就是上一话那个“脆”

先看上一话的提示词 hack 是怎么翻车的。你在 system prompt 里写“想查天气就输出 {"tool":"get_weather","city":"..."}”,然后:

  • 模型输出的不是纯 JSON,前后还夹着“好的,我帮你查一下”这种话;
  • JSON 本身残缺,少个引号、少个逗号,json.loads 直接崩;
  • 换了个问法,模型就把格式忘得一干二净。

每次翻车,你都得靠更长的提示词、更严的正则去兜,兜到最后还是不可靠。

协议的意义就在这里:别再用文字去“暗示”模型输出格式,而是给一个结构化的位置,让模型把调用填进去。 这个位置是 API 里明确规定好的,模型要么填对,要么 API 直接拒绝,没有“碰运气”的中间地带。

二、协议长什么样:tools 进,tool_calls

整个协议,一进一出两件事:

  • 请求里传 tools:你告诉模型“现在有哪些工具可以用”。这是一份菜单,写清楚每个工具叫什么、干什么、参数长什么样。
  • 响应里收 tool_calls:模型告诉你“我想调哪个工具、带什么参数”。这是一张申请单。

下面是一份声明了本地天气工具的菜单:

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的天气演示数据",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "城市名,如北京"}
                },
                "required": ["city"],
                "additionalProperties": False,
            },
        },
    }
]

注意,tools 不是把 Python 函数“上传”给模型。它只是一份描述:函数叫 get_weather、查天气的、需要一个 city 字符串参数。模型拿到的是这些文字,不是那个函数本身。Chat Completions API

把菜单连同问题一起发过去:

assistant_message = client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content": "请查询北京现在的天气。"}],
    tools=TOOLS,
    tool_choice="required",
).choices[0].message

响应里我们关心的不是自然语言,而是这张申请单:

{
  "id": "call_00_...",
  "type": "function",
  "function": {
    "name": "get_weather",
    "arguments": "{\"city\": \"北京\"}"
  }
}

拆开看每个字段:

  • id:这条调用的唯一标识,回传结果时要靠它关联;
  • type:固定是 function,标记这是一次函数调用;
  • function.name:想调哪个工具;
  • function.arguments:一个 JSON 字符串——注意它还是字符串,不是对象,代码里得再 json.loads 一层才能用。

它表达的就是一句话:“请调用 get_weather,参数是北京。”到这里,模型既没有运行 Python,也没有拿到天气数据。它只是把“想干什么”用协议规定的格式写清楚了。

三、这套协议,是谁先做出来的

它不是我编的,也不是模型厂商某天灵机一动。时间线很清晰:

  • 2022 年,ReAct(Yao et al., arXiv 2210.03629)提出“推理 + 行动”交替,用提示词让模型按 Thought / Action 的格式输出,再让代码去执行;
  • 2023 年 2 月,Toolformer(Meta AI, arXiv 2302.04761)证明“模型自己决定何时调 API”这个能力是能被训练出来的;
  • 2023 年 5 月,Gorilla(UC Berkeley, arXiv 2305.15334)开始微调模型学写 API 调用。

这几篇走的都是“提示词 + 解析”的路线——也就是上一话讲的那个脆办法。

真正的转折在 2023 年 6 月 13 日:OpenAI 首次发布原生 Function Calling,随 gpt-4-0613gpt-3.5-turbo-0613 两个模型快照一起推出。从这一天起,“让模型提工具调用”不再靠提示词暗示,而是变成了 API 里的标准字段——就是我们第二节看到的 tools 进、tool_calls 出。

四、别看就一个 tool_calls,背后做了很多工作

有个问题自然冒出来:同样是“让模型输出调用”,为什么提示词 hack 天天翻车,而协议一出来,模型就能稳定地输出合法参数、不再给你残缺 JSON?

不是因为提示词写得好了。是厂商在背后做了工作——模型是被训练成“会正确提出调用”的,这个能力不是运气,是训练出来的。

这里只说结论,不展开原理(那是下一话的事):

  • 微调:训练阶段喂了大量“工具调用对话”样本,让模型学会判断要不要调、选哪个工具、生成合法参数;
  • 约束解码:生成的时候,在输出层卡住模型,让它只能吐出符合 schema 的内容,从根上杜绝“残缺 JSON”。

这两件事把“正确提出调用”从提示词的玄学,变成了模型本身的能力。至于模型内部到底是怎么学会的、这两件事的具体机制——那是第三话要专门讲的问题,这里先按下不表。

五、协议只负责“提出”,不负责“执行”

回到那张申请单。tool_calls 拿回来,协议的部分就结束了。接下来是你的代码接手:

def execute_tool(name: str, arguments_json: str) -> str:
    if name != "get_weather":
        raise ValueError(f"不允许执行未知工具:{name}")

    arguments = json.loads(arguments_json)
    if not isinstance(arguments, dict) or set(arguments) != {"city"}:
        raise ValueError("get_weather 参数必须且只能包含 city")

    city = arguments["city"]
    if not isinstance(city, str) or not city.strip():
        raise ValueError("city 必须是非空字符串")

    return get_weather(city.strip())

模型输出在这里被当成外部输入处理:先校验工具名,再校验参数,然后才真正执行。25℃ 来自 get_weather() 的返回值,不是模型编的:

def get_weather(city: str) -> str:
    weather = {
        "北京": "多云,25℃,东北风3级",
        "上海": "小雨,22℃,东南风2级",
    }
    return weather.get(city, f"暂无{city}的天气演示数据")

协议让模型“说清楚要干什么”,执行永远在模型外面——这是上一话那条结论的延续,在这里变成了可运行的代码。

六、把结果交回去,也是协议的一部分

函数执行完,Python 手里有了天气文本,但用户还没看到一句话。模型上一次只停在“我想调什么工具”,所以要把两样东西放回消息历史:

messages.append(assistant_message)          # 模型提出的调用请求
messages.append(
    {
        "role": "tool",                     # 工具执行结果
        "tool_call_id": tool_call.id,       # 关联到上面那条 tool_calls
        "content": result,
    }
)

role=tool 这一步不是可选的,是协议强制要求的。这里有个真实的坑:有人把 role=tool 写成了 role=user,API 直接报 HTTP 400——

An assistant message with 'tool_calls' must be followed by
tool messages responding to each 'tool_call_id'.

意思很直白:你给模型看了一张 tool_calls 申请单,就必须逐条配上一份对应的 tool 结果,用 tool_call_id 对上号。少一条、错一条,协议层面就拒绝,根本轮不到模型来猜。这个报错本身,就是“协议不是约定俗成、而是硬约束”的最好证据。

补上结果后再问一次模型,它才能把“多云,25℃”组织成一句人话。事实来自函数,句子由模型生成,两边分得清清楚楚。

七、回到最开始的问题:到底谁调用了工具?

角色输入输出不负责什么
模型用户问题与 tools 菜单tool_calls 申请单不执行本地函数
你的代码工具名、参数与本地能力校验后的工具结果不应把模型请求原样放行
模型role=tool 结果面向用户的回答不产生工具返回的外部事实

Function Calling 没有把函数“装进模型”。它做的,是把上一话那个脆弱的提示词 hack,变成了一套 tools 进、tool_calls 出的标准协议——模型提请求,你的代码执行,模型再说话。

跑通一次,你会看到这三步按顺序发生:

模型请求:get_weather({"city":"北京"})
程序执行:多云,25℃,东北风3级
模型回答:北京现在多云,气温 25℃,东北风 3 级。

配套 Python 与 Java 实现在 GYA 仓库GYA-Java 仓库c02-function-calling 目录,按 README 的命令跑,不复制这篇文章里零散的代码块。

协议跑通了,但一个更根本的问题还悬着:模型凭什么知道,该从菜单里挑 get_weather、该填北京? 这已经不是协议能回答的了——得回到模型训练本身。下一话。


参考资料:

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。