1. 为什么 Function Calling 是 Agent 从玩具走向工具的分水岭
如果你跟着这个系列一路做下来,到第三篇为止,手里的 Agent 大概率已经能聊天、能记住上下文、能按预设流程走几步了。但只要你真拿它去干点正事,就会立刻撞上一堵墙:它只会“说”,不会“做”。你问它今天天气怎么样,它要么编一个,要么告诉你它无法获取实时信息。你让它帮你查一下数据库里某个订单的状态,它只能给你一段看起来很像 SQL 的文字,然后眼巴巴看着你,等你手动去执行。
这个阶段我称之为“嘴炮 Agent”——知识面挺广,表达也流畅,但所有输出都停留在文本层面,跟真实世界之间隔着一层玻璃。Function Calling 就是砸碎这层玻璃的那把锤子。它让模型不再只是生成文字,而是能生成一个结构化的“调用意图”,由你的代码去执行真实操作,再把结果喂回模型,让它基于真实数据继续推理。说白了,就是从“我觉得应该是这样”变成“我去查了一下,确实是这样”。
这一篇我会把 Function Calling 的完整链路拆开讲透。不是那种“调个 API 就完事”的浅层教程,而是从协议设计、参数约束、执行编排、错误恢复到多轮工具调用的工程化落地。如果你正在做自己的 Agent 项目,或者准备把 LLM 接入现有业务系统,这篇的内容可以直接拿去抄作业。我踩过的坑、调过的参数、翻过的文档,都会尽量还原成你能直接复用的经验。
2. Function Calling 到底在做什么:从模型视角拆解
2.1 模型不是执行者,它只是“意图翻译器”
很多人第一次接触 Function Calling 时会有个误解,以为模型真的去调用了某个函数。不是的。模型做的事情只有一件:根据你的问题和你提供的工具描述,判断“要不要调工具”“调哪个工具”“参数填什么”,然后输出一段符合约定格式的 JSON。真正执行函数的是你自己的代码。
这个认知非常重要,因为它决定了整个架构的设计方向。模型负责的是语义理解和意图映射,你的代码负责的是实际执行和结果处理。两者之间通过一个结构化的协议来通信。理解了这个分工,你就不会纠结“为什么模型调完函数之后还要我再调一次模型”——因为模型需要看到执行结果才能继续推理。
我习惯用一个类比来解释:模型就像一个坐在指挥中心的调度员,它能看到所有工具清单,能听懂用户的需求,然后决定“这个活该派给谁、带什么参数去”。但调度员自己不出去干活,它只负责下指令。干完活之后,结果要回报给调度员,它才能决定下一步是继续派活还是给用户答复。
2.2 一次完整的 Function Calling 生命周期
把链路拆开来看,一次标准的 Function Calling 交互包含以下几个阶段:
第一阶段:工具声明。你在请求里附带一个 tools 数组,里面每个工具都有名字、描述和参数 schema。这个 schema 通常用 JSON Schema 格式描述,告诉模型这个函数接受哪些参数、每个参数是什么类型、哪些是必填的。
第二阶段:模型决策。模型收到用户消息和工具列表后,判断是否需要调用工具。如果需要,它不会直接回复文本,而是返回一个 tool_calls 结构,里面包含函数名和参数 JSON 字符串。
第三阶段:本地执行。你的代码解析这个 tool_calls,找到对应的本地函数,把参数传进去执行,拿到返回值。
第四阶段:结果回传。你把执行结果包装成一条 role 为 tool 的消息,附上对应的 tool_call_id,追加到对话历史里,再次请求模型。
第五阶段:模型整合。模型看到工具返回的真实数据后,结合原始问题生成最终回复。如果一次调用不够,它可能再次发起新的 tool_calls,进入下一轮循环。
这五个阶段构成了一个完整的闭环。实际工程中,最难的不是调通这个流程,而是处理各种边界情况:模型返回的参数格式不对怎么办、函数执行超时怎么办、模型连续调用同一个函数陷入死循环怎么办。这些后面会逐一展开。
2.3 为什么不用 Prompt 硬编码,而要用原生 Function Calling
有人可能会想,我直接在 prompt 里写“如果你需要查天气,就输出 <CALL:get_weather city=北京>”,然后自己解析不就行了?早期确实有人这么干,我也试过。但很快就会发现几个致命问题。
第一,格式稳定性极差。你永远不知道模型会在哪个位置多打一个空格、少写一个引号,或者把参数顺序换一下。解析正则写到崩溃是常态。第二,无法利用模型的原生训练。主流模型在训练阶段就见过大量 Function Calling 格式的数据,它们对这种结构化输出有天然的亲和力,比你自己发明的格式靠谱得多。第三,多工具并发调用。原生 Function Calling 支持一次返回多个 tool_calls,你的代码可以并行执行,而 prompt 硬编码很难做到这一点。
所以我的建议很明确:只要模型支持原生 Function Calling,就别自己造轮子。省下来的时间拿去处理业务逻辑,比什么都值。
3. 工具定义的艺术:让模型准确理解你的意图
3.1 工具描述写得好,调用准确率翻倍
Function Calling 的准确率,八成取决于你的工具描述写得怎么样。我见过太多人抱怨“模型怎么老是调错函数”,结果一看他的工具描述,就写了一行“查询数据”,参数叫“param1”。这能调对才怪。
工具描述的本质是给模型看的“使用说明书”。你要假设模型对你的系统一无所知,它只能通过你给的文字来判断这个工具是干什么的、什么时候该用、参数怎么填。所以描述要具体、要有场景、要有边界。
举个例子,假设你有一个查询订单状态的工具。差的描述是这样的:
{ "name": "query_order", "description": "查询订单", "parameters": { "type": "object", "properties": { "id": {"type": "string"} } } }好的描述应该是这样的:
{ "name": "query_order_status", "description": "根据订单号查询订单的当前状态,包括待付款、已付款、已发货、已签收、已取消。当用户询问某个订单的进度、物流状态或是否已发货时使用此工具。不适用于查询商品信息或用户账户信息。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,通常是12位数字组成的字符串,例如 202405180001" } }, "required": ["order_id"] } }差别在哪里?好的描述告诉模型:这个工具能返回什么状态、什么场景该用、什么场景不该用、参数长什么样。这些信息会直接影响模型的判断准确率。我实测下来,光是优化工具描述这一项,调用准确率就能从六成提到九成以上。
3.2 参数 schema 的约束技巧
JSON Schema 提供了丰富的约束能力,但很多人只用了 type 和 required。其实还有几个关键字段能大幅提升参数质量。
enum 枚举约束。如果某个参数只能取固定几个值,一定要用 enum 列出来。比如订单状态查询,如果支持按状态筛选,就把所有合法状态列进去。这样模型不会瞎编一个“运输中”这种你系统里根本不存在的状态。
description 参数级说明。每个参数都要写 description,尤其是那些名字不够自解释的。比如一个参数叫type,鬼知道是什么 type。写成"description": "查询类型,可选值:by_date 按日期查,by_status 按状态查"就清楚多了。
format 格式提示。日期、邮箱、URL 这类有固定格式的参数,用 format 字段标注。虽然模型不一定严格遵守,但有个提示总比没有好。
嵌套对象和数组。复杂参数用 object 和 array 嵌套描述。但要注意,嵌套层级不要太深,超过三层模型就容易出错。如果参数确实复杂,考虑拆成多个工具。
3.3 工具粒度的取舍:粗一点还是细一点
这是我在实际项目中纠结最久的问题。工具到底是设计得粗一些好,还是细一些好?
粗粒度工具的例子:一个manage_user函数,通过 action 参数区分创建、查询、更新、删除。细粒度工具的例子:拆成create_user、get_user、update_user、delete_user四个独立函数。
我的经验是:优先细粒度,但不要过细。细粒度的好处是每个工具职责单一,模型判断起来更简单,参数也更清晰。粗粒度的问题是 action 参数容易填错,而且不同 action 需要的参数不一样,schema 会变得很复杂,模型容易漏填或填错。
但也不能细到每个字段一个函数。比如查询用户,不要拆成get_user_name、get_user_email、get_user_phone,这纯属给自己找麻烦。合理的粒度是:一个工具对应一个完整的业务操作,参数控制在五个以内。
如果确实需要粗粒度,比如某些系统接口就是统一的 action 模式,那就在 description 里把每个 action 的用途和所需参数写清楚,并且在 schema 里用条件约束尽量表达。不过说实话,条件约束在 JSON Schema 里表达起来很别扭,模型也不一定看得懂,能拆还是拆。
4. 执行编排:从模型输出到真实操作的工程化落地
4.1 解析 tool_calls 的正确姿势
模型返回的 tool_calls 结构大概长这样:
{ "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "query_order_status", "arguments": "{\"order_id\": \"202405180001\"}" } } ] }注意 arguments 是一个 JSON 字符串,不是对象。你需要先 json.loads 解析它。这里有个坑:模型有时候会返回不完整的 JSON,比如少个括号或者多个逗号。虽然主流模型现在这种情况少了很多,但还是要做防御性处理。我的做法是包一层 try-except,解析失败就返回一个错误信息给模型,让它重新生成。
还有一个细节:tool_calls 可能包含多个调用。比如用户问“北京和上海今天天气怎么样”,模型可能一次返回两个 get_weather 调用。你的代码要能处理这种并发情况。简单做法是串行执行,但更好的做法是用线程池或异步并发执行,尤其是当工具涉及网络请求时,并发能省不少时间。
4.2 函数注册与路由设计
在你的代码里,需要维护一个从函数名到实际函数的映射表。最简单的做法是用字典:
TOOL_REGISTRY = { "query_order_status": query_order_status, "get_weather": get_weather, "search_products": search_products, }然后根据模型返回的函数名去查表执行。但实际项目中,我建议加一层封装,把参数校验、异常处理、日志记录都统一管起来。比如:
def execute_tool_call(tool_call): name = tool_call.function.name args = json.loads(tool_call.function.arguments) if name not in TOOL_REGISTRY: return {"error": f"未知工具: {name}"} try: result = TOOL_REGISTRY[name](**args) return {"result": result} except TypeError as e: return {"error": f"参数错误: {str(e)}"} except Exception as e: return {"error": f"执行失败: {str(e)}"}这样做的好处是,无论工具执行成功还是失败,你都能返回一个结构化的结果给模型。模型看到错误信息后,可以决定是重试、换参数还是告诉用户出了问题。千万别让异常直接抛出来中断整个流程,那样用户体验极差。
4.3 多轮工具调用的循环控制
模型可能一次调用不够,需要多轮。比如先查订单状态,发现已发货,再调物流查询接口拿物流详情。你的代码需要用一个循环来驱动这个过程:
messages = [{"role": "user", "content": user_input}] for _ in range(MAX_ROUNDS): response = call_llm(messages, tools=TOOLS) messages.append(response) if not response.tool_calls: break for tool_call in response.tool_calls: result = execute_tool_call(tool_call) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result) }) return response.content这里 MAX_ROUNDS 是必须的。我一般设 5 到 8 轮。设太少,复杂任务跑不完;设太多,万一模型陷入死循环,你的 API 账单会教你做人。我遇到过模型反复调用同一个函数十几次的情况,原因是我工具返回的错误信息不够明确,模型以为换个参数就能成功,结果一直试。后来我在错误信息里加了“请勿重试,直接告知用户”的提示,情况就好多了。
5. 实战中的坑与排查手册
5.1 模型不调用工具怎么办
这是最常见的问题。用户明明问了需要实时数据的问题,模型却直接编了一个答案。原因通常有三个:工具描述不够清晰、模型不知道有这个工具、或者模型觉得自己的知识够用。
排查顺序:先看工具描述是不是太模糊,把使用场景写具体。然后在 system prompt 里加一句“当需要实时数据或外部信息时,优先使用提供的工具,不要依赖自身知识”。如果还不行,可以在工具描述里加一些“必须使用此工具”的强提示。实测下来,这三板斧下去,九成的情况都能解决。
5.2 参数填错或漏填
模型有时候会漏掉必填参数,或者把参数类型搞错。比如把数字填成字符串,把日期格式写错。防御手段有两个:一是在 schema 里把 required 和 type 写清楚,二是在函数内部做二次校验。
我习惯在函数入口加一层参数校验,不合法就返回明确的错误信息。比如“order_id 必须是12位数字字符串,你提供的是 abc,请重新提取”。模型看到这种具体错误,下一轮通常能改对。如果只说“参数错误”,模型往往不知道该改什么。
5.3 工具执行超时或失败
外部 API 挂了、数据库连不上、网络超时,这些都会导致工具执行失败。你的代码要能捕获这些异常,并返回一个模型能理解的错误信息。不要返回一堆堆栈信息,模型看不懂。返回类似“物流查询服务暂时不可用,请稍后重试或告知用户手动查询”这样的自然语言描述,模型更容易处理。
另外,对于可能超时的操作,建议加一个超时时间。比如 HTTP 请求设 5 秒超时,超了就返回错误。不要让整个 Agent 卡死在一个慢接口上。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 模型不调用工具 | 描述模糊、缺少场景说明 | 优化工具描述,加 system prompt 引导 |
| 调用错误的工具 | 工具之间描述重叠 | 明确各工具边界,加“不适用于”说明 |
| 参数格式错误 | schema 约束不足 | 加 enum、format、description |
| 参数漏填 | required 未标注 | 补全 required 列表 |
| 多轮调用死循环 | 错误信息不明确 | 错误信息加“请勿重试”提示 |
| 工具执行超时 | 外部依赖慢 | 加超时控制,返回友好错误 |
| 并发调用结果错乱 | tool_call_id 未对应 | 确保每个结果绑定正确的 id |
6. 进阶技巧:让 Function Calling 更稳更高效
6.1 工具返回结果的精简与结构化
工具返回的数据不要一股脑全塞给模型。比如你查数据库返回了 50 个字段,模型根本处理不过来,还浪费 token。我的做法是在工具函数内部就把结果精简成模型需要的字段,并且用清晰的结构返回。
比如订单查询,返回{"order_id": "xxx", "status": "已发货", "tracking_no": "SF123456", "estimated_delivery": "2024-05-20"}就够了,不需要把创建时间、修改时间、操作人这些无关字段带上。如果确实需要多个字段,也尽量用扁平结构,别嵌套太深。
6.2 并行工具调用的处理
当模型一次返回多个 tool_calls 时,如果这些调用之间没有依赖关系,可以并行执行。Python 里用 concurrent.futures 的 ThreadPoolExecutor 就能搞定:
from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=5) as executor: futures = [executor.submit(execute_tool_call, tc) for tc in tool_calls] results = [f.result() for f in futures]但要注意,如果工具之间有依赖,比如第二个工具需要第一个工具的返回值作为参数,那就不能并行。这种情况模型通常会分两轮调用,你按顺序处理就行。
6.3 工具调用的可观测性
生产环境里,你需要知道模型调了什么工具、传了什么参数、返回了什么结果、耗时多久。这些信息对于排查问题和优化工具体验至关重要。我一般会在 execute_tool_call 里加日志,记录完整的调用链路。如果用的是 LangSmith 或类似的可观测性平台,可以直接接入,可视化效果更好。
另外,建议给每个工具调用加一个唯一的 trace_id,方便在日志里串联。尤其是多轮调用场景,没有 trace_id 的话,日志看起来就是一锅粥。
6.4 安全边界:别让模型为所欲为
Function Calling 给了模型操作真实系统的能力,这也意味着风险。模型可能被诱导调用不该调用的函数,或者传入恶意参数。几个基本的安全措施:
第一,工具白名单。只注册你允许模型调用的函数,不要动态注册。第二,参数校验。在函数内部严格校验参数,尤其是涉及数据库操作、文件操作、外部请求的。第三,权限控制。敏感操作加确认机制,比如删除数据前要求用户二次确认。第四,速率限制。防止模型疯狂调用某个工具把你的配额刷爆。
我见过有人把数据库的 raw query 接口直接暴露给模型,结果模型生成了一条 DROP TABLE。虽然模型本身没有恶意,但用户可能通过 prompt 注入诱导它这么做。所以安全边界一定要在代码层面守住,不能指望模型自觉。
7. 我个人在实际项目中的几点体会
Function Calling 这个机制,刚上手的时候觉得挺简单,不就是定义几个函数让模型调嘛。但真做到生产级别,细节多到令人发指。我最大的体会是:工具描述的质量决定了整个系统的上限。你花一个小时优化工具描述,可能比花一天调 prompt 效果还好。
另一个体会是,错误处理比正常流程更重要。正常流程跑通很容易,但用户不会按你设想的路径走。他们会问奇怪的问题,会在网络不好的时候使用,会在工具挂掉的时候继续操作。你的 Agent 能不能在这些情况下优雅降级,才是真正体现工程能力的地方。
最后分享一个小技巧:如果你不确定工具描述写得好不好,可以拿给一个完全不了解你系统的同事看,问他“你觉得这个工具是干什么的、什么时候该用”。如果他看完还是一头雾水,那模型大概率也看不懂。工具描述是写给人看的,顺便让模型也能看懂,这个标准不能降。