提到 ToolCall(工具调用)或 FunctionCall(函数调用),很多人会觉得是 LLM 的"高级黑科技"------仿佛模型能直接操作外部程序、调用 API。但真相是:它没有任何魔法,就是"提示词引导 + 结构化生成 + 后端解析"的组合拳。
这篇文章会剥掉所有框架封装,从最底层讲清实现逻辑,最后用 50 行代码写一个极简版 ToolCall,让你彻底明白:原来 LLM 调用工具这么简单。
一、核心真相:ToolCall 本质是什么?
一句话概括:
ToolCall 是让 LLM 放弃"自然语言回答",转而输出「固定格式的工具调用指令」,再由后端解析指令并执行真实工具,最后把结果回传给 LLM 生成最终回答的闭环。
拆解成 3 个关键环节:
- 告诉模型"有什么工具":通过提示词传递工具名称、功能、参数结构;
- 让模型"输出规范指令":LLM 按约定格式(如 JSON)输出"调用哪个工具、传什么参数";
- 后端"执行并回传结果":解析指令→调用工具→把结果塞回对话上下文,让 LLM 继续处理。
本质上,这就是「提示工程 + 结构化生成」的应用------模型没有任何"调用能力",只是个"格式正确的指令生成器"。
二、从 0 拆解实现流程(无框架版)
我们用"计算 3+5 并乘以 2"的场景,一步步拆解完整流程,不依赖 LangChain、LlamaIndex 等任何框架。
步骤 1:定义工具(真实可执行的函数)
首先,我们需要一个"真实能用的工具"------比如两个简单的数学函数:
python
# 真实的工具函数(后端实现)
def add(a: int, b: int) -> int:
"""计算两个整数的和"""
return a + b
def multiply(a: int, b: int) -> int:
"""计算两个整数的积"""
return a * b
# 工具映射表(方便后续根据指令匹配函数)
tool_map = {
"add": add,
"multiply": multiply
}
这些工具就是普通的 Python 函数,和 LLM 没有任何直接关联------LLM 永远不会"直接调用"它们,只会输出调用指令。
步骤 2:用提示词"教模型用工具"
模型不知道我们有哪些工具,也不知道该怎么输出指令,所以必须通过提示词明确告诉它:
python
system_prompt = """
你是一个数学助手,需要通过调用工具完成计算任务。
你可以使用以下工具:
1. 工具名:add
功能:计算两个整数的和
参数:a(整数)、b(整数)
2. 工具名:multiply
功能:计算两个整数的积
参数:a(整数)、b(整数)
### 输出规则:
如果需要调用工具,请严格按照以下 JSON 格式输出,不要加任何额外内容:
{
"tool_calls": [
{
"name": "工具名",
"parameters": {
"参数名1": 值1,
"参数名2": 值2
}
}
]
}
如果不需要调用工具(如直接能回答的问题),直接输出自然语言回答。
"""
这是 ToolCall 的核心:用提示词定义工具、约束输出格式。早期的 FunctionCall 本质就是这么实现的------没有任何特殊接口,全靠提示词引导。
步骤 3:让模型输出工具调用指令
用户输入问题后,我们把「系统提示词 + 用户问题」传给 LLM,模型会输出结构化的调用指令:
python
import openai
# 用户问题
user_query = "3加5等于多少,再乘以2?"
# 构造对话上下文
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query}
]
# 调用 LLM(这里用 OpenAI 示例,其他模型逻辑一致)
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=messages,
temperature=0 # 温度设为 0,保证输出格式稳定
)
# 提取模型输出的指令(JSON 字符串)
tool_calls_str = response.choices[0].message["content"]
print("模型输出的工具调用指令:")
print(tool_calls_str)
模型输出结果(严格遵循我们约定的格式):
json
{
"tool_calls": [
{
"name": "add",
"parameters": {"a": 3, "b": 5}
},
{
"name": "multiply",
"parameters": {"a": 8, "b": 2}
}
]
}
步骤 4:解析指令,执行工具
模型输出的是 JSON 字符串,我们需要解析它,找到对应的工具并执行:
python
import json
# 解析 JSON 指令
tool_calls = json.loads(tool_calls_str)["tool_calls"]
# 执行每个工具调用
tool_results = []
for call in tool_calls:
tool_name = call["name"]
tool_args = call["parameters"]
# 匹配工具函数
tool_func = tool_map.get(tool_name)
if not tool_func:
tool_results.append(f"工具 {tool_name} 不存在")
continue
# 执行工具并记录结果
result = tool_func(**tool_args)
tool_results.append(f"调用 {tool_name}({tool_args}) → 结果:{result}")
# 打印工具执行结果
print("工具执行结果:")
print("\n".join(tool_results))
执行结果:
调用 add({"a": 3, "b": 5}) → 结果:8
调用 multiply({"a": 8, "b": 2}) → 结果:16
步骤 5:把结果回传给 LLM,生成最终回答
工具执行完成后,我们需要把结果塞回对话上下文,让 LLM 整理成自然语言回答:
python
# 构造工具结果的提示词
tool_result_content = "\n".join(tool_results)
messages.append({"role": "system", "content": f"工具执行结果:{tool_result_content}"})
# 再次调用 LLM,生成最终回答
final_response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=messages,
temperature=0.7
)
# 输出最终回答
print("LLM 最终回答:")
print(final_response.choices[0].message["content"])
最终回答(自然语言整理):
首先调用 add 工具计算 3+5,结果为 8;再调用 multiply 工具计算 8×2,结果为 16。因此,3 加 5 再乘以 2 的最终结果是 16。
三、为什么后来有了"ToolCall"(而非 FunctionCall)?
早期 OpenAI 提出的 FunctionCall,本质是上面流程的"简化版"------只支持单个函数调用,格式是:
json
"function_call": {
"name": "...",
"parameters": "..."
}
但实际场景中,我们需要:
- 多工具并行调用:比如同时调用"搜索天气"和"查询日历";
- 非函数工具:比如代码解释器、知识库检索(RAG)、浏览器等,这些不是单纯的函数;
- 更灵活的格式:需要给每个调用加 ID,方便匹配结果(比如并行调用时区分哪个结果对应哪个调用)。
所以后来升级为 ToolCall,核心变化是:
- 支持多个调用(
tool_calls数组); - 增加
id和type字段(type可指定是 function、code_interpreter 等); - 格式更通用,适配各种工具类型。
比如 OpenAI 新版 ToolCall 格式:
json
{
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "add",
"parameters": {"a": 3, "b": 5}
}
}
]
}
但本质没变------还是"提示词引导 + 结构化生成 + 解析执行"。
四、框架(LangChain)是怎么封装的?
上面的流程需要手动处理"解析指令、执行工具、回传结果",LangChain 等框架做的就是"自动化这些重复工作"。比如 LangChain 的 ToolCall 封装:
- 工具标准化:用
@tool装饰器自动生成工具描述和 JSON Schema,不用手动写提示词; - 指令解析自动化:自动识别模型输出的
tool_calls字段,不用手动解析 JSON; - 执行流程自动化:通过
AgentExecutor自动循环"调用工具→回传结果",直到完成任务; - 多工具适配:支持函数、RAG、代码解释器等各种工具,统一接口。
但底层逻辑和我们上面写的 50 行代码完全一致------框架没有创造新原理,只是减少了重复工作。
五、3 个关键真相(避坑必看)
-
LLM 不会"直接调用"工具
模型只输出字符串指令,真正执行工具的是你的后端代码。
-
格式稳定性靠"提示词 + 微调"
早期 FunctionCall 纯靠提示词约束格式,后来模型通过微调优化了格式稳定性(比如不会输出多余字符)。
-
ToolCall 是 FunctionCall 的超集
FunctionCall 只能调用函数,ToolCall 可以调用任何工具(函数、搜索、RAG 等),格式更灵活。
六、极简版 ToolCall 完整代码(50 行)
把上面的流程整合,得到一个可直接运行的极简版 ToolCall:
python
import json
import openai
# 1. 定义工具
def add(a: int, b: int) -> int:
return a + b
def multiply(a: int, b: int) -> int:
return a * b
tool_map = {"add": add, "multiply": multiply}
# 2. 提示词配置
system_prompt = """
你是数学助手,可调用 add/multiply 工具,格式如下:
{
"tool_calls": [{"name": "工具名", "parameters": {"参数名": 值}}]
}
"""
# 3. 核心流程
def toolcall_demo(user_query):
# 第一步:模型生成工具调用指令
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query}
]
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo", messages=messages, temperature=0
)
tool_calls = json.loads(response.choices[0].message["content"])["tool_calls"]
# 第二步:执行工具
tool_results = []
for call in tool_calls:
func = tool_map[call["name"]]
res = func(**call["parameters"])
tool_results.append(f"{call['name']}{call['parameters']} → {res}")
# 第三步:生成最终回答
messages.append({"role": "system", "content": "\n".join(tool_results)})
final_res = openai.ChatCompletion.create(
model="gpt-3.5-turbo", messages=messages, temperature=0.7
)
return final_res.choices[0].message["content"]
# 测试
print(toolcall_demo("3加5再乘以2等于多少?"))
运行结果:
首先通过 add(a=3, b=5) 计算得到结果 8,再通过 multiply(a=8, b=2) 计算得到结果 16,因此 3 加 5 再乘以 2 的结果是 16。
总结
ToolCall/FunctionCall 不是什么高深技术,核心就是"让 LLM 按规则输出指令,后端按指令执行工具"。理解了这个逻辑,你就能轻松看透各种框架的封装,甚至自己实现一个定制化的工具调用系统。
如果需要扩展:
- 想支持并行调用?只需把工具执行部分改成异步;
- 想支持 RAG 工具?只需在
tool_map中加入 RAG 检索函数; - 想支持错误处理?只需在执行工具时加入异常捕获。
本质不变,只是在"解析指令→执行工具"的环节做扩展而已。