01.08实操更新于 2026年9月17日

实操:调通第一个 API

从发请求到读 JSON,完成一次“手动版 Tool Calling”。

01.08 · 课时讲解

实操:调通第一个 API

模型决定查什么,程序去真正调用 API,再把 JSON 交回模型组织回答。

1 / 12

一句话理解:Tool Calling 并不神秘——模型决定“查什么”,程序去真正调用 API,再把返回的 JSON 交回模型组织回答;这一课你将亲手把这三步各做一遍。

前面几课讲了 HTTP、JSON、认证与运行环境。这一课把它们串起来:你会先用浏览器和命令行调通一个真实的天气 API,再用 Postman 或 Apifox 看清请求的每个部分,最后写一个不到 100 行的 Python 程序,完成一次“手动版 Tool Calling”。

这正是贯穿项目「天气 Agent」的第一步。做完之后,你再看到Function Calling 原理时,会发现它只是把你手动做的事情自动化了。

你将完成什么

四个步骤由浅入深,第 4 步把前面的内容串成一次手动版 Tool Calling:

实操的四个步骤:浏览器或 curl、Postman 或 Apifox、Python 调 API、Python 加模型的手动版 Tool Calling 第 1 步 浏览器 / curl 调用 API,读懂 JSON 第 2 步 Postman / Apifox 拆解参数、状态码、响应 第 3 步 Python 调 API 程序调用并精简结果 第 4 步 Python + 模型 手动版 Tool Calling 第 4 步展开:手动版 Tool Calling 用户问题 自然语言 ① 模型提取参数 {"city": "杭州"} ② 程序调用 API get_weather() ③ 模型基于数据回答 只依据 JSON 回答
图 1 · 四个步骤由浅入深,第 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&current=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 调试工具能让你看清请求的每个部分,这也是你以后和工程师对接接口时最常用的工具。两者操作类似:

  1. 新建一个 HTTP 请求,方法选择 GET。
  2. URL 填 https://api.open-meteo.com/v1/forecast,不要带问号后面的部分。
  3. 在 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 预报天数
  1. 点击发送,观察右侧:状态码 200、响应耗时、响应体 JSON、响应头中的 Content-Type。
  2. 删掉 latitude 参数再发送一次,看看状态码和错误信息如何变化。
  3. 把 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 的对应关系:从文字描述 JSON 到工具定义,从写死调用一次到模型自己决定、循环多轮 你刚才做的(手动版) 模型 API 的原生 Tool Calling ① 用文字描述要输出的 JSON 程序自己解析和校验 用工具定义描述参数 模型返回结构化的 tool_use 块 ② 程序调用 get_weather 写死:一定调用一次 按 tool_use 的 name 和 input 调用 模型决定调不调、调哪个 ③ 把 JSON 拼进新请求 让模型基于数据回答 以 tool_result 放回对话 模型读取结果后继续 一个固定工具、只走一轮 多个工具任选、可循环多轮
图 2 · 手动版 → 原生 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 分钟内完成以下任意两项:

  1. 增加字段:让 get_weather 额外返回每日最大风速(在 Open-Meteo 文档中查找对应的 daily 参数名),并让回答在风大时提醒用户。
  2. 处理重名城市:把地理编码的 count 改为 3,当返回多个同名地点时,让程序列出选项请用户确认。
  3. 记录一次“执行轨迹”:在 main 中把每一步的输入、输出和耗时写入 trace.json,体会Tracing 要记录什么。
  4. 迁移到业务场景:参照 get_weather 的写法,为电商场景写一个假的 get_order(order_no) 函数(返回硬编码的 JSON),把问题换成“订单 A1234 现在是什么状态?”,跑通同样的三步流程。

延伸阅读

下一步

软件与工程基础模块到此结束。下一个模块从「LLM 如何工作(直觉版)」开始,帮你理解第 4 步里那个“会提取参数、会组织回答”的模型到底是怎么工作的。

试试:上下文、MCP、沙箱、Prompt 注入、外链