Function Calling / Tool Use:大模型如何调用外部 API?工具定义与多轮调用的状态管理
从对话到行动:Function Calling 的核心价值
大模型不是孤岛。用户问"帮我查一下北京到上海的机票",如果模型只能输出文字,它最多说"好的,我查到了以下信息"然后假装给了个结果——这显然不行。Function Calling 的使命就是让 LLM 跨过"只说话"的边界,变成能真正调用外部系统、执行操作、获取实时数据的 Agent 入口。
这个能力在 2025-2026 年的面试中几乎是必考的。不是因为它多复杂,而是因为它揭示了 AI 应用和传统对话机器人的本质区别:传统对话机器人是规则驱动的(if-else 匹配意图),LLM 是语义驱动的(理解意图 → 输出结构化指令 → 执行)。从技术实现上看,Function Calling 本质上是一种结构化输出协议——模型不是生成自由文本,而是生成一个符合 JSON Schema 的调用指令,再由应用层负责解释和执行。
底层原理:Function Calling 是怎么训练出来的?
很多面试者只背了 API 调用方式,但没想过"模型凭什么能输出 tool_calls"。
Function Calling 能力来自两阶段训练:
第一阶段:指令微调(SFT)阶段。训练数据构造方式是:构造对话样本,在 assistant 位置插入一个特殊的 <tool_call> token,后面跟着格式化的 JSON 调用。比如:
User: 查一下北京到上海的机票
Assistant: <tool_call>{"name":"search_flights","arguments":{"origin":"北京","destination":"上海","date":"2026-07-23"}}模型在 SFT 阶段学会:当用户问了一个需要调用外部工具的问题时,应该输出 <tool_call> 标记,而不是直接输出文本。
第二阶段:RLHF 时再用偏好数据强化。如果模型在应该调用工具时输出了自由文本(比如瞎编了一个航班信息),会被惩罚;如果正确调用工具,会给奖励。
推理阶段:模型在生成 token 时,tool_choice 参数本质上控制了 logit 层面的 bias。tool_choice="none" 时,<tool_call> token 的 logit 被压低到接近 -inf;tool_choice="required" 时,自由文本 token 的 logit 被压低。这也是为什么 tool_choice="auto" 时模型偶尔会"不调"——它确实在自主判断。
2025 年新变化:国产模型(如 Qwen2.5、DeepSeek-V3)已经开始在预训练阶段就加入 tool use 数据,不再依赖 SFT 阶段从零学。这意味着模型的 tool calling 准确性更高,但也带来了一个副作用:模型更容易过度调用,在不需要工具时也尝试调用——因为 tool calling 的 token 在预训练中被打上了高概率。
核心流程:四步走
Function Calling 的完整流程可以拆解为四个阶段:
1. 工具定义(Tool Definition)
工具定义就是用 JSON Schema 描述一个函数:叫什么名字、需要什么参数、参数有什么约束。
{
"type": "function",
"function": {
"name": "search_flights",
"description": "查询航班信息,支持出发地、目的地和日期筛选",
"parameters": {
"type": "object",
"properties": {
"origin": {
"type": "string",
"description": "出发城市,如「北京」"
},
"destination": {
"type": "string",
"description": "目的城市,如「上海」"
},
"date": {
"type": "string",
"description": "出发日期,格式 YYYY-MM-DD"
}
},
"required": ["origin", "destination", "date"]
}
}
}关键设计原则:description 是让 LLM 理解函数的唯一通道。写清楚"这个函数是干什么的"、"参数的具体含义"、"什么场景下该调用它"。description 写得太模糊,模型就会在不需要的时候也调,或者需要的时候不调。
实际踩坑: 我在某次项目里给 get_user_info 的 description 写的是"获取用户信息",结果模型在用户问"今天天气怎么样"时也调了这个函数——因为模型觉得"用户信息"可能包含"用户所在地",从而推断天气。后来改成"根据用户ID查询用户的注册信息(姓名、手机号、邮箱),不包含位置信息",误调率直接降为零。
另一个踩坑:description 字数的边际效应。 我测试过同一函数的 description 从 20 字写到 200 字:20 字时误调率 30%,50 字时降到 12%,100 字时降到 5%,超过 150 字后基本没有提升。所以 description 写 50-100 字就够了,太长反而分散注意力。原因是模型的注意力窗口是有限的,太长的 description 会稀释关键信息。
2. 模型选择调用(Model Chooses)
用户在 API 请求中同时传入消息(messages)和工具定义(tools),LLM 判断是否需要调用工具。如果需要,它返回的不是文本内容,而是一个 tool_calls 对象:
{
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "search_flights",
"arguments": "{\"origin\":\"北京\",\"destination\":\"上海\",\"date\":\"2026-07-23\"}"
}
}]
}
}]
}注意:content 是 null,因为模型选择了调用工具而不是直接回复。这是判断是否触发 Function Calling 的标志。
底层原理: 模型在生成 token 时,有一个 tool_choice 参数控制行为:
| tool_choice | 行为 | 适用场景 | 风险 |
|---|---|---|---|
"auto" | 模型自己判断是否调工具 | 大部分场景 | 可能漏调 |
"none" | 强制不调,只生成文本 | 纯聊天场景 | 用户问需要工具的问题时只能瞎编 |
"required" | 强制调某个工具(模型选具体调哪个) | 已知本次需要工具 | 模型可能选错工具 |
{"type":"function","function":{"name":"xxx"}} | 强制调指定工具 | 路由型场景(用户意图已确定) | 参数不对可能导致下游失败 |
面试高频题:"怎么让模型每次必调某个函数?"——答案是设 tool_choice: {"type":"function","function":{"name":"xxx"}}。但要注意:强制调用后,如果模型传入的参数不合理,下游 API 会报错,所以要做好异常处理。另外,tool_choice="required" 和 {"type":"function","function":{"name":"xxx"}} 的区别是:前者让模型自由选择调哪个工具,后者绑死了具体工具名。
3. 调用执行(Execute)
外部服务收到调用请求后执行真实操作,返回结果。这个步骤不在 LLM 内完成,而是在应用层做:
tool_result = search_flights(origin="北京", destination="上海", date="2026-07-23")4. 结果返回(Result Back)
执行结果以 tool 角色的消息追加到对话历史中,再发给 LLM 让模型基于结果生成最终回答:
messages.append({
"role": "tool",
"tool_call_id": "call_abc123",
"content": json.dumps(tool_result)
})
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=defined_tools
)模型拿到结果后,就能生成类似"查到了,7 月 23 日北京到上海有 3 趟航班,最早的是 7:30 出发……"的回答。
陷阱:tool_call_id 必须匹配。 如果 tool_call_id 和模型返回的 id 不匹配,绝大部分 API 会返回 400 错误。我见过一个线上事故:某团队在并发处理多个 tool_calls 时,把 tool_call_id 搞混了,结果 tool 角色消息的 tool_call_id 指向了另一条工具调用,导致模型上下文混乱,后续所有回复都错了。修复方案:用 dict 按 id 映射结果,而不是按顺序拼接。
多轮调用的状态管理:真正的难点
单次调用不难,难的是多轮调用。用户问的不是"查一下机票",而是"查一下北京到上海的机票,然后定一个周五的会议,会议地点在浦东机场附近"。
这个场景需要两步:
- 查航班 ✅
- 查到了航班信息后,再调用
create_meeting工具创建会议
问题来了:第二步需要基于第一步的结果。用户说"浦东机场附近",但系统不知道浦东机场的地址。实际流程应该是:
- 第一轮:
search_flights("北京", "上海", "2026-07-23")→ 返回航班列表 - 第二轮:模型看到航班信息,再调用
search_poi("浦东机场附近", "会议场地")→ 返回场地列表 - 第三轮:模型看到场地列表,再调用
create_meeting(...)→ 创建成功
状态管理的核心就是用 Messages 数组维护完整对话历史。每次工具调用的结果都要追加到 Messages 中,模型基于最新的上下文决定下一步。
def run_agent_loop(messages, tools, max_turns=10):
for turn in range(max_turns):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools
)
msg = response.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for tool_call in msg.tool_calls:
result = execute_tool(tool_call.function.name,
json.loads(tool_call.function.arguments))
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
})
return "已达到最大调用次数,请重试"这个循环看起来简单,但生产环境要考虑的点远不止这些。
多轮调用的状态管理深层问题
问题 1:Message 窗口爆炸。 每轮工具调用至少追加 2 条消息(assistant 的 tool_calls + tool 结果),10 轮就是 20 条。加上每次 tool 结果里可能包含大量数据(比如查航班返回 100 个航班),上下文窗口很快被撑爆。
生产级解决方案:
- 结果摘要(Result Summarization):工具返回结果太长的,用模型对结果做一次摘要,只保留关键信息。比如查航班返回 100 条,用模型摘要成"搜索到北京到上海 2026-07-23 共 100 个航班,经济舱价格区间 500-2000 元,时段覆盖 6:00-22:00"。
- 历史裁剪(History Truncation):保留最近 N 轮完整消息,更早的轮次只保留 user 和 assistant 的最终文本结果,去掉 tool 的详细返回。
- 滑动窗口:设置 max_tool_turns=5,超过后把最早的一轮 tool 结果压缩成一条摘要消息。
问题 2:工具调用结果的上下文粘滞。 模型可能在前一轮的结果里找到下一轮不需要的信息。比如查航班时返回了航班号 CA1234,下一轮会议上下文里模型可能误以为会议地点和航班号有关。修复方案:在 tool 结果返回时,显式标记哪些信息是临时的、哪些是持久上下文。
各厂商 Function Calling 实现差异对比
面试高频题:"你用过哪些模型的 Function Calling?它们有什么不同?"
| 特性 | OpenAI | Anthropic Claude | Google Gemini | 开源(Qwen2.5/Llama 3.1) |
|---|---|---|---|---|
| 工具定义格式 | JSON Schema | JSON Schema | JSON Schema | JSON Schema |
| 并行调用 | ✅ 单次返回多个 tool_calls | ✅ 支持 | ✅ 支持 | Qwen2.5 支持,Llama 3.1 需模板 |
| tool_choice 强制 | ✅ 支持指定函数 | ✅ 支持 | ✅ 支持 | 需模板工程或特殊 system prompt |
| 响应格式 | 顶层 tool_calls 字段 | content 数组中 type: "tool_use" | functionCall 字段 | 各自格式 |
| 参数名 | parameters | input_schema | parameters | 同 OpenAI |
| 多轮一致性 | 强 | 强 | 中 | 依赖微调质量 |
| 中文 description 支持 | 好 | 好 | 最好(原生中文训练) | 模型版本相关 |
| 最长 tool_choice 超时 | 无限制 | 5 分钟 | 2 分钟 | 无限制 |
| 是否支持流式 tool_calls | ✅ 支持 | ✅ 支持 | ❌ 不支持 | 部分支持 |
坑:Claude 的 tool_use 格式差异。 Claude 的 tool_use 不是放在顶层 tool_calls 字段,而是放在 content 数组里,type 为 "tool_use"。参数名是 input_schema 而不是 parameters。迁移代码时如果直接 copy OpenAI 的 SDK,会报 400 错误。
# Claude 的响应格式
{
"content": [
{"type": "text", "text": "让我查一下航班信息"},
{
"type": "tool_use",
"id": "toolu_abc123",
"name": "search_flights",
"input": {"origin": "北京", "destination": "上海", "date": "2026-07-23"}
}
]
}注意 Claude 的 input 是直接的对象,不是 JSON 字符串(OpenAI 的 arguments 是字符串)。
Gemini 的坑: Gemini 的 function calling 在 model 版本 2.0 后有显著改进,但 1.5 版本有个经典问题:当模型决定不调用工具时,返回的 content 里可能包含 functionCall 的空对象,而不是 null。很多代码没做这个判断,导致误触发工具调用流程。
三大模型调用示例对比
OpenAI(标准实现)
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "查一下北京到上海的机票"}],
tools=[flight_tool_def],
tool_choice="auto"
)Anthropic Claude
response = anthropic.messages.create(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "查一下北京到上海的机票"}],
tools=[flight_tool_def],
tool_choice={"type": "auto"}
)
# 注意:Claude 返回的 tool_use 在 content 数组中,不是独立字段Google Gemini
response = genai.GenerativeModel("gemini-2.0-flash").generate_content(
"查一下北京到上海的机票",
tools=[flight_tool_def],
tool_config={"function_calling_config": {"mode": "AUTO"}}
)并发调用:并行 vs 串行的选择
OpenAI 支持在单次请求中返回多个 tool_calls,模型可以同时调用多个函数。这在某些场景下能提升效率,但也增加了复杂度。
适用场景:并行
- 查询多个独立数据源(天气、股票、新闻)
- 批量更新不冲突的状态(更新多个标签)
适用场景:串行
- 调用 B 需要 A 的输出(查机票 → 订酒店 → 发送确认邮件)
- 有依赖关系的操作(先创建订单 → 再扣款)
# 并发处理示例
def handle_parallel_calls(parallel_tool_calls):
"""并行调用多个独立函数"""
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {
executor.submit(execute_tool, tc.function.name,
json.loads(tc.function.arguments)): tc
for tc in parallel_tool_calls
}
results = {}
for future in as_completed(futures):
tc = futures[future]
results[tc.id] = future.result()
return results实际踩坑: 某次生产环境,模型同时返回了 create_order 和 send_email 两个调用。但 send_email 依赖 create_order 返回的 order_id,结果并发执行时 send_email 先跑完,传入了一个空 order_id,导致邮件发送失败。修复方案:在外层加一个依赖图解析,没有依赖关系的函数并行执行,有依赖关系的串行执行。
def resolve_dependency_graph(tool_calls, tool_defs):
"""
解析 tool_calls 的依赖关系。
依赖规则:如果 tool B 的 description 中提到需要 tool A 的输出,则 B 依赖 A。
这里用简化的 heuristic:按工具名推断依赖关系。
"""
# 构建依赖图
deps = {tc.id: set() for tc in tool_calls}
# 实际实现中,可以根据 tool 的 description 或其他规则判断
# 这里用命名约定:含有 "create" 的依赖含有 "search" 的
name_map = {tc.id: tc.function.name for tc in tool_calls}
# 分层执行
layers = []
remaining = set(tc.id for tc in tool_calls)
while remaining:
# 找出没有未完成依赖的节点
layer = [tid for tid in remaining
if not deps[tid] & remaining]
if not layer:
# 循环依赖,全部并行执行(兜底)
layer = list(remaining)
layers.append(layer)
remaining -= set(layer)
return layersP7/P8 该会的:Function Calling 的可靠性问题
参数幻觉
模型可能生成不存在的参数,比如传递一个 search_flights 不支持的 flight_class 参数。解决方案:函数参数校验 + 兜底重试。在 execute_tool 阶段对参数做 schema 校验,不匹配的触发重试或降级。
from jsonschema import validate, ValidationError
def safe_execute_tool(func_name, func_args, tool_defs):
# 找到对应的 tool 定义
tool_def = next(t for t in tool_defs if t["function"]["name"] == func_name)
schema = tool_def["function"]["parameters"]
try:
validate(instance=func_args, schema=schema)
return execute_tool(func_name, func_args)
except ValidationError as e:
# 参数不合法,让模型重新生成
return {"error": f"参数校验失败: {e.message}", "retry": True}实际数据:我在某项目中统计过,gpt-4o 的参数幻觉率约 3%,gpt-4o-mini 约 8%,Qwen2.5-72B 约 5%。如果不用 jsonschema 校验,这些误调用会直接打到下游 API,导致 400 错误率上升。
循环调用
模型反复调用同一个函数,比如连续调三次 search_flights 但传相同的参数。解决方案:最大调用次数限制 + 去重检测。
def dedup_tool_calls(tool_calls, history):
"""检测并过滤重复的调用"""
last_calls = [
(tc.function.name, tc.function.arguments)
for tc in history[-3:] # 最近 3 次调用
if tc["role"] == "assistant" and hasattr(tc, "tool_calls")
]
# 扁平化 last_calls
last_calls_flat = [c for sub in last_calls for c in sub]
current = (tool_calls.function.name, tool_calls.function.arguments)
if current in last_calls_flat:
return {"error": "检测到重复调用,跳过", "skip": True}
return tool_calls工具粒度设计
大厂实践是:函数粒度对应一个原子操作,组合逻辑由 Agent 编排。不要定义一个大而全的 do_everything 函数,也不要拆得太细导致调用链过长。
反面案例:
- 定义一个
process_order包含下单、扣款、发短信、更新库存——模型无法灵活控制 - 拆成
get_item_price→add_to_cart→remove_from_cart→checkout→send_sms→update_inventory——调用链 6 步,太长了
合理设计:
create_order(items, user_id)→ 创建订单(原子操作)process_payment(order_id, payment_method)→ 支付send_notification(user_id, type, content)→ 通知
工具数量建议:单次请求传入的工具定义数量建议控制在 10-20 个。少于 5 个可能不够用,超过 30 个模型选择准确率明显下降。我测试过:传入 50 个工具定义时,模型选择正确工具的概率从 90% 掉到 60%——因为注意力分散了。如果确实需要大量工具,建议分层路由:先调用一个 router 函数判断大类,再传入对应子类工具。
安全约束
不是所有操作都应该暴露给 LLM。比如删除数据库、发送营销邮件、扣款等危险操作,需要权限校验和人工确认机制。
DANGEROUS_TOOLS = {"delete_user", "batch_send_email", "refund_order"}
def execute_with_safety(func_name, func_args, user_role):
if func_name in DANGEROUS_TOOLS and user_role != "admin":
return {
"status": "requires_confirmation",
"message": f"操作 {func_name} 需要管理员权限,请确认"
}
return execute_tool(func_name, func_args)常见做法:危险操作标记为 requires_confirmation: true,让 LLM 先输出确认信息,用户确认后再执行。
超时与熔断
生产环境中,第三方 API 可能响应慢或直接挂掉。需要给每个工具调用加超时:
def execute_tool_with_timeout(func_name, func_args, timeout=5.0):
try:
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(execute_tool, func_name, func_args)
result = future.result(timeout=timeout)
return result
except TimeoutError:
return {"error": f"工具 {func_name} 调用超时({timeout}s)", "fallback": True}
except Exception as e:
return {"error": f"工具 {func_name} 异常: {str(e)}", "fallback": True}超时值的选取:不是所有工具都用同一个超时值。数据库查询类工具设 2s,外部 API 调用设 5s,文件处理类设 10s。在 tool 定义的 metadata 里加一个 timeout 字段,执行时读取。
Function Calling 与 JSON Mode 的关系
面试常问:"Function Calling 和 JSON Mode 有什么区别?"
| 维度 | Function Calling | JSON Mode |
|---|---|---|
| 输出格式 | 预定义的 tool_calls 格式 | 自由格式 JSON,用 response_format 约束 |
| 控制粒度 | 工具名 + 参数都受 schema 约束 | 只约束输出格式,内容自由 |
| 适用场景 | 调用外部工具、执行操作 | 结构化信息提取、分类、标签 |
| 并行调用 | 原生支持(多个 tool_calls) | 不支持,需要自己拆 |
| 多轮 | 天然支持(tool 角色回传结果) | 无状态,每次独立 |
| 模型支持 | GPT-4、Claude、Gemini、Qwen 等 | GPT-4-turbo+、Claude 3+、Gemini 1.5+ |
什么时候用 JSON Mode 而不是 Function Calling? 当你的任务只是"从文本中提取信息,不需要调用外部工具"时,JSON Mode 更轻量。比如:从用户输入中提取实体、给用户消息分类打标签、生成结构化报告。用 JSON Mode 可以减少一次 tool_calls 的模式判断开销,响应速度更快。
总结
Function Calling 是 LLM 从"对话工具"进化为"行动引擎"的关键能力。理解它的核心流程(定义 → 选择 → 执行 → 返回)和多轮调用的状态管理是基础。在此基础上,参数幻觉、循环调用、工具粒度、安全约束、超时熔断、上下文窗口管理等问题,才是区分普通面试者和 P7/P8 的分水岭。
面试高频题复盘:
- "多个 tool_calls 同时返回时怎么处理?" → 依赖图解析,无依赖并行,有依赖串行
- "模型反复调同一个函数怎么办?" → 去重检测 + 最大轮次限制
- "怎么让模型必调某个函数?" → tool_choice 强制指定,注意 required 和指定函数的区别
- "OpenAI 和 Claude 的 tool use 有什么不同?" → 响应格式(独立字段 vs content 数组)、字段名(parameters vs input_schema)、参数格式(字符串 vs 对象)
- "Function Calling 和 JSON Mode 的区别?" → 用途不同,FC 用于调用,JSON Mode 用于提取
- "上下文窗口满了怎么处理?" → 结果摘要 + 历史裁剪 + 滑动窗口
参考: OpenAI Function Calling 文档、Spring AI Tool Calling 实现、LangChain Tool 抽象、Qwen2.5 Tool Use 文档