大模型工具调用:Function Calling 实战

当大模型只会「聊天」时,它更像搜索引擎;一旦具备工具调用(Function Calling)能力,它就能查天气、跑 SQL、调内部 API、写文件——真正变成能办事的 AI Agent。本文从协议原理到本地执行器,手把手实现一个可运行的工具调用链路,帮你把「会说话的模型」升级成「会干活的助手」。

一、Function Calling 到底解决了什么问题

大模型的训练语料有截止日期,也接触不到你公司的私有数据。Function Calling 的本质,是让模型在生成自然语言之外,额外输出一段结构化调用指令:告诉系统「我要调用哪个函数、参数是什么」。真正干活的是你写的代码,模型只负责「决策」与「填参」。

这带来三个关键收益:实时数据(查库存、查天气)、确定性行为(发短信、写库)、以及把不可信的生成收敛成可校验的函数入参。如果你已经在用我们之前介绍的Dify + Ollama 搭建私有助手,理解这层机制能让你把「问答机器人」升级为「任务执行体」。

二、核心协议:函数声明 + 模型回传

2.1 用 JSON Schema 声明函数

工具调用不是魔法,而是约定。你先用 JSON Schema 把「有哪些函数、每个参数长什么样」告诉模型。下面以「查天气」为例:

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"]
      }
    }
  }
]

2.2 模型回传 tool_calls

当用户问「北京今天多少度」,模型不会直接编一个数字,而是回传一个 tool_calls 结构,里面是它「想调用」的函数名与参数。注意:这一步模型并未真正执行函数,只是提出了调用意图。

# 模型返回的 tool_calls(节选)
{
  "name": "get_weather",
  "arguments": "{\"city\": \"北京\", \"unit\": \"celsius\"}"
}

拿到 intent 后,由你的执行器去真正调接口、跑函数,再把结果塞回对话,让模型基于真实数据生成最终回答。这就是「模型决策、代码执行」的分工。

三、最小可运行示例(OpenAI 兼容接口)

绝大多数国产与开源模型都兼容 OpenAI 的 tools 协议,因此同一套代码能对接云端模型与本地模型。下面是一段可运行骨架(用官方 SDK 示意):

from openai import OpenAI

client = OpenAI(base_url="https://你的网关/v1", api_key="YOUR_KEY")

messages = [{"role": "user", "content": "北京今天适合穿什么?"}]

resp = client.chat.completions.create(
    model="your-model",
    messages=messages,
    tools=tools,
    tool_choice="auto",
)

msg = resp.choices[0].message
if msg.tool_calls:
    # 1) 把模型的调用意图原样追加进上下文
    messages.append(msg)
    # 2) 本地执行函数
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        result = dispatch(call.function.name, args)
        # 3) 把执行结果作为 tool 消息回传
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(result, ensure_ascii=False),
        })
    # 4) 再请求一次,让模型基于真实结果作答
    final = client.chat.completions.create(model="your-model", messages=messages)
    print(final.choices[0].message.content)

关键在于第四步的二次请求:必须把函数执行结果以 role: "tool" 的形式回传,模型才能把「原始 JSON」翻译成「人话」。漏掉这一步是最常见的 bug——模型会假装自己已经知道答案。

四、执行器:把函数名映射到真实代码

dispatch 是工具调用落地的最后一公里。一个稳妥的写法是「白名单 + 显式映射」,绝不让模型直接拼命令,避免命令注入:

def dispatch(name: str, args: dict):
    handlers = {
        "get_weather": get_weather,
        "query_order": query_order_by_id,
        "create_ticket": create_support_ticket,
    }
    fn = handlers.get(name)
    if not fn:
        return {"error": f"未知函数: {name}"}
    try:
        return fn(**args)
    except Exception as e:
        return {"error": str(e)}

def get_weather(city: str, unit: str = "celsius"):
    # 真实场景这里调第三方天气 API
    return {"city": city, "temp": 22, "unit": unit, "desc": "晴"}

对内部系统(订单、工单、数据库),函数体里放的是你已有的服务客户端。回忆我们写过的MySQL 索引优化PostgreSQL 慢查询排查,完全可以把「按条件查库」封装成一个工具,让模型用自然语言驱动 SQL——但务必在 handler 内做参数校验与只读权限控制。

五、多函数与并行调用编排

真实 Agent 常常需要一次调用多个工具:比如「对比北京和上海天气」会触发两次 get_weather。现代模型支持在单个 tool_calls 里返回多个调用,你的执行器应并行执行再汇总,而不是串行傻等。

import concurrent.futures

with concurrent.futures.ThreadPoolExecutor() as ex:
    futures = {
        ex.submit(dispatch, c.function.name, json.loads(c.function.arguments)): c.id
        for c in msg.tool_calls
    }
    for fut in concurrent.futures.as_completed(futures):
        tid = futures[fut]
        messages.append({
            "role": "tool",
            "tool_call_id": tid,
            "content": json.dumps(fut.result(), ensure_ascii=False),
        })

并行能显著降低「多工具聚合」类任务的延迟。但要注意:若工具之间有依赖(B 的参数来自 A 的结果),就必须串行,并让模型分两轮决策。是否并行由「依赖关系」决定,不要为了快而盲目并发。

六、让 Agent 多轮自主:ReAct 循环

单次工具调用只是「一问一答」;真正的 Agent 会进入循环:思考 → 调用 → 观察结果 → 再思考,直到任务完成。把第四节的执行器包进一个 while 循环即可:

MAX_TURNS = 8
while True:
    resp = client.chat.completions.create(
        model="your-model", messages=messages, tools=tools, tool_choice="auto")
    msg = resp.choices[0].message
    if not msg.tool_calls:
        break  # 模型认为任务已完成,给出最终答复
    messages.append(msg)
    for call in msg.tool_calls:
        result = dispatch(call.function.name, json.loads(call.function.arguments))
        messages.append({"role": "tool", "tool_call_id": call.id,
                         "content": json.dumps(result, ensure_ascii=False)})
    if len(messages) > MAX_TURNS * 2:
        break  # 安全阀:防止无限循环烧 token

MAX_TURNS 是必备的安全阀:模型偶尔会陷入「反复调同一个无效工具」的死循环。给循环设上限,超限就强制收尾并提示人工介入,是生产环境的基本素养。把这套循环跑在Kubernetes里做成一个无状态服务,再配合GitHub Actions做回归测试,就能稳定对外提供 Agent 能力。

七、生产避坑清单

坑位现象根因解法
参数解析失败json.loads 抛错模型偶发输出非法 JSON用 tolerant 解析 + 把错误回传模型重试一次
假回答模型没调工具却编答案漏传 tool 结果 / tool_choice 设错确保回传 role=tool;关键场景 tool_choice 锁函数
无限循环token 暴涨不收敛工具失败未终结、依赖误判设 MAX_TURNS;失败返回结构化 error 而非异常
命令注入模型拼出危险命令动态拼接 shell / eval白名单 dispatch + 参数校验,绝不 eval

这张表覆盖了 80% 的线上事故。其中「命令注入」最致命:工具执行器永远不要 evalos.system 拼接模型输出,所有入参都走显式函数签名与类型校验。安全层面的其他加固(如最小权限账号)可参考运维实践里的Docker 网络隔离思路。

八、本地化与成本控制

工具调用会显著拉长上下文(多轮 tool 消息堆叠),云端模型按 token 计费时成本上升明显。对高频内部场景,可改用本地量化模型承接:把开源模型量化后跑在内部 GPU,通过 Ollama 暴露 OpenAI 兼容接口,代码无需改动即可切换。具体量化档位与部署步骤见GGUF + llama.cpp 调优,按显存选 Q4/Q5 即可。需要更强代码与推理能力的,也可以用我们测评过的AI 编程助手辅助生成 handler 代码,再人工 review。

九、总结

Function Calling 把大模型从「知识库」变成了「执行器」:你用 JSON Schema 声明能力,模型负责决策与填参,你的代码负责落地与兜底。记住四件事——协议就是 tools + tool_calls、二次请求回传真实结果、执行器必须白名单防注入、循环要设安全阀。掌握这套机制,你就拥有了搭建任意 AI Agent 的底层骨架。

上一篇 Git rebase 与 cherry-pick 实战踩坑
下一篇 Prometheus + Grafana 监控面板实战