工具调用真正难的是边界、失败和组合,而不是 schema 本身

模型并不"执行"函数

Function Calling 的入门体验通常很顺:定义一个 get_weather 函数,问模型"北京天气怎么样",它准确返回了调用意图,你执行、回填,模型说出天气。整个过程顺畅得让人觉得这事不难。

但往系统里塞进第二个、第五个、第十个工具,问题就来了:模型开始选错工具、用错参数,一个工具报错整条链路就崩,或者它自顾自地把同一个工具调了好几遍。单工具是玩具,工具一多、要串起来、会失败,才是 Function Calling 真正的难点。

讲清楚这些之前,要先纠正一个常见误解:模型并不"执行"你的函数。它做的只是输出一段结构化的意图——"我想调用 get_weather,参数是 {city: '北京'}"。真正执行函数的是你的代码。执行完,你把结果回填进对话,模型读到结果再决定下一步。所以一次工具调用本质是一个由你的代码驱动的循环,模型只负责决策,编排、执行、容错都是你的事。下文代码统一用 DeepSeek 作示例供应商。

工具调用的完整循环

一次完整的工具调用,至少需要两次模型请求。这是新手最常踩的坑——以为调一次就完了。

实际流程是:第一次请求把用户问题和工具定义一起发给模型,模型如果决定调工具,返回的不是答案而是一个 tool_calls;你的代码执行对应函数,把结果以 role="tool" 的消息回填;第二次请求让模型基于工具结果生成回答。如果模型还要继续调工具,这个过程就重复,直到它给出纯文本。

把这个过程写成一个循环,就是工具调用的骨架:

import json
from openai import OpenAI

client = OpenAI(api_key="...", base_url="https://api.deepseek.com/v1")


def run(messages: list[dict], tools: list[dict], max_turns: int = 8) -> str:
    for _ in range(max_turns):
        resp = client.chat.completions.create(
            model="deepseek-chat", messages=messages, tools=tools)
        msg = resp.choices[0].message
        messages.append(msg)

        if not msg.tool_calls:           # 模型不再要工具,给出了最终回答
            return msg.content or ""

        for call in msg.tool_calls:      # 同一轮可能有多个工具调用
            result = dispatch_tool(call.function.name,
                                   json.loads(call.function.arguments))
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,         # 必须与该次调用的 id 对应
                "content": json.dumps(result, ensure_ascii=False),
            })
    return "任务较复杂,未能在限定步数内完成,请拆分后再试。"

注意那个 max_turns。模型偶尔会陷进"调工具、看结果不满意、再调"的循环里出不来,没有轮数上限,它会一直消耗 Token。这个上限是必须有的安全闸。

工具一多,模型就开始选错

单工具时模型几乎不会错,因为没得选。工具一多,选择就成了真问题。模型选错,原因基本逃不出几类:工具描述写得含糊、两个工具功能重叠、命名太相似(get_userget_user_info)、参数语义不清。

而且有个规律:工具数量越多,选择准确率越往下掉。当系统有几十个工具时,全部一股脑塞给模型,命中率会很难看——上下文里那么多 schema,既占 Token 又干扰判断。经验上,一次对话暴露给模型的工具最好不超过 10 到 15 个。

解法不是让模型更聪明,而是不让它一次面对那么多选择。常见做法有三种:给工具分组、加命名空间,让相关的聚在一起;按场景做路由,先用一个轻量判断当前意图属于哪类,再只暴露那一类的几个工具;工具特别多时,甚至可以像 RAG 一样把工具描述做成向量库,按用户请求检索出最相关的几个工具再传进去。核心思想一致:模型每一轮看到的工具集,应该是裁剪过、和当前任务相关的那一小撮。

工具链:让模型把 A 的结果喂给 B

真实任务很少一个工具就能完成。"把张三这个月的工单导出",得先 search_user("张三") 拿到 user_id,再 list_tickets(user_id, month)。后一个工具的参数依赖前一个的结果,这就是工具链。

这里要分清两种情况。彼此没有依赖的工具,模型可以在一轮里同时发起多个调用(并行工具调用),你并行执行即可。有依赖的工具必须串行:模型调 A,你执行回填,模型看到 A 的结果才能正确地调 B。驱动这一切的还是上面那个循环——只要模型还在要工具,就执行、回填、继续。前面 run 函数里 for call in msg.tool_calls 处理的是并行,外层 for _ in range(max_turns) 处理的是串行的多轮,两者都已经涵盖。

工具失败了,把错误回填给模型

新手代码里的工具函数往往是"理想路径"写法——假设外部 API 一定通、参数一定合法、返回一定有数据。但工具天生会失败:参数非法、第三方接口超时、查询结果为空、权限不足。

这里有一条最重要的原则:工具失败时,不要直接抛异常让整个流程崩掉,而要把失败本身作为一条信息回填给模型。模型有纠错能力——它看到"工具报错:city 参数应为城市拼音,收到的是中文",往往能自己改对参数重试;看到"未找到该用户",会换个名字或转而询问用户。一旦异常抛飞,这个纠错机会就没了。

但回填不等于什么错都丢给模型。要分类处理:纯瞬时性的失败(网络超时、5xx),在代码层先重试几次,别拿这种噪音打扰模型;参数类错误,回填给模型让它改;确定办不成的(权限不够、资源不存在),回填并引导模型优雅地告知用户。

还有两个防御点容易漏。一是执行前校验参数——模型可能幻觉出不存在的参数或类型不对的值,用 Pydantic 在真正执行前先挡一道。二是写操作要幂等——模型可能因重试或误判把同一个"创建订单"调用发两次,有副作用的工具要么设计成幂等,要么加去重键。

from pydantic import BaseModel, ValidationError

# TOOL_REGISTRY: name -> {"fn": 可调用对象, "arg_model": Pydantic 模型}


def dispatch_tool(name: str, raw_args: dict) -> dict:
    """统一的工具分发:所有失败都返回结构化 error,而不是抛异常"""
    spec = TOOL_REGISTRY.get(name)
    if spec is None:                       # 模型幻觉出不存在的工具
        return {"error": f"工具 {name} 不存在,可用工具:{list(TOOL_REGISTRY)}"}
    try:
        args = spec["arg_model"](**raw_args)   # 执行前做参数校验
    except ValidationError as e:
        return {"error": f"参数不合法:{e.errors()},请修正后重试"}
    try:
        return {"ok": True, "data": spec["fn"](args)}
    except TimeoutError:
        return {"error": "外部服务超时,可稍后重试"}
    except PermissionError:
        return {"error": "无权限执行此操作,请告知用户改用其它方式"}

所有失败路径都返回结构化的 error 字段而不是抛出去——这就是让模型"看得见失败、且能据此纠错"的关键。

一个好工具,描述比实现更重要

最后讲工具设计。这里最反直觉的一点是:对工具调用成败影响最大的,不是函数实现写得多好,而是那段 description 写得好不好。因为描述是模型选不选、怎么用这个工具的唯一依据——模型看不到你的函数体,它只看 schema。

所以描述要写清楚的不只是"这个工具做什么",还有"什么时候该用、什么时候不该用",边界尤其重要。两个功能相近的工具,靠的就是描述里的边界把它们区分开。

参数设计上原则是少而明确。每个参数都要有自己的 description;能用枚举收敛取值的,绝不留成自由文本——status 参数给 ["open", "closed", "pending"],比让模型自由填字符串可靠得多。返回值也要设计,别把外部 API 那一大坨原始 JSON 原样甩回去,那既占 Token 又会淹没关键信息,只返回模型完成任务真正需要的字段。

工具的粒度也有讲究:一个工具做一件事。粒度太粗的工具模型驾驭不了,太细又会让一个任务调十几次工具。有副作用的工具——写库、删除、发邮件、扣款——要在描述里明确标注,并在执行前留一道人工确认。

SEND_EMAIL_TOOL = {
    "type": "function",
    "function": {
        "name": "send_email",
        # 描述里写清用途、边界、副作用,这段才是模型用对工具的依据
        "description": "向指定收件人发送一封邮件。仅在用户明确要求发邮件时使用;"
                       "查询邮件请用 search_email。注意这是发送操作,会真实送达,不可撤销。",
        "parameters": {
            "type": "object",
            "properties": {
                "to": {"type": "string", "description": "收件人邮箱地址"},
                "subject": {"type": "string", "description": "邮件主题,简洁明确"},
                "body": {"type": "string", "description": "邮件正文,纯文本"},
                "priority": {
                    "type": "string",
                    "enum": ["normal", "high"],     # 用枚举收敛,别留自由文本
                    "description": "优先级,默认 normal",
                },
            },
            "required": ["to", "subject", "body"],
        },
    },
}

完整示例:一个带容错的工具调用循环

把工具注册、参数校验、失败回填、调用循环整合到一起,是一个可以直接用的最小框架。注册工具时同时给出 Pydantic 参数模型和 schema,循环负责驱动:

# tool_loop.py
import json
import os
from openai import OpenAI
from pydantic import BaseModel, ValidationError
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.getenv("DEEPSEEK_API_KEY"),
                base_url="https://api.deepseek.com/v1")

TOOL_REGISTRY: dict = {}


def register(name: str, schema: dict, arg_model: type[BaseModel]):
    def deco(fn):
        TOOL_REGISTRY[name] = {"fn": fn, "schema": schema, "arg_model": arg_model}
        return fn
    return deco


class WeatherArgs(BaseModel):
    city: str


@register(
    "get_weather",
    {"type": "function", "function": {
        "name": "get_weather",
        "description": "查询指定城市的实时天气。city 须为城市的中文名。",
        "parameters": {"type": "object",
                       "properties": {"city": {"type": "string"}},
                       "required": ["city"]}}},
    WeatherArgs,
)
def get_weather(args: WeatherArgs) -> dict:
    fake = {"北京": "晴 8℃", "上海": "多云 15℃"}
    return {"city": args.city, "weather": fake.get(args.city, "暂无数据")}


def dispatch_tool(name: str, raw_args: dict) -> dict:
    spec = TOOL_REGISTRY.get(name)
    if spec is None:
        return {"error": f"工具 {name} 不存在"}
    try:
        args = spec["arg_model"](**raw_args)
    except ValidationError as e:
        return {"error": f"参数不合法:{e.errors()}"}
    try:
        return {"ok": True, "data": spec["fn"](args)}
    except Exception as e:
        return {"error": f"工具执行失败:{e}"}


def run(question: str, max_turns: int = 8) -> str:
    messages = [{"role": "user", "content": question}]
    tools = [s["schema"] for s in TOOL_REGISTRY.values()]
    for _ in range(max_turns):
        msg = client.chat.completions.create(
            model="deepseek-chat", messages=messages,
            tools=tools).choices[0].message
        messages.append(msg)
        if not msg.tool_calls:
            return msg.content or ""
        for call in msg.tool_calls:
            result = dispatch_tool(call.function.name,
                                   json.loads(call.function.arguments))
            messages.append({"role": "tool", "tool_call_id": call.id,
                             "content": json.dumps(result, ensure_ascii=False)})
    return "未能在限定步数内完成。"


if __name__ == "__main__":
    print(run("北京今天天气怎么样?"))

这个框架把工具注册、参数校验、失败回填、轮数上限都固化进了结构。新增工具只需写一个带 @register 的函数,循环逻辑完全不用动。

几个常见踩坑

以为工具调用只需一次模型请求。完整流程至少两次:拿到 tool_calls、执行回填后再请求一次。

循环不设轮数上限。模型可能陷入反复调工具的循环,必须有 max_turns 兜底。

把几十个工具一次性塞给模型。工具越多选择准确率越低,要分组、路由或检索,单轮控制在 10 到 15 个内。

工具失败直接抛异常。应把错误回填给模型,让它有机会纠错;只有瞬时性失败才在代码层重试。

不校验模型传来的参数。模型会幻觉出错误参数,要用 Pydantic 在执行前校验。

只写函数实现、不打磨描述。description 是模型选对工具的唯一依据,要写清用途和边界。

本篇要点

  • 模型不执行函数,只输出调用意图,编排和执行由你的代码驱动
  • 一次完整工具调用至少两次模型请求,多轮要靠循环驱动并设轮数上限
  • 工具越多选择准确率越低,要靠分组、路由、检索把每轮工具集裁剪到相关的一小撮
  • 工具失败要把错误回填给模型而非抛异常,瞬时失败在代码层重试,执行前校验参数
  • 工具的 description 比实现更关键,参数用枚举收敛,返回值精简,副作用工具要标注并确认
  • 把注册、校验、回填、循环整合成框架,新增工具不影响循环逻辑

下一篇

上面那个工具调用循环,其实已经是一个 Agent 的雏形了。下一篇正式进入 Agent——它远不只是套一个 while 循环,背后是控制流的选型、记忆机制的设计,以及多个 Agent 的协作。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:Function Calling 用好了才叫工具调用

本文链接:https://www.sshipanoo.com/blog/ai/llm-advanced/05-Function-Calling用好了才叫工具调用/

本文最后一次更新为 天前,文章中的某些内容可能已过时!