大模型是怎么调用外部函数的?Function Calling 完整链路拆解
问模型今天杭州的天气,它回一句"我无法获取实时信息"。再问它1234乘5678等于多少,它可能给你一个看着挺像、但算错了的数。这两件事指向同一个原因:模型只会生成文字,它接触不到外部世界,也不擅长精确计算。
Function Calling(函数调用)就是给模型开的一道口子。让它在需要的时候,输出一段结构化的指令,由你本地的代码去执行真实操作,再把结果交回去。整个过程模型自己不动手,它只负责"决定调哪个函数、参数填什么"。
很多人刚读文档时会以为这是模型内部实现了某种调用能力。并不是。把这套机制的链路拆开看,你会发现它是几方配合出来的一套协议。下面按数据流动的顺序讲。
为什么需要工具调用

模型的参数里存的是训练数据的统计规律,不是实时数据库。它不知道今天几点、某只股票什么价、你数据库里有多少条订单。计算类任务也一样,它是靠"见过足够多算式"去猜答案,位数一多就容易出错。
工具调用解决的就是这个边界问题。能查的交给接口查,能算的交给代码算,模型只保留它擅长的部分——理解意图、决定动作、组织语言。
契约层:把函数写成模型能读懂的说明书
要让模型知道有哪些工具可用,你得先把每个函数描述成一段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的实现,会发现它们封装的都是同一套协议,换的只是调用方式和字段命名。
