iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

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

Day 17:Microsoft Agent Framework 初探——model client、agent 迴圈與 tools,以及在哪一層取證才算數

  • 分享至 

  • xImage
  •  

Day 16 花了一整篇決定「要不要做 agent」,這天開始動手。最先花時間的卻是搞清楚:我裝的這個東西到底是誰、它替我做了哪些事——因為 agent 框架的本質就是把控制流交給它,交出去之前總得知道交給了什麼。這篇用三個零件當地圖:model client、agent 迴圈、tools;一路上還會遇到四次查證失誤,每一次都是把某個「名字」當成了「事實」,而推翻它們的方法比 API 本身更值得帶走。

這天的 milestone 是 services/agent_framework.pyday-17 tag):一個藏在自家 Protocol 後面的 agent wrapper、三個唯讀工具,和一支會自己下斷言的 demo 腳本。文末附第一份真的跑過 provider 的成本數字,兌現 Day 16 欠的帳。

出身:Semantic Kernel 與 AutoGen 的下一代

先回答「這是誰」。Microsoft 之前有兩條 agent 路線:Semantic Kernel 走企業功能(session 狀態管理、型別安全、middleware、telemetry),AutoGen 走簡單的 agent 抽象與 multi-agent 模式。

Agent Framework 是兩條路線的合流——官方 overview 明講它是「direct successor, created by the same teams」、「the next generation of both Semantic Kernel and AutoGen」,並附上兩份 migration guide(查核 2026-08,overview)。

所以選它,賭的成分比看起來小:跟的是兩條既有路線的官方後繼。

框架分三類能力:agents(單一 agent 呼叫 LLM、用工具)、harness(配好規劃、記憶、觀測等配件的長任務 agent)、workflows(graph-based,把 agents 與一般 functions 接成顯式控制的流程;multi-agent 編排是其中一種用法)。本系列只用第一類——Day 16 的結論就是這個系列的題目用單一 agent 加工具就夠,workflows 與 multi-agent 都不做。

另一個打開官方文件會撞到的落差:Learn 的 quickstart 現在示範的是 FoundryChatClient 加 Foundry project endpoint(查核 2026-08,同上頁)。本系列的資源是 Day 4 開的 Azure OpenAI,不走 Foundry,對應的 client 是 OpenAIChatClient——下一節的主角。

版本釘死,還要上鎖

裝的版本是 agent-framework-core==1.13.0agent-framework-openai==1.12.0,exact pin 不是 >=。原因是這個框架的 API surface 才剛重構過。

provider 主導的 namespace 重組發生在 python-1.0.0rc6升版指南,查核 2026-08):agent_framework.azure.AzureOpenAIResponsesClient 對應改為 agent_framework.openai.OpenAIChatClient,相容 shim 其後移除。

Learn 的部分頁面到現在還留著舊類別名。在這個 pin 上實際 import 舊路徑,得到的是 ImportError。

pin 之外還寫了五個 API-surface lock test:它們不是行為測試,是「pin 一升版就先在這裡爆」的斷言——預設 iteration 上限是 40、OpenAIChatClientasync_clientAgent.run 收 per-run tools⋯⋯之類。其中一個要誠實講:它 lock 的是 inspect.getsource() 的字串子字串,上游只要改排版就可能誤報。有測試不等於穩,那個測試自己就是最脆的一環。

裝下去會多幾個套件?

Day 16 的文章裡有一句「最小組合 21 套件」。動手時對不上:uv lock 之後套件數從 63 變成 67,只多了 4 個agent-framework-coreagent-framework-openaimsgspecopentelemetry-api)。

兩個數字都沒錯,講的是不同的東西。21 是相依 closure:把這兩個套件裝進一個空環境要拉幾個下來;4 是這個專案的增量,因為 pydanticpython-dotenvtyping-extensionsopenai 早就在了(FastAPI、pydantic-settings,還有 Day 5 開始用的 openai SDK)。

順帶一提,我原本連 21 的主詞都記錯。重新解析一次(uv pip compile 進乾淨環境,2026-08):

echo 'agent-framework-core==1.13.0' > core-only.in
uv pip compile core-only.in -o core-only.txt --no-annotate --no-header
# → 9 個套件

printf 'agent-framework-core==1.13.0\nagent-framework-openai==1.12.0\n' > both.in
uv pip compile both.in -o both.txt --no-annotate --no-header
# → 21 個套件

agent-framework-core 單獨的 closure 是 9,21 是兩個一起。至於 pip install agent-framework 那個 meta 套件:它等於 agent-framework-core[all]——這是它在 PyPI 上的自我宣告,沿用先前的查核記錄,這次沒重驗;[all] 會拖 30 個 optional 的 agent-framework-* 進來——這個 30 倒是這次對本機已安裝的 core 1.13.0 metadata 數出來的(2026-08)。本系列只用其中一個。

這是第一次把名字當成證據:數字本身不重要,會誤導人的是它的類別。「裝下去會多幾個套件」講的是環境,不是套件的性質。它寫進評估 checklist 當驗收條件時,會在別人的機器上失效。

地圖:三個零件

官方 overview 列的 building blocks 不只三個:model clients、agent session、context providers、middleware、MCP clients(查核 2026-08,同上頁)。本篇從 backend 視角挑三條主線當地圖,分工與邊界是:

零件 負責 我們的邊界
Model client 跟 provider 講話:把選項翻譯成 wire payload、送出、解析回應 餵它 Day 5 建的 AsyncOpenAI,底層就是同一條 Responses API
Agent + 迴圈 拿著 task 反覆呼叫 model client:收 tool call、派工、把結果塞回去再問一次,直到有最終答案或撞上限 上限、成本、stop 判定
Tools 你的普通函式,框架負責產 schema 與 dispatch 唯讀、least-privilege、byte 上限

少了的那個 session 要交代一下,因為它在規劃重點裡。framework 的 AgentSession 是一個輕量的狀態容器,Agent.run(..., session=...) 逐次傳入,管 run 與 run 之間的狀態;它不是迴圈——迴圈是一次 run 內部的事。

容器裡除了自己的 session_id,跟接續有關的是兩樣:一個 state dict 放本地狀態(對話歷史可以存這裡),和一個可選的 service_session_id 放 provider 發的接續識別(conversation id 或 response id,看 provider)。

本系列不用它。這是所有權選擇,不是 store=False 推出來的必然:Day 7 把對話狀態定為 app-owned,而 session 的兩樣東西——本地歷史另存一份、或把接續交給 provider 的識別——一個重複、一個牴觸,所以 wrapper 每次 run(task, tools=...) 都不帶 session。app-owned 的對話要怎麼接上 agent run,是 Day 18 endpoint contract 的題目。

整組東西藏在自家的 AgentService Protocol 後面:框架的型別不出 adapter,這是 Day 2 起的架構鐵律,也是 fake adapter(測試用)能跟 real adapter 並存的前提。接下來一個零件一個零件看,每一節都有一次在那一層踩到的查證失誤。

零件一:model client——選項名與 wire 之間隔著一張翻譯表

OpenAIChatClient 是 Azure OpenAI 對應的 client:可以給 azure_endpointapi_versioncredential,或像本專案直接餵自家的 AsyncOpenAI。它底層呼叫的就是 Day 5 定下的 Responses API,default_optionsstore: False 也如實生效——對話狀態的所有權留在我們手上,延續 Day 7 的決定。

但「選項有沒有生效」這件事,我在這一層栽了兩次。

第二次把名字當成證據:設計成本上限時,我需要知道能不能關掉「一個 response 裡發多個 tool call」。查了一輪,結論寫進設計文件:這個版本沒有 allow_multiple_tool_calls

錯的。那次查核只 grep 了 agent_framework/_tools.py,因為 tool 相關的東西「應該」住在那裡。但這個選項根本不住在那。用單一檔案的 grep 去證明某個東西不存在,前提就不成立——「不存在」是全稱否定命題,而搜錯檔案得到的零筆結果,跟真的不存在長得一模一樣。

它其實三層都接通了:

位置 內容
宣告 agent_framework/_types.py ChatOptionsallow_multiple_tool_calls: bool
轉發 Agent(default_options=...) adapter 建 agent 時傳進去
翻譯 agent_framework_openai/_chat_client.py 送出前改名成 wire 上的 parallel_tool_calls

推翻它的不是「再 grep 一次」,是換一層看:對安裝好的套件做 import 與型別內省,而不是對某個檔案做文字搜尋。

第三次踩在同一塊石頭上,只是這次名字是對的、含意是錯的。實作計畫寫著 default_options 要帶 max_output_tokens。照抄,mypy strict 直接擋下來:OpenAIChatOptions 繼承的是一個封閉的 TypedDict,宣告的是 max_tokens,沒有 max_output_tokens

第一個念頭是 cast("Any", ...) 硬塞過去。這是錯的解法,理由不是潔癖。那個 cast 會連帶把 storeallow_multiple_tool_calls 也排除在型別檢查外,為了塞一個 key 放棄三個 key 的保護。

真正的答案是:max_tokens 在這個 client 上就是 Responses API 那個 max_output_tokens,client 會在送出前的 translation table 幫你改名。它不是 chat-completions 時代的遺跡。

但這裡才是重點:上面那段話我沒辦法用讀原始碼來「證明」給讀者看,因為那正是我前兩次犯的錯。所以測試是攔在 SDK 真正要送出去的那一層,捕獲每一筆 payload,逐筆斷言 store is Falsemax_output_tokens 等於設定值、parallel_tool_calls is False

# tests/unit/test_agent_real_adapter.py(節錄)
for payload in captured_payloads:
    assert payload["store"] is False
    assert payload["max_output_tokens"] == settings.llm_max_output_tokens
    assert payload["parallel_tool_calls"] is False

選項名字證明不了任何事,wire payload 才證明選項真的送出去了。 至於 provider 收到後理不理會,wire 這一層也答不了——那要到 live 那一層才看得到。

零件二:agent 迴圈——上限、計數器,與那句會漏出去的英文

Agent 拿著 task 跑一個迴圈:問一次模型,模型回 tool call 就派工執行、把結果塞回對話再問一次,直到模型給出最終答案,或撞到上限。這個迴圈是 agent 框架真正替你做的事,也是 Day 16 說的「控制流交給模型」在程式碼裡的長相:每一圈要不要再呼叫工具、呼叫哪個,是模型決定的。框架管的是圈數與額度:預設 iteration 上限 40,本專案收緊到 5。

上限就是成本邊界,這裡設計了三層:

  1. Sequential modeallow_multiple_tool_calls: False):主要邊界,一個 response 只出一個 tool call。
  2. 框架自己的 max_function_calls:在 batch 與 batch 之間檢查。
  3. 自家的 admission counter:per-run,槽位一經准入永不歸還,連取消都不還。

寫第三層是因為第二層是 best-effort:它在 batch 之間檢查,管不到單一 batch 內部超收。寫完之後我想確認它真的會擋,於是去追框架在額度用完時做了什麼。結果是它不會開火

sequential 模式下,框架的動作順序是這樣:某個 batch 之後累計數達到上限,框架把 tool_choice 設成 "none"下一個 response 進來時,框架先把裡面的 function-call 內容整段刪掉,再補一則 fallback 文字訊息。等到 dispatch 那一步,已經沒有 call 可以派了。

那第三個 call 在被 dispatch 之前就消失了,admission wrapper 根本沒被問到。

所以正確的說法是:自家的計數器是保險,不是主閥。 它哪天開火,代表的是 sequential mode 沒守住,那才是它存在的意義。我一度想把這層刪掉(不會執行的程式碼是負債),但兩條路徑各有一個測試釘著:sequential 那條 refused_call_count == 0,超收那條 >= 1。留著的理由是後者:provider 端只要哪天不理會 parallel_tool_calls,第三層就是唯一還站著的東西。

那撞到上限時,使用者會看到什麼?框架寫死的那句英文:"Function invocation limit reached before a final answer could be produced." adapter 的 answer 取的是終端 assistant 訊息的文字,所以這句話會原封不動變成 API 的回答。測試就是照這樣斷言的,沒有包裝;包裝掉就看不到上游詞彙洩漏進自家 API 這件事。這是 Day 18 要收的邊界,延續 Day 6 定下的「上游詞彙不出 adapter」。

測迴圈而不測 provider:在正確的層下刀

前面兩次失誤都是在錯的層取證。這裡有個正面案例,而且這段可以直接照抄去測別的框架。

我要測「撞到上限時會發生什麼」,但不想真的呼叫 provider。直覺做法是繼承一個 base chat client 假裝成 provider——這條路是錯的,而且錯得很安靜。看一眼 MRO:

OpenAIChatClient → FunctionInvocationLayer → ChatMiddlewareLayer
    → ChatTelemetryLayer → RawOpenAIChatClient → BaseChatClient → ...

tool-calling 的那個迴圈住在 FunctionInvocationLayer。繼承 BaseChatClient 來假裝 provider,會整層跳過那個迴圈。測起來一切正常,但測到的不是要測的東西。

正確的下刀位置在更底下的 transport。框架其實不呼叫 responses.create,它呼叫的是 client.responses.with_raw_response.create(...),然後 .parse(),再讀 .headers。實測那個 raw-response wrapper 的全部合約就這兩個成員,所以換掉它只要覆寫一個屬性。

刀口在那裡,上面全是真貨:真的 OpenAIChatClient、真的 option 翻譯、真的 FunctionInvocationLayer 迴圈、真的 tool dispatch、真的 usage 聚合。這也是為什麼下面的成本數字敢寫:它們是真的 loop 跑出來的,機制面早在 mock transport 上驗過同一套。

零件三:tools——普通函式進去,邊界自己畫

框架這一側最省事:tools 就是帶型別註記的普通 Python 函式,schema 產生與 dispatch 框架包了。要操心的全在自己這一側——工具拿到的權限,就是 agent 拿到的權限,所以這天的三個工具全部唯讀、least-privilege:查文件(search_docs,走 Day 15 那條 ACL-filtered 檢索,固定一個 demo principal)、查執行期設定、查對話的 token ledger。

自己畫的邊界有兩類。一是輸出上限:單一 snippet 先做 1,200 字元的初篩,整包 JSON 再以 4,800 UTF-8 bytes 封頂,超了才按 byte 回縮 snippet。因為工具輸出會整段進下一輪 prompt,不設限等於讓檢索結果決定你的 token 帳單——最終邊界不引 tokenizer、用 byte 計,是 Day 9 與 Day 14 的老紀律。

二是准入:admission wrapper 包住每一次 dispatch,記帳、量延遲(try/finally,失敗也記),也就是上一節那個「保險」實際安裝的位置。

真的跑一次,要多少錢

Day 16 那篇欠了一筆帳:agent 的成本乘數當時是結構推論,沒有數字。這裡補上(gpt-5-mini 2025-08-07、japaneast,查核 2026-08,單次執行)。

題目 模型呼叫 tool 輪 end-to-end tool 時間 模型時間 tokens
純設定查詢 2 1 3,377 ms 0 ms 3,377 ms 1,612
純文件查詢 2 1 6,902 ms 544 ms 6,358 ms 2,267
設定+文件 3 2 8,134 ms 472 ms 7,662 ms 3,150
診斷(額度將盡) 3 2 13,373 ms 489 ms 12,884 ms 4,170
診斷(全新對話) 3 2 13,472 ms 522 ms 12,950 ms 4,720
查無資料 3 2 8,683 ms 675 ms 8,008 ms 3,691

同一題「設定+文件」拿去跟單次呼叫的 baseline 比:agent 是 3,150 tokens、3 次模型呼叫;baseline 是 1,267 tokens、1 次呼叫。token 約 2.5 倍、呼叫 3 倍。

那個 2.5 倍要連著範圍讀:baseline 帶的 instructions 跟 agent 的不等價(capture 自己記著這條 caveat),所以它是這一次、這組 prompt 下的觀察值,不是隔離出來的框架淨乘數。3 倍呼叫倒是 trace 直接數出來的。

時間就更粗了。8,134 ms 是 agent 的 end-to-end,6,003 ms 是 baseline 的模型時間,口徑本來就混;同用模型時間算是 7,662 對 6,003,約 1.28 倍——但兩邊 prompt 不同,哪個比值都只能當粗略指標。

真正反直覺的是時間花在哪。工具總共 0 到 675 毫秒,模型 3.4 到 13.0 秒。純記憶體的那個設定查詢工具是 0.02 到 0.08 毫秒等級,真正花時間的只有搜尋(約 470 到 676 毫秒)。但就算把工具時間全部歸零,帳單也幾乎不動。

agent 貴在多跑幾次模型,不在工具本身。 Day 16 推論的 N+1 成本形狀,這裡量到了:多一輪 tool-result,就是多一次完整的模型呼叫,而且每一次都要把長大的 context 重送一遍。

https://ithelp.ithome.com.tw/upload/images/20260817/20168288e5A92fQhvN.png
忍喵:「這一題量到 2.5 倍 token、3 倍呼叫,買到的是讓模型自己選路。上線前先想好哪些題目值這個價——答不出來的,走 Day 14 那條單次呼叫就好。」

第四次:assertion 的措辭

前三次都在 live run 之前發現。第四次是 live run 自己抓到的,而且錯的不是 agent,是我。

demo 有一題故意問語料裡沒有的東西,斷言要求回答裡出現 prompt 那句「no supporting evidence」。跑下去,失敗。看回答:

I couldn't find any documentation in this deployment's docs about retention for uploaded fine-tuning datasets. I searched the docs for relevant terms twice and found no supporting material.

行為完全正確——搜了兩次、明說找不到、沒有捏造引用、沒有拿通用知識來答。失敗的是同義詞

這是這個 milestone 第三次踩到同一族的措辭脆弱(前兩次在寫測試時就抓到了)。修法不是放寬成「只要有承認的意思就算過」,那等於這條斷言什麼都不擋。改成兩半。前一半接受一組 absence 措辭家族,後一半是結構檢查:查無資料的回答不得帶引用,因為根本沒有東西可引。

這比原本更強:舊版會放過「用了那句魔法字、但同時捏了一個引用」的回答。改完重跑一次,13 個斷言全過。

這天的誠實邊界

  • per_round 的 usage 永遠是 None。非串流 client 把 usage 掛在 response 物件上,不在訊息內容裡;串流聚合器則會把它從訊息裡剝掉。所以每一輪的 token 帳拿不到,只有整個 run 的總數。延遲倒是真的量到的。
  • 失敗的 run 帶不出 usage。框架把聚合用量當成函式區域變數,丟例外時什麼都不掛。所以 run 到一半失敗時,已經完成的那幾次呼叫已經被計費,但那個數字在我們拿到之前就沒了。這跟 Day 9 記過的 failed-turn 缺口同形。權威帳是 Cost Management,我們的 ledger 只負責止血。Day 17 沒有讓這個缺口變小。
  • fake 模式的「通過」不是行為證據。demo 的 fake adapter 以固定順序呼叫工具、conversation id 寫死,所以「兩個診斷問題的 trace 會分岔」這類主張在 fake 下結構上就不可能成立。處置是:六個 provider 相依的斷言(模型選路、答案語意、ledger 指向)在 fake 下記成 skipped_fake 並附理由,永遠不記成 passed;其餘七個——含兩個答案內容的佐證斷言——兩種模式都跑。目前 fake 模式的輸出是 7 passed、6 unverified。
  • 上面的數字是單次執行。同一題重跑五次,跑出五條不同的 trace(差異在搜尋 query 的措辭)。單次量測不是分佈。
  • 調大逾時救不了服務端的取消(查核 2026-08)。live 驗證那天,Free tier 的 Search 查詢面 15 次嘗試零成功——多數在 client timeout 內等不到回應,拉長 timeout 的三次探測在 60.3、85.0、60.2 秒收到 503 "Operation was canceled"。取消是服務端做的、時點會浮動,調大 client 逾時救不回來。metadata 端點全程 0.2 秒回應;改開 Basic tier 後同一查詢 0.16 到 0.31 秒回來,跑完就砍掉。
  • 這個 milestone 的可驗證性,掛在一台廉價服務的死活上。ephemeral-by-default 的省錢策略(Day 4 起的成本紀律)換來的代價,就是驗證窗口要跟共用的廉價基礎設施搶運氣。而且 demo 自己不知道它需要 Search 活著,spec 沒寫,review 也沒問。
  • 那份 capture 的 commit 記錄低報了。13 個斷言全過的那次 run,跑的是記錄的 commit 加一個當時尚未 commit 的 assertion 修正,capture 卻只寫了 bare SHA——工作樹乾不乾淨沒有記。修正已進 day-17 tag,之後的 capture 會在不乾淨時加 +dirty 後綴,但這救不回那份已經寫壞的舊 capture,也不會回頭改寫它。一篇講「在哪一層取證」的文章,自己的證據檔先踩了 provenance 的坑。

https://ithelp.ithome.com.tw/upload/images/20260817/20168288gQkeOMQUhl.png
忍喵:「fake 全綠只證明 fake 跟你想的一樣。回頭看看自己 CI 上那排綠燈——有幾顆是這種綠?」

收束

三個零件收攏成三句話:model client 是一張會替你改選項名的翻譯表,所以斷言要下在 wire payload;agent 迴圈是控制流的新主人,上限與計數器是你替自己保留的否決權;tools 是框架幫你接線的普通函式,最終邊界得自己畫在 byte 上。

方法論那條線也能收成一句:一個框架主張要在哪一層取證才算數。套件的相依足跡看 metadata 與乾淨環境重解析;選項的翻譯與送出看 wire payload;framework 迴圈在真框架加 mock transport 上實跑;但模型的行為——選哪個工具、答什麼、遵不遵守選項——只能看真的 provider。每降一層,前一層的「證據」就可能整個作廢。四次失誤的共同結構都是拿名字當事實:套件數的主詞、檔名、選項名、斷言裡的字面措辭。

技術上最值得帶走的一條,是那個永遠不會開火的計數器——自己加在工具准入上的那層保險。它不是死碼,是保險;它開火的那天,代表主閥壞了。

工程需求 Azure / Microsoft 對應服務 本篇怎麼用
Agent loop、tool dispatch、usage 聚合 Microsoft Agent Framework(agent-framework-core 1.13.0、agent-framework-openai 1.12.0) 藏在自家 AgentService Protocol 後;框架型別不出 adapter
模型推論 Azure OpenAI(gpt-5-mini 2025-08-07,japaneast) agent 的 model client 與成本量測
文件檢索 Azure AI Search(查核 2026-08) agent 的 search_docs 工具;驗證當天臨時開 Basic tier,跑完刪除

下一篇 Day 18,把 agent 包成 backend API:endpoint contract、session(app-owned 對話怎麼接上 agent run)、tool 執行結果怎麼進 response、timeout/retry/observability,以及錯誤正規化——那句會漏進 API 回答的框架英文字串就在這裡收掉。

而動手訂 contract 之前,有兩個這天沒解的前置問題得先裁決:一個 agent run 算「一個 turn」還是「N 次模型呼叫」(決定 token ledger 怎麼記帳),以及預算檢查要 per round 還是 per turn(Day 9 的規則是 inference 前檢查,但 run 開始前不知道會跑幾圈)。


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


上一篇
Day 16:什麼時候需要 Agent——分界線不是任務難度,是控制流的所有權
系列文
Backend 工程師的 Azure GenAI 實戰17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言