在之前的帖子中,我们教会模型读取我们的文档。它可以搜索一堆文件并从中回答,这非常有用。
但我仍然无法问它是否会下雨,查看实时价格,甚至今天的日期是什么。
正如我们之前讨论的,基础模型本身是被冻结在时间里。它的知识停留在训练截止点,被锁在一个盒子里。没有通往外部世界的窗口。
本文将介绍这个窗口——工具调用。我们给模型一个工具,观察它如何获取实时数据,再添加第二个工具,然后探讨应用程序向模型提供其不知晓的事实的两种截然不同的方式。其中一种方式正是你曾使用的某些 AI 助手能够告诉你今天日期的原因。
所有代码都在我的 GitHub 仓库中,位于
ep07-tool-calling文件夹。三个小脚本,每个一个想法:一个工具、两个工具以及注入技巧。
模型会调用工具?
当我第一次听到“模型调用工具”时,我想象模型会伸出手去自行运行代码。
事实并非如此。
模型实际上不会运行任何东西,因为它根本做不到。它仍然只是读取提示并生成文本。
它生成的是一个结构化请求,表示“我希望调用此工具,使用这些输入”。
它只是递给你一个便条,你的代码读取该便条后运行实际的工具。然后它将结果返回给模型,以便进行后续操作——要么告诉你答案,要么调用另一个工具。
模型是决策者。你的代码是双手。
四步循环

每次都会执行以下步骤:
- 您将问题发送给模型,并附上它被允许使用的工具的描述。
- 模型决定:我能自己回答这个问题吗,还是需要一个工具?如果需要,它会回复一个结构化请求——一个小包裹,内容为
call get_weather, city is Toronto。 - 您的代码看到该请求后会运行实际的函数,即真正调用天气 API 的那个函数。
- 您将结果发送回模型。现在它会基于它本身永远无法知道的真实数据,撰写最终答案。
准备工作:描述工具
和整个系列一样,我这次依然使用 Amazon Bedrock,通过 Converse API 调用 Claude 模型。Converse API 专门为工具预留了一个位置,叫作 toolConfig。
response = bedrock.converse(
modelId=MODEL,
messages=messages,
toolConfig={"tools": [WEATHER_TOOL]},
inferenceConfig={"maxTokens": 2048},
additionalModelRequestFields=THINKING,
)
让我们从最简单的工具开始:获取天气。
向模型描述一个工具由三部分组成:名称、普通英文描述以及参数的输入模式。
WEATHER_TOOL = {
"toolSpec": {
"name": "get_weather",
"description": "Get the current weather for a single city.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "A plain city name, e.g. Toronto or Paris.",
}
},
"required": ["city"],
}
},
}
}
此描述和模式是模型用来决定何时以及如何使用此工具的唯一内容。
您的工具描述即是一个提示词,请像对待提示词一样对待它。
此外,真正执行工作的函数如下:
import requests
# Open-Meteo returns a numeric weather_code; map the ones we need to plain words.
WEATHER_CODES = {0: "clear sky", 2: "partly cloudy", 3: "overcast", 61: "light rain", 63: "moderate rain"}
def get_weather(city: str) -> dict:
geo = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1},
).json()["results"][0]
now = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": geo["latitude"],
"longitude": geo["longitude"],
"current": "temperature_2m,weather_code,wind_speed_10m",
},
).json()["current"]
return {
"city": geo["name"],
"country": geo["country"],
"temperature_c": now["temperature_2m"],
"conditions": WEATHER_CODES.get(now["weather_code"], "unknown"),
"wind_kph": now["wind_speed_10m"],
}
这是普通代码。没有 AI 介入。它调用了 Open-Meteo,这是一个不需要密钥的免费天气 API。
演示:模型调用工具
问题: "今天多伦多我需要带伞吗?"
我将其发送给模型,并附带 get_weather 的定义。
模型以 tool_use 作为 stopReason 停止,并返回一个请求:
{
"toolUse": {
"toolUseId": "tooluse_abc123",
"name": "get_weather",
"input": { "city": "Toronto" }
}
}
我既没告诉它该用哪个工具,也没告诉它参数。它读取了一个问题,自己算出了两个答案。不过目前还没有任何东西运行。
于是我的代码运行 get_weather("Toronto"),调用 API,获取真实天气情况。然后我把结果封装起来,以 toolResult 的形式发送回模型:
messages.append({
"role": "user",
"content": [{
"toolResult": {
"toolUseId": "tooluse_abc123",
"content": [{"json": {
"city": "Toronto",
"country": "Canada",
"temperature_c": 23.8,
"conditions": "overcast",
"wind_kph": 3.9,
}}],
}
}],
})
用单一工具,整个过程就是一条直线。发送、获取请求、执行、把结果发送回来、得到答案。从上到下,没有循环:
messages = [{"role": "user", "content": [{"text": QUESTION}]}]
# 1. Send the question + the tool.
response = bedrock.converse(
modelId=MODEL,
messages=messages,
toolConfig={"tools": [WEATHER_TOOL]},
)
messages.append(response["output"]["message"])
# 2. The model asks for the tool. 3. Run it. 4. Send the result back.
tool_request = next(
b["toolUse"] for b in response["output"]["message"]["content"] if "toolUse" in b
)
result = get_weather(tool_request["input"]["city"])
messages.append({
"role": "user",
"content": [{
"toolResult": {
"toolUseId": tool_request["toolUseId"],
"content": [{"json": result}],
}
}],
})
# The model writes the final answer, grounded in the real data.
final = bedrock.converse(modelId=MODEL, messages=messages, toolConfig={"tools": [WEATHER_TOOL]})
只需一个工具,一次往返。我知道确切地会发生什么,所以我可以直接写出来。
手头有真实数据后,模型写出答案:"基于多伦多当前的天气,**你现在可能不需要带伞**."
该答案在模型中根本不存在。它在一次工具调用中从冻结状态变为当前状态。
给它第二个工具
现在有些东西感觉应该是微不足道的。
问题: "今天是几号?"
没有工具调用返回。模型直白地说,它没有访问当前日期的权限。
它唯一能访问的工具是天气,所以这里没有办法得到日期。它无法回答,而我喜欢的这一点是,它不会假装知道。它只是告诉我它不知道,这与关于幻觉的帖子形成了真正的转变。
如果问题是"没有日期的工具",解决办法很明显,让我们给它一个工具。
DATETIME_TOOL = {
"toolSpec": {
"name": "get_current_datetime",
"description": "Get the current date and time.",
"inputSchema": {"json": {"type": "object", "properties": {}}},
}
}
def get_current_datetime() -> dict:
from datetime import datetime
now = datetime.now()
return {
"date": now.strftime("%Y-%m-%d"),
"day_of_week": now.strftime("%A"),
"time": now.strftime("%H:%M"),
}
无参数,无 AI,它仅仅返回今天的日期和时间。我把它添加到模型被允许使用的工具列表中。现在模型有两个工具——天气和日期。
问题: "我今天在多伦多需要带伞吗?今天是几号?"
两个请求返回,分别对应两个工具。get_weather 带有 {"city": "Toronto"},随后是 get_current_datetime 带有 {}。我的代码依次运行它们,将两个结果返回,模型使用这两个结果写出一个答案。
一句话,两个不同的需求,各自使用合适的工具。它就这样完成了路由。
有什么变化?我们现在需要一个循环
但请注意,之前我那条直线的问题。当只有一个工具时,我知道会恰好有一次往返。而现在有两个工具时,我不知道模型会选择哪一个,会调用多少次,或者在看到第一个结果后是否会再回来请求更多。于是把这四个步骤放进一个循环里。只要模型继续请求工具,就继续循环;当它写出答案而不是请求工具时,停止。
# name → the real function to run when the model asks for it.
TOOLS = {
"get_weather": get_weather,
"get_current_datetime": get_current_datetime,
}
messages = [{"role": "user", "content": [{"text": QUESTION}]}]
while True:
response = bedrock.converse(
modelId=MODEL,
messages=messages,
toolConfig={"tools": [WEATHER_TOOL, DATETIME_TOOL]},
)
assistant_message = response["output"]["message"]
messages.append(assistant_message)
# Done? The model stopped asking for tools and wrote its answer.
if response["stopReason"] != "tool_use":
answer = "".join(b["text"] for b in assistant_message["content"] if "text" in b)
break
# Otherwise: run every tool the model requested, send the results back.
tool_results = []
for block in assistant_message["content"]:
if "toolUse" not in block:
continue
request = block["toolUse"]
result = TOOLS[request["name"]](**request["input"])
tool_results.append({
"toolResult": {
"toolUseId": request["toolUseId"],
"content": [{"json": result}],
}
})
messages.append({"role": "user", "content": tool_results})
那个 while 循环就是全部区别。一种工具是我可以直接硬编码的直线。如果有多个,我就会把控制权交给模型,让它一直运行直到完成。

这一点非常非常重要,因为这是一个智能体的种子!
AI 助手是如何知道日期的?
这是我在学习时一直困惑的地方。如果原始模型不知道今天的日期,ChatGPT、Claude 或任何 AI 助手是怎么知道的?你问今天是星期几,它们会立刻回答。它们每次都在调用日期工具吗?简短答案:不。
Anthropic 实际上在他们的 发布说明 中公开了他们用于 Claude 的系统提示。他们说 Claude 的网页界面和移动应用使用 系统提示 在每次对话开始时提供最新信息,例如当前日期。
就这样。没有工具运行。这只是一段在你的消息到达之前被塞进指令的文本。模型被给予了日期作为上下文。
你可以在脚本中做完全相同的事情。移除日期工具,今天的日期以纯文本形式粘贴到系统提示中:
system_prompt = [{
"text": f"Today's date is {datetime.now():%A, %d %B %Y}."
}]
用户问“今天是几号?”它会直接回答,完全正确,而且根本不用调用任何工具。因为你已经把日期告诉它了。
工具还是注入?简洁原则

有两种方式可以让模型获得它不知道的事实:一是让它调用工具由你来运行,二是直接把上下文注入到提示中。哪种情况该用哪种方式?
- 廉价且静态,比如今天的日期?直接注入。 一行代码即可,无需工具。
- 实时且不断变化,比如天气?使用工具。 你不能直接把天气注入提示,因为需要提前知道,这失去了意义。工具会在模型请求时去获取最新数据。
还记得只有 city 字段的 schema 吗?这就是为什么我不能让它预测下周的天气——没有日期可传入。如果我想要预报,那就需要另一个工具。
硬编码问题及 MCP
我们目前已经有两个工具在工作:天气和日期。很好。但实际系统远不止这两个工具,往往有几十个——查看日历、搜索 CRM、查询数据库、发送邮件以及/或读取文件。
而我们刚才构建的这些,每一个都需要我亲手编写——编写 schema、编写函数、注册它,并在工具变更时保持描述同步。
两个工具这样还好。但如果是五个应用共五十个工具,而且随时间不断变化?那就是维护噩梦。而且构建 AI 应用的开发者们一直在为同样的工具反复编写相同的胶水代码。
这就是 MCP 要解决的问题。MCP 是 Model Context Protocol(模型上下文协议)的缩写。它是一个开放标准,由 Anthropic 首创,如今已在行业内广泛使用,用于定义 AI 应用与工具之间的通信方式。

理解 MCP 的一种简洁方式:它就像是 AI 工具的 USB-C。在 USB-C 出现之前,每个设备都有自己的线缆和接口,线缆混乱不堪。USB-C 是一种统一的插头。MCP 也是如此,只不过它用于将模型与工具和数据连接起来。
该工具位于 MCP 服务器之后,该服务器会自我描述:这里是我提供的工具,这里是每个工具的作用,这里是我需要的输入。您的应用是 MCP 客户端。它只需问一句“你有什么?”,服务器就会告诉它。工具会在运行时被发现。
如果有人为 GitHub、您的数据库或 Slack 构建 MCP 服务器,您就不需要编写集成代码。只需将您的应用指向该服务器,工具就会出现。
今天我们不构建它,那是另一个完整的话题。目前的心智模型已经足够:工具调用是单个模型使用工具的方式,而 MCP 是任何模型发现并使用工具的方式。
要点总结
如果您刚开始:工具调用是 AI 不再是封闭黑箱的方式。赋予它工具,它就能获取实时信息并采取行动,而不仅仅是空谈。关键在于:模型是大脑,您的代码是双手。
如果您更偏向构建者:模型会选择工具并填充参数,它进行调用时仅读取您的描述和 schema。因此,请像编写提示一样来编写它们,并明确说明工具能做什么和不能做什么。随后:静态事实会被注入,实时事实会使用工具。当您掌握几个工具后,请停止硬编码,转而考虑 MCP。
接下来是什么
今天模型分别调用了一次一个工具或两个工具,然后给出答案。但如果一个问题需要按正确顺序使用多个工具会怎样?先查看我的日历,再查看那天的天气,然后起草邮件。模型需要制定计划、执行、查看结果并决定下一步。如此循环反复,直至完成。
实际上,这个循环被称为代理。在下一篇文章中,我们将使用 Strands 代理 SDK 构建一个代理。
跟上。
本文是 "Learning AI Out Loud" 系列的一部分,该系列由一位云架构师从第一原则学习 AI。



