实操:调通第一个 API
从发请求到读 JSON,完成一次“手动版 Tool Calling”。
一句话理解:Tool Calling 并不神秘——模型决定“查什么”,程序去真正调用 API,再把返回的 JSON 交回模型组织回答;这一课你将亲手把这三步各做一遍。
前面几课讲了 HTTP、JSON、认证与运行环境。这一课把它们串起来:你会先用浏览器和命令行调通一个真实的天气 API,再用 Postman 或 Apifox 看清请求的每个部分,最后写一个不到 100 行的 Python 程序,完成一次“手动版 Tool Calling”。
这正是贯穿项目「天气 Agent」的第一步。做完之后,你再看到Function Calling 原理时,会发现它只是把你手动做的事情自动化了。
你将完成什么
四个步骤由浅入深,第 4 步把前面的内容串成一次手动版 Tool Calling:
| 准备项 | 说明 |
|---|---|
| 时间 | 约 60–90 分钟 |
| 天气 API | Open-Meteo,非商业用途无需 API Key |
| 工具 | 浏览器;Postman 或 Apifox 任选其一(第 2 步) |
| 环境 | Python 3.10 或更高版本(第 3、4 步) |
| 模型 API Key | 第 4 步需要,本文示例使用 Anthropic API;使用其他厂商时替换调用部分即可 |
第 1 步:在浏览器里调用 API
天气 API 需要经纬度,所以要先把城市名转换成坐标。Open-Meteo 提供了两个接口:地理编码(城市名 → 经纬度)和天气预报(经纬度 → 天气)。
1.1 查城市坐标。 在浏览器地址栏打开:
https://geocoding-api.open-meteo.com/v1/search?name=Hangzhou&count=1&language=zh&format=json
你会看到类似下面的 JSON(字段已省略一部分):
{
"results": [
{
"name": "杭州",
"latitude": 30.29365,
"longitude": 120.16142,
"country": "中国",
"admin1": "浙江",
"timezone": "Asia/Shanghai"
}
]
}
1.2 用坐标查天气。 把上一步的经纬度填进去:
https://api.open-meteo.com/v1/forecast?latitude=30.29&longitude=120.16¤t=temperature_2m,weather_code,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=3
返回结果的结构(数值会随时间变化):
{
"timezone": "Asia/Shanghai",
"current_units": { "temperature_2m": "°C", "weather_code": "wmo code", "wind_speed_10m": "km/h" },
"current": { "time": "2026-09-17T20:30", "temperature_2m": 26.2, "weather_code": 3, "wind_speed_10m": 11.9 },
"daily_units": { "temperature_2m_max": "°C", "precipitation_probability_max": "%" },
"daily": {
"time": ["2026-09-17", "2026-09-18", "2026-09-19"],
"temperature_2m_max": [31.8, 31.8, 32.6],
"temperature_2m_min": [21.4, 23.2, 24.1],
"precipitation_probability_max": [2, 2, 71]
}
}
读懂这份 JSON:
current是当前天气,daily是逐日预报。daily里的各个数组按位置一一对应:time[2]是 9 月 19 日,对应的precipitation_probability_max[2]是 71%。- 单位不在数值里,而在
current_units与daily_units中。 weather_code是 WMO 天气代码,例如 0 表示晴、3 表示阴、61–65 表示不同强度的雨。
1.3 故意制造一个错误。 把 latitude 改成 999 再打开,会得到:
{ "error": true, "reason": "Latitude must be in range of -90 to 90°. Given: 999.0." }
此时 HTTP 状态码是 400。这就是HTTP 与 API 里说的“客户端错误”:问题出在调用方的参数,原样重试没有意义,应该根据 reason 修正参数。
可选:用命令行调用。 macOS、Linux 与较新的 Windows 都自带 curl:
curl -s -G "https://geocoding-api.open-meteo.com/v1/search" \
--data-urlencode "name=杭州" -d count=1 -d language=zh
# 加 -w 输出状态码,观察错误请求
curl -s -o /dev/null -w "%{http_code}\n" "https://api.open-meteo.com/v1/forecast?latitude=999&longitude=120"
--data-urlencode 会把中文城市名正确编码后拼进 URL;第二条命令应输出 400。
第 2 步:用 Postman 或 Apifox 拆解请求
浏览器只能发 GET 请求,也不方便观察细节。API 调试工具能让你看清请求的每个部分,这也是你以后和工程师对接接口时最常用的工具。两者操作类似:
- 新建一个 HTTP 请求,方法选择
GET。 - URL 填
https://api.open-meteo.com/v1/forecast,不要带问号后面的部分。 - 在 Params(查询参数)表格中逐行添加:
| 参数名 | 值 | 含义 |
|---|---|---|
latitude |
30.29 |
纬度 |
longitude |
120.16 |
经度 |
current |
temperature_2m,weather_code |
需要的当前天气字段 |
daily |
temperature_2m_max,temperature_2m_min,precipitation_probability_max |
需要的逐日字段 |
timezone |
auto |
按当地时区返回时间 |
forecast_days |
3 |
预报天数 |
- 点击发送,观察右侧:状态码
200、响应耗时、响应体 JSON、响应头中的Content-Type。 - 删掉
latitude参数再发送一次,看看状态码和错误信息如何变化。 - 把
forecast_days改成30,观察 API 如何描述这个错误。
观察要点: 在工具里你手动填写的这张参数表,就是以后工具定义里参数 Schema 要描述的内容——哪些必填、取值范围是多少、格式是什么。对照JSON 与 JSON Schema 想一想,你会如何为这个接口写 Schema。
第 3 步:用程序调用 API
现在把手动操作变成程序。新建一个文件夹,打开终端:
mkdir weather-lab && cd weather-lab
python3 -m venv .venv
source .venv/bin/activate # Windows 使用:.venv\Scripts\activate
pip install httpx anthropic
新建文件 weather_api.py:
"""第 3 步:只用程序调用天气 API,不涉及模型。"""
import json
import sys
import httpx
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
def geocode(city: str) -> dict:
"""把城市名转换成经纬度。找不到时抛出 ValueError。"""
resp = httpx.get(
GEOCODING_URL,
params={"name": city, "count": 1, "language": "zh", "format": "json"},
timeout=10,
)
resp.raise_for_status() # 4xx / 5xx 时抛出异常
data = resp.json()
if not data.get("results"): # 找不到城市时,响应里没有 results 字段
raise ValueError(f"找不到城市:{city}")
place = data["results"][0]
return {
"name": place["name"],
"admin1": place.get("admin1", ""),
"country": place.get("country", ""),
"latitude": place["latitude"],
"longitude": place["longitude"],
}
def get_weather(city: str, days: int = 3) -> dict:
"""查询城市的当前天气与未来几天预报,返回精简后的 JSON。"""
place = geocode(city)
resp = httpx.get(
FORECAST_URL,
params={
"latitude": place["latitude"],
"longitude": place["longitude"],
"current": "temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m",
"daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code",
"timezone": "auto",
"forecast_days": days,
},
timeout=10,
)
resp.raise_for_status()
raw = resp.json()
# 只保留回答问题需要的字段,这就是"工具返回值精简"
return {
"location": place,
"current": raw["current"],
"current_units": raw["current_units"],
"daily": raw["daily"],
"daily_units": raw["daily_units"],
}
if __name__ == "__main__":
city = sys.argv[1] if len(sys.argv) > 1 else "杭州"
print(json.dumps(get_weather(city), ensure_ascii=False, indent=2))
运行:
python weather_api.py 杭州
预期结果: 输出一份格式化的 JSON,包含 location(杭州、浙江、中国及经纬度)、current(当前温度、湿度、天气代码、风速)和 daily(3 天的最高最低温、降水概率、天气代码)。再试试:
python weather_api.py 不存在的城市xyz
应该看到以 ValueError: 找不到城市:不存在的城市xyz 结尾的报错。
注意 get_weather 最后只挑选了回答问题需要的字段,而不是把原始响应全部返回。这就是工具设计原则中“返回值精简”的最小实践:交给模型的数据越干净,它越不容易被干扰,Token 成本也越低。
第 4 步:手动版 Tool Calling
这一步引入模型。先设置 API Key(在模型厂商的控制台创建,不要写进代码或提交到仓库):
export ANTHROPIC_API_KEY="你的 API Key" # Windows PowerShell:$env:ANTHROPIC_API_KEY="你的 API Key"
# 可选:指定模型,默认使用 claude-sonnet-5
export MODEL="claude-sonnet-5"
模型 ID 会随版本更新变化,请以 Anthropic 模型概览 中的当前列表为准。
在同一个文件夹中新建 manual_tool_calling.py:
"""第 4 步:手动版 Tool Calling。
模型负责"理解问题"和"组织回答",程序负责"真正调用 API"。
"""
import json
import os
import sys
from datetime import date
import anthropic
from weather_api import get_weather
MODEL = os.environ.get("MODEL", "claude-sonnet-5")
client = anthropic.Anthropic() # 自动读取环境变量 ANTHROPIC_API_KEY
def ask_model(system: str, user: str, max_tokens: int = 1024) -> str:
"""调用一次模型,返回拼接后的文本内容。"""
response = client.messages.create(
model=MODEL,
max_tokens=max_tokens,
system=system,
messages=[{"role": "user", "content": user}],
)
return "".join(block.text for block in response.content if block.type == "text")
def extract_intent(question: str) -> dict:
"""① 让模型把自然语言问题转成结构化参数(相当于模型"决定调用工具")。"""
system = (
f"今天是 {date.today().isoformat()}。"
"你负责从用户的天气问题中提取参数。只输出一个 JSON 对象,不要输出任何其他文字。\n"
'格式:{"city": "城市中文名或 null", "days": 1 到 7 的整数}\n'
"days 表示从今天起需要查询的天数,要覆盖用户关心的日期;"
"如果用户没有提到城市,city 为 null;没有提到时间时 days 为 3。"
)
text = ask_model(system, question, max_tokens=200).strip()
# 模型偶尔会用 Markdown 代码块包裹 JSON,这里做一次简单清理
fence = "`" * 3
text = text.removeprefix(fence + "json").removeprefix(fence).removesuffix(fence).strip()
intent = json.loads(text) # 解析失败会抛出 json.JSONDecodeError
# 程序侧校验:不信任模型输出,检查类型与范围
if intent.get("city") is not None and not isinstance(intent["city"], str):
raise ValueError(f"city 字段不合法:{intent}")
days = intent.get("days", 3)
intent["days"] = days if isinstance(days, int) and 1 <= days <= 7 else 3
return intent
def answer_with_data(question: str, weather: dict) -> str:
"""③ 把 API 返回的 JSON 交给模型,生成自然语言回答(相当于"工具结果回到上下文")。"""
system = (
f"今天是 {date.today().isoformat()}。"
"你是天气助手。只根据提供的 JSON 数据回答,不要编造数据中没有的信息。\n"
"weather_code 是 WMO 天气代码:0 晴;1、2、3 分别为大致晴朗、局部多云、阴;"
"45、48 雾;51–57 毛毛雨;61–67 雨;71–77 雪;80–82 阵雨;85、86 阵雪;95–99 雷暴。\n"
"回答要简洁,给出温度范围、降水概率和一句出行建议。"
)
user = (
f"用户问题:{question}\n\n"
f"天气数据(JSON):\n{json.dumps(weather, ensure_ascii=False)}"
)
return ask_model(system, user)
def main(question: str) -> None:
print(f"用户问题:{question}\n")
intent = extract_intent(question)
print(f"① 模型提取的参数:{json.dumps(intent, ensure_ascii=False)}")
if not intent.get("city"):
print("\n没有识别到城市,请告诉我你想查哪个城市。")
return
try:
weather = get_weather(intent["city"], intent["days"])
except ValueError as e: # 找不到城市
print(f"\n② 调用 API 失败:{e}")
return
print(f"② 程序调用 API 成功,拿到 {len(weather['daily']['time'])} 天的预报")
answer = answer_with_data(question, weather)
print(f"\n③ 模型基于数据的回答:\n{answer}")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "杭州这周末适合出去玩吗?")
运行:
python manual_tool_calling.py "杭州这周末适合出去玩吗?"
预期结果(模型措辞与天气数值每次会不同):
用户问题:杭州这周末适合出去玩吗?
① 模型提取的参数:{"city": "杭州", "days": 4}
② 程序调用 API 成功,拿到 4 天的预报
③ 模型基于数据的回答:
(一段基于真实数据的回答:周末两天的温度范围、降水概率,以及是否适合出行的建议)
再试几个问题,观察程序的不同分支:
| 输入 | 预期表现 | 对应的 Agent 设计问题 |
|---|---|---|
"明天冷吗?" |
① 中 city 为 null,程序请用户补充城市 | 缺少必要参数时,应该追问而不是猜 |
"火星城明天天气" |
② 调用失败,提示找不到城市 | 工具报错时,要给出可理解的反馈 |
"Shanghai 未来一周会下雨吗?" |
正常回答,days 接近 7 | 模型负责把多样的表达转成规范参数 |
这三步和真正的 Tool Calling 是什么关系
左右对照,看两者如何一一对应:
差别在于:原生 Tool Calling 让模型 自己决定调不调、调哪个、调几次,而你的程序写死了“一定调一次天气 API”。把这个“写死”变成“由模型决定并循环”,就是从 Workflow 走向 Agent 的一步。你将在实操:做一个 Tool Agent 中完成这个升级。
排错提示
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
ModuleNotFoundError: No module named 'httpx' 或 'anthropic' |
没有激活虚拟环境或没有安装依赖 | 执行 source .venv/bin/activate 后重新 pip install httpx anthropic |
ModuleNotFoundError: No module named 'weather_api' |
两个文件不在同一目录,或不在该目录下运行 | 确认两个文件在同一文件夹,并在该文件夹中运行命令 |
httpx.ConnectTimeout 或 ConnectError |
网络无法访问 Open-Meteo | 检查网络或代理设置,先用浏览器确认第 1 步能打开 |
httpx.HTTPStatusError: Client error '400 Bad Request' |
参数错误,例如预报天数超出范围 | 打印 resp.text 查看 reason 字段 |
提示缺少 API Key 或 anthropic.AuthenticationError |
未设置 ANTHROPIC_API_KEY 或 Key 无效 |
在当前终端重新 export,注意新开的终端需要重新设置 |
anthropic.NotFoundError,提示模型不存在 |
模型 ID 写错或已下线 | 对照官方模型列表修改 MODEL 环境变量 |
anthropic.RateLimitError |
请求过于频繁或额度不足 | 稍等后重试,检查账户额度 |
json.JSONDecodeError |
模型没有只输出 JSON | 打印 text 查看原始输出;加强 system 指令,或改用结构化输出 / Tool Calling |
| 回答里出现数据中没有的信息 | 模型“补充”了自己的知识 | 在 system 中强调只依据数据回答,并检查传入的 JSON 是否完整 |
中文显示为 \u676d\u5dde |
JSON 序列化时转义了非 ASCII 字符 | 确认 json.dumps 使用了 ensure_ascii=False |
常见误区
- “模型自己会去查天气。” 在这个程序里,模型从未访问网络。它只输出了参数、读取了你给它的 JSON。所有对外部世界的动作都由程序完成,这也是权限控制得以生效的基础。
- “把原始 API 响应整个丢给模型最省事。” 原始响应常含大量无关字段,会浪费 Token、干扰判断。先精简,再交给模型。
- “模型输出的 JSON 可以直接用。” 第 4 步中程序检查了
city类型和days范围。永远在执行前校验模型给出的参数。
设计要点
- 模型负责理解与表达,程序负责调用与校验,两者边界清晰。
- API Key 由程序从环境变量读取,不出现在代码、Prompt 或日志中。
- 工具返回值只保留回答所需字段,并保留单位信息。
- 缺少参数时追问,调用失败时如实说明,而不是编造答案。
- 在回答指令中要求“只依据数据回答”,降低幻觉风险。
练习
在 30–60 分钟内完成以下任意两项:
- 增加字段:让
get_weather额外返回每日最大风速(在 Open-Meteo 文档中查找对应的daily参数名),并让回答在风大时提醒用户。 - 处理重名城市:把地理编码的
count改为 3,当返回多个同名地点时,让程序列出选项请用户确认。 - 记录一次“执行轨迹”:在
main中把每一步的输入、输出和耗时写入trace.json,体会Tracing 要记录什么。 - 迁移到业务场景:参照
get_weather的写法,为电商场景写一个假的get_order(order_no)函数(返回硬编码的 JSON),把问题换成“订单 A1234 现在是什么状态?”,跑通同样的三步流程。
延伸阅读
- Open-Meteo:Weather Forecast API 与 Geocoding API,全部可用参数与天气代码表。
- Anthropic:Tool use with Claude,原生 Tool Calling 的完整往返示例,可与本课的手动版对照阅读。
下一步
软件与工程基础模块到此结束。下一个模块从「LLM 如何工作(直觉版)」开始,帮你理解第 4 步里那个“会提取参数、会组织回答”的模型到底是怎么工作的。