当大模型只会「聊天」时,它更像搜索引擎;一旦具备工具调用(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% 的线上事故。其中「命令注入」最致命:工具执行器永远不要 eval 或 os.system 拼接模型输出,所有入参都走显式函数签名与类型校验。安全层面的其他加固(如最小权限账号)可参考运维实践里的Docker 网络隔离思路。
八、本地化与成本控制
工具调用会显著拉长上下文(多轮 tool 消息堆叠),云端模型按 token 计费时成本上升明显。对高频内部场景,可改用本地量化模型承接:把开源模型量化后跑在内部 GPU,通过 Ollama 暴露 OpenAI 兼容接口,代码无需改动即可切换。具体量化档位与部署步骤见GGUF + llama.cpp 调优,按显存选 Q4/Q5 即可。需要更强代码与推理能力的,也可以用我们测评过的AI 编程助手辅助生成 handler 代码,再人工 review。
九、总结
Function Calling 把大模型从「知识库」变成了「执行器」:你用 JSON Schema 声明能力,模型负责决策与填参,你的代码负责落地与兜底。记住四件事——协议就是 tools + tool_calls、二次请求回传真实结果、执行器必须白名单防注入、循环要设安全阀。掌握这套机制,你就拥有了搭建任意 AI Agent 的底层骨架。




