Skip to content

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 描述一个函数:叫什么名字、需要什么参数、参数有什么约束。

json
{
  "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 对象:

json
{
  "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 内完成,而是在应用层做:

python
tool_result = search_flights(origin="北京", destination="上海", date="2026-07-23")

4. 结果返回(Result Back)

执行结果以 tool 角色的消息追加到对话历史中,再发给 LLM 让模型基于结果生成最终回答:

python
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 映射结果,而不是按顺序拼接。

多轮调用的状态管理:真正的难点

单次调用不难,难的是多轮调用。用户问的不是"查一下机票",而是"查一下北京到上海的机票,然后定一个周五的会议,会议地点在浦东机场附近"。

这个场景需要两步:

  1. 查航班 ✅
  2. 查到了航班信息后,再调用 create_meeting 工具创建会议

问题来了:第二步需要基于第一步的结果。用户说"浦东机场附近",但系统不知道浦东机场的地址。实际流程应该是:

  • 第一轮:search_flights("北京", "上海", "2026-07-23") → 返回航班列表
  • 第二轮:模型看到航班信息,再调用 search_poi("浦东机场附近", "会议场地") → 返回场地列表
  • 第三轮:模型看到场地列表,再调用 create_meeting(...) → 创建成功

状态管理的核心就是用 Messages 数组维护完整对话历史。每次工具调用的结果都要追加到 Messages 中,模型基于最新的上下文决定下一步。

python
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?它们有什么不同?"

特性OpenAIAnthropic ClaudeGoogle Gemini开源(Qwen2.5/Llama 3.1)
工具定义格式JSON SchemaJSON SchemaJSON SchemaJSON Schema
并行调用✅ 单次返回多个 tool_calls✅ 支持✅ 支持Qwen2.5 支持,Llama 3.1 需模板
tool_choice 强制✅ 支持指定函数✅ 支持✅ 支持需模板工程或特殊 system prompt
响应格式顶层 tool_calls 字段content 数组中 type: "tool_use"functionCall 字段各自格式
参数名parametersinput_schemaparameters同 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 错误。

python
# 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(标准实现)

python
response = openai.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "查一下北京到上海的机票"}],
    tools=[flight_tool_def],
    tool_choice="auto"
)

Anthropic Claude

python
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

python
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 的输出(查机票 → 订酒店 → 发送确认邮件)
  • 有依赖关系的操作(先创建订单 → 再扣款)
python
# 并发处理示例
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_ordersend_email 两个调用。但 send_email 依赖 create_order 返回的 order_id,结果并发执行时 send_email 先跑完,传入了一个空 order_id,导致邮件发送失败。修复方案:在外层加一个依赖图解析,没有依赖关系的函数并行执行,有依赖关系的串行执行。

python
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 layers

P7/P8 该会的:Function Calling 的可靠性问题

参数幻觉

模型可能生成不存在的参数,比如传递一个 search_flights 不支持的 flight_class 参数。解决方案:函数参数校验 + 兜底重试。在 execute_tool 阶段对参数做 schema 校验,不匹配的触发重试或降级。

python
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 但传相同的参数。解决方案:最大调用次数限制 + 去重检测

python
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_priceadd_to_cartremove_from_cartcheckoutsend_smsupdate_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。比如删除数据库、发送营销邮件、扣款等危险操作,需要权限校验和人工确认机制。

python
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 可能响应慢或直接挂掉。需要给每个工具调用加超时:

python
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 CallingJSON 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 的分水岭。

面试高频题复盘:

  1. "多个 tool_calls 同时返回时怎么处理?" → 依赖图解析,无依赖并行,有依赖串行
  2. "模型反复调同一个函数怎么办?" → 去重检测 + 最大轮次限制
  3. "怎么让模型必调某个函数?" → tool_choice 强制指定,注意 required 和指定函数的区别
  4. "OpenAI 和 Claude 的 tool use 有什么不同?" → 响应格式(独立字段 vs content 数组)、字段名(parameters vs input_schema)、参数格式(字符串 vs 对象)
  5. "Function Calling 和 JSON Mode 的区别?" → 用途不同,FC 用于调用,JSON Mode 用于提取
  6. "上下文窗口满了怎么处理?" → 结果摘要 + 历史裁剪 + 滑动窗口

参考: OpenAI Function Calling 文档、Spring AI Tool Calling 实现、LangChain Tool 抽象、Qwen2.5 Tool Use 文档

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。