前面十二天,我們的程式都只在自己的電腦裡打轉。今天要讓它走出去。
這一步為什麼重要?回想一下第一天講的目標:
使用者說「幫我安排明天的行程」,AI 不只是給一段文字,而是可以取得天氣、行程等資訊,再透過不同工具進行分析。
「取得天氣、行程等資訊」——這些資料不在我們的電腦裡,在別人的伺服器上。要拿到它們,就得學會用 HTTP 跟 API 溝通。
API(Application Programming Interface),直譯是「應用程式介面」,但這個翻譯對理解沒什麼幫助。
我比較喜歡的比喻是餐廳的菜單:
中央氣象署有一堆氣象資料,它不會讓你直接連進它的資料庫。但它提供一份菜單:「你告訴我城市名稱,我告訴你天氣」。這就是 API。
程式跟 API 溝通用的協定是 HTTP。每一次請求都包含四個部分:
https://opendata.cwa.gov.tw/api/v1/rest/datastore/F-C0032-001?locationName=臺北市&Authorization=KEY
└─┬─┘ └────────────┬────────────┘└──────────┬──────────────┘└──────────────┬──────────────────┘
協定 網域 路徑 查詢參數
? 後面的是查詢參數(query parameters),用 & 分隔多個。
| Method | 意思 | 用途 |
|---|---|---|
GET |
給我資料 | 查詢(最常用) |
POST |
這是新資料 | 建立、送出表單、呼叫 AI |
PUT / PATCH |
改這筆資料 | 更新 |
DELETE |
刪掉這筆 | 刪除 |
我們這個系列主要會用到 GET(查天氣)和 POST(呼叫 AI)。
像是信封上的資訊,最常見的是:
{
"Content-Type": "application/json", # 我送的是 JSON
"Authorization": "Bearer sk-xxx", # 我的身分憑證
"User-Agent": "MyApp/1.0", # 我是誰
}
只有 POST / PUT 這類請求才有。通常是 JSON:
{"model": "claude-opus-5", "messages": [...]}
伺服器回應也有兩個部分:狀態碼(status code)和內容(body)。
狀態碼用三位數字表示結果,記住開頭數字就懂一半:
| 範圍 | 意思 | 常見代表 |
|---|---|---|
| 2xx | 成功 | 200 OK、201 Created |
| 3xx | 重新導向 | 301、302 |
| 4xx | 你的問題 | 400 參數錯、401 沒授權、403 沒權限、404 找不到、429 太頻繁 |
| 5xx | 伺服器的問題 | 500 內部錯誤、503 暫時無法服務 |
這個區分很重要:
這個差別在寫重試邏輯時是關鍵。
Python 標準庫有 urllib,但沒人愛用。大家都用 requests:
pip install requests
import requests
response = requests.get("https://api.github.com/users/github")
print(response.status_code) # 200
print(response.json()) # 自動把 JSON 解析成 dict
response.json() 內建幫你做了 json.loads()(Day 10 學過),直接回傳 dict。
response.status_code # 200
response.ok # True(狀態碼 < 400)
response.text # 回應的原始文字
response.json() # 解析成 Python 物件(不是 JSON 就會噴錯)
response.headers # 回應的標頭
response.url # 實際請求的完整 URL(除錯很好用)
response.elapsed # 花了多久
不要自己拼字串:
# ❌ 不要這樣
url = f"https://api.example.com/search?q={keyword}&limit=10"
因為如果 keyword 裡有空白、中文、&、#,URL 就壞掉了。正確做法是用 params:
response = requests.get(
"https://api.example.com/search",
params={"q": "台北 天氣", "limit": 10},
)
print(response.url)
# https://api.example.com/search?q=%E5%8F%B0%E5%8C%97+%E5%A4%A9%E6%B0%A3&limit=10
requests 自動幫你做 URL 編碼。
response = requests.get(
"https://api.example.com/me",
headers={"Authorization": f"Bearer {api_key}"},
)
response = requests.post(
"https://api.example.com/items",
headers={"Content-Type": "application/json"},
json={"title": "買牛奶", "priority": "高"}, # 用 json= 不是 data=
)
用 json= 參數,requests 會自動幫你序列化,也會自動設好 Content-Type。用 data= 的話送出去的是表單格式,不一樣。
新手寫 API 呼叫最常漏掉這三件事,而它們在 Agent 裡都會出問題。
requests.get(url) # ❌ 沒有 timeout,可能永遠卡住
requests.get(url, timeout=10) # ✅
**requests 預設是沒有 timeout 的。**如果對方伺服器掛掉但沒回應,你的程式就會一直卡在那裡。對 Agent 來說這是災難——它會整個停住,使用者也不知道發生什麼事。
**每一個 requests 呼叫都要設 timeout。**沒有例外。
response = requests.get(url, timeout=10)
if not response.ok:
print(f"請求失敗:{response.status_code}")
return None
data = response.json()
或是用 raise_for_status(),讓它在 4xx/5xx 時丟出例外:
response = requests.get(url, timeout=10)
response.raise_for_status() # 4xx/5xx 會 raise HTTPError
data = response.json()
import requests
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
except requests.Timeout:
print("請求逾時")
except requests.ConnectionError:
print("連線失敗,檢查網路")
except requests.HTTPError as e:
print(f"HTTP 錯誤:{e.response.status_code}")
except ValueError:
print("回應不是合法的 JSON")
requests.Timeout 和 requests.ConnectionError 都是 requests.RequestException 的子類別,想一次抓完可以用它。
把上面所有東西整合起來,加上重試機制:
"""http_client.py — 一個帶重試的 HTTP 工具。"""
import time
import requests
# 這些狀態碼代表「等一下再試可能會成功」
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
def fetch_json(url, params=None, headers=None, timeout=10, retries=3):
"""發送 GET 請求並回傳 JSON。
Args:
url: 目標網址
params: 查詢參數 dict
headers: 標頭 dict
timeout: 單次請求逾時秒數
retries: 最多重試幾次
Returns:
dict: 解析後的 JSON
Raises:
RuntimeError: 重試後仍然失敗
"""
last_error = None
for attempt in range(retries):
try:
response = requests.get(
url, params=params, headers=headers, timeout=timeout
)
# 4xx(除了 429)是我們的問題,重試沒用,直接放棄
if response.status_code not in RETRYABLE_STATUS:
response.raise_for_status()
return response.json()
last_error = f"HTTP {response.status_code}"
except (requests.Timeout, requests.ConnectionError) as e:
last_error = f"{type(e).__name__}: {e}"
except requests.HTTPError as e:
# 明確的客戶端錯誤,不重試
raise RuntimeError(
f"請求失敗({e.response.status_code}):{e.response.text[:200]}"
) from e
except ValueError as e:
raise RuntimeError("回應不是合法的 JSON") from e
# 指數退避:1 秒、2 秒、4 秒
if attempt < retries - 1:
wait = 2 ** attempt
print(f"第 {attempt + 1} 次失敗({last_error}),{wait} 秒後重試…")
time.sleep(wait)
raise RuntimeError(f"重試 {retries} 次後仍然失敗:{last_error}")
這裡有幾個設計值得說明:
指數退避(exponential backoff)
重試間隔是 1、2、4 秒,而不是每次都等 1 秒。如果對方伺服器正在忙,你一直用固定頻率打它只會讓情況更糟。越等越久是網路請求的標準做法。
區分「該重試」和「不該重試」404 Not Found 重試一百次還是 404。503 Service Unavailable 等一下可能就好了。把這兩種分開處理,才不會浪費時間。
raise ... from e
保留原始的錯誤鏈,除錯時可以看到完整的錯誤來源。
錯誤訊息包含 response.text[:200]
API 通常會在回應裡說明錯誤原因,把它帶出來,比只看到一個 400 有用太多。截 200 字是避免一大堆 HTML 洗版。
用起來就很簡單:
data = fetch_json(
"https://api.github.com/users/github",
timeout=10,
)
print(data["name"], data["public_repos"])
在正式申請 key 之前,先用免費的公開 API 練手:
from http_client import fetch_json
# 1. 隨機貓咪事實
cat = fetch_json("https://catfact.ninja/fact")
print("貓咪小知識:", cat["fact"])
# 2. 匯率(以台幣為基準)
rates = fetch_json("https://open.er-api.com/v6/latest/TWD")
print(f"1 TWD = {rates['rates']['JPY']:.4f} JPY")
# 3. GitHub 使用者資訊
user = fetch_json("https://api.github.com/users/torvalds")
print(f"{user['name']} 有 {user['followers']} 個追蹤者")
建議的練習流程,我自己都是這樣做的:
先看清楚資料結構,再寫程式。直接寫的話會一直在猜 key 的名字。
HTTP 是 Agent 的手腳。
回到第一天的那個例子——「幫我安排明天的行程」。Agent 要完成這件事,需要:
| 需要的資訊 | 從哪來 |
|---|---|
| 明天的天氣 | 氣象署 API |
| 我的行事曆 | Google Calendar API |
| 通勤時間 | 地圖 API |
| 判斷與建議 | AI API |
每一個都是 HTTP 請求。
而且注意最後一項——連「AI 本身」都是透過 HTTP 呼叫的。我們 Day 18 會用官方 SDK,但 SDK 底層做的事情就是今天學的這些:組一個 POST 請求,帶上 Authorization header,送一包 JSON 出去,收一包 JSON 回來。
所以今天講的 timeout、重試、狀態碼判斷,不是「等有空再補」的細節,而是 Agent 能不能穩定運作的基礎。一個 Agent 可能在一次任務裡呼叫五、六個 API,只要有一個沒處理好,整件事就做不完。
requests 用 params= 帶查詢參數、json= 送 JSON,不要自己拼字串timeout,這是最常被忽略也最致命的一件事明天我們要拿這些工具去做真的事情——實作天氣查詢:中央氣象署 API。這會是我們生活助理的第一個真實資料來源。