你在 MCP 客户端里已经能看到 tools/list 返回了四个工具,但把同一份 JSON Schema 发给大模型后,choices0.message.tool_calls 却是 None。别急着换模型,问题多半不在模型本身,而在 tools 参数和提示词的衔接方式。这篇文章会用一个最小 Python 脚本跑通从工具 schema 到 tool_call 返回的完整路径,再逐步加上执行回传和排错方法。
一、场景与前置条件
这篇教程解决一个很具体的问题:在 MCP 协议接入链路里,大模型明明拿到了工具定义,却不发起工具调用;或者发起了调用,客户端却不知道如何把执行结果回传。你手上需要有的东西不多:Python 3.10 以上、openai 包 1.x、一个能访问 OpenAI 兼容端点的 API Key,以及一个 MCP 客户端或你手工维护的 tools 列表。
版本限制要说清楚,这里写的不是一份可以套用到任何平台的通用 demo。示例代码全部从环境变量读取密钥,因为硬编码的 Key 很容易被提交进 Git 历史,一旦泄露只能删掉重发。大模型调用名使用 minimax-m2.5,这是模型广场里的稳定 alias,不要根据展示名自行拼接。后面的代码如果没有特殊说明,都在本地终端执行。
二、环境准备
先安装依赖。openai 包会使用我们传入的 base_url 和 api_key,不需要额外封装。
bash
pip install openai python-dotenv
密钥放环境变量的具体做法是:在项目根目录建 .env,写入密钥,变量名以脚本里的 os.environ 读取项为准。这样 .env 可以加进 .gitignore,即使仓库公开也不会漏 Key。另一个容易填错的是 Base URL,只有一个正确写法;下面脚本里已经写死,复制时不要把域名和 /api/v1 路径拆开改。填错的表现很直接:连最基础的 /chat/completions 都会 404 或连接超时。
运行脚本前先确认终端能读到这个变量。用 print(os.environ.get(...)) 看一眼不是必须的,但排错时比反复重跑更省时间。如果你是 Windows 终端,export 要换成 set;用 .env 文件就可以绕开这个差异。
三、最小可跑示例
下面这个脚本不启动 MCP server,而是把 MCP 客户端里 tools/list 返回的 tools 字段手工搬进 tools 列表。跑通后你就能确认:大模型在拿到工具描述后会不会返回 tool_calls。
python
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(
api_key=os.environ['SILVAMUX_API_KEY'],
base_url='https://www.silvamux.com/api/v1',
)
tools = [
{
'type': 'function',
'function': {
'name': 'get_weather',
'description': '查询指定城市的当前天气',
'parameters': {
'type': 'object',
'properties': {
'city': {'type': 'string', 'description': '城市名称'}
},
'required': ['city'],
},
},
}
]
resp = client.chat.completions.create(
model='minimax-m2.5',
messages=[
{'role': 'system', 'content': '你只在需要实时数据时发起工具调用。'},
{'role': 'user', 'content': '北京现在天气怎么样?'},
],
tools=tools,
tool_choice='auto',
)
msg = resp.choices[0].message
print('content:', msg.content)
print('tool_calls:', msg.tool_calls)
if msg.tool_calls:
print('function name:', msg.tool_calls[0].function.name)
print('arguments:', msg.tool_calls[0].function.arguments)
这段脚本的关键在 tools 参数。MCP 协议负责把工具、资源、提示组织成统一描述,但大模型这端要的是 OpenAI 兼容的 tools 数组格式。你在 MCP 客户端看到的是带包裹层的 JSON,不能整段塞进 messages。需要把工具名、描述和 parameters 映射过来,像上面这样以 type 为 function 的字典传给 create。
另一个容易填错的参数是 tool_choice。写成 tool_choice='auto' 表示由模型根据 system 和 user 消息判断是否需要工具。省略它不一定报错,但有些模型会直接回答而不是发起工具调用。如果你的业务逻辑要求必须调用,可以把 tool_choice 设成具体的函数名,不过示例里先保持 auto 更容易观察模型行为。
四、逐步扩展
最小示例拿到了 tool_call,但没有执行工具,也没有把结果告诉模型。下一步加一个本地函数 get_weather,并把它返回的对象作为 tool 角色消息回传。这个改动前后的差别很明显:没有回传时,对话停在调用动作;回传后,模型才会根据真实结果组织最终回答。
python
import json
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(
api_key=os.environ['SILVAMUX_API_KEY'],
base_url='https://www.silvamux.com/api/v1',
)
def get_weather(city: str):
if not city:
return {'error': 'city is required'}
return {'temperature': -2, 'condition': '晴', 'city': city}
tools = [
{
'type': 'function',
'function': {
'name': 'get_weather',
'description': '查询指定城市的当前天气',
'parameters': {
'type': 'object',
'properties': {
'city': {'type': 'string', 'description': '城市名称'}
},
'required': ['city'],
},
},
}
]
messages = [
{'role': 'system', 'content': '你只在需要实时数据时发起工具调用。'},
{'role': 'user', 'content': '北京现在天气怎么样?'},
]
first = client.chat.completions.create(
model='minimax-m2.5',
messages=messages,
tools=tools,
tool_choice='auto',
)
if not first.choices[0].message.tool_calls:
raise SystemExit('模型没有发起工具调用,先检查 tools 和 tool_choice')
tool_call = first.choices[0].message.tool_calls[0]
args = json.loads(tool_call.function.arguments or '{}')
result = get_weather(args.get('city', ''))
messages.append(first.choices[0].message)
messages.append({
'role': 'tool',
'tool_call_id': tool_call.id,
'content': json.dumps(result, ensure_ascii=False),
})
second = client.chat.completions.create(
model='minimax-m2.5',
messages=messages,
tools=tools,
)
print(second.choices[0].message.content)
关键在 messages.append(first.choices0.message) 这一行。它把首轮助手消息里的 tool_calls 原样保留;后面跟着的 tool 消息必须带同一个 tool_call_id。如果 id 写错,服务端会返回 400,提示 tool_call_id 不匹配。
执行本地函数前用 json.loads 解析 arguments,因为模型返回的是 JSON 字符串,不是 Python 字典。arguments 有时会是空串,直接 json.loads('') 会抛 JSONDecodeError,所以示例里写了 or '{}' 做兜底。这个兜底在真实业务里很重要,因为用户输入可能没有提供必填参数。
五、排错
这里列四个在 MCP 协议与大模型工具调用联调时最容易出现的报错。每个都跟参数形态有关,不是网络抖动。
1. tool_calls 永远是 None
报错文本:content 为 None,tool_calls 为 None,finish_reason 是 stop。
直接原因:模型判断不需要调用工具,通常是提示词没有给出实时数据需求,或者 tools 的 description 太模糊。
修法:把 system 消息改成"你只在需要实时数据时发起工具调用",并在工具 description 里写清楚触发条件。不要只写"查询天气"四个字。
2. JSONDecodeError
报错文本:
text
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
直接原因:tool_call.function.arguments 是空字符串,直接交给 json.loads 会解析失败。
修法:使用 json.loads(tool_call.function.arguments or '{}'),或者先判断空值再走默认参数。
3. tool_call_id 缺失
报错文本:
text
openai.BadRequestError: Error code: 400 - Missing required parameter: 'messages.3.tool_call_id'.
直接原因:tool 角色消息没有携带与上一轮 tool_call 对应的 id。
修法:从第一轮响应里的 tool_call.id 原样复制。即使只有一个工具调用,也不能省略这个字段。
4. 401 Invalid API Key
报错文本:
text
openai.AuthenticationError: Error code: 401 - Invalid API Key
直接原因:环境变量没有加载成功,或者 .env 里的 Key 前多了空格、换行。
修法:确认脚本顶部有 load_dotenv(),确认变量名与 os.environ 读取的名称完全一致。把 Key 重新粘贴成一行,避免尾随空格。
常见问题
问:MCP 协议和 OpenAI 的 tool_calls 是不是一回事?
答:不是。MCP 协议解决的是客户端怎么发现工具、资源和提示;OpenAI tool_calls 是大模型根据工具描述选择调用哪个函数并生成 JSON 参数。集成时通常要先把 MCP server 的 tools/list 结果映射成 OpenAI tools 数组,再把用户消息和这个数组一起发给大模型。
问:工具调用为什么总是不触发?
答:最常见是 tools 参数没传,或者描述过于笼统。也可能是 tool_choice 缺失时模型选择直接回答。先打印 finish_reason,如果是 stop 而不是 tool_calls,就把 system prompt 的限制条件收紧,并给工具 description 写清"何时应使用"。
问:MCP 客户端已经拿到工具列表,能不能直接塞给大模型?
答:不能。MCP 客户端返回的是协议层的数据结构,和 OpenAI 兼容端点期望的 tools 数组字段层级不同。需要去壳,只保留 name、description、parameters。映射完成后,再把用户需求作为普通 message 发出,不然会看到 400。
以上示例在千木SilvaMux的 OpenAI 兼容端点上验证,模型调用名为 minimax-m2.5。参数变更或调用名以 开发者文档上的模型广场为准,读者可用该地址查询模型调用名和错误处理说明。

