Function Calling - 让 LLM 具备行动能力 / Function Calling for Giving LLMs the Ability to Act
📅 创建时间:2026-05-08 🏷️ 标签:#FunctionCalling #ToolUse #Agent基础 📚 前置知识:[[00-agent-overview]]
📋 本章目标
- 理解 Function Calling 的本质和作用
- 掌握 Tool 的定义与注册方法
- 理解 Tool Call 的完整工作流程
- 能够使用 OpenAI SDK 实现简单的 Function Calling
- 理解从"文字接龙"到"工具调用"的思维转变
- 理解 API 网关的"解析层"作用,以及 LLM、网关、Agent 三者之间的关系
第0部分:先搞清数据到底是怎么流动的
在理解 Function Calling 的设计意图之前,有一个更底层的问题必须先回答:数据在网络上到底长什么样? 很多人卡在这里,因为他们只见过 SDK 封装后的 client.chat.completions.create(),不知道这行代码背后 HTTP 请求体和响应体里到底是什么。
0.1 一次普通 LLM 调用的 HTTP 请求和响应
你写的代码:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "1+1 等于几?"}]
)这行代码向 https://api.openai.com/v1/chat/completions 发送了一个 HTTP POST 请求。去掉 SDK 的封装,请求体(Request Body)是一个 JSON:
POST /v1/chat/completions
{
"model": "gpt-4o",
"messages": [
{
"role": "user",
"content": "1+1 等于几?"
}
]
}LLM 返回的响应体也是一个 JSON:
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "1+1 等于 2。"
},
"finish_reason": "stop"
}
]
}到此为止,输入和输出都是纯文本(嵌在 JSON 结构里)。LLM 看到的是 messages 数组中的文本内容,返回的也是文本内容放在 content 字段中。
0.2 带工具调用的 HTTP 请求和响应
现在加一个工具。你写的代码:
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的实时天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"}
},
"required": ["city"]
}
}
}]
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "北京今天多少度?"}],
tools=tools
)实际发出的 HTTP 请求体——tools 就是请求 JSON 里的一个额外字段:
POST /v1/chat/completions
{
"model": "gpt-4o",
"messages": [
{
"role": "user",
"content": "北京今天多少度?"
}
],
"tools": [ ← 就多了这个字段
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的实时天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"}
},
"required": ["city"]
}
}
}
]
}LLM 返回的响应体:
{
"id": "chatcmpl-xxx",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null, ← 没有文本内容!
"tool_calls": [ ← 替代 content 的是这个
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
}
]
},
"finish_reason": "tool_calls" ← 不是 "stop",是 "tool_calls"
}
]
}关键点:响应体始终是 JSON。区别在于 finish_reason:
"stop"→ LLM 认为对话结束了,看message.content取文本"tool_calls"→ LLM 想调工具,看message.tool_calls取调用信息
0.3 那 LLM 到底"看到"了什么?
这个问题很关键。LLM 本身不直接处理 JSON——它只处理 token 序列。API 服务端在把请求发给模型之前,会把整个 JSON 请求体序列化为模型能理解的 token 格式。大致过程是:
你的 JSON 请求
↓
API 服务端序列化为 token 序列(概念示意):
[system] 你是一个助手
[user] 北京今天多少度?
[tools] 你可以使用以下函数:
1. get_weather(city: string) — 获取指定城市的实时天气
[assistant] ← 等待模型生成tools 定义被 API 服务端转换成了一段自然语言风格的描述(在训练时模型就是这样学的),拼接在对话上下文中。模型看到这段描述后,知道"如果需要查天气,我可以输出一个特殊标记来请求调用 get_weather"。
所以你说的没错——传给 LLM 的本质上就是一段文字。tools 的 JSON 定义被 API 网关序列化成文字描述混入了 prompt。但这个过程对你透明——你只需要在请求 JSON 里加 tools 字段就行。
0.4 模型输出的也是文字——怎么从文字里知道要调工具?
模型输出的也是 token 序列(本质上是文字)。关键问题是:API 怎么知道模型"想调工具"而不是"想回答文本"?
答案是:模型在训练阶段被教会了一种特殊输出格式。当它判断需要调工具时,它输出的 token 序列包含特殊的分隔标记:
模型原始输出(token 层面,概念示意):
<|tool_calls_begin|>
get_weather
{"city": "北京"}
<|tool_calls_end|>API 服务端的网关层实时监听模型输出的 token 流。当它检测到 <|tool_calls_begin|> 这个特殊标记时,就切到"工具调用解析模式"——把后续的 token 按结构化格式解析,填到响应 JSON 的 tool_calls 字段里,同时把 finish_reason 设为 "tool_calls"。如果模型没有输出这些特殊标记,网关就把所有 token 正常放进 content 字段,finish_reason 设为 "stop"。
所以:模型输入和输出在底层始终是文字(token 序列)。JSON 是 API 服务端在模型之外包的一层外壳——输入端把 tools JSON 转成文字拼进 prompt,输出端把特殊 token 模式解析成 tool_calls JSON。这就是为什么用本地模型(Ollama 等)跑 tool-use 时经常需要自己写解析层——因为你拿到的直接是模型的原始 token 输出,没有网关帮你做这一步转换。
0.5 所以"Agent 循环"到底是什么?
现在你知道了:
- 每次 API 调用 = 一个 HTTP 请求(JSON in, JSON out)
- 如果
finish_reason == "tool_calls"→ LLM 想调工具,你需要执行工具并把结果发回去 - 如果
finish_reason == "stop"→ LLM 给出最终回答,结束
Agent 循环就是:
while True:
response = POST /v1/chat/completions (把 messages + tools 发过去)
if response.finish_reason == "stop":
return response.message.content ← 结束了
if response.finish_reason == "tool_calls":
执行工具(response.tool_calls)
把执行结果追加到 messages 里
继续循环 ← 下一轮 POST 时 messages 里多了工具结果每次 POST 都是独立的 HTTP 请求。LLM 不"记住"之前的对话——你的程序通过把完整的 messages 数组每次都发回去来维持上下文。这就是为什么 messages 数组越来越长。
带着这个数据流模型,下面进入具体实现。
第1部分:为什么需要 Function Calling?
1.1 LLM 的本质困境
回顾 LLM 的工作方式:
┌─────────────────────────────────────────────────────────────┐
│ 传统 LLM 调用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 输入:文字(如"北京今天的天气怎么样?") │
│ ↓ │
│ LLM(语言模型) │
│ ↓ │
│ 输出:文字(如"北京今天晴,温度 25 度...") │
│ │
│ 关键点:LLM 只做一件事——根据输入生成输出文字 │
│ │
└─────────────────────────────────────────────────────────────┘问题来了:
┌─────────────────────────────────────────────────────────────┐
│ LLM 无法做到的事情 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ 查询实时信息 → 模型知识有截止日期,不知道今天发生了什么 │
│ ❌ 执行操作 → 只能"说",不能"做" │
│ ❌ 访问私有数据 → 没有权限访问你的数据库、文件、API │
│ ❌ 进行精确计算 → 1+1=2 可能对,但 12345×67890 大概率错 │
│ ❌ 调用外部服务 → 无法连接第三方 API │
│ │
└─────────────────────────────────────────────────────────────┘Function Calling 的解决思路:
┌─────────────────────────────────────────────────────────────┐
│ Function Calling 解决思路 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 当 LLM "想" 做上述事情时: │
│ │
│ 1. LLM 不是自己硬猜答案 │
│ 2. 而是"告诉程序":我需要调用某个函数 │
│ 3. 程序执行函数,获取真实结果 │
│ 4. 把结果返回给 LLM │
│ 5. LLM 整合结果,给出最终回答 │
│ │
└─────────────────────────────────────────────────────────────┘1.2 什么是 Function Calling?
Function Calling = LLM 生成"调用指令",程序执行"真实操作"
┌─────────────────────────────────────────────────────────────┐
│ Function Calling 示意图 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户:"北京今天多少度?" │
│ ↓ │
│ LLM "看到"这个问题 │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ LLM 判断:我需要查询天气 │ │
│ │ 生成调用指令: │ │
│ │ { │ │
│ │ "name": "get_weather", │ │
│ │ "arguments": {"city": "北京"} │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 程序执行 get_weather("北京") → 返回 25°C │
│ ↓ │
│ LLM 整合结果 → "北京今天气温 25°C,晴朗。" │
│ │
└─────────────────────────────────────────────────────────────┘第2部分:Function Calling 的工作流程
2.1 完整工作流程
Step 1:定义工具(Define Tools)
程序员告诉 LLM:"你有这些工具可以用"
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的实时天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如'北京'"
}
},
"required": ["city"]
}
}
}
]Step 2:发送请求(Send Request)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "北京今天多少度?"}],
tools=tools, # 把工具列表传给 LLM
tool_choice="auto" # 让 LLM 决定是否调用工具
)Step 3:解析响应(Parse Response)
tool_call = response.choices[0].message.tool_calls[0]
function_name = tool_call.function.name # "get_weather"
function_args = tool_call.function.arguments # {"city": "北京"}Step 4:执行函数(Execute Function)
# 根据函数名执行对应的真实逻辑
if function_name == "get_weather":
result = fetch_weather_from_api(function_args["city"])
# result = {"temp": 25, "condition": "晴"}Step 5:返回结果(Return Result)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result) # 把函数结果转成 JSON 字符串
})
# 再次调用 LLM,整合结果,生成最终回答
final_response = client.chat.completions.create(
model="gpt-4o",
messages=messages
)2.1.1 JSON 里的 name 怎么关联到真实的 Python 函数?
这里有一个容易被忽略的细节:JSON 里 "name": "get_weather" 只是一个字符串——它不会自动调用你的 Python 函数。你需要自己维护一个名字到函数的映射表:
# 工具的实际实现就是普通 Python 函数
def get_weather(city: str) -> dict:
resp = requests.get(f"https://api.weather.com?city={city}")
return resp.json()
def send_email(to: str, subject: str, body: str) -> dict:
# SMTP 逻辑
return {"status": "sent"}
# 手动映射:把 JSON 里的 "name" 字符串和真实函数关联
AVAILABLE_FUNCTIONS = {
"get_weather": get_weather, # key 必须和 tools JSON 里的 name 一致
"send_email": send_email,
}
# 当 LLM 返回 tool_calls: [{"function": {"name": "get_weather", "arguments": "{\"city\": \"北京\"}"}}]
# Agent 做的事:
func = AVAILABLE_FUNCTIONS["get_weather"] # 通过字符串名查表找到函数
args = json.loads(tool_call.function.arguments) # 把 LLM 给的 JSON 字符串解析为 dict
result = func(**args) # 调用函数,传入 LLM 给的参数所以 LLM 从始至终没有"调用"任何东西。 它只是输出了一段文本(这段文本恰好是 {"name": "get_weather", "arguments": "{\"city\": \"北京\"}"}),你的程序读到这段文本后,用 name 的值去 AVAILABLE_FUNCTIONS 字典里找到对应的真实函数,然后调用它。函数名和参数不是程序员写死在代码里的,而是 LLM 在运行时决定的——这就是 Function Calling 的全部实质。
2.2 Tool Call 的五种状态
┌─────────────────────────────────────────────────────────────┐
│ Tool Call 状态机 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 状态1:不需要工具 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户:"1+1 等于几?" │ │
│ │ LLM:直接回答"2"(不需要查天气) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 状态2:调用单个工具 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户:"北京今天多少度?" │ │
│ │ LLM:调用 get_weather("北京") │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 状态3:调用多个工具(并行) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户:"北京、上海、广州今天分别多少度?" │ │
│ │ LLM:同时调用 get_weather 三次(并行) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 状态4:多次调用(链式) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户:"帮我查天气然后发邮件" │ │
│ │ LLM:先查天气 → 把结果放入上下文 → 决定发邮件 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 状态5:拒绝调用 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户:"帮我删掉公司所有数据" │ │
│ │ LLM:拒绝调用(危险操作) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第3部分:Function Calling 实战
3.1 完整示例:天气 + 邮件 Agent
from openai import OpenAI
import json
client = OpenAI()
# Step 1: 定义工具
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "send_email",
"description": "发送邮件",
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "收件人邮箱"},
"subject": {"type": "string", "description": "邮件主题"},
"body": {"type": "string", "description": "邮件正文"}
},
"required": ["to", "subject", "body"]
}
}
}
]
# Step 2: 初始化消息
messages = [
{
"role": "system",
"content": "你是一个智能助手。你有天气查询和邮件发送的能力。"
},
{
"role": "user",
"content": "北京今天的天气怎么样?顺便帮我发封邮件给 zhang@example.com,主题是'今日天气',内容告诉他北京今天25度,晴天。"
}
]
# Step 3: 第一次调用
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto"
)
assistant_message = response.choices[0].message
messages.append(assistant_message)
# Step 4: 处理工具调用(可能有多个)
while assistant_message.tool_calls:
for tool_call in assistant_message.tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
# 模拟执行函数
if function_name == "get_weather":
result = {"temp": 25, "condition": "晴天", "humidity": 40}
elif function_name == "send_email":
result = {"status": "sent", "message_id": "12345"}
# 把结果返回给 LLM
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
})
# 再次调用 LLM
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools
)
assistant_message = response.choices[0].message
messages.append(assistant_message)
# 最终输出
print(assistant_message.content)3.2 工具定义的要点
┌─────────────────────────────────────────────────────────────┐
│ 好的 Tool 定义的关键 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 名称(name)要清晰 │
│ ❌ get_w → ✅ get_weather │
│ ❌ query → ✅ search_database │
│ │
│ 2. 描述(description)是给 LLM 看的,决定调用时机 │
│ ❌ "查询函数" │
│ ✅ "用于查询实时天气信息,输入城市名,返回温度和天气状况" │
│ │
│ 3. 参数(parameters)要完整且类型明确 │
│ • required:必填参数 │
│ • description:每个参数的用途(帮助 LLM 正确填参) │
│ │
│ 4. 不要暴露过于底层或复杂的参数 │
│ LLM 不是程序员,尽量用业务语言描述 │
│ │
└─────────────────────────────────────────────────────────────┘第4部分:强制调用 vs 自动选择
4.1 tool_choice 的三种模式
# 模式1:auto(默认)—— LLM 自己决定是否调用
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto"
)
# 模式2:none —— 强制不调用工具,LLM 直接回答
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="none" # 强制 LLM 直接回答
)
# 模式3:强制调用指定工具 —— 适合必须用工具的场景
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice={"type": "function", "function": {"name": "get_weather"}}
)4.2 何时用哪种模式?
┌─────────────────────────────────────────────────────────────┐
│ tool_choice 选择指南 │
├─────────────────────────────────────────────────────────────┤
│ │
│ auto(自动): │
│ • 通用场景,LLM 自主决定 │
│ • 节省不必要的 API 调用 │
│ • 推荐作为默认选择 │
│ │
│ none(强制不调用): │
│ • 简单问答不需要工具时 │
│ • 调试时隔离工具影响 │
│ │
│ 强制指定工具: │
│ • 工作流固定、必须先查再处理的场景 │
│ • 例如:Agent 必须先搜索,才能总结 │
│ │
└─────────────────────────────────────────────────────────────┘第5部分:Function Calling 的局限性与注意事项
5.1 常见问题
问题1:LLM 乱填参数
┌─────────────────────────────────────────────────────────────┐
│ 用户问:"给我查一下天气" │
│ LLM 可能调用 get_weather(city=null) 或 city="地球" │
│ │
│ 解决: │
│ • 增强 description,明确要求必填城市参数 │
│ • 使用 enum 限制可选值 │
│ • 在代码中校验参数,不合法则返回错误让 LLM 重试 │
└─────────────────────────────────────────────────────────────┘
问题2:LLM 该用工具时不用
┌─────────────────────────────────────────────────────────────┐
│ 用户:"帮我查今天北京最高气温,然后发个朋友圈" │
│ LLM 可能直接回答了最高气温,忘记调用发朋友圈的工具 │
│ │
│ 解决: │
│ • 在 System Prompt 中明确:"用户请求涉及工具时,必须调用" │
│ • 分解任务,每个工具单独一次调用 │
└─────────────────────────────────────────────────────────────┘
问题3:工具调用死循环
┌─────────────────────────────────────────────────────────────┐
│ LLM 不断调用工具 → 返回结果 → 又调用 → 永远不停 │
│ │
│ 解决: │
│ • 设置最大调用次数(如 max_calls=10) │
│ • 在 System Prompt 中明确:"调用工具后,根据结果直接回答" │
└─────────────────────────────────────────────────────────────┘5.2 安全注意事项
┌─────────────────────────────────────────────────────────────┐
│ Tool 安全 Checklist │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 永远不要在 Tool 定义中包含敏感凭证 │
│ ❌ {"api_key": "sk-xxx"} │
│ ✅ 在服务器端存储,工具只接收业务参数 │
│ │
│ 2. 所有工具调用都要在服务端验证和执行 │
│ • LLM 生成的调用指令只是"建议" │
│ • 服务端必须校验参数合法性 │
│ • 敏感操作需要二次确认 │
│ │
│ 3. 限制工具的能力边界 │
│ • 只暴露必要的操作 │
│ • 避免暴露删除、清空等高危操作 │
│ │
└─────────────────────────────────────────────────────────────┘第6部分:深入理解——API 网关与三层架构
6.1 一个容易误解的关键问题
前面讲到 LLM 返回的响应里有 content 和 tool_calls 两个字段:
API 响应
├── message
│ ├── content: "这是说给用户听的文本"
│ └── tool_calls: [{name: "get_weather", ...}]这个结构化的响应是 LLM 的原始输出吗?答案是否定的。
LLM 在最底层做的事从来没变过:输入 token 序列 → 预测下一个 token → 输出 → 循环。它输出的永远只有一个东西:token 流,不是结构化 JSON。
6.2 谁做了"解析"这件事?
答案是 API 服务端的网关层。它在 LLM 和你的代码之间,做了一步关键转换:
LLM 原始输出的 token 流(概念示意):
"...用户需要天气<|tool_start|>get_weather<|arg_start|>"
"{"city":"北京"}<|tool_end|>好的,查询结果是..."
↓
API 网关层拦截并解析
↓
┌───────────────────────────────────────┐
│ 识别到 <|tool_start|>...<|tool_end|> │
│ 把它们从文本中剥离 │
│ 解析成结构化 JSON 放进 tool_calls │
│ 其余文本留在 content │
└───────────────────────────────────────┘
↓
返回给用户的最终结构:
{
"content": null,
"tool_calls": [{
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}]
}所以 LLM 本身不"知道"什么叫 content 字段、什么叫 tool_calls 字段。这些概念是 API 网关层发明出来给你的。LLM 在训练时学到的只是:当需要调用工具时,输出一种特定的 token 模式(比如特殊的标记符号),就像它学会输出句号表示句子结束一样。
这也解释了为什么你无法通过 prompt 注入来伪造一个 tool_call——即使你在对话内容里让 LLM "复述一段 tool_calls 格式的文本",那段文本只会留在 content 字段里。网关层只认 LLM 输出的特殊 token 标记,不认 content 里的字符串。
6.3 模型训练:为什么有的模型会、有的不会?
一个只做过"文本补全"训练的原始基座模型(如早期的 GPT 基座、Llama 基座),你给它一个 tools 定义的 JSON 菜单,它完全不知道这是"可以调用的工具"。它只会把菜单当成普通文本,然后接着往下编瞎话。
而 GPT-4、Claude 这些支持 Function Calling 的模型,在训练阶段被专门输入了大量这样的示例:
训练示例(简化):
输入: 用户问"北京多少度?" + tools 定义
期望输出: <|tool_start|>get_weather{"city":"北京"}<|tool_end|>
输入: 用户问"1+1等于几?" + tools 定义
期望输出: 1+1 等于 2。(不需要调工具,正常回答即可)
... 成千上万条经过微调后,模型学会了模式识别:当看到 tools 定义且用户问题超出了自身知识范围时,不要编造答案,而是输出特殊标记格式的"调用请求"。这不是模型"理解了函数"或"变聪明了",纯粹是训练出来的行为模式。
所以 Function Calling 在模型侧有一个硬性前提:模型必须被专门训练过这种输出模式。 不是所有模型都会,没训练过的模型你给它 tools 定义它也不会有反应。
6.4 完整的三层架构
搞清楚网关层的存在后,Function Calling 的完整架构就可以清晰拆成三层:
┌───────────┐ ┌────────────────┐ ┌────────────────┐
│ │ │ │ │ │
│ LLM │ ───→ │ 网关/解析层 │ ───→ │ Agent 程序 │
│ (纯模型) │ │ │ │ │
│ │ │ 解析 token 流 │ │ 读 tool_calls │
│ 输出 token │ │ 分离 content │ │ 执行真实函数 │
│ 流 │ │ 与 tool_calls │ │ 把结果返回 LLM │
│ │ │ │ │ │
└───────────┘ └────────────────┘ └────────────────┘| 层级 | 由谁提供 | 干什么 |
|---|---|---|
| LLM | 模型文件 | 根据输入预测并输出 token 流 |
| 网关/解析层 | API 厂商 或 客户端软件 | 把 token 流解析为 content + tool_calls |
| Agent 程序 | 你自己写的代码 | 读 tool_calls → 匹配函数 → 执行 → 反馈结果给 LLM |
网关层可以叫它"Agent 的前处理层"——它不执行工具,但负责把 LLM 的原始输出翻译成 Agent 程序能消费的格式。
6.5 不同部署场景下的对应关系
理解了三层架构之后,再看不同的实际场景,每层的位置就清楚了:
场景 A:使用 OpenAI / 硅基流动等云端 API
你的电脑 云端服务器
┌──────────────┐ ┌─────────────────────────┐
│ Agent 程序 │ ←──│ API 网关 + LLM │
│ (只有执行层) │ │ (厂商一体提供) │
└──────────────┘ └─────────────────────────┘你拿到的 response 已经是处理好的,content 和 tool_calls 干干净净。网关层对你不可见。
场景 B:用 Ollama 跑本地模型 + 自己写代码调用
你的电脑
┌──────────────────────────────────────┐
│ LLM (Ollama/本地) │
│ ↓ 原始 token 流 │
│ 你的解析代码 ← 你得自己写这层! │
│ ↓ │
│ Agent 执行层 │
└──────────────────────────────────────┘原生 Ollama 或 llama.cpp 输出的就是 token 流(或纯文本),没有 tool_calls 字段。你需要自己写解析逻辑:用正则或 json.loads() 从 content 里把工具调用的 JSON 扒出来。开源 Agent 框架(LangChain、CrewAI 对接 Ollama 时)内部都带了一层这样的解析器。
场景 C:本地模型 + CherryStudio / Chatbox 等对话客户端
你的电脑
┌──────────────────────────────────────┐
│ LLM (Ollama/本地) │
│ ↓ 原始 token 流 │
│ CherryStudio 内置解析层 ← 客户端自带 │
│ ↓ │
│ 你看到干净的 UI 和结构化响应 │
└──────────────────────────────────────┘同一个本地模型文件,挂在 CherryStudio 下面时你看到的是干净的聊天界面和工具调用,因为 CherryStudio 充当了网关层。但它并不是有魔法——底层 LLM 还是那个 LLM,如果模型本身没有经过 tool-use 训练,客户端解析得再漂亮也解析不出 tool_calls。
关键认知:同一个模型文件,它的能力完全没变。变的是你获取它输出时,中间有没有那一层解析器,以及那个解析器有多聪明。
6.6 工具函数到底是什么样的?
理解了三层架构,工具函数本身的形态就很简单了。它不是 exe、不是独立进程、不需要继承基类,就是项目中一个普通的函数:
# 这就是一个"工具"——普通函数,接受参数,返回结果
def get_weather(city: str) -> dict:
"""查询天气。真实项目里这里调 API"""
import requests
resp = requests.get(f"https://api.weather.com?city={city}")
return resp.json()
def send_email(to: str, subject: str, body: str) -> dict:
"""发送邮件。真实项目里这里调 SMTP"""
import smtplib
# ... SMTP 逻辑 ...
return {"status": "sent"}
# Agent 做的事:按 LLM 给的名字找到函数,调用它
TOOL_MAP = {
"get_weather": get_weather,
"send_email": send_email,
}
# 当 LLM 返回 tool_calls: [{name: "get_weather", args: {city: "北京"}}]
# Agent 执行的就是这一行:
result = TOOL_MAP["get_weather"](city="北京")它和普通函数的唯一区别是:函数名和参数不是程序员写死在代码里的,而是 LLM 在运行时决定的。 这就是 Function Calling 的全部实质。
核心总结
总结1:Function Calling 的本质
Function Calling = 让 LLM 多了"行动能力"
• LLM 不再只是"说话"
• 它可以"决定"调用什么工具
• 程序负责"执行"工具
• 结果返回 LLM,最终整合回答总结2:工作流程
定义工具 → 发送请求(含工具列表) → LLM 判断是否调用
→ 程序执行 → 结果返回 → LLM 整合 → 最终回答总结3:三层架构
LLM(输出 token 流)
→ 网关/解析层(把 token 流转成 content + tool_calls)
→ Agent 程序(读 tool_calls,执行真实函数,返回结果)- 厂商 API(OpenAI/硅基流动):网关层在云端,对开发者透明
- 本地模型 + 自己写代码:网关层需要自己实现
- 本地模型 + 客户端(CherryStudio):客户端充当网关层
- 工具函数就是普通 Python 函数,函数名和参数由 LLM 运行时决定
总结4:tool_choice 三模式
| 模式 | 适用场景 |
|---|---|
| auto | 默认选项,LLM 自主决定 |
| none | 强制不用工具,直接回答 |
| 强制指定 | 工作流固定,必须用某工具 |
章节测试
测试1:概念理解
Function Calling 的核心作用是什么?
测试2:工作流程
请描述 Function Calling 的完整工作流程(5个步骤)。
测试3:参数设置
在定义 Tool 时,required 字段的作用是什么?
测试4:tool_choice
什么场景下应该使用 tool_choice="none"?
测试5:安全问题
以下哪个是 Function Calling 中的安全风险? A. LLM 生成的参数包含非法值 B. Tool 定义放在消息数组中传给 LLM C. 使用 tool_choice="auto" D. 同时调用多个工具
测试6:架构理解
API 返回的 content 和 tool_calls 字段是谁生成的? A. LLM 直接输出的结构化 JSON B. API 网关层解析 token 流后分离出来的 C. Agent 程序自己从 content 里提取的 D. 用户在 tools 定义中指定的
测试7:部署场景
用 Ollama 跑本地模型 + 自己写 Python 代码调用,与用 OpenAI API 相比,最大的区别是什么? A. 本地模型运行速度更慢 B. 本地模型不支持工具调用 C. 需要自己实现网关/解析层,从原始输出中提取工具调用信息 D. 本地模型无法联网
参考答案
测试1答案
答案:让 LLM 从"只能说话"变成"能够行动",通过调用外部工具获取实时信息、执行操作、访问私有数据。
测试2答案
答案:
- 定义工具(Describe Tools):告诉 LLM 有哪些可用工具
- 发送请求(Send Request):把工具列表和用户问题一起发给 LLM
- 解析响应(Parse Response):从 LLM 响应中提取函数名和参数
- 执行函数(Execute Function):在程序中执行对应的真实逻辑
- 返回结果(Return Result):把执行结果发回 LLM,整合生成最终回答
测试3答案
答案:required 字段声明哪些参数是必填的。如果 LLM 没有提供 required 参数,程序应该报错并让 LLM 重试。
测试4答案
答案:当你想强制 LLM 直接回答而不使用任何工具时。例如:调试时隔离工具影响,或确定问题不需要工具(如常识问答)。
测试5答案
答案:A(LLM 生成的参数包含非法值)
解析:
A. ✅ 风险:LLM 可能生成 null、错误格式、或超出范围的参数值
必须服务端校验
B. ❌ 不是风险:Tool 定义放在 messages 中传给 LLM 是标准做法
敏感凭证不应该放在 Tool 定义中
C. ❌ 不是风险:tool_choice="auto" 是安全的默认选项
D. ❌ 不是风险:并行调用多个工具是 Function Calling 的正常能力测试6答案
答案:B(API 网关层解析 token 流后分离出来的)
解析:
LLM 的原始输出只是 token 流,不是结构化 JSON。
API 网关层识别到特殊的 token 模式后,
将其解析分离为 content 和 tool_calls 两个字段。
这就是为什么你用 Ollama 跑本地模型时需要自己写解析层,
而用 OpenAI API 时拿到的已经是干干净净的结构化数据。测试7答案
答案:C(需要自己实现网关/解析层,从原始输出中提取工具调用信息)
解析:
本地模型和云端模型的能力相同(前提是模型本身支持 tool-use),
但 OpenAI API 替你做了 token 流解析这一步。
使用本地模型时,Ollama 等工具输出的是原始文本,
你需要自己解析 tool_calls,或者使用 CherryStudio 等
内置了解析层的客户端软件来充当网关层。相关笔记
- [[00-agent-overview]] - Agent 整体学习路线
- [[02-framework-evolution]] - 框架如何封装 Function Calling
- [[05-agent-workflow]] - Tool Calling 在工作流中的应用
下一步学习
- [ ] 阅读 02 - Agent 框架演进
学习状态:🟡 开始学习