LLM 函数调用(Function Calling)工程实战:从 JSON Schema 到多轮工具编排

本文详解大语言模型函数调用(Function Calling)的完整工程链路,涵盖JSON Schema定义、工具执行校验与白名单管控、多轮工具编排循环、参数类型强校验、上下文裁剪及异步阻塞规避等核心实践。强调模型仅输出调用意图,执行权在服务端,需严格校验、超时控制与权限隔离,确保从本地验证到生产部署的可靠性。

让大模型自己学会"用工具",是很多 AI 应用的起点:问天气就调天气接口,查库存就查数据库,算个东西就直接算。现在主流模型都支持函数调用(Function Calling / Tool Use),原理不复杂——模型并不真的执行代码,它只是输出"我要调用哪个函数、传什么参数",执行权始终在你手里。但真正把一个工具挂到线上,坑比想象的多。本文用 OpenAI 风格的接口做示例,讲清楚从定义工具到多轮编排的完整链路,附可直接改的代码。

一、先理解调用链:模型只是"编剧",不是"执行者"

整个流程就四步:把工具清单和 JSON Schema 塞进请求 → 模型返回它想调用的函数名和参数 → 你在本地真正执行该函数 → 把结果以 role: "tool" 的消息回传给模型,让它基于结果继续回答。这样一个"工具 → 结果 → 回答"的回合就算走完了。

很多人第一次写就翻车,是因为把"返回 JSON"和"执行"混为一谈:模型返回的 arguments 是一段 JSON 字符串,必须先 json.loads 再校验,绝不能直接 eval。下面的代码演示第一轮请求怎么写:

from openai import OpenAI

client = OpenAI()  # base_url 可按需替换为兼容的本地服务

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "查询指定城市的实时天气情况",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "城市名,如 北京"}
            },
            "required": ["city"]
        }
    }
}]

resp = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "北京今天冷不冷?"}],
    tools=TOOLS,
)
print(resp.choices[0].message.tool_calls)

如果模型判断需要查工具,tool_calls 就是非空列表,里面带着 id、function.name 和 function.arguments。记住这个 id,接下来回传结果时靠它一一对应。

二、执行工具:校验、白名单、超时一个都不能少

模型给出的参数不一定合法,也可能"冒名"调用你没打算给它用的能力。三层防护是必须的:

防护

做什么

典型代码/配置

参数校验

对 arguments 先 json.loads 再校验字段

json.loads(...) + 缺失必填报错

函数白名单

只允许调用你显式注册的函数

if name not in FUNCS: 拒绝

超时与异常

外部接口调用要限时、失败要兜底

functools 包装 + try/except

尤其白名单:你在 tools 里放了 send_email,模型就真有可能在对话里主动帮你发邮件——这往往是用户没料到的副作用。生产环境建议把"读操作"和"写操作"分开授权,写操作默认拦截。

import json

FUNCS = {
    "get_weather": lambda args: service.query_weather(args["city"]),
}

def execute_tool(call) -> str:
    name = call.function.name
    args = json.loads(call.function.arguments or "{}")
    if name not in FUNCS:
        return json.dumps({"error": f"未知工具: {name}"})
    try:
        # 真实项目这里要加超时,比如用 concurrent.futures 限定 5s
        result = FUNCS[name](args)
        return json.dumps(result, ensure_ascii=False)
    except Exception as e:
        return json.dumps({"error": str(e)})

三、多轮循环编排:别让模型"卡死在原地"

一个对话往往要连续用好几次工具才能回答完整,比如"北京和上海温差大吗"可能触发两次天气查询。所以你的服务端要有循环:每轮把「模型回复 + 工具结果」都追加进 messages 再发回去,直到模型不再返回 tool_calls。这是最常见的套路:

def run_with_tools(user_msg: str, max_iterations: int = 5) -> str:
    messages = [{"role": "user", "content": user_msg}]
    for _ in range(max_iterations):
        resp = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            tools=TOOLS,
        )
        msg = resp.choices[0].message
        messages.append(msg.model_dump(exclude_none=True))

        if not msg.tool_calls:
            return msg.content  # 不再调用工具,直接给出最终回答

        for call in msg.tool_calls:
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,   # 必须回传,一一对应
                "content": execute_tool(call),
            })
    raise RuntimeError("工具调用次数超限,可能陷入循环")

两个必踩的坑:一是回传工具结果时 tool_call_id 写不对,接口直接 400;二是 arguments 里的 JSON 有时带多余空格或字段类型对不上,json.loads 前先想办法兜底,比如模型服务端把非法 JSON 直接重试一次,成功率能提不少。

四、把坑提前讲完:三个高频踩雷点

1. 参数类型强校验。模型有时会传 "city": 123 这种类型错位的值。别依赖"模型应该不会错",服务端要做一次类型检查,出错就回传一个人类可读的错误信息,让模型自己修正重试——这比直接抛异常体验好太多。

2. 上下文越攒越长。每轮循环 messages 都在膨胀,多工具对话很容易超 token 上限。实用的做法:工具结果只回传精简摘要(比如查询 500 行数据只回传前 20 行 + 总数),并在循环里对历史做裁剪。

3. 同步阻塞别堵死并发。如果你的服务是异步框架(FastAPI + asyncio),执行外部工具请求时记得用 asyncio.to_thread 包一层,否则一个慢接口会把整个事件循环卡住。

4. 流式输出时工具调用也要"分片拼装"。开了 stream=True 之后,tool_calls 不是一次性给全的,而是按 delta 分片返回,name 和 arguments 都可能是碎片,必须自己在服务端拼接:

tool_calls = {}
for chunk in stream:
    delta = chunk.choices[0].delta
    if not delta.tool_calls:
        continue
    for tc in delta.tool_calls:
        idx = tc.index
        tool_calls.setdefault(idx, {"id": tc.id or "", "name": "", "args": ""})
        tool_calls[idx]["name"] += tc.function.name or ""
        tool_calls[idx]["args"] += tc.function.arguments or ""
# 拼完再 json.loads(tool_calls[idx]["args"])

常见做法是:能接受"非流式等结果"的环节就别开流式,工具编排阶段关掉 stream,只在最后给用户吐最终回答时才开流式,逻辑清晰很多。

五、收尾:先本地跑通,再上生产

本文的闭环可以直接粘到本地跑:定义 2-3 个安全工具(比如天气、当前时间),先和模型对话到"多轮调用成功"再谈别的。等要上生产,再补上参数 Schema 的强校验库(如 pydantic)、链路追踪和工具的权限隔离。函数调用本身不难,难的是把"模型会编故事"这件事兜住——所有不可信的都校验,所有有副作用的都拦截,你的 Agent 才能从"演示"走到"能干活"。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值