出錯、重試、限流和花錢
復現幾種最常見的 API 錯誤,看 openai SDK 預設幫你做了哪些重試,再寫一個帶超時、指數退避、併發限制和費用記錄的呼叫函式,放進專案裡就能用。
- 約 40 分鐘
- 難度:進階
- 實測:2026-09-14 deepseek-flash,openai 3.14
程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。
在自己電腦上跑例子時,API 呼叫幾乎從不出錯。上線之後就不一樣了:高峰期伺服器返回 429 說你請求太多,某次請求卡了一分鐘沒反應,偶爾還會有 500 錯誤。如果程式碼裡沒有準備,一個使用者就會看到一大段報錯,或者頁面一直轉圈。
還有錢。一個寫錯的迴圈,一個沒設上限的重試,可能一晚上燒掉你一個月的預算。
這一課先把常見的錯誤都復現一遍,然後寫一個能直接放進專案裡的呼叫函式。
常見的錯誤
code/03-llm-apps/errors.py 故意製造了三種錯誤:
print("== 1. 密钥错误")
try:
OpenAI(api_key="sk-wrong", base_url=BASE_URL).chat.completions.create(model=MODEL, messages=HELLO)
except openai.AuthenticationError as e:
print(f"{type(e).__name__},状态码 {e.status_code}")
print("== 2. 模型名写错")
try:
OpenAI(api_key=os.environ["LLM_API_KEY"], base_url=BASE_URL).chat.completions.create(model="deepseek-flsh", messages=HELLO)
except openai.APIStatusError as e:
print(f"{type(e).__name__},状态码 {e.status_code},{e.message[:100]}")
print("== 3. 超时(故意把超时设成 0.5 秒,并关掉 SDK 自带的重试)")
start = time.time()
try:
OpenAI(api_key=os.environ["LLM_API_KEY"], base_url=BASE_URL, timeout=0.5, max_retries=0).chat.completions.create(
model=MODEL, messages=[{"role": "user", "content": "写一篇 800 字的文章"}], extra_body=NO_THINKING)
except openai.APITimeoutError as e:
print(f"{type(e).__name__},用了 {time.time() - start:.1f} 秒")
執行結果:
== 1. 密钥错误
AuthenticationError,状态码 401
== 2. 模型名写错
BadRequestError,状态码 400,Error code: 400 - {'error': {'message': 'The supported API model names are deepseek-flash, deepseek-
== 3. 超时(故意把超时设成 0.5 秒,并关掉 SDK 自带的重试)
APITimeoutError,用了 0.7 秒
openai SDK 把不同的錯誤變成了不同的異常類,你可以按型別分別處理。常見的有這些:
| 異常 | 狀態碼 | 原因 | 該不該重試 |
|---|---|---|---|
AuthenticationError |
401 | 金鑰錯誤或失效 | 不該,重試多少次都一樣 |
PermissionDeniedError |
403 | 沒有許可權 | 不該 |
BadRequestError |
400 | 請求本身有問題:模型名不對、參數不合法、超出上下文長度 | 不該,要改程式碼 |
RateLimitError |
429 | 請求太頻繁,被限流了 | 該,等一會兒再試 |
InternalServerError |
500 及以上 | 伺服器那邊出了問題 | 該,通常是暫時的 |
APITimeoutError |
無 | 等太久沒有響應 | 該 |
APIConnectionError |
無 | 網路連不上 | 該 |
規律很簡單:錯在你的,重試沒用;錯在對方或者網路的,可以重試。模型名寫錯的報錯資訊很友好,直接列出了支援的模型名,照著改就行。
另外,餘額不足時,DeepSeek 會返回 402 狀態碼,在 SDK 裡是一個通用的 APIStatusError。這種錯誤也不該重試,應該通知你去充值。
SDK 已經幫你做了什麼
很多人不知道,openai SDK 預設就會自動重試。我查了當前版本(3.14)的原始碼:
- 預設
max_retries=2,也就是失敗後最多再試 2 次。 - 會重試的情況:超時、連線失敗,以及狀態碼 408、409、429 和所有 500 以上的錯誤。伺服器在響應頭裡明確要求重試或者不重試時,也會照辦。
- 兩次重試之間會等待,從 0.5 秒開始逐次加倍,最長 8 秒。
- 預設超時是 600 秒,其中建立連線最多等 5 秒。
也就是說,就算你什麼都不寫,偶爾的 429 和 500 也會被 SDK 默默地重試掉。這是好事,但有兩個問題需要注意。
600 秒的超時太長了。使用者不會在網頁上等 10 分鐘。一般的對話場景,建議把超時設成 30~60 秒;開著思考做複雜任務時可以長一點。在建立客戶端時傳 timeout=60 就行。
你看不到重試發生了。SDK 的重試是靜默的,你不知道一次呼叫其實試了三次、等了十幾秒。排查"為什麼這麼慢"時,這個資訊很重要。
一個能放進專案裡的呼叫函式
所以我通常關掉 SDK 自帶的重試,自己寫一個呼叫函式,把重試、限流、記賬放在一起:
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=BASE_URL,
timeout=60, # 单次请求最多等 60 秒
max_retries=0, # 关掉 SDK 自带的重试,由下面的函数统一处理,方便记录
)
RETRYABLE = (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError, openai.InternalServerError)
limiter = threading.Semaphore(5) # 同一时刻最多 5 个请求在路上
log_lock = threading.Lock()
LOG = Path("calls.jsonl")
def call_llm(messages, max_attempts=4, **kwargs):
for attempt in range(1, max_attempts + 1):
start = time.time()
try:
with limiter:
response = client.chat.completions.create(model=MODEL, messages=messages, **kwargs)
except RETRYABLE as e:
if attempt == max_attempts:
raise
# 指数退避:1 秒、2 秒、4 秒……再加一点随机,避免大家同时重试
wait = 2 ** (attempt - 1) + random.random()
print(f" 第 {attempt} 次失败({type(e).__name__}),{wait:.1f} 秒后重试")
time.sleep(wait)
continue
record = {
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"model": response.model,
"seconds": round(time.time() - start, 2),
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"cost_usd": round(cost_usd(response.usage, MODEL), 6),
"attempts": attempt,
}
with log_lock:
with LOG.open("a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
return response
逐塊解釋。
只重試該重試的錯誤。RETRYABLE 列出了四種可以重試的異常。401、400 這類錯誤不在裡面,會直接丟擲去,讓你第一時間發現。
指數退避加隨機抖動。第一次失敗等 1 秒多,第二次 2 秒多,第三次 4 秒多。等待時間逐次加倍,是為了給伺服器恢復的時間:如果對方正在過載,你每秒重試一次只會讓情況更糟。加上 random.random() 的隨機部分,是為了避免很多請求在同一時刻一起失敗、又在同一時刻一起重試,形成一波又一波的衝擊。
設重試次數的上限。4 次都失敗就放棄,把異常拋給呼叫方。永遠不要寫無限重試。
限制併發。threading.Semaphore(5) 保證同一時刻最多有 5 個請求在進行。服務商對併發數和每分鐘請求數都有限制(截至 2026 年 9 月,DeepSeek 文件寫的是 deepseek-flash 併發上限 2500,deepseek-v4-pro 是 500),自己先控制住,比等著被 429 更好。更重要的是,它能防止程式碼裡一個 bug 瞬間發出幾千個請求。
每次呼叫記一筆賬。時間、模型、耗時、輸入輸出詞元、費用、試了幾次,寫成一行 JSON 追加到 calls.jsonl。費用用的是第 01 模組第 4 課的 cost_usd。有了這個日誌,就能回答"這個功能一天花多少錢"、"哪些請求特別慢"、"重試有多頻繁"這些問題。06 模組第 3 課會在此基礎上做完整的日誌和監控。
試一下:併發 20 個請求
questions = [f"用一句话解释 HTTP 状态码 {code} 的含义。" for code in [200, 201, 204, 301, 302, 304, 400, 401, 403, 404,
405, 408, 409, 418, 429, 500, 502, 503, 504, 505]]
with ThreadPoolExecutor(20) as pool:
answers = list(pool.map(lambda q: call_llm([{"role": "user", "content": q}], extra_body=NO_THINKING), questions))
20 個執行緒同時呼叫,但 limiter 只允許 5 個同時進行:
== 4. 并发 20 个请求,最多同时 5 个,每次调用记账
20 个请求用了 3.5 秒
第一条回答: HTTP 状态码 200 表示服务器成功处理了请求,并正常返回了所请求的资源。
日志共 20 条,总花费 0.000655 美元,第一条:{'time': '2026-09-14 21:35:48', 'model': 'deepseek-flash', 'seconds': 0.62, 'prompt_tokens': 16, 'completion_tokens': 19, 'cost_usd': 2.8e-05, 'attempts': 1}
20 個請求用了 3.5 秒,一個一個序列呼叫大約要 12 秒。這次執行沒有遇到需要重試的錯誤,所以每條記錄的 attempts 都是 1。
花錢的幾道保險
重試和併發控制防的是意外,下面這些防的是賬單:
- 給每次呼叫設
max_tokens。防止模型沒完沒了地輸出。開著思考時要給得寬裕一些,第 00 模組第 3 課講過思考會佔用這個額度。 - 給迴圈設上限。工具呼叫的迴圈、重試的迴圈,都要有最大次數。第 3 課的工具呼叫迴圈最多 5 輪,就是這個道理。
- 在平臺上設餘額提醒。DeepSeek 是預付費的,餘額用完就停,天然有一個上限。但最好還是隻充夠一段時間用的錢,並留意餘額。
- 看日誌。每天看一眼
calls.jsonl的總花費,異常的增長一眼就能看出來。
練習
- 把
limiter的併發數改成 1 和 20,分別跑一次,比較 20 個請求的總耗時。 - 寫一個模擬的故障:定義一個函式,前兩次呼叫時丟擲
openai.APITimeoutError,第三次才真正呼叫模型。把它換進call_llm裡,看看重試和等待的輸出,以及日誌裡的attempts。(提示:openai.APITimeoutError(request=...)需要一個request參數,可以用httpx.Request("POST", "https://example.com")。) - 寫一個小指令碼,讀取
calls.jsonl,列印總呼叫次數、總花費、平均耗時、最慢的 3 次呼叫。
自測
1. 遇到 401 錯誤,要不要重試?遇到 429 呢?
401 是金鑰錯誤,重試多少次結果都一樣,應該直接報錯,讓人去檢查金鑰。429 是請求太頻繁被限流,是暫時的,應該等一會兒再重試,並且等待時間要逐次加長。
2. 為什麼重試之間的等待時間要逐次加倍,還要加一點隨機數?
加倍是為了給伺服器恢復的時間,對方過載時頻繁重試只會讓情況更糟。隨機數是為了讓很多同時失敗的請求錯開重試的時間,避免它們在同一時刻再次湧向伺服器。
3. 什麼都不設定時,openai SDK 會自動重試嗎?
會。當前版本預設 max_retries=2,遇到超時、連線失敗,以及 408、409、429 和 500 以上的狀態碼時,會等待一小段時間後自動重試,最多 2 次。預設超時是 600 秒,對大多數互動場景來說太長,建議自己設定 timeout。
提問與討論
這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。
提問 +3 點,回答別人 +6 點。內容經審核後公開。
正在載入討論…