OpenAI Function Calling 逻辑说明
概述
本文件实现纯 OpenAI Function Calling(Tool Calling)模式,不依赖 LangChain,直接使用 OpenAI SDK 与 LLM 交互,支持本地工具和 MCP 远程工具。
核心流程
用户输入
↓
┌─────────────────────────────────────────────────────┐
│ for turn in range(MAX_TURNS): │
│ │
│ ① LLM 推理(携带所有历史消息) │
│ ↓ │
│ ② 检查 msg.tool_calls │
│ ↓ ↓ │
│ [有工具调用] [无工具调用] │
│ ↓ ↓ │
│ ③ 并行执行所有工具 直接返回 │
│ ↓ │
│ ④ 将 ToolMessage 填入消息历史 │
│ ↓ │
│ ⑤ 截断历史(MAX_HISTORY) │
│ ↓ │
│ ⑥ 回到 ① 继续下一轮推理 │
└─────────────────────────────────────────────────────┘
↓
最终回复
消息循环详解
消息格式(OpenAI 规范)
python
# 用户消息
{"role": "user", "content": "上海今天天气怎么样?"}
# LLM 回复(可能含 tool_calls)
{"role": "assistant", "content": "我来查询一下...", "tool_calls": [...]}
# 工具执行结果(role 必须为 "tool")
{
"role": "tool",
"tool_call_id": "abc123",
"name": "get_weather",
"content": "晴,28°C,湿度 65%"
}
循环终止条件
| 条件 | 结果 |
|---|---|
LLM 回复无 tool_calls |
返回 msg.content,正常结束 |
达到 MAX_TURNS = 10 |
抛 RuntimeError,异常结束 |
工具定义与执行
本地工具
工具通过 OpenAI Function Calling 格式定义,存入 tools 列表:
python
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询城市天气",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名称"}},
"required": ["city"]
}
}
}
执行时通过 tool_map 查找对应函数:
python
args = json.loads(call.function.arguments) # 安全解析,避免 eval
result = tool_map[call.function.name](**args)
MCP 远程工具
MCP 工具通过 Streamable HTTP 协议接入,流程:
MCP 服务器 ──→ fetch_mcp_tools() ──→ 格式转换 ──→ 合并到 tools/tool_map
│
↓
┌─────────────────────────┐
│ MCP 格式 → OpenAI 格式 │
│ name, description, │
│ parameters 原样保留 │
└─────────────────────────┘
调用时:
python
result = call_mcp_tool(tool_name, arguments) # HTTP POST 到 MCP 服务器
工具查找优先级
tool_map 查找
↓
[本地工具] 直接执行
↓ 未找到
[MCP 工具] HTTP 调用 MCP 服务器
↓ 未找到
返回 "未知工具: xxx"
安全机制
| 防护项 | 实现位置 | 说明 |
|---|---|---|
| 代码注入 | 第97行 | json.loads() 替代 eval() |
| API Key 校验 | 第10-13行 | 未设置环境变量时启动即抛异常 |
| 工具执行异常 | 第105-108行 | try/except 捕获工具内部错误 |
| 消息历史截断 | 第209-210行 | 防止内存无限增长 |
| 最大调用次数 | 第78-79行 | MAX_TURNS=10 防死循环 |
错误处理
python
# 参数解析失败(JSON 格式错误)
→ "参数解析失败: ..."
# 工具名称不存在
→ "未知工具: xxx"
# 工具执行内部异常
→ "工具执行出错: ..."
所有错误通过 ToolMessage 反馈给 LLM,由 LLM 决定如何整合错误信息。
配置参数
| 参数 | 默认值 | 说明 |
|---|---|---|
MAX_TURNS |
10 | 最大推理轮次,防死循环 |
MAX_HISTORY |
20 | 保留最近 N 条消息,防止内存溢出 |
MCP_SERVER_URL |
http://localhost:8080/mcp |
MCP 服务器地址 |
MCP_API_KEY |
环境变量 MCP_API_KEY |
MCP 认证密钥(可选) |
与 LangChain 版本对比
| 特性 | 本文件 | LangChain 版本 |
|---|---|---|
| 依赖 | 仅 openai |
langchain-core + langchain-openai |
| 代码量 | 约 220 行 | 约 170 行 |
| 工具定义 | 手动拼接 dict | @tool 装饰器 |
| 消息构建 | 手动管理 | 自动 Message 对象 |
| 并行执行 | 需手动改造 | asyncio.gather |
| MCP 集成 | 需手动实现 | 需 MCP 扩展 |
选择建议:
- 追求轻量、掌控力 → 本文件
- 追求开发效率、复杂流程 → LangChain 版本
python
"""
直接使用 OpenAI Function Calling(Tool Calling),无 LangChain 依赖
依赖: openai
"""
import json
import os
from openai import OpenAI
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY 环境变量未设置")
client = OpenAI(api_key=api_key)
# ── 1. 定义工具 ───────────────────────────────────────────────────
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询城市天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "search_web",
"description": "搜索网页获取实时信息",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"top_k": {"type": "integer", "default": 3}
},
"required": ["query"]
}
}
}
]
# ── 2. 工具执行函数 ────────────────────────────────────────────────
def get_weather(city: str) -> str:
weather_data = {
"上海": "晴,28°C,湿度 65%",
"北京": "多云,24°C,湿度 40%",
"东京": "小雨,22°C,湿度 80%",
}
return weather_data.get(city, f"未收录城市 {city} 的数据")
def search_web(query: str, top_k: int = 3) -> list[dict]:
return [
{"title": f"结果{i+1} for {query}", "url": f"https://example.com/{i}"}
for i in range(top_k)
]
tool_map = {
"get_weather": get_weather,
"search_web": search_web,
}
# ── 2.5 MCP 工具集成 ────────────────────────────────────────────────
MCP_SERVER_URL = "http://localhost:8080/mcp" # MCP 服务地址
MCP_API_KEY = os.getenv("MCP_API_KEY", "") # MCP 认证密钥(可选)
def fetch_mcp_tools() -> list[dict]:
"""从 MCP 服务器获取工具列表(Streamable HTTP 协议)
真实项目中替换为实际 HTTP 请求:
response = requests.post(
f"{MCP_SERVER_URL}/tools/list",
headers={"Authorization": f"Bearer {MCP_API_KEY}"} if MCP_API_KEY else {},
)
return response.json()["tools"]
"""
# ── Mock: 模拟 MCP 服务器返回的工具列表 ──────────────────────
return [
{
"name": "mcp_calculator",
"description": "数学计算器,支持加减乘除",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "数学表达式,如 2+3*5"}
},
"required": ["expression"]
}
},
{
"name": "mcp_currency_convert",
"description": "货币换算",
"parameters": {
"type": "object",
"properties": {
"amount": {"type": "number", "description": "金额"},
"from_currency": {"type": "string", "description": "源货币,如 USD"},
"to_currency": {"type": "string", "description": "目标货币,如 CNY"}
},
"required": ["amount", "from_currency", "to_currency"]
}
}
]
def call_mcp_tool(tool_name: str, arguments: dict) -> str:
"""调用 MCP 工具(Streamable HTTP 协议)
真实项目中替换为实际 HTTP 请求:
response = requests.post(
f"{MCP_SERVER_URL}/tools/call",
json={"name": tool_name, "arguments": arguments},
headers={"Authorization": f"Bearer {MCP_API_KEY}"} if MCP_API_KEY else {},
)
return response.json()["result"]
"""
# ── Mock: 模拟 MCP 工具执行 ────────────────────────────────
if tool_name == "mcp_calculator":
try:
return str(eval(arguments["expression"]))
except Exception:
return "表达式计算错误"
elif tool_name == "mcp_currency_convert":
rates = {"USD_CNY": 7.2, "CNY_USD": 0.14, "USD_JPY": 149.5, "JPY_USD": 0.0067}
key = f"{arguments['from_currency']}_{arguments['to_currency']}"
if key in rates:
return str(arguments["amount"] * rates[key])
return f"暂不支持 {arguments['from_currency']} → {arguments['to_currency']}"
return f"未知 MCP 工具: {tool_name}"
def load_mcp_tools():
"""从 MCP 服务器加载工具并合并到全局 tools 和 tool_map"""
mcp_tools = fetch_mcp_tools()
for tool in mcp_tools:
# 转换 MCP 格式 → OpenAI Function Calling 格式
openai_tool = {
"type": "function",
"function": {
"name": tool["name"],
"description": tool["description"],
"parameters": tool["parameters"]
}
}
tools.append(openai_tool)
# 用默认参数捕获当前值,避免闭包陷阱
tool_map[tool["name"]] = lambda name=tool["name"], args={}: call_mcp_tool(name, args)
# 启动时加载 MCP 工具(可延迟到需要时再加载)
load_mcp_tools()
# ── 3. 对话循环 ───────────────────────────────────────────────────
def run(user_query: str) -> str:
messages = [{"role": "user", "content": user_query}]
MAX_TURNS = 10
MAX_HISTORY = 20
for _ in range(MAX_TURNS):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = response.choices[0].message
messages.append({"role": msg.role, "content": msg.content or ""})
if not msg.tool_calls:
return msg.content
for call in msg.tool_calls:
try:
args = json.loads(call.function.arguments)
except json.JSONDecodeError as e:
result = f"参数解析失败: {e}"
else:
tool_name = call.function.name
if tool_name not in tool_map:
result = f"未知工具: {tool_name}"
else:
try:
result = tool_map[tool_name](**args)
except Exception as e:
result = f"工具执行出错: {e}"
messages.append({
"role": "tool",
"tool_call_id": call.id,
"name": call.function.name,
"content": str(result),
})
# 每轮结束后截断历史
if len(messages) > MAX_HISTORY:
messages = messages[-MAX_HISTORY:]
raise RuntimeError("工具调用超过最大次数限制,可能陷入死循环")
if __name__ == "__main__":
print(run("上海今天天气怎么样?顺便搜一下上海旅游攻略"))