让大模型自己学会"用工具",是很多 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,接下来回传结果时靠它一一对应。
二、执行工具:校验、白名单、超时一个都不能少
模型给出的参数不一定合法,也可能"冒名"调用你没打算给它用的能力。三层防护是必须的:
|
防护 |
做什么 |
典型代码/配置 |
|
参数校验 |
对 |
|
|
函数白名单 |
只允许调用你显式注册的函数 |
|
|
超时与异常 |
外部接口调用要限时、失败要兜底 |
|
尤其白名单:你在 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 才能从"演示"走到"能干活"。
本文详解大语言模型函数调用(Function Calling)的完整工程链路,涵盖JSON Schema定义、工具执行校验与白名单管控、多轮工具编排循环、参数类型强校验、上下文裁剪及异步阻塞规避等核心实践。强调模型仅输出调用意图,执行权在服务端,需严格校验、超时控制与权限隔离,确保从本地验证到生产部署的可靠性。
工程实战:从 JSON Schema 到多轮工具编排&spm=1001.2101.3001.5002&articleId=168881604&d=1&t=3&u=e9394e8f36e643b6b5d3eeec2fff92b8)
256

被折叠的 条评论
为什么被折叠?



