把 Day 4 的 wrapper 接上 POST /chat,五分鐘就能寫完:handler 收 message、丟給 SDK、把回傳塞進 JSON。所以今天的問題不是「怎麼接」,而是接完之後你欠了什麼——從 client 拿到第一個 200 開始,回應裡的每個欄位、每種錯誤行為,都是你從此要維護的合約。
這篇要把 chat endpoint 從 Day 3 的 501 佔位變成真正的合約:自己的 schema、上游錯誤的映射表、顯式的 timeout。Day 4 留下的選型也要在這裡定案:本系列用 Responses API,還是 chat completions?
官方的立場很明確:Azure OpenAI 模型建議用 Responses API(查核 2026-07,v1 API 文件)。GA、japaneast、gpt-5-mini 都在支援範圍(查核 2026-07,Responses API 文件)。
表面上它只是把 messages=[...] 換成 input=...,但 backend 在意的差異在三個合約層面:
第一,狀態主權。 Responses API 預設是有狀態的:每次回應存在伺服器端 30 天,下一輪用 previous_response_id 就能接續,不用自己帶歷史。很方便,但這代表對話狀態的主權跑到了 Azure 的資料層:資料保留政策、使用者要求刪除時的義務、哪些內容存在哪個 geography,全部變成你要回答的問題。
本系列 Day 7 會自己設計 conversation state,所以這裡設 store=False,把 Responses API 當無狀態 API 用。注意這不是效能選項,是資料治理決策:預設值站在「存」那一邊,不做決定就等於決定了。
忍喵:「預設值幫你做了資料治理決策,還沒通知你。上線前 grep 一次store有沒有設,比事後跟法務解釋便宜太多。」
第二,streaming 的詞彙。 chat completions 的串流是一串裸的 delta chunk,事件語意要自己拼;Responses API 的串流是型別化的語意事件(response.output_text.delta 這類)。Day 6 要設計我們自己的 SSE 事件詞彙表,上游先有語意,下游的合約才好對映。
第三,演進方向。 remote MCP tools、背景任務與 encrypted reasoning items 等新能力目前集中在 Responses API。Chat completions 還是持續支援的,但本系列後續功能不會蓋在它上面。Day 16–18 如果 Agent Framework spike 不順、退回原生 SDK tool calling,這個選擇會直接受益。
那 chat completions 什麼時候仍是對的?兩個正當場景:v1 API 下呼叫 DeepSeek、Grok 這類他牌模型,走的是 chat completions 語法;或者你有一大坨既有 chat completions 程式碼,遷移成本吃掉所有好處。本系列是全新專案、只打 Azure OpenAI 模型,兩個條件都不成立。
斷案之後,改動小得不成比例,adapter 換一個 method:
class AzureOpenAIChatService:
async def complete(self, message: str) -> ChatResult:
try:
response = await self._client.responses.create(
model=self._deployment_name, # still the deployment name
input=message,
store=False, # state ownership stays with us (Day 7)
)
except openai.OpenAIError as exc:
raise _translate_upstream_error(exc) from exc
return ChatResult(message=response.output_text, model=response.model)
API 合約、handler、測試一行都不用動。這就是 Day 4 把 fake/real 藏在 ChatService Protocol 後面的回報:換 API 風格是 adapter 內部的事。(那個 _translate_upstream_error 是什麼,錯誤邊界那節馬上講。)
最省事的寫法是把 SDK 的 response 物件直接 return,FastAPI 會忠實地序列化給 client,然後你的 API 合約就變成了 SDK 回應格式的原樣複製。
官方文件自己就警告:「新的 response 欄位隨時可能加進 API 回應,建議只解析你需要的欄位」(查核 2026-07,v1 API 文件)。上游把「隨時加欄位」當成演進方式,你把它原樣轉手,等於替上游對你的 client 承諾了穩定性。
所以 request 與 response 都是自己的 Pydantic model,跟 Day 3 定下的樣子一致:
class ChatRequest(BaseModel):
message: str = Field(min_length=1)
conversation_id: str | None = Field(
default=None,
description="Reserved for conversation state (Day 7); accepted but ignored today.",
)
class ChatResponse(BaseModel):
message: str
correlation_id: str
兩個設計註記。其一,ChatResponse 只承諾兩個欄位:client 需要更多(用了哪個模型、花了多少 token)時再加,加欄位向後相容,拿掉欄位才是破壞性變更,所以起手式越小越好。其二,conversation_id 是 Day 3 就放進骨架的保留欄位,今天收下但不賦予語意。保留欄位比日後新增容易,但要在 OpenAPI description 註明 reserved,別讓 client 以為 API 已經有記憶。
合約的穩定性不是靠自律,是靠 CI:Day 3 起每次 push 都會重新匯出 OpenAPI spec 並比對 drift,schema 改了而 spec 沒更新,CI 直接紅燈。合約變更從「不小心發生」變成「必須明示發生」。
上游會用各種方式失敗,而 SDK 把它們變成一組例外。邊界設計的第一步不是寫 try/except,是回答一個問題:這個錯,對 client 而言是誰的錯? 分完類,狀態碼自然就出來了(SDK 例外對照查核 2026-07,openai-python):
| 上游狀況 | SDK 例外 | 回給 client | 為什麼 |
|---|---|---|---|
| API key 錯、權限不足(401/403) | AuthenticationError / PermissionDeniedError |
500 configuration_error |
client 什麼都沒做錯,是我們的部署壞了;透傳 401 會誤導 client 去檢查自己的憑證 |
| deployment 名稱錯(404) | NotFoundError |
500 configuration_error |
DeploymentNotFound 是我們的 config 問題,不是 client 打錯路徑 |
prompt 被 content filter 擋(400 content_filter) |
BadRequestError |
400 content_filtered |
client 輸入觸發過濾,是 client 端能處理的錯(查核 2026-07,Responses API 文件) |
輸入超過 context window(400 context_length_exceeded) |
BadRequestError |
400 invalid_input |
也是 client 輸入的問題;Day 9 的 token 防線做好之前,至少分類要正確 |
| 上游額度滿(429) | RateLimitError |
503 upstream_throttled |
見下面的取捨 |
| 上游逾時 | APITimeoutError |
504 upstream_timeout |
語意就是 gateway timeout |
| 上游 5xx/連線失敗 | InternalServerError / APIConnectionError |
502 upstream_error |
上游壞了,不代表我們壞了 |
429 那格值得多說兩句,因為常見做法是原樣透傳。我不透傳。429 對 client 的語意是「你打太快,慢點再來」,但上游 TPM 額度用完是我們與 Azure 之間的容量問題,client 就算只發一個 request 也可能撞上。回 503(可帶 Retry-After)語意才對。429 這個狀態碼留給 Day 9:等我們有了自己的 token 預算政策,才有資格對 client 說「你超速了」。
那認不出來的 400 呢?不硬猜。對 client 謊稱「你的輸入有問題」或「服務組態壞了」,都比一個誠實的 502 更糟。映射成 upstream_error,原文進 log,人再來判斷是不是該加新分類。
實作上,SDK 例外在 adapter 邊界就翻譯成領域例外,api/ 層完全不 import openai:
# services/azure_openai.py — SDK 例外到此為止
def _translate_upstream_error(exc: openai.OpenAIError) -> UpstreamError:
if isinstance(exc, openai.APITimeoutError):
return UpstreamTimeoutError(str(exc))
if isinstance(exc, openai.RateLimitError):
return UpstreamThrottledError(str(exc))
# ...其餘依上表翻譯
API 層用一個 exception handler 把領域例外接上 Day 3 的 error envelope({"error": {"code", "message"}, "correlation_id"})。好處在換供應商的那天才看得到:錯誤合約一個字都不用動。另一條紅線:上游的錯誤訊息原文不進 response。那裡面可能有 deployment 名稱、endpoint 這類不該給 client 的細節;原文進 log,client 拿到的是我們自己的 code 與一句通用訊息。
Envelope 還順手多吃下一種錯:FastAPI 對驗證失敗(422)預設回自家的 {"detail": [...]} 形狀,跟我們的 envelope 完全是兩回事。這是 Day 3 骨架留下的洞,今天一併補上:422 也回 envelope,code 是 validation_error。client 從此只需要認得一種錯誤形狀,不管錯在輸入、我們的組態,還是上游。
這張映射表也不是只活在文章裡:每個承諾的 status code 都寫進 route 的 OpenAPI responses(共用 ErrorEnvelope schema,correlation_id 是必填欄位不是 nullable),CI 的 drift check 一併看守。錯誤合約跟成功路徑一樣,是 spec 的一部分。
還有一種「不是錯誤的錯誤」:輸出側被 content filter 攔截時,HTTP 是 200,但內容不完整。Responses API 會在回應頂層的 content_filters 陣列帶過濾結果(查核 2026-07,Responses API 文件)。
200 不等於拿到完整輸出。這屬於 semantic contract 的範疇,本篇先把 transport 與 error 立好,語意層的驗收留給後面的 evaluation 篇。
openai SDK 的預設 timeout 是 10 分鐘,預設對連線錯誤、408、409、429 與 5xx 自動重試 2 次(查核 2026-07,openai-python)。10 分鐘對互動式 API 是災難:client 端早就放棄了,你的 worker 還吊在那裡等上游。跑批次腳本可以慢慢等,API 不行,所以 timeout 必須顯式設定,而且進 config:
client = AsyncOpenAI(
api_key=settings.azure_openai_api_key.get_secret_value(),
base_url=settings.azure_openai_endpoint.rstrip("/") + "/openai/v1/",
timeout=settings.llm_timeout_seconds, # per attempt (default 30s), not end-to-end
max_retries=settings.llm_max_retries, # explicit policy; the SDK default is 2
)
忍喵:「600 秒是給批次腳本等的。你的 client 四十秒就關頁面了,worker 還在痴痴等上游——沒寫進 config 的 timeout 都不算數。」
30 秒的理由:gpt-5-mini 是 reasoning model,非串流回應本來就以秒計,太緊會誤殺正常請求;再長 client 體感就是「掛了」。這個數字沒有唯一正解,有正解的是「它必須是你選的,而且寫在 config 裡」。真正的解法其實是 Day 6 的 streaming:首 token 延遲取代總時長成為體感指標之後,timeout 的算法整個會變。
Retry 保留 2 次,但跟 timeout 一樣寫進 config,讓整組政策都是顯式決策。這條路徑沒有 application write,重試不會重複寫入業務資料;但 retry 不是零副作用,每次 attempt 都可能重新推論、重新計費,Day 9 算成本時這個乘數要放進去。
另一個容易誤會的點:timeout=30 是每次 attempt 的上限,不是整個 endpoint 的 deadline。最壞情況(兩次重試都吃滿 timeout,加上 backoff)client 會等超過 90 秒。設 SLO 或 client 端 timeout 時,要用這個最壞值,不是 30。
Day 3 的 middleware 已經做了大半:每個 request 帶 X-Correlation-Id(沒有就生成)、寫回 response header、error envelope 也帶。今天補上最後一段:領域例外把上游錯誤原文帶在身上,exception handler 寫 log 時把它跟 correlation id 記在同一行。原文進 log,不進 response。
於是 client 回報「502,correlation_id 是 abc123」時,你 grep 一下就知道上游那一筆到底發生什麼事。這條線 Day 27 接上 Application Insights 後會變成分散式追蹤,但骨架今天就要立好。
環境前提:Day 4 的 resource 與 deployment(或者不用,fake 路徑不碰 Azure)、day-05 tag 的程式碼、Python 3.13 + uv(macOS,2026-07 實測)。預設 USE_FAKE_LLM=true,起服務直接打:
curl -s localhost:8000/api/v1/chat -X POST \
-H 'content-type: application/json' -d '{"message": "ping"}'
預期輸出(fake adapter 是決定性的):
{"message": "[fake-llm] ping", "correlation_id": "…"}
切真的:.env 設 USE_FAKE_LLM=false 加上 Day 4 的三個值(endpoint、key、deployment name),同一條 curl 會回 gpt-5-mini 的真實回應。
BDD 這邊,chat_api_contract.feature 從骨架的「應回 501」情境改成三條合約情境:200(驗 schema 欄位與 correlation id)、422 與上游拒收輸入的 400(都驗 envelope)。測試一律強制 fake adapter:不管本機 .env 怎麼設,CI 與測試永遠不碰 Azure。完整程式碼與測試在 day-05 tag。
開發時最常見的三個錯:
USE_FAKE_LLM=false 但環境變數沒設全 → 服務啟動即拋 ValueError。這是故意的 fail fast:組態錯誤在啟動時爆,比第一個真實 request 才爆好查十倍。DeploymentNotFound → model 參數放了模型名而不是 deployment name(Day 4 那面牆)。client 只會看到 500 configuration_error,真相在 log 裡。store=False → 功能一切正常,但每筆對話默默存在 Azure 30 天。一般的功能測試抓不到這種「不會壞的錯」:fake 不會知道你少傳了參數。要靠 adapter 的 interaction test 把 store=False 釘住(repo 裡的 test_real_service_never_stores_responses_upstream 就是幹這件事的),code review 再確認沒有其他呼叫路徑繞過。到這裡,client 只看得到我們定義的 request/response schema、錯誤碼與 correlation id;Azure SDK 的形狀停在 adapter。API 基礎也定案了:Responses API、store=False——採用官方建議的新介面,但把狀態主權留在自己手上。
目前仍有三個限制:回應一次成形,等待體驗差;API 沒有記憶,conversation_id 只是保留欄位;輸入側也還沒有 token 防線。超過 context window 的 message 會被擋成 400,但「很長卻合法」的 message 仍會直接吃掉額度。前兩個分別是 Day 6 與 Day 7 的主題,最後一個 Day 9 處理。
明天(Day 6)做 streaming。SSE 事件詞彙表要自己設計,而今天選的 Responses API 會讓那個設計順很多,因為它的串流本來就是語意事件。比較麻煩的是錯誤處理:HTTP 200 已經送出去之後上游才失敗,error envelope 根本來不及登場,這才是 streaming 真正改變的事。
(本篇無新增雲端資源——沿用 Day 4 建立的 resource 與 deployment,純 token 計費。)
| 工程需求 | Azure / Microsoft 對應服務 | 本篇怎麼用 |
|---|---|---|
| LLM 推論 API | Azure OpenAI in Microsoft Foundry Models | 沿用 Day 4 的 chat-mini deployment,改以 v1 Responses API(store=False)接進 /api/v1/chat 合約 |
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。