工具调用真正难的是边界、失败和组合,而不是 schema 本身
你大概试过 Function Calling 的"hello world":定义一个 get_weather 函数,问模型"北京天气怎么样",它准确地返回了调用意图,你执行、回填,模型说出天气。整个过程顺得不得了,于是你觉得这事不难。
然后你往系统里塞进第二个、第五个、第十个工具,问题就来了:模型开始选错工具,开始用错参数,一个工具报错整条链路就崩,或者它自顾自地把同一个工具调了八遍。说白了,单工具是玩具,工具一多、要串起来、会失败,才是 Function Calling 真正的难点。
先搞清楚:模型并不"执行"函数
很多坑源于一个误解——以为模型真的"运行"了你的函数。它没有。
Function Calling 的真相是:你把工具的 schema(名字、描述、参数定义)随请求发给模型,模型不执行任何东西,它只是输出一段结构化的意图——"我想调用 get_weather,参数是 {city: '北京'}"。真正执行这个函数的是你的代码。执行完,你把结果作为一条 role="tool" 的消息回填进对话,模型读到结果,再决定是继续调别的工具,还是给出最终回答。
所以一次完整的工具调用,本质是一个由你的代码驱动的循环:发请求 → 拿到 tool_calls → 执行 → 回填结果 → 再发请求,直到模型不再要求调工具、给出纯文本为止。理解这个循环是后面一切的基础——模型只负责"决策",编排、执行、容错全是你的活。
工具一多,模型就开始选错
单工具时模型几乎不会错,因为没得选。工具一多,选择就成了真问题。模型选错,原因基本逃不出这几个:工具的描述写得含糊,两个工具功能重叠让它犯难,命名太相似(get_user 和 get_user_info),或者参数语义不清。
而且有个规律:工具数量越多,选择准确率越往下掉。当你的系统有几十个工具时,全部一股脑塞给模型,它的命中率会很难看——上下文里那么多 schema,既烧 token 又干扰判断。
解法不是让模型更聪明,而是别让它一次面对那么多选择。常见做法有三种:给工具分组、加命名空间,让相关的聚在一起;按场景做路由,先用一个轻量判断当前意图属于哪类,再只把那一类的几个工具暴露给模型;工具特别多时,甚至可以像 RAG 一样,把工具描述也做成向量库,按用户的请求检索出最相关的几个工具再传进去。核心思想一致:模型每一轮看到的工具集,应该是裁剪过的、和当前任务相关的那一小撮。
工具链:让模型自己把 A 的结果喂给 B
真实任务很少一个工具就能完成。"把张三这个月的工单导出",得先 search_user("张三") 拿到 user_id,再 list_tickets(user_id, month)。后一个工具的参数依赖前一个的结果——这就是工具链。
这里要分清两种情况。彼此没有依赖的工具,模型可以在一轮里同时发起多个调用(parallel tool calls),你并行执行就行。有依赖的工具必须串行:模型调 A,你执行回填,模型看到 A 的结果才能正确地调 B。驱动这一切的,还是那个循环——只要模型还在要求调工具,就执行、回填、继续。
def run_agent(messages: list[dict], tools: list[dict], max_turns: int = 8) -> str:
for turn in range(max_turns):
resp = client.chat.completions.create(
model="gpt-4.1", 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({ # 每个调用的结果都要回填,且 id 要对上
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
# 兜底:到达轮数上限还没收敛,别让它无限转下去
return "任务较复杂,未能在限定步数内完成,请拆分后再试。"
注意那个 max_turns。模型偶尔会陷进"调工具—看结果不满意—再调"的循环里出不来,没有轮数上限,它能一直烧你的 token。这个上限是必须有的安全闸。
工具失败了,别让它直接崩
新手代码里,工具函数往往是"happy path"写法——假设外部 API 一定通、参数一定合法、返回一定有数据。可工具天生就会失败:参数非法、第三方接口超时、查询结果为空、权限不足。
这里有一条最重要的原则:工具失败时,不要直接抛异常让整个流程崩掉,而要把失败本身作为一条信息回填给模型。模型是有纠错能力的——它看到"工具报错:city 参数应为城市拼音,收到的是中文",往往能自己改对参数重试;看到"未找到该用户",它会换个名字或者转而问用户。一旦你把异常抛飞,这个纠错机会就没了。
但回填不等于什么错都丢给模型。要分类处理:纯瞬时性的失败(网络超时、5xx),在你的代码层就该先重试几次,别拿这种噪音去打扰模型;参数类错误,回填给模型,让它改;确定办不成的(权限不够、资源不存在),回填并引导模型优雅地告知用户,而不是反复试。
还有两个防御点容易漏。一是执行前校验参数——模型可能幻觉出根本不存在的参数、或者类型不对的值,用 Pydantic 在真正执行函数之前先挡一道,把校验错误变成给模型的反馈。二是写操作要幂等——模型可能因为重试或误判把同一个"创建订单"调用发两次,涉及副作用的工具要么设计成幂等,要么加去重键。
from pydantic import BaseModel, ValidationError
class SendEmailArgs(BaseModel):
to: str
subject: str
body: str
def dispatch_tool(name: str, raw_args: dict) -> dict:
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;能用枚举(enum)收敛取值的,绝不留成自由文本——status 参数给 ["open", "closed", "pending"],比让模型自由发挥填字符串可靠得多。
返回值也要设计,别把外部 API 那一大坨原始 JSON 原样甩回去。那坨东西模型要为它付 token,关键信息还会被无关字段淹没。只返回模型完成任务真正需要的字段。
工具的粒度也有讲究。一个工具做一件事。粒度太粗的工具(一个 do_everything 带十五个参数)模型驾驭不了;太细又会让一个任务要调十几次工具,循环又长又脆。最后,有副作用的工具——写库、删除、发邮件、扣款——要在描述里明确标注,并且在执行前最好留一道人工确认。
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"],
},
},
}
把工具选择、链式编排、失败回退、工具设计这四件事做扎实,模型才算真的"会用工具",而不只是会念函数签名。
不过你可能已经注意到了:前面那个 run_agent 循环,其实已经是一个 Agent 的雏形了。下一篇我们就正式进入 Agent——它远不只是套一个 while 循环,背后是控制流的选型、记忆的设计和多个 Agent 的协作。
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:Function Calling 用好了才叫工具调用
本文链接:https://www.sshipanoo.com/blog/ai/llm-advanced/05-Function-Calling用好了才叫工具调用/
本文最后一次更新为 天前,文章中的某些内容可能已过时!