当前位置:首页 > AI编程类

大模型是怎么调用外部函数的?Function Calling 完整链路拆解

2周前 (09-30)AI编程类20

问模型今天杭州的天气,它回一句"我无法获取实时信息"。再问它1234乘5678等于多少,它可能给你一个看着挺像、但算错了的数。这两件事指向同一个原因:模型只会生成文字,它接触不到外部世界,也不擅长精确计算。

Function Calling(函数调用)就是给模型开的一道口子。让它在需要的时候,输出一段结构化的指令,由你本地的代码去执行真实操作,再把结果交回去。整个过程模型自己不动手,它只负责"决定调哪个函数、参数填什么"。

很多人刚读文档时会以为这是模型内部实现了某种调用能力。并不是。把这套机制的链路拆开看,你会发现它是几方配合出来的一套协议。下面按数据流动的顺序讲。

为什么需要工具调用

function-calling-liucheng.jpg

模型的参数里存的是训练数据的统计规律,不是实时数据库。它不知道今天几点、某只股票什么价、你数据库里有多少条订单。计算类任务也一样,它是靠"见过足够多算式"去猜答案,位数一多就容易出错。

工具调用解决的就是这个边界问题。能查的交给接口查,能算的交给代码算,模型只保留它擅长的部分——理解意图、决定动作、组织语言。

契约层:把函数写成模型能读懂的说明书

要让模型知道有哪些工具可用,你得先把每个函数描述成一段JSON Schema,跟用户的消息一起发过去。

{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "查询指定城市的实时天气",
    "parameters": {
      "type": "object",
      "properties": {
        "city": {
          "type": "string",
          "description": "城市名称,例如:杭州"
        },
        "unit": {
          "type": "string",
          "enum": ["celsius", "fahrenheit"],
          "description": "温度单位,默认摄氏度"
        }
      },
      "required": ["city"]
    }
  }
}

这段描述的质量,直接决定模型能不能调对。name是函数的标识,description告诉模型这个函数干什么用、什么场景该调用,parameters定义参数的类型和含义。description写得含糊,模型要么该调的时候不调,要么把参数填错。

enum这个约束值得单独提一句。把可选值写死成枚举,模型基本不会填出范围外的值,比在文字里叮嘱它有效得多。

模型侧:它返回的不是文字,是一段结构化调用

带着工具定义发一次请求,如果模型判断需要调用工具,它返回的就不再是自然语言,而是一段结构化的tool_calls字段。

{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "get_weather",
        "arguments": "{\"city\": \"杭州\", \"unit\": \"celsius\"}"
      }
    }
  ]
}

这里有个容易被忽略的细节:arguments是一个JSON字符串,不是JSON对象。不少人在这一步直接按字典取值,结果报错,就是因为忘了先做一次解析。这个坑在调试里出现的频率不低。

还有一点,模型在这个阶段并没有真正执行任何东西。它只是"申请"了一次调用,工具名和参数都是它生成的建议。真正跑不跑、怎么跑,决定权在你的代码手里。

执行层:真正干活的是你的代码

拿到tool_calls之后,需要一段分发逻辑:按函数名找到对应的实现,解析参数,执行,然后把结果收集起来。

import json

# 白名单:只允许模型调用已登记的函数
ALLOWED_TOOLS = {"get_weather", "get_time"}

def dispatch(tool_call):
    name = tool_call["function"]["name"]

    # 不信任模型给出的函数名,先校验
    if name not in ALLOWED_TOOLS:
        return {"error": f"unknown tool: {name}"}

    # arguments 是字符串,需要二次解析
    try:
        args = json.loads(tool_call["function"]["arguments"])
    except json.JSONDecodeError:
        return {"error": "invalid arguments"}

    try:
        return registry[name](**args)
    except Exception as exc:
        return {"error": str(exc)}

这段代码里有几处安全考虑,值得强调。函数名走白名单校验,避免模型被诱导去调用未授权的函数;参数解析包在异常处理里,避免脏数据把整个流程打断;执行结果统一成可序列化的结构,方便下一步回传。

回填:把执行结果交还给模型

工具的执行结果不能直接展示给用户,得先塞回对话历史,让模型自己组织语言。回填时要用role为tool、并带上对应的tool_call_id,模型靠这个id把结果和刚才那次调用对上。

messages.append({
    "role": "tool",
    "tool_call_id": tool_call["id"],
    "content": json.dumps(result, ensure_ascii=False)
})

完整的循环大致是这样:请求模型 → 判断有没有tool_calls → 有就执行并把结果回填 → 再请求模型 → 直到模型输出普通文本。

MAX_ROUNDS = 5

for _ in range(MAX_ROUNDS):
    resp = client.chat.completions.create(
        model=MODEL, messages=messages, tools=tools
    )
    msg = resp.choices[0].message

    if not msg.tool_calls:          # 没有工具调用,说明已经拿到答复
        return msg.content

    messages.append(msg)
    for tc in msg.tool_calls:
        messages.append({
            "role": "tool",
            "tool_call_id": tc.id,
            "content": json.dumps(dispatch(tc), ensure_ascii=False)
        })

MAX_ROUNDS这个上限不能省。工具连续调用多轮是正常需求(比如先查城市编码、再查天气),但一旦参数有误或工具报错,模型有可能反复重试,陷入死循环。加上轮次上限是兜底。

几个容易踩的坑

现象可能原因处理方式
该调工具时不调description太模糊,模型判断不出适用场景写清用途和触发时机,参数补上说明
参数解析报错把arguments当成对象,没做二次解析先json.loads,再做类型校验
反复调用停不下来没有轮次上限,或工具持续报错设MAX_ROUNDS,错误信息如实回传
枚举值填出范围只用文字描述可选值改用enum约束
工具名被任意指定直接按模型给的名字去调用白名单校验,只放行登记过的函数

还有一个安全上的点常被忽视:工具返回的内容要当作数据处理,不能当作指令执行。如果某个接口能返回用户可控的文本,而你把这段文本原样拼进了下一步的提示里,就有被注入的风险。稳妥的做法是明确区分"数据"和"指令",工具结果只作为事实提供,不参与决定下一步该调什么。

小结

把整条链路归拢一下:工具定义以JSON Schema的形式交给模型,模型生成结构化的tool_calls,本地代码校验并执行,结果按tool_call_id回填,模型据此生成答复,循环直到没有新的调用请求。

模型在这个过程中始终只做一件事——决定调什么、参数填什么。真正的执行、鉴权、限流、错误处理,全都在你的代码里。理解了这一点,再去看各家SDK的实现,会发现它们封装的都是同一套协议,换的只是调用方式和字段命名。

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。