Function Calling 完全指南:让 LLM 调用工具的工程实战

Function Calling 让 LLM 从"只会说话"升级到"能调工具",是当代 AI Agent 的基础设施。但它的工程细节(参数 schema、错误处理、并行调用、流式响应)远比看起来复杂。这篇文章把 Function Calling 从最基础调用讲到生产级实现,讲清楚它和 Tool Use / Plugin / Action 的关系。

Function Calling 解决什么问题

LLM 本身有几个根本能力缺陷:

  • 不知道现在的时间、天气、最新数据。
  • 不能精确计算大数(算 1234567 × 89 大概率错)。
  • 不能访问公司内部系统(数据库、CRM、ERP)。
  • 不能执行操作(发邮件、下订单)。

Function Calling 给 LLM 配一组"工具",LLM 可以决定什么时候调用、用什么参数。LLM 不直接执行,而是输出"调用意图",程序解析后执行,再把结果返回给 LLM。这把 LLM 的局限性变成了"LLM 主导决策 + 程序负责执行"的协作模式。

最简单的 Function Calling 流程

from openai import OpenAI
import json

client = OpenAI()

# 1. 定义工具(函数 schema)
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_current_time",
            "description": "获取当前时间",
            "parameters": {
                "type": "object",
                "properties": {
                    "timezone": {
                        "type": "string",
                        "description": "时区,如 Asia/Shanghai"
                    }
                },
                "required": []
            }
        }
    }
]

# 2. 用户问问题
messages = [{"role": "user", "content": "现在北京时间几点了?"}]

# 3. 第一次调用:LLM 决定调用工具
response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=tools,
)
msg = response.choices[0].message
messages.append(msg)

# 4. 程序执行工具
if msg.tool_calls:
    for call in msg.tool_calls:
        if call.function.name == "get_current_time":
            args = json.loads(call.function.arguments)
            tz = args.get("timezone", "UTC")
            from datetime import datetime
            from zoneinfo import ZoneInfo
            result = datetime.now(ZoneInfo(tz)).isoformat()

            # 把结果加入对话
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": result,
            })

# 5. 第二次调用:LLM 根据工具结果生成最终回答
final = client.chat.completions.create(model="gpt-4o", messages=messages)
print(final.choices[0].message.content)
# "现在北京时间是 2026 年 5 月 15 日 14:23"

这就是 Function Calling 的核心循环:提问 → LLM 决定调工具 → 程序执行 → 结果回喂 → LLM 总结回答

Schema 设计:决定 LLM 调用准确性的关键

函数 schema 是 LLM 唯一能看到的"工具说明书"。schema 写得好 = LLM 调得对,schema 写得差 = LLM 经常调错。原则:

1. 描述清晰

# 不好:LLM 不知道什么时候用
{
    "name": "search",
    "description": "搜索",
    "parameters": {"q": {"type": "string"}}
}

# 好:用例 + 边界都说清楚
{
    "name": "search_products",
    "description": "在商品库中搜索商品。当用户询问商品价格、库存、规格等具体信息时使用。不要用于通用知识查询。",
    "parameters": {
        "type": "object",
        "properties": {
            "keyword": {
                "type": "string",
                "description": "搜索关键词,如 '红色 T 恤'"
            },
            "max_price": {
                "type": "number",
                "description": "最高价格(单位:元)"
            },
            "in_stock": {
                "type": "boolean",
                "description": "是否只显示有货商品"
            }
        },
        "required": ["keyword"]
    }
}

2. 用枚举限制选项

"category": {
    "type": "string",
    "enum": ["electronics", "clothing", "food", "books"],
    "description": "商品类别"
}

枚举比"用 string 描述允许值"严格得多 —— LLM 输出的 schema 强约束模式下不可能产生非法值。

3. 用 required 强制必填

不必填的参数,LLM 经常会"主动猜"一个值。如果不希望它猜,要么用 required 强制(没参数就要求用户提供),要么在 description 里说"不知道时留空"。

并行 Function Calling

OpenAI / Claude 都支持一次返回多个 tool call,可以并行执行:

# 用户问:"对比 iPhone 15 和 Galaxy S24 的价格"
# LLM 返回:tool_calls = [
#   { name: "search_product", arguments: '{"keyword":"iPhone 15"}'  },
#   { name: "search_product", arguments: '{"keyword":"Galaxy S24"}' },
# ]

import asyncio

async def execute_all(tool_calls):
    tasks = [execute_one(c) for c in tool_calls]
    return await asyncio.gather(*tasks)        # 并行

results = await execute_all(msg.tool_calls)
for call, result in zip(msg.tool_calls, results):
    messages.append({
        "role": "tool",
        "tool_call_id": call.id,
        "content": json.dumps(result),
    })

并行能把 N 次串行调用的延迟从 O(N) 降到 O(1)。这对涉及多源数据聚合的场景(电商查多店、行程规划、市场调研)是关键。

错误处理

LLM 不一定每次都调对 —— 参数缺失、类型错、调用了不存在的函数。生产级处理:

def execute_tool(call):
    try:
        args = json.loads(call.function.arguments)
        if call.function.name not in TOOLS:
            return {"error": f"unknown tool: {call.function.name}"}
        result = TOOLS[call.function.name](**args)
        return {"ok": True, "result": result}
    except json.JSONDecodeError as e:
        return {"error": f"invalid JSON: {e}"}
    except TypeError as e:
        return {"error": f"argument error: {e}"}
    except Exception as e:
        return {"error": f"execution failed: {e}"}

# 把错误也喂回 LLM,让它"自我修正"
messages.append({
    "role": "tool",
    "tool_call_id": call.id,
    "content": json.dumps(execute_tool(call)),
})

# LLM 看到 error 字段,通常能改正参数再试一次

这种"错误反馈 → LLM 改正"循环让系统鲁棒得多。把错误信息写得明确(缺什么参数、类型该是什么),LLM 修正的概率会很高。

限制循环次数

"LLM 调工具 → 看结果 → 决定下一步"是循环。如果 LLM 一直认为"还不够",理论上能无限循环。必须设硬上限:

MAX_ITERATIONS = 10

iterations = 0
while iterations < MAX_ITERATIONS:
    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:
        break    # LLM 没再调工具,说明答案已经给了

    for call in msg.tool_calls:
        result = execute_tool(call)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(result),
        })

    iterations += 1

if iterations == MAX_ITERATIONS:
    print("达到最大循环,可能任务过复杂")

结构化输出 vs Function Calling

两者技术机制接近(都用 JSON Schema 约束 LLM),用途不同:

  • 结构化输出:LLM 直接生成结构化数据(提取实体、分类、打分)。不调用外部函数,LLM 自己完成任务。
  • Function Calling:LLM 决定调用哪个外部函数。外部世界做事,LLM 只负责"判断 + 调用 + 解读"。

实际项目里两者经常混用:用结构化输出抽取信息 → 用 Function Calling 调 API → 用结构化输出整理结果。

Function Calling 的进阶模式

1. Tool with Approval

危险操作(发邮件、删数据)不应自动执行,要人工确认:

if call.function.name in DANGEROUS_TOOLS:
    if not ask_user_approval(call):
        return {"error": "user denied"}
result = execute_tool(call)

2. Tool Result Caching

同一个工具+参数,缓存结果。LLM 在多轮对话里可能反复查同一信息,加缓存能省钱省时间。

3. Tool Routing(动态工具)

工具有上百个时,全塞进 prompt 会超 token 限制且 LLM 选择困难。先用 embedding 检索"和当前任务最相关的 10 个工具",再让 LLM 在这 10 个里选。

4. Hierarchical Tools

"Tool of tools" —— 一个 super tool 内部可以调多个 sub tool。让 LLM 看到的工具简化,实际执行可以非常丰富。

MCP(Model Context Protocol):工具的标准化

Anthropic 2024 年提出的 MCP 协议,让 LLM 和工具的连接标准化。任何遵循 MCP 的工具(数据库、文件系统、Slack、Notion)都能被任何遵循 MCP 的 LLM 客户端使用。它的核心思想是"工具作为可独立部署的服务",让生态可组合。Claude Desktop、Cursor、Continue 等已经原生支持 MCP。

常见坑

坑 1:函数描述模糊导致频繁误调或漏调。 "search" 描述只写"搜索",LLM 不知道用它还是用 RAG。修复:写明使用场景、不适用场景。

坑 2:LLM 编造参数。 用户没说邮箱地址,LLM 自己编一个调 send_email。修复:让 LLM 必须先问用户确认关键参数。Prompt 里强调"未知信息必须先问,不能编造"。

坑 3:Tool 返回数据太长。 一个 SQL 查询返回 10000 行,塞回 LLM 直接爆 context。修复:工具自己做摘要 / 分页,LLM 只看摘要。

坑 4:并发竞态。 并行 tool call 里两个工具都改了同一个状态。规则:有副作用的工具不应并行,只读工具才能并行。

坑 5:Token 成本失控。 每次循环都把全部历史发回 LLM,10 轮下来 token 翻 10 倍。优化:对工具结果做截断 / 摘要,只保留必要历史。

Anthropic 的 Tool Use 风格差异

Claude 的 Function Calling 在 API 上和 OpenAI 略有差异,有几个值得注意的实战点:

response = anthropic_client.messages.create(
    model="claude-sonnet-4",
    max_tokens=1024,
    tools=[{
        "name": "get_weather",
        "description": "获取城市天气",
        "input_schema": {     # 注意是 input_schema 不是 parameters
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"]
        }
    }],
    messages=[{"role": "user", "content": "北京天气怎么样?"}]
)

# 区分 stop_reason
if response.stop_reason == "tool_use":
    for block in response.content:
        if block.type == "tool_use":
            result = execute(block.name, block.input)
            # 把结果作为 tool_result 块回传
            response2 = anthropic_client.messages.create(
                model="claude-sonnet-4",
                tools=tools,
                messages=[
                    {"role": "user", "content": "北京天气怎么样?"},
                    {"role": "assistant", "content": response.content},
                    {"role": "user", "content": [{
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps(result)
                    }]}
                ]
            )

Claude 在工具调用的"推理过程"上表现一般很显式,如果开 Extended Thinking,会输出大段思考再决定调哪个工具。这对 debug 极有帮助。

本地模型的 Function Calling

不是所有模型都原生支持 Function Calling。Qwen 2.5、Llama 3.1+、DeepSeek-V2.5、Mistral Large 等支持得较好,小模型(7B 以下)效果普遍较差。本地部署时:

# vLLM 启用 tool calling
vllm serve Qwen/Qwen2.5-72B-Instruct \
    --enable-auto-tool-choice \
    --tool-call-parser hermes

# 之后用 OpenAI 兼容 API 即可
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
response = client.chat.completions.create(
    model="Qwen/Qwen2.5-72B-Instruct",
    messages=[{"role": "user", "content": "..."}],
    tools=[...],
)

本地模型选型:对工具描述的遵循度是关键指标。Qwen2.5 / DeepSeek 在中文 Function Calling 上目前最稳。

写在最后

Function Calling 是 LLM 从"聊天机器人"升级到"能完成任务的助手"的关键技术。它让 LLM 突破"只能输出文本"的限制,变成系统的"决策中枢"。所有现代 Agent 应用 —— Cursor、Claude Code、Devin、AutoGPT —— 的核心都是 Function Calling 循环。

给一个工程心得:设计工具时,把它想成"团队里的新同事的接口"。给他写明确的文档(description)、明确的输入(schema)、明确的边界(使用场景)、明确的错误反馈(error message)。LLM 调用工具的准确率,几乎和"一个新人能不能正确调用你的 API" 直接挂钩。把工具接口设计好,LLM 用起来自然就准。下一篇我们看怎么把 Function Calling 编织成完整的 Agent。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Prompt Engineering 完全指南:从 Zero-shot 到 Function Calling 的实战技巧

2026-5-15 15:54:03

技术教程

AI Agent 完全指南:从 ReAct 到 Multi-Agent 的工程架构

2026-5-15 16:01:23

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索