iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 5

Day 5:第一個 Chat API——不是把 SDK 包起來,是決定上游到你為止

  • 分享至 

  • xImage
  •  

把 Day 4 的 wrapper 接上 POST /chat,五分鐘就能寫完:handler 收 message、丟給 SDK、把回傳塞進 JSON。所以今天的問題不是「怎麼接」,而是接完之後你欠了什麼——從 client 拿到第一個 200 開始,回應裡的每個欄位、每種錯誤行為,都是你從此要維護的合約。

這篇要把 chat endpoint 從 Day 3 的 501 佔位變成真正的合約:自己的 schema、上游錯誤的映射表、顯式的 timeout。Day 4 留下的選型也要在這裡定案:本系列用 Responses API,還是 chat completions?

先斷案:Responses API,但 store=false

官方的立場很明確: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 用。注意這不是效能選項,是資料治理決策:預設值站在「存」那一邊,不做決定就等於決定了。

https://ithelp.ithome.com.tw/upload/images/20260805/20168288Mzgh0iCvca.png
忍喵:「預設值幫你做了資料治理決策,還沒通知你。上線前 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 是什麼,錯誤邊界那節馬上講。)

合約是你的 schema,不是上游的

最省事的寫法是把 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 篇。

Timeout 與 retry:SDK 預設值是給 script 用的

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
)

https://ithelp.ithome.com.tw/upload/images/20260805/201682884EWH4nr2hS.png
忍喵:「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。

correlation id:把 client 手上的錯誤對回 log

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 後會變成分散式追蹤,但骨架今天就要立好。

驗證:fake 一條路,真的一條路

環境前提: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": "…"}

切真的:.envUSE_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

開發時最常見的三個錯:

  1. USE_FAKE_LLM=false 但環境變數沒設全 → 服務啟動即拋 ValueError。這是故意的 fail fast:組態錯誤在啟動時爆,比第一個真實 request 才爆好查十倍。
  2. server log 出現 404 DeploymentNotFoundmodel 參數放了模型名而不是 deployment name(Day 4 那面牆)。client 只會看到 500 configuration_error,真相在 log 裡。
  3. 忘了設 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)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 4:Azure OpenAI 的 resource、deployment、model——搞懂哪些名字會進你的 config
系列文
Backend 工程師的 Azure GenAI 實戰5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言