iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
AI Engineering

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

Day 18:把 Agent 包成 Backend API——一個 run 算幾個 turn、預算下在哪,以及誰的權限在跑工具

  • 分享至 

  • xImage
  •  

Day 17 結束時,agent 還是一支 demo 腳本:跑得起來、有斷言,但沒有 API 面;收尾還留了兩題沒解——一個 agent run 在 token ledger 上算幾個 turn、預算檢查要下在哪,外加一句會漏進回答的框架英文字串。這天把 agent 接上 POST /api/v1/agent,三題全收,途中又冒出第四題。讀完你能說出這四個契約裁決各選了什麼、為什麼,以及其中最大的一個為什麼動的不是 /agent,是 /chat

動工前我以為這是加法:AgentTurnService 鏡射既有的 ConversationChatService,handler 十幾行,一天收工。實際上大部分時間花在另一種工作上——把這個 backend 已經承諾過的契約,一條一條對 agent run 重新談:Day 9 的 turn-commit 記帳、Day 9 的預算檢查點、Day 6 的 incomplete 詞彙、Day 15 的 ACL。agent 沒有帶來新問題,它只是讓每個舊答案都不再自動成立。

這天的 milestone:agent 接進既有 conversation——讀得到前面的 user/assistant turn,自己的結果也寫回去成為一個 turn。

完整程式碼在 day-18 tag 上:

裁決一:一個 run 算幾個 turn

第一題是記帳單位。一個 agent run 裡有 N 次 model call、M 次 tool call;Day 9 的 ledger 以 turn 為單位記帳,turn-commit 原子。那 agent run 的 N 次呼叫,要在對話裡記成幾筆?

裁決:一 run = 一 turn。對話的最小可重播單位是「使用者問一次、得到一個答案」;中間那幾次 model call 是實作細節,屬於 trace(後面會講),不屬於 transcript。usage 也照這個單位走——整個 run 的聚合用量,跟著這一個 turn 一次 commit。

失敗語意跟著變得乾淨:mid-run 失敗,什麼都不寫。沒有「寫一半」這種中間狀態:連 user message 都不進 store,這個 turn 從未存在過。誠實缺口也在這裡:框架把聚合 usage 當函式區域變數,丟例外時什麼都不掛(Day 17 記過),所以失敗的 run 裡已經完成、已經被計費的那幾次呼叫,數字到不了我們手上。AgentRunError 誠實地帶著 usage=None,ledger 不記,權威帳仍然是 Cost Management。Day 18 沒有讓這個缺口變小,只是讓它有名字。

兩個邊界 case 值得寫清楚。run 被上限停下(後述)而 answer 是空的:commit user message+usage,不寫 assistant message——錢花了、帳要記,但沒有答案就不假裝有。模型正常收尾(natural)卻交白卷:502、不 commit,鏡射 /chat 對空回覆的既有契約——natural 的空不是「不完整」,是上游壞了。

裁決二:預算檢查下在哪

Day 9 的規則是 inference 檢查預算。但 agent run 有 N 圈,開跑前不知道 N 是多少——那檢查是要 per round(每圈進 model 前查一次),還是 per turn(run 前查一次)?

per round 看起來更精準,實際上買到的東西很怪:run 跑到第三圈被預算擋下,前兩圈的錢已經花了,答案卻沒拿到——花了錢買一個 429

而它想防的「無上限失血」,其實另有東西在防:Day 17 的三層成本控制把一個 run 的 model 迴圈封在 ~6 圈(AGENT_MAX_ITERATIONS=5 加最後一次收尾呼叫),每圈又有 max_output_tokens(預設 1000)封頂。

不過 ~6 是框架迴圈的邏輯圈數,不是請求數的硬上限。SDK transport 層遇到 retryable failure 時,每次 model request 最多再重試 2 次(LLM_MAX_RETRIES)——健康的 request 打一次就過,但最壞情況的請求包絡是 6 × 3 = 18 次 attempts。估「一個 run 最多打幾次 provider」要用 18 不是 6;這是 attempt 的上界,不直接等於計費用量。

所以裁決是 pre-run 單次檢查:run 前查一次 ledger,超了就 429;沒超就讓整個 run 跑完。overshoot 的上界=一整個 run 的用量,而這個量有封頂、可推導——它不是「無上限」,是「一個已知大小的最後一口」。這跟 Day 9 post-paid ledger 的立場一脈相承:預算是止血帶,不是精算器。

429 的形狀從 /chat 原樣搬來:token_budget_exceeded envelope、沒有 Retry-After——預算不回補,等也沒用,解法是開新對話。實作上這不是「風格一致」,是字面上 import 同一個 helper 函式。

裁決三:那句不請自來的英文

第三題是 Day 17 埋的雷。tool 預算用盡時,框架不會丟例外,它會替你寫一句話當最終答案

Function invocation limit reached before a final answer could be produced.

全中文的對話裡突然冒出這句英文,而且從 API 形狀上看不出來它不是模型說的——它就坐在 answer 欄位裡。框架的 UX 決定,就這樣漏成了我們的 API 契約。

追源時多抓到一條路:不只 tool 上限(function_call_limit)這條路會注入,model call 上限(iteration_limit)強制收尾的路徑也會——spec review 第二輪用 runtime 重現抓到的。只堵一條路的版本會在另一條路上原句漏出。

裁決:在 adapter 內 strip,條件是 exact equality 且 stop_reason 不是 natural

FRAMEWORK_FALLBACK_TEXT = (
    "Function invocation limit reached before a final answer could be produced."
)


def strip_framework_fallback(
    answer: str,
    stop_reason: Literal["natural", "iteration_limit", "function_call_limit"],
) -> str:
    if stop_reason != "natural" and answer == FRAMEWORK_FALLBACK_TEXT:
        return ""
    return answer

兩個條件各有理由。exact equality:框架要嘛整句注入、要嘛不注入,不存在「半句」,所以 substring 手術只可能誤傷模型自己寫的文字。natural 不 strip:正常收尾時如果 answer 恰好等於這句話,那是模型自己打出來的——內容雷同不是我們該審查的事。

還有一個問題:釘一句字面,框架改版怎麼辦?答案是讓它壞得夠大聲——一個 lock test 直接 import 框架的私有常數 _FUNCTION_INVOCATION_LIMIT_FALLBACK_TEXT 跟我們的常數比對,pin bump 若改了那句話,測試當場紅,而不是 strip 靜默失效、英文重新漏出。Day 17 的教訓是「不要拿名字當證據」;這裡是同一課的反向操作——不信名字,釘字面,讓字面變動可偵測。

strip 之後 answer 可能是空的,於是 response 多了兩個 incomplete_reason 值:tool_call_limititeration_limit。命名沿用 Day 6 的 incomplete 詞彙,但這不是 Day 6 伏筆的回收——Day 6 那條「client 必須忽略未知」管的是 SSE 事件名(Day 9 才擴及 additive 欄位),沒有替 enum 值背書。這兩個值是 /agent 這個 JSON endpoint 自己的契約,從第一版 schema 就寫明。

tool 結果與 log:什麼出得去、什麼只落地

agent 跑的過程,對 client 暴露多少、對 log 留多少?兩個面各有一條裁決。

對 client:response 帶一份執行 trace,但 tool 的輸出刻意不在裡面

class AgentToolCallModel(BaseModel):
    tool_name: str
    arguments: dict[str, Any] | None   # 模型吐了 unparseable JSON 時為 null
    round_index: int
    executed: bool

trace 回答的是「agent 做了什麼」:查了哪個工具、帶什麼參數、第幾圈、有沒有真的執行(executed=False 代表 Day 17 那個 admission counter 拒絕了它——保險絲的狀態在 trace 上看得到)。但工具查回來的 4,800 byte JSON 不出去:那些內容是給模型消化的,模型消化後的 answer 才是這個 API 的輸出。把工具原始輸出附進 response,等於把內部檢索面升格成公開 API 面——之後想改工具的回傳形狀,就是 breaking change。

對 log:三個事件,各管一段。

agent_tool_execution 每次工具呼叫一行,記 tool_nameseqexecutedlatency_msargs_bytes——argument 的內容永遠不落 log,只記位元組數。查詢字串可能含使用者資料,這跟 Day 15「group ids 不進 log」是同一條紅線。

agent_run_summary 在 adapter 的 finally 裡發,保證成功失敗都有一行;失敗時 model_calls/stop/usage 這些欄位填的是字串 "unavailable"——不是 0(那是捏造)、不是 natural(那是說謊)。

agent_turn_stage 記 service 層每個階段的 outcome 與耗時,只留 exception class 不留訊息文字。三個事件都掛 correlation id,跟 Day 8 的 prompt 溯源、Day 9 的 usage 行 join 得起來。

timeout 與 retry 這天沒有新東西,值得一句交代:timeout 沿用 Day 5 的 client 設定;retry 要分兩層說。SDK 的 transport 層在 retryable failure 時會替單一 model request 重試(LLM_MAX_RETRIES,預設 2——Day 5 起的既有設定,agent 的 client 同樣吃它)。

這天刻意不做的是 run 層的重試——失敗 turn 零寫入、預算不回補,在這套語意下「重跑整個 agent run」是 client 拿著同一個問題再問一次的決定,server 代勞反而攪亂記帳語意。

壓軸:誰的權限在跑工具

第四題沒人預告,是 spec review 抓出來的,而它最後動的是 /chat

先看起點。agent 的工具在每個 request 用 caller 的 principal 綁定——模型能選 query,永遠選不了身份,Day 15 的 fail-closed 原則。看起來擋住了:B 沒有 oncall group,B 叫 agent 查 oncall 文件,檢索面就是查不到。

漏洞在另一個面。對話是 tenant-scoped 的(Day 15),同租戶任何人拿到 conversation_id 都能 continue。於是:A(有 oncall group)請 agent 查過 escalation 流程,group-限定文件的內容進了 answer、進了 history;B(同租戶、無 group)拿著這個 conversation_id 來 continue——B 的 principal 確實跑不動新的檢索,但 history 裡已經躺著 A 檢索到的內容,一句「幫我摘要這段對話」就全部取走。

ACL 擋在檢索面,擋不住 replay 面。這個洞在 /chat 就存在,只是 agent 把「權限內容自動進 history」變成常態,才逼它現形。

https://ithelp.ithome.com.tw/upload/images/20260818/20168288EZTEbn2r9q.png
忍喵:「權限系統最怕的不是破門,是內容自己長腳。凡是把檢索結果寫進 history 的設計,都欠這一題一個答案。」

裁決:conversation 在誕生的那個 turn 綁定 creator 的 exact sorted group_ids,之後所有 continuation——/chat/chat/stream/agent 一體適用——檢查 exact match,不合就 404 conversation_not_found,與「不存在」同形(Day 15 的 404 同形律:不告訴你它存在但你不能看)。

為什麼是 exact match 而不是 superset(權限更大的人可以看)?因為 scope 記的不是「誰夠格」,是這段內容產生時的權限上下文。superset 判斷等於把每次 continue 都變成一次隱式的 re-authorization,而群組成員是會變動的——今天夠格的人,明天未必;用 exact match,對話的可見範圍在第一個 turn 就凍結,之後不隨任何人的群組變動而漂移。

對話授權 scope 綁定的時序圖:使用者 A(有 oncall 群組)POST /agent 開新對話,第一個 turn commit 時凍結 scope=A 的 sorted group_ids,接著以 A 的 principal 檢索到 oncall 群組限定的 chunk、答案含限定內容寫回 history,回 200。之後使用者 B(同租戶、無 oncall 群組)拿同一個 conversation_id POST /chat 要求摘要這段對話:Day 18 之前,閘門只掛在檢索那條邊——以 B 的 principal 檢索確實查不到 oncall 內容(ACL 仍然有效),但 history 裡已經躺著 A 檢索到的內容,replay 這條邊上沒有任何閘門,於是回 200 摘要,限定內容經由 history 外流;Day 18 起,continuation 檢查 exact scope,B 的 group_ids 不等於凍結的 scope 就回 404 conversation_not_found,與「不存在」同形——exact match 而非 superset,因為 scope 記的是內容產生時的權限上下文,不隨任何人的群組變動而漂移

ACL 那道閘門一直掛在 search 那條邊上,從來沒有掛在 history 那條邊上——而摘要一段對話,根本不需要檢索。

https://ithelp.ithome.com.tw/upload/images/20260818/20168288sK6pXM0u4D.png
忍喵:「反過來說:哪天你被加進新群組,昨天自己開的對話對你就是 404——這是規格,不是 bug。客服說詞先想好。」

實作面留一句給讀者對照程式碼:store 的 append 長出一個 first_turn_authorization_group_ids 參數,None 表示「這不是第一個 turn,不准帶 scope」、空 tuple 表示「合法的空 scope」——沒有群組也是一種權限上下文,跟「忘了帶」是兩件事,型別上就分開。

/chat 的 service 簽名也跟著從 tenant_id 換成整個 principal。所以這天 diff 裡改動最大的既有檔案,是 /chat 的——加的是 /agent,改約的是 /chat

附帶一個組合層的裁決:/chat/agent 兩個 service 共用同一個 conversation store 與同一把 per-conversation lock。沒有共用,同一個對話一邊 chat、一邊 agent 同時寫,revision 就 race。這件事沒有任何 handler 程式碼可看——它就是 main.py 組合點上的兩行注入,但它跟任何 handler 邏輯一樣是正確性的一部分:組合即契約

證明它真的接上了

最後是取證,Day 17 學費的複利。

改 Protocol 之前,先寫了一個直接打在框架上的 wire 測試:把三則 Message 當 history 傳進 Agent.run(messages=...),斷言 wire payload 上 user/assistant/user 順序不變、task 恰好出現一次、store=False 還在。這次沒把文件裡的參數名當證據——測試若紅,整個設計停工重談,而不是寫完 Protocol 才發現框架根本不是那樣帶 history。

然後是 live 驗證的證法問題:200 不是證據。fake 後端能讓整條路全綠——fake 正確回報 history 長度,只證明 wiring 接對了,不證明 history 真的到了模型面前。

live 的證法是內容:先用 /chat 種一個只存在於記憶體的隨機 marker(不寫進任何檔案、不出現在任何 log),接著 agent 的 task 不重述它,要求模型「複述對話裡那個代號」;最後再開一個 /chat follow-up,要求從 agent 的 answer 裡把 marker 取回來。鏈上任何一環斷掉——history 沒進 run、agent turn 沒 commit 回對話——斷言當場紅。

實跑結果(2026-08-03,japaneast,deployment chat-mini=gpt-5-mini 2025-08-07):

$ uv run python tools/agent_endpoint_smoke.py --backend real --mode full --base-url http://127.0.0.1:8200
PASS  chat turn 200
PASS  agent turn 200
PASS  agent turn committed usage
PASS  agent answer carries prior-turn marker
PASS  follow-up chat 200
PASS  follow-up chat sees agent turn
PASS  scope mismatch 404
PASS  unknown id 404
conversation_id=9e5e8557-... total_duration_s=10.0

budget 模式另起一個 CONVERSATION_TOKEN_BUDGET=1 的 server,驗的是 live 的 429:seed 一個 turn 讓 ledger 有數字,下一個 agent turn 撞牆——429、token_budget_exceeded、沒有 Retry-After,四個斷言全過。成本量級沿用 Day 17 的實測(同題 agent 約 3,150 tokens、3 次 model call);這次 smoke 驗的是行為不是成本,沒有另抓 per-call 的 usage capture。

這天的誠實邊界

  • smoke 的 --backend real 是自報的主張。腳本強制你明講 fake 還是 real(決定這份 capture 證明哪個 claim),但沒有 runtime 對帳——server 是不是真的接著 Azure,靠跑的人確認環境。宣稱 fake 卻接著真後端,字面斷言多半當場紅;宣稱 real 卻接著 fake,則沒有機械保證會被抓到。
  • 「跨 endpoint 序列化」的直接測試其實是兩個 agent turn 在共用 lock 上序列化;chat+agent 跨服務的保證是組合層的——同一把 lock 注入兩個 service——沒有單一測試直接釘住「create_app 給了兩邊同一把鎖」。
  • 沒有 two-principal 對照測試證明兩個不同 principal 的 run 各自過濾;per-run 綁定目前是結構性證明(綁定發生在 run() 內、fake 記錄收到的 principal),不是行為對照。
  • 失敗 run 的帳追不回usage=None 那段不進 ledger,Day 9 的 failed-turn 缺口原樣繼承,這天沒有縮小。
  • live 驗證是單次執行,不是分佈。
  • 最後一條與 agent 無關,但值得記:merge 前的整支 review 在一份公開文件的末尾抓到一行混進去的工具痕跡(一個 </content> 標籤)。diagram gate 只 render fence 裡的圖,fence 外的垃圾行它根本不看——所有 gate 都綠,不等於檔案乾淨。Day 13 學過「寫在文件裡但沒有閘門擋的規則等於沒有規則」;這次是同一課的反面:有閘門,但閘門視野外的地方,等於沒有閘門

收束

四個裁決各收一句:帳——一 run 一 turn,失敗零寫入;錢——run 前查一次,overshoot 有上界;話——框架的英文出不了 adapter,改字會紅;權——對話凍結在 creator 的 scope,不合就 404 同形。

再收一個 pattern:這四條沒有一條是 agent 的新發明。它們分別是 Day 9 的 ledger、Day 9 的預算、Day 6 的詞彙、Day 15 的 ACL——只是這次談判的對象,換成了「一次呼叫會自己滾很多圈」。把 agent 接進 backend 的難度不在 agent——在你的 backend 已經承諾過的事。

工程需求 Azure / Microsoft 對應服務 本篇怎麼用
Agent loop、tool dispatch Microsoft Agent Framework(agent-framework-core 1.13.0) 藏在 AgentService Protocol 後,history 以 messages 傳入、stateless
模型推論 Azure OpenAI(gpt-5-mini 2025-08-07,japaneast) agent run 與 marker 驗證的 live 後端
文件檢索 Azure AI Search agent 的 search_docs 工具接的檢索面;本次 smoke 未斷言它被呼叫

下一篇 Day 19,處理這整天都踩在上面的那塊浮冰:X-Tenant-IdX-Group-Idstrusted headers——scope binding 整套機制的地基,是「這兩個 header 可信」這個假設。Day 19 用 Microsoft Entra ID 把假設換成驗證;而有了真身份,per-user quota 這類 Day 9 就想做、一直沒有主詞可掛的東西,才終於有地方掛。


本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 17:Microsoft Agent Framework 初探——model client、agent 迴圈與 tools,以及在哪一層取證才算數
下一篇
Day 19:使用 Microsoft Entra ID 做 API 身份驗證——驗簽不是難題,簽章證明不了的才是
系列文
Backend 工程師的 Azure GenAI 實戰19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言