上一篇 Transformer 解码策略 把 LLM 怎么"挑下一个词"讲完了 —— 模型的输出是一段段文字接龙。
但你打开 ChatGPT 问"北京今天天气怎么样",它能给你一个实时正确的答案;你让它"把这段会议纪要发给我邮箱",它真的能发出去。
模型只会接龙,为什么它能干这些超出"语言"范围的事?
答案就是 —— Function Calling(函数调用,也叫 Tool Use / Tool Calling)。
一句话定义
模型本身不会调函数,它只是在"该用工具的时候"按约定吐出一段结构化 JSON,描述自己想调哪个工具、用什么参数 —— 真正执行函数的是你写的代码,执行完再把结果喂回去让模型继续接龙。
读两遍,这一句话基本就把 function calling 的精髓讲完了。剩下都是细节。
为什么需要这玩意
LLM 自身有三个改不掉的短板:
| 短板 | 例子 |
|---|---|
| 知识有截止日期 | 问它"昨天 A 股收盘多少",它不知道 |
| 算数不靠谱 | 问它 983476 × 12947 等于多少,大概率算错 |
| 没法影响外部世界 | 让它发邮件、改数据库、下单 —— 它只会跟你说"好的我帮你下单了",其实啥也没干 |
业界目前两条主流的"打补丁"思路:
- RAG(检索增强生成):在提问前先把相关文档查出来,塞进上下文 —— 解决"知识不够新 / 不够专"
- Function Calling:让模型能主动调用你定义好的函数 —— 解决"读、写、动作"全套
RAG 只解决了"读"的问题,而且是你主动塞数据。Function Calling 是更通用的方案:模型自己决定什么时候要查、查什么、查哪里,以及查完之后是不是还要查别的。
一次完整的调用长什么样
这里是 function calling 最关键的部分,一定要看懂。
整个过程是一次或多次"模型 ↔ 你"的来回,不是模型自己跑出去调了个函数。
以"用户问北京天气"为例,完整流程是这样的:
┌─────────────┐ ┌──────────┐
│ 你的代码 │ │ 模型 │
└─────┬───────┘ └────┬─────┘
│ │
│ 1. messages=[用户:"北京天气"] │
│ tools=[get_weather, send_email, ...] │
├────────────────────────────────────────────►│
│ │
│ 2. 模型决定要调工具 │
│ finish_reason: "tool_calls" │
│ tool_calls: [{ │
│ name: "get_weather", │
│ arguments: { city: "北京" } │
│ }] │
│◄────────────────────────────────────────────┤
│ │
│ 3. 你的代码真的去调 get_weather("北京") │
│ 拿到 "晴,28℃,东南风 3 级" │
│ │
│ 4. messages=[ │
│ 用户:"北京天气", │
│ 助手:tool_calls, │
│ 工具结果:"晴,28℃,东南风 3 级" │
│ ] │
├────────────────────────────────────────────►│
│ │
│ 5. 模型基于真实数据组织自然语言 │
│ "北京今天天气晴朗,气温 28 度..." │
│◄────────────────────────────────────────────┤
│ │注意几个关键点:
- 模型从头到尾没有"调用"任何函数,它只生成了一段 JSON
- 真正执行函数的是你的代码 —— 你拿到
tool_calls之后,自己switch (name) { case 'get_weather': ... } - 工具结果要塞回 messages 里,模型才能"看见"
- 如果模型基于工具结果还想调另一个工具,可以再次返回
tool_calls,你再调、再塞回去 —— 这就是多轮工具调用 / Agent 的基础
工具长什么样:JSON Schema
要让模型知道"有哪些工具可用、参数怎么填",你得用 JSON Schema 把工具描述出来。
一个典型的工具定义:
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市当前的天气情况。仅支持中国大陆城市,不支持港澳台和海外。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市的中文名,例如 '北京' / '上海' / '深圳'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,默认摄氏度",
"default": "celsius"
}
},
"required": ["city"]
}
}
}模型选不选这个工具、参数填得对不对,90% 取决于 description 写得好不好。所以工具描述这件事千万不能糊弄,后面会专门讲。
完整代码示例:OpenAI Python SDK
为了把抽象的流程落到代码,这里用 OpenAI 的 Python SDK 演示一次"问天气"的完整 round-trip。
from openai import OpenAI
import json
client = OpenAI(api_key="sk-...")
def get_weather(city: str, unit: str = "celsius") -> dict:
return {
"city": city,
"temperature": 28 if unit == "celsius" else 82,
"condition": "晴",
"wind": "东南风 3 级",
}
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市当前的天气。仅支持中国大陆城市。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市的中文名"},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"default": "celsius",
},
},
"required": ["city"],
},
},
}]
messages = [
{"role": "user", "content": "北京今天天气怎么样?"}
]
resp = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)
msg = resp.choices[0].message
messages.append(msg)
if msg.tool_calls:
for call in msg.tool_calls:
name = call.function.name
args = json.loads(call.function.arguments)
print(f"模型想调用 {name}({args})")
if name == "get_weather":
result = get_weather(**args)
else:
result = {"error": f"unknown tool: {name}"}
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
final = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)
print(final.choices[0].message.content)
else:
print(msg.content)跑一遍的实际输出大概是这样:
模型想调用 get_weather({'city': '北京'})
北京今天天气晴朗,气温约 28℃,东南风 3 级,适合外出。整个过程有两次 API 调用:
- 第一次:把工具列表给模型,模型决定要调
get_weather - 第二次:把工具执行结果塞进 messages,模型组织自然语言回复
第二次调用时还是要带上
tools参数,因为模型可能基于第一个工具的返回值,决定再调另一个工具(比如根据天气决定要不要调send_notification)。
多工具 / 并行调用
现实场景里,工具往往不止一个。比如同时给模型 get_weather / search_web / send_email / query_database 等十几个工具,让它自己选。
主流模型现在都支持并行工具调用(parallel tool calling) —— 一次响应里同时返回多个 tool_calls,你并发地执行所有工具,再把所有结果一次塞回去。
例如用户问"北京和上海今天哪里更热",模型可能一次返回:
{
"tool_calls": [
{ "id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"北京\"}" },
{ "id": "call_2", "name": "get_weather", "arguments": "{\"city\":\"上海\"}" }
]
}你的代码用 asyncio.gather / Promise.all 并发跑两次 get_weather,然后把两条 tool 消息都塞回 messages,再调一次模型 —— 一回合解决,不需要往返两次。
并行调用的支持情况(写这篇时):
| 模型 | 并行支持 |
|---|---|
| GPT-4o / GPT-4.1 / GPT-5 系列 | ✅ 默认开启 |
| Claude 3.5 / 3.7 / 4 系列 | ✅ 默认开启,可手动关 |
| Gemini 2.0+ | ✅ |
| DeepSeek-V3 / V3.1 | ✅ |
| 千问 Qwen2.5+ | ✅ |
工具调用 vs 普通对话:模型怎么"决定"调不调
很多人有个误解:"是不是我提供了工具,模型就一定会调?"
不是。 模型每一步都在做两个判断:
- 当前用户的需求,能不能只靠模型自己回答?(比如"1 + 1 等于几"显然不需要查工具)
- 如果需要外部信息 / 动作,哪个工具最匹配?
这两个判断完全靠你写的 description:
- 工具的
description—— 模型从一堆工具里挑哪个 - 参数的
description—— 模型怎么从用户的话里抽出参数值
举个反例:
{
"name": "search",
"description": "搜索"
}这种工具描述,模型完全不知道:能搜什么?搜哪里?返回什么?于是要么不调,要么乱调。
正确的写法应该是:
{
"name": "search_internal_docs",
"description": "在公司内部技术文档库里检索。适用场景:用户问 '我们公司用 xxx 框架的最佳实践'、'部署流程文档在哪'。不适用:通用编程问题、外部新闻、实时数据 —— 这些场景应该让我直接回答或者使用 web_search 工具。"
}"什么时候用 + 什么时候不用",这两条信息比一堆参数说明还重要。
"Functions" 还是 "Tools"?
这个命名上的混乱坑了不少新人,简单说一下。
- OpenAI 2023 年 6 月首次推出这个能力,叫
functions+function_call - 2023 年 11 月改名为
tools+tool_choice(functions字段保留但标记 deprecated) - Anthropic 一开始就叫
tools+tool_use - Google Gemini 也叫
tools
所以现在写新代码,统一用 tools 就对了。除非你维护的是老代码,看到 functions / function_call 字段不要奇怪 —— 等同语义,只是历史包袱。
由于命名混乱,业界对这个能力本身的称呼也有两套:
- Function Calling(函数调用) —— OpenAI / 国内中文圈用得多
- Tool Use(工具使用) —— Anthropic / 学术圈用得多
说的是同一件事。
工程实践:踩坑要点
写过几个真实项目的工具集成之后,总结下值得注意的地方。
1. 工具描述要"行为驱动"
错的写法(只描述"是什么"):
"description": "查询订单表"对的写法(描述"什么时候用 / 输入输出 / 限制"):
"description": "根据订单号查询订单的当前状态、下单时间、收货地址。只支持最近 90 天的订单;查询历史订单请用 query_archived_order。"模型挑工具的逻辑非常像"读招标书"—— 你描述得清楚,它就挑得准。
2. 参数加约束,别让模型自由发挥
枚举(enum)、范围(minimum/maximum)、必填(required)、默认值(default)、格式(format: "date"、format: "email")能加都加。
模型不是 100% 守规矩,但加约束能显著降低参数填错的概率。
3. 工具内部错误要"说人话"返回
工具执行出错时,不要 raise / throw 让请求挂掉,而是返回一段模型能读懂的错误信息:
def get_weather(city: str):
if city not in SUPPORTED_CITIES:
return {
"error": "city_not_supported",
"message": f"暂不支持 {city} 的天气查询,目前只支持中国大陆地级市及以上",
"hint": "如果用户问的是港澳台或海外,请明确告知用户暂不支持",
}模型看到这段会自己组织一句"抱歉,暂不支持 xxx 的天气查询..."。如果你直接 throw,整个请求就挂了,体验很差。
4. 危险操作要二次确认
涉及"动外部世界"的工具(发邮件、删数据、下单、扣款),强烈建议:
- 工具内部加 dry-run 模式,只校验不执行
- 真正执行前要人工 confirm(返回一个
confirm_token,前端弹窗,用户点确认后再调一个execute_action工具) - 日志全量记录,出问题能追溯
模型再聪明也会幻觉 —— 你能想象它把"取消订阅"调成"取消订单"的场景吗?这事真的发生过。
5. 别让模型自由生成 SQL
很多人第一反应是给模型一个 run_sql(query: str) 工具。不推荐。
原因:
- 模型生 SQL 容易写出全表 scan / 死循环 / 修改数据的语句
- 注入攻击面巨大
- 一旦表结构变了,模型生成的 SQL 全废
推荐做法:暴露"业务层"高级函数,例如 query_orders_by_date(start, end) / get_user_by_email(email),把 SQL 封在工具实现里。
如果一定要 SQL 灵活查询,至少:
- 强制
SELECT开头(禁止INSERT/UPDATE/DELETE/DROP) - 强制带
LIMIT - 走只读账号
- 加超时
6. 控制 Token 消耗
工具定义本身是要算 token 的。一个 20 个工具的项目,光工具描述就能占 2-3k token。每次请求都要带上,长对话里成本不低。
应对策略:
- 工具描述精炼,删掉冗余
- 工具按需注入 —— 比如检测到用户问编程问题再注入代码相关的工具,问天气再注入天气工具(用"工具路由"思路)
- 多轮对话的历史里,老的
tool_calls+tool消息可以适当截断或概要
7. 多轮 Agent 一定要有 max_iter
模型可以基于一次工具结果再调下一个工具,这种"自主循环"就叫 Agent。
但模型偶尔会陷入死循环,比如:
调 search("ABC") → 没结果
调 search("ABC ") → 没结果(就多了个空格)
调 search(" ABC") → 没结果
...务必加 max_iterations 上限(一般 10-30 次),超了就强制返回当前结果 / 报错。
Function Calling、Agent、MCP 是什么关系
这三个词在 LLM 圈经常一起出现,新人很容易蒙。一张表理清楚:
| 概念 | 是什么 | 在哪一层 |
|---|---|---|
| Function Calling / Tool Use | 模型"喊"工具调用的协议(JSON Schema + tool_calls) | 最底层 |
| Tool | 一个具体的可调用单元(get_weather / send_email ...) |
在 Function Calling 之上 |
| Agent | 拿到工具结果后能继续思考、再调工具、自主循环直到完成任务的系统 | 在 Function Calling 之上的应用模式 |
| MCP(Model Context Protocol) | Anthropic 提出的"工具提供方"标准化协议,让工具能像浏览器插件一样被任何 AI 客户端复用 | 工具的分发标准,在 Function Calling 上一层 |
关系大致是这样:
┌──────────────────────────┐
│ Agent / AI 应用层 │ ← Cursor / Claude Desktop / 你的产品
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ MCP(工具发现 / 分发) │ ← 可选,用于把工具做成"插件"
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ Function Calling 协议 │ ← 模型层 API 的能力
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ 基础模型 │ ← GPT / Claude / Gemini / ...
└──────────────────────────┘- Function Calling 是模型自带的能力,没有它后面都谈不上
- Tool 是 Function Calling 的具体实例
- Agent 是"工具调用 + 自主循环"的工程模式 —— 没新东西,就是循环加上记忆 / 规划
- MCP 解决的是"工具复用"问题 —— 不至于每个 AI 客户端都重新实现一遍 GitHub / Slack / Postgres 的对接代码
简单类比:
- Function Calling ≈ HTTP 协议(底层规范)
- Tool ≈ 一个 HTTP API(具体功能)
- Agent ≈ 一个能自己浏览网页的浏览器(组合调用)
- MCP ≈ Chrome 插件标准(工具的可移植化)
局限性
Function Calling 不是银弹,主要的"翻车点":
| 翻车点 | 表现 | 缓解 |
|---|---|---|
| 工具选错 | 描述不清,模型用了不该用的工具 | 优化 description,加"不适用场景" |
| 参数填错 | 日期格式 / 嵌套对象 / 边界值搞错 | 加 schema 约束 + 在工具内部二次校验 |
| 幻觉调用 | 假装调了工具其实没调(老模型多见) | 用支持 tool_calls 的新模型,严格判 finish_reason |
| 死循环 | 反复调同一工具 / 自己跟自己聊 | max_iterations 上限 |
| 上下文爆炸 | 多轮工具调用历史太长,超 context window | 历史摘要 / 工具调用结果裁剪 |
| 延迟叠加 | 每轮工具调用 = 一次模型往返,5 轮就 30 秒+ | 并行调用、流式输出 |
| 成本叠加 | 工具定义 + 历史调用 = 大量 token | 工具按需注入、对话压缩 |
上手建议
如果你想自己玩一下 function calling,推荐这条学习路径:
- 先跑通"问天气"这种 hello-world 例子(就是上面的代码),把 round-trip 流程吃透
- 再加第二个工具,看模型怎么挑工具
- 再加并行调用,看模型怎么一次调多个
- 再做个"自主循环"Agent,加个
max_iter,跑个"查天气 → 发邮件提醒"的多步任务 - 再去看 MCP,体会"工具可移植化"的价值
不建议一上来就上 LangChain / LlamaIndex 这种重框架 —— 它们把 function calling 包了好几层抽象,反而看不清底下的协议长什么样。先把原生 SDK 跑通,你就会发现 90% 的"Agent 框架"都是在原生 function calling 上加点循环和 prompt 模板而已。
一句话总结
Function Calling 是 LLM 的"USB 接口"—— 模型自己不长手,但你可以给它接上任何工具,让它学会用。
会用 Function Calling 之后,你就不再是"问 LLM 一个问题让它回答",而是**"派 LLM 去完成一个任务,中间它需要什么资源自己找"**。这是从"聊天机器人"到"Agent"的本质跨越。
下次见。
-EOF-