Learning Objectives
- 用 stream=True 逐段接收回答,並把片段接回完整的文字
- 發現 finish_reason 是 length 的回答,並選擇處理方式
- 依錯誤種類判斷該不該重試
- 寫出含指數退避、抖動與次數上限的重試函數
上線後才會遇到的問題
上一課的程式在順利時可以跑,但做成應用會遇到三件事:
- 回答要等全部生成才顯示,使用者盯著空白畫面。
- 回答可能被長度上限截斷,看起來卻像正常的文字。
- 網路會斷、服務會忙、參數會寫錯。
這一課依序處理這三件事。串流、截斷與 400 錯誤是 2026-11-03 錄下來的真實回應;429 這類無法穩定錄到的錯誤,範例用自己寫的假函數模擬,範例裡會標明。
逐步看:一段一段收到回答
加上 stream=True,create 回傳的不再是回應物件,而是可以用 for 走訪的串流。注意第一段和最後一段的內容,還有文字是怎麼被切開的。
串流的幾個細節
從真實的輸出可以看到:
- 切法不是一字一段。 大多是一個字(
'位'、'元'),也有'byte'、' 個'、'資料'、'基本'。片段不一定對應你以為的「字」,不要依賴切法。 - 第一段是空字串,最後一段是
None。 直接pieces.append(text)再join會出錯,所以範例先判斷if text:。 finish_reason要到最後一段才有值。 串流中途無法知道會不會被截斷。
串流沒有讓生成變快。 總時間差不多,但使用者很快就看到第一個字。程式要等全部結果才能處理時(例如解析 JSON),串流沒有好處。
用量: 串流預設不會帶 usage。要加 stream_options={"include_usage": True},API 會在結尾多送一個 choices 是空串列的 chunk,裡面有用量,所以 chunk.choices[0] 之前要先檢查(本站的預錄不含這個 chunk,範例沒有示範)。串流中途斷線時,可能收不到這個 chunk,見 API 參考。
接到網頁: 伺服器一段一段送給瀏覽器,常用的方式是 Server-Sent Events。Python SDK 的自動重試也不涵蓋已經開始接收的串流,中途斷線要由你的程式決定是保留已收到的部分,還是重新請求。
逐步看:回答被截斷
第 7 課看過 finish_reason。這裡把輸出上限設成 20 個 token,看真實被截斷的回答長什麼樣子,再試一種補救方式:請模型接著寫。
被截斷時怎麼辦
被截斷的回答最麻煩的地方是不會報錯。文字看起來正常,只是少了後面。所以只要程式依賴回答的完整性,就要檢查 finish_reason。
| 做法 | 適合 | 代價 |
|---|---|---|
調高 max_completion_tokens |
上限本來就設得太緊 | 最壞情況的費用變高 |
| 提示詞要求短一點 | 不需要那麼長的回答 | 模型不一定做到(第 5 課的 83 字) |
| 請模型接著寫 | 長文,接縫可以接受 | 又多一次呼叫;上面的範例就有接不順的地方 |
| 告訴使用者「回答不完整」 | 其他做法都不適合時 | 使用者要自己決定 |
回答如果是 JSON,被截斷的內容根本解析不了,下一課會看到。
逐步看:真實的 400 錯誤
這是真實 API 回傳的錯誤,不是模擬的。SDK 會把錯誤回應變成 Python 例外。
哪些錯誤該重試
判斷的原則:再送一次有沒有可能成功?
| 狀態 | SDK 的例外 | 原因 | 重試? |
|---|---|---|---|
| 400 | BadRequestError |
參數或內容有誤 | 不要,改請求 |
| 401 | AuthenticationError |
金鑰有誤或被撤銷 | 不要,檢查金鑰 |
| 429(速率) | RateLimitError |
送得太快 | 可以,要等一下 |
| 429(額度) | RateLimitError |
額度或消費上限用完 | 不要,要處理帳務 |
| 500 | InternalServerError |
服務端的問題 | 可以,等一下 |
| 503 | InternalServerError |
模型目前忙碌 | 可以,等一下 |
| 無回應 | APITimeoutError、APIConnectionError |
逾時或網路問題 | 可以 |
同樣是 429,意思可能不同:要看錯誤內容裡的 code,額度用完時重試也沒用(OpenAI 錯誤代碼指南)。
立刻一直重送會更糟。 官方的速率限制指南提醒,失敗的請求也算進每分鐘的限制(說明)。所以每次失敗後要等一下,而且越等越久。
逐步看:指數退避加上抖動
這個範例的 429 是模擬的。 flaky_api 是我們自己寫的假函數,前兩次丟出 RateLimitError,沒有呼叫任何模型。例外的建立方式也是這裡的模擬環境才有;真實環境的例外由 SDK 依 API 回應建立,你只需要 except。
在這裡 time.sleep 不會真的等,秒數請看輸出。
重試的要點
- 指數退避: 等待時間 1、2、4、8 秒逐次加倍,服務端有時間恢復。
- 抖動(jitter): 同時被擋的程式如果等一樣久,會在同一時間一起回來。在等待時間裡加入隨機,可以把重試打散,Marc Brooker 2015 年的文章用模擬比較過。
- 次數上限: 一直失敗就回報錯誤,不要無限重試。
Retry-After: 回應裡有這個標頭時,依它指定的時間等待(官方指南)。
SDK 已經內建重試。 OpenAI 的 Python SDK 預設對連線錯誤、408、409、429、5xx 自動重試 2 次,並使用短暫的指數退避;預設逾時是 10 分鐘(SDK 說明)。這兩項都可以調整:
client = OpenAI(timeout=30, max_retries=3) # 使用者在等,逾時要短一點
10 分鐘對批次工作也許可以,對網頁上等待的使用者太久。自己再寫一層重試時,通常要設 max_retries=0,否則兩層會相乘。
重試前想一下: 同一個請求送兩次會不會造成問題?純問答只是多花一點錢;如果這個請求會讓工具寄信或扣款(第 15 課起),重試可能造成重複的動作。
實作:寫一個重試函數
完成 retry(fn, max_attempts=4, base=1):
- 呼叫
fn(),成功就回傳結果。 - 遇到
RETRYABLE裡的錯誤:如果已經是最後一次嘗試,把錯誤往外丟(raise);否則印出第 N 次重試前等 W 秒,等待base * 2 ** attempt秒後再試。 - 其他錯誤(例如 400)不要接住,讓它直接往外丟。
這一題不呼叫模型,make_flaky、bad_request 都是模擬的假函數。裡面建立例外的寫法只有這裡的模擬環境能用,真實 SDK 的例外由 SDK 自己丟出,不要照抄。為了讓輸出固定,這一題不加抖動。
小測驗:串流與錯誤
串流時為什麼要先判斷 if text: 才加進串列?
使用者的回答看起來少了結尾,程式也沒有報錯。最先該檢查什麼?
哪一種錯誤不該自動重試?
為什麼指數退避要加上抖動?
下面的程式會印出什麼?
base = 2
print([base * 2 ** attempt for attempt in range(4)])
重點整理
stream=True讓使用者很快看到文字;片段切法不固定,空字串與None要先判斷,finish_reason在最後一段。- 被截斷不會報錯,要檢查
finish_reason;依用途調高上限、接著寫,或告訴使用者。 - 依錯誤種類決定是否重試:400、401、額度用完不重試;速率限制、5xx、逾時可以重試。
- 重試要有指數退避、抖動與次數上限。SDK 預設已有重試,自己再寫一層時要避免相乘。
下一課: 程式要的不是文字,而是欄位。下一課讓模型輸出 JSON,並處理它不符合預期的情況。