2026年10月20日 星期二

MFA006 多回合對話

MFA006 多回合對話

在上一篇關於 Microsoft Agent Framework 系列教學文章 "MFA005 使用自訂 DelegatingHandler 查看通訊內容" 中,我們學會了如何使用自訂的 DelegatingHandler 來記錄每一次 HTTP 請求與回應的內容,從而觀察底層的通訊細節。在這篇 MAF 教學文章中,我們將學習如何在多回合對話中,讓 AI 代理記住之前的對話內容,從而實現更自然的對話體驗。為什麼需要說明多回合對話呢?這是因為在大語言模型的原始設計架構中,就是為了單一回合對話而設計的,當使用者送入一份提示詞之後,模型只會根據這一次的輸入生成回應,而不會記住之前的對話內容。這樣的設計雖然簡單,但在實際應用中,往往需要 AI 代理能夠記住對話歷史,從而提供更連貫的回應。

在真實環境應用中,這些系統是需要能夠記憶住之前對話的內容,如此便可以降低幻覺產生更大的機率,舉的例子來說,在一個使用AI為基礎的客服系統中,如果代理能夠記住使用者之前的問題與回應,那麼在後續的對話中,代理就能提供更精準的建議與解答,而不會因為每次都是單一回合對話而遺忘之前的上下文。若是系統忘記了之前對話的內容,將會造成使用者需要重複提供相同的資訊,降低使用者體驗,並可能導致錯誤的建議或解答。不過,若要記住之前所有的詢問與回答內容,將會面臨到記憶空間的問題,對於大語言模型而言,這是一個需要平衡的挑戰。在大語言模型中,每一輪對話將會受限於大語言模型的上下文長度(context length),也就是模型在生成回應時,能夠考慮的歷史對話內容的最大範圍。當對話歷史超過這個限制時,模型將無法記住更早的對話內容,因此在設計多回合對話系統時,需要考慮如何有效管理和利用對話歷史。

有了這樣的背景說明之後,在這個練習專案內,將會示範如何建立一個能夠記住多回合對話歷史的 AI 代理,從而實現更自然的對話體驗。這裡將會進行兩輪的對話,第一個使用沒有多回合記憶的 AI 代理,第二個使用具備多回合記憶的 AI 代理,從而比較兩者在對話連貫性上的差異。

接下來看看如何做到這樣的效果。

建立 Console 專案

  • 開啟 Visual Studio 2026
  • 選擇「建立新專案」
  • 在 [建立新的專案] 視窗中,在右方清單內,找到並選擇「主控台應用程式」 項目
  • 然後點擊右下方「下一步」按鈕
  • 此時將會看到 [設定新的專案] 對話窗
  • 在該對話窗的 [專案名稱] 欄位中,輸入專案名稱,例如 "csConversationAgent"
  • 然後點擊右下方「下一步」按鈕
  • 接著會看到 [其他資訊] 對話窗
  • 在這個對話窗內,確認使用底下的選項
    • 架構:.NET 10.0 (或更新版本)
    • 勾選 不要使用最上層陳述式 (這是我的個人習慣)
  • 然後點擊右下方「建立」按鈕
  • 現在,已經完成了這個 Console 主控台 專案的建立

安裝需要用到的 NuGet 套件

要完成這個第一次接觸 Microsoft Agent Framework 練習專案,需要安裝以下幾個 NuGet 套件:

Microsoft.Extensions.AI

這個套件將會為生成式 AI 定義的一層「統一抽象介面 + Middleware 管線」,讓開發者可以更方便地整合不同的生成式 AI 模型,並且在應用程式中使用一致的方式進行呼叫與管理。

  • 滑鼠右擊 [csConversationAgent] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • Microsoft.Extensions.AI

Microsoft.Extensions.AI.OpenAI

這個套件是微軟官方為 .NET 整合 AI 服務所提供的擴充套件。它實現了 IChatClient 與 IEmbeddingGenerator 核心抽象介面,讓開發人員能以一致的程式碼,輕鬆串接 OpenAI 或相容的 API 端點(例如 Azure OpenAI、GitHub Models 等)。

  • 滑鼠右擊 [csConversationAgent] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • Microsoft.Extensions.AI.OpenAI

Microsoft.Agents.AI

這個套件是微軟官方推出 Microsoft Agent Framework(微軟智慧代理框架)的核心 .NET 套件。它主要用於在 .NET 環境中建構、協調與部署 AI 代理(AI Agents) 及多代理工作流(Multi-agent workflows),是用來取代舊版 AutoGen 的企業級解決方案。

  • 滑鼠右擊 [csConversationAgent] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • Microsoft.Agents.AI

OpenAI

這個套件為官方的 OpenAI .NET 客戶端類別庫,是 OpenAI NuGet 套件。此套件由 OpenAI 與 Microsoft 合作開發,提供直接存取 OpenAI 官方 REST API 的便利管道。

  • 滑鼠右擊 [csConversationAgent] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • OpenAI

宣告 API 端點與 Key 環境變數

關於這個小節的內容,將會在這個系列每篇文章中都會用到,因此,可以參考 MFA001 Microsoft Agent Framework 初次體驗開發出 AI 代理 文章中的 [宣告 API 端點與 Key 環境變數] 說明內容,若這裡提到的環境變數已經建立好了,則可以跳過這個小節內容。

修改 Program.cs 程式碼

為了要完成這個需求 : 如何用 Azure OpenAI 的 gpt-5.6-luna 聊天模型建立一個簡單 AI 代理,並產生一篇與鵝有關的詩。 因此,我們需要修改 Program.cs 程式碼,這時需要完成底下的操作。

  • 在專案內,找到並且開啟 [Program.cs] 檔案
  • 使用底下程式碼內容來取代 [Program.cs] 檔案的內容
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.Text;

namespace csConversationAgent;

internal class Program
{
    private const string Turn1 = "我叫 Vulcan,我養了三隻鵝,分別叫大白、小白和阿花。";
    private const string Turn2 = "我養了幾隻鵝?牠們叫什麼名字?";

    static async Task Main(string[] args)
    {
        Console.OutputEncoding = Encoding.UTF8;

        var apiKey = Environment.GetEnvironmentVariable("AzureOpenAI_Key");
        var endpoint = Environment.GetEnvironmentVariable("AzureOpenAI_Endpoint");
        var model = "gpt-5.6-luna";

        if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(endpoint))
        {
            Console.WriteLine("請先設定環境變數 AzureOpenAI_Key 與 AzureOpenAI_Endpoint 後再執行。");
            return;
        }

        IChatClient chatClient =
            new ChatClient(
                    model,
                    new ApiKeyCredential(apiKey),
                    new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
                .AsIChatClient();

        AIAgent agent = new ChatClientAgent(
            chatClient,                                    // 聊天用戶端
            "你是一位友善的助理,請用繁體中文簡短回答。",  // 系統提示詞 instructions
            "助理",                                        // 代理名稱   name
            null);                                         // 代理描述   description

        Console.WriteLine($"{DateTime.Now} 使用的模型: {model}");

        await RunWithoutSessionAsync(agent);   // 對照組:會失憶
        await RunWithSessionAsync(agent);      // 正解:記得
    }

    // ────────────────────────────────────────────────────────────────
    // 執行 1:不傳 session —— 代理會失憶
    // ────────────────────────────────────────────────────────────────
    private static async Task RunWithoutSessionAsync(AIAgent agent)
    {
        Header("執行 1 / 不傳 session —— 代理會失憶");

        Say("你", Turn1);
        var answer1 = await agent.RunAsync(Turn1);
        Say("助理", answer1.Text);

        Say("你", Turn2);
        var answer2 = await agent.RunAsync(Turn2);
        Say("助理", answer2.Text);
    }

    // ────────────────────────────────────────────────────────────────
    // 執行 2:兩輪共用同一個 session —— 代理記得
    // ────────────────────────────────────────────────────────────────
    private static async Task RunWithSessionAsync(AIAgent agent)
    {
        Header("執行 2 / 共用同一個 session —— 代理記得");

        var session = await agent.CreateSessionAsync();

        Say("你", Turn1);
        var answer1 = await agent.RunAsync(Turn1, session);
        Say("助理", answer1.Text);

        Say("你", Turn2);
        var answer2 = await agent.RunAsync(Turn2, session);
        Say("助理", answer2.Text);

        Console.WriteLine();
        Console.WriteLine("【session 裡實際存放的對話歷史】");

        if (session.TryGetInMemoryChatHistory(out var history) && history is not null)
        {
            foreach (var message in history)
            {
                Say(message.Role.ToString(), message.Text);
            }
        }
        else
        {
            Console.WriteLine("  這個 session 沒有把歷史存在記憶體(例如改由服務端保存時)。");
        }
    }

    private static void Header(string title)
    {
        Console.WriteLine();
        Console.WriteLine(new string('=', 74));
        Console.WriteLine($"  {title}");
        Console.WriteLine(new string('=', 74));
    }

    private static void Say(string who, string? text)
    {
        Console.WriteLine();
        Console.WriteLine($"  {who} ▸ {text}");
    }
}

這個程式一開始將會讀取 API Key 與服務端點的數據,接著如同前面的範例相同,先建立一個 IChatClient 物件,接者建立一個 AIAgent 物件,並使用它來與模型進行互動。在建立 AIAgent 物件時,使用的系統提示詞為 "你是一位友善的助理,請用繁體中文簡短回答。"

首先將會來體驗沒有記憶的多回合對話,這裡將會呼叫 await RunWithoutSessionAsync(agent); 來執行第一輪對話,觀察代理在沒有 session 的情況下是否能記住之前的對話內容。

在這個 RunWithoutSessionAsync 方法內,使用不同的提示詞,呼叫了兩次 RunAsync 方法來與代理互動,但由於沒有傳入 session,因此代理不會記住之前的對話內容,每次都是獨立的回合。

Say("你", Turn1);
var answer1 = await agent.RunAsync(Turn1);
Say("助理", answer1.Text);

Say("你", Turn2);
var answer2 = await agent.RunAsync(Turn2);
Say("助理", answer2.Text);

由於 LLM 產生的結果是隨機的,因此,底下為可能的輸出範例,實際結果可能會有所不同。

==========================================================================
  執行 1 / 不傳 session —— 代理會失憶
==========================================================================

  你 ▸ 我叫 Vulcan,我養了三隻鵝,分別叫大白、小白和阿花。

  助理 ▸ 你好,Vulcan!你養了三隻鵝,分別是大白、小白和阿花,真可愛!

  你 ▸ 我養了幾隻鵝?牠們叫什麼名字?

  助理 ▸ 我目前不知道你養了幾隻鵝,也不知道牠們的名字。

從這裡的可以看到,在第一輪對話中,使用者說明自己的名字與養的三隻鵝的名字,但由於代理沒有記憶,因此在第二個回合中 這裡送出提示詞 "我養了幾隻鵝?牠們叫什麼名字?",代理無法回答使用者的問題,這裡看到LLM回答的是 "我目前不知道你養了幾隻鵝,也不知道牠們的名字。" ,一般人看到這樣結果,都會覺得 AI 患了失憶症。開始在胡說八道。

接著,將會呼叫 await RunWithSessionAsync(agent); 來執行第二輪對話,在這個方法一開始,將會透過 var session = await agent.CreateSessionAsync(); 程式碼建立一個 session,之後的每一次與代理的互動都會傳入這個 session,從而讓代理能夠記住之前的對話歷史。接著將剛剛 RunWithoutSessionAsync 方法內同樣的呼叫 AIAgent 物件的方法,都在執行一次,但這次會傳入 session,這樣可以看看會有甚麼變化,這裡用的程式碼如下:

Say("你", Turn1);
var answer1 = await agent.RunAsync(Turn1, session);
Say("助理", answer1.Text);

Say("你", Turn2);
var answer2 = await agent.RunAsync(Turn2, session);
Say("助理", answer2.Text);

底下為執行結果範例

==========================================================================
  執行 2 / 共用同一個 session —— 代理記得
==========================================================================

  你 ▸ 我叫 Vulcan,我養了三隻鵝,分別叫大白、小白和阿花。

  助理 ▸ 你好,Vulcan!大白、小白和阿花,三隻鵝的名字都很可愛呢。

  你 ▸ 我養了幾隻鵝?牠們叫什麼名字?

  助理 ▸ 你養了三隻鵝,牠們叫大白、小白和阿花。

【session 裡實際存放的對話歷史】

  user ▸ 我叫 Vulcan,我養了三隻鵝,分別叫大白、小白和阿花。

  assistant ▸ 你好,Vulcan!大白、小白和阿花,三隻鵝的名字都很可愛呢。

  user ▸ 我養了幾隻鵝?牠們叫什麼名字?

  assistant ▸ 你養了三隻鵝,牠們叫大白、小白和阿花。

在這個文字 "【session 裡實際存放的對話歷史" 之前,可以看到這兩輪對話,AI是有記住之前的對話內容的,因此能夠在第二個回合中正確回答使用者的問題。他回答的是 : "你養了三隻鵝,牠們叫大白、小白和阿花。" 這樣回答的結果是正確的,這也說明了在共用同一個 session 的情況下,代理能夠保持對話的連貫性。

在這個文字 "【session 裡實際存放的對話歷史】" 之後,可以看到代理是如何將使用者與助理的對話歷史存放在 session 中的。這也說明了在共用同一個 session 的情況下,代理能夠保持對話的連貫性。

其實,在這個程式碼的背後運作方式為,在第二輪對話的時候,AIAgent 不只送出 "我養了幾隻鵝?牠們叫什麼名字?" 這個提示詞,還會將整個 session 中的對話歷史一併送出給 LLM,這樣 LLM 才能根據之前的對話內容,給出連貫且正確的回應。

[AI 觀察] AI 寫程式愈快,程式碼稽核為何更重要?

[AI 觀察] AI 寫程式愈快,程式碼稽核為何更重要?

摘要:Anthropic 分享 AI 開發經驗:加快產出之餘,仍須以獨立驗證、風險分級與人工判斷守住品質。



內容:


一個 AI 寫程式、另一個 AI 找錯,兩者整夜往返修改超過五十輪,最後仍由人叫停。這是 Claude Code 團隊披露的經歷,也點出 AI 開發的難題:產出速度提高,不代表稽核能同樣加速。


Anthropic 在 2026 年 10 月 15 日的開發者部落格表示,Claude 編寫了 Claude Code 約九成的程式碼;6 月至 9 月,合併的變更請求增加逾一倍,回滾比例下降逾四成。這些是團隊的內部數據,不能直接推論其他團隊也會得到相同成效。回滾「比例」下降,也不等於回滾「次數」或事故一定減少。


稽核的難處不只在讀懂程式碼,更在確認它解決了正確的問題。代理可能寫出清楚的註解和通過測試的程式,卻誤解業務規則。例如取消訂單後恢復優惠券,還必須釐清重複取消通知是否會重複發券、部分退款該如何處理。若需求與不可接受的後果沒有先說清楚,程式寫得愈快,誤解也可能擴散得愈快。


因此,測試綠燈需要有可核對的依據:測試是否真的執行、環境和輸出是什麼、案例是否檢查了關鍵結果。讓同一個代理依照自己的錯誤實作補測試,可能只會讓兩者在同一套誤解中互相證明。較可靠的做法是結合不同證據來源——程式碼檢查、實際執行、型別與安全檢查,以及人對業務意圖的確認,而不是把多個代理的同意當成完全獨立的保證。


稽核投入也應隨風險調整。一行權限設定的變更,可能比數百行介面調整更危險;低風險修改可在驗證可靠、影響可控等條件下採用較輕流程,敏感修改則仍需人工批准。代理的批准不能取代規則要求的人類審批,而審批者也不能只看摘要點選同意,必須知道證據涵蓋了什麼、還有哪些後果需要判斷。


檢查不應止於合併前。並行修改可能各自通過測試,合在一起卻互相衝突;分階段發布需要觀察指標,回滾方案也須按實際系統驗證。尤其資料遷移可能刪除舊欄位,即使重新部署舊程式碼,也未必能恢復原始資料。功能正確之外,真實資料量下的效能同樣可能成為問題。


自動稽核也要避免過度報錯。每項發現都應指出具體位置、觸發方式與後果;大量不重要或不正確的意見,會耗費審查時間,甚至讓代理為迎合建議而引入新問題。團隊需要明確規則、停止條件,以及誰有權接受理由並關閉意見。


對準備採用 AI 開發的小團隊,更穩妥的起點是選擇影響有限的變更,寫明行為要求,建立可執行的驗證,記錄真實故障與誤報,再依證據調整人工流程。AI 可以降低寫程式碼的成本;決定什麼值得做、如何證明它可用,以及出事後如何恢復,仍是團隊必須承擔的工作。

[AI 分享] Claude Code 如何重新設計程式碼審查

[AI 分享] Claude Code 如何重新設計程式碼審查

摘要 : Anthropic以分層檢查、風險分級與事故回歸測試,應對AI帶來的程式碼產量成長。



內容:


Anthropic 近期的工程部落格提供了三組值得一起看的數字:Claude Code 約有 9% 的程式碼由 Claude 撰寫;今年 6 月到 9 月,Claude Code 倉庫合併的 PR 數量翻了一倍多;同期需要回滾的 PR 佔比卻下降了 40% 以上。


這不代表 AI 寫的程式碼已經不必審查。相反地,團隊投入了更多心思,只是重新設計了審查流程。要注意的是,PR 與回滾數據來自 Claude Code 的獨立倉庫;另一組常被提及的產量數據——Anthropic 主倉庫每日落地變更從 5 月約 2,500 處增至 9 月超過 3 萬處——屬於不同倉庫,不能混為一談。


當 Agent 大幅降低寫程式碼的成本,瓶頸便轉移到驗證。面對每天數萬處變更,人工逐行閱讀 diff 已不切實際。問題不再只是「有沒有人讀過這段程式碼」,而是「這項變更可能出什麼事、哪一道檢查最適合抓住它」。


檢查從建立 PR 之前開始。 工程師交付任務時,不只要求 Claude 完成功能,也提供可執行的驗證方式,例如測試、建置或可比對的截圖,並要求它展示結果,而非口頭宣稱完成。Claude Code 作者 Boris Cherny 認為,讓 Agent 能自行驗證,是改善成果最重要的做法之一;他估計最終結果可因此改善兩到三倍。提交前,團隊還會讓具備全新上下文的 subagent 閱讀 diff,避免寫程式碼時形成的思路替自己的修改辯護。


PR 建立後,AI 審查者會結合整個倉庫檢查:變更是否解決它聲稱的問題、是否符合系統其他部分,以及會不會破壞別處。多個 Agent 並行尋找問題,另一組 Agent 驗證候選發現;純風格意見則會被排除。留下的意見按重要性整理,並區分問題是否早已存在,避免把舊問題算在這次 PR 作者頭上。上線初期,收到實質審查意見的 PR 比例由 16% 升至 54%;這是評論覆蓋率,不能直接當成缺陷檢出率。


審查規則會寫進檔案:一類說明專案的技術棧、約定、目標與邊界;另一類由程式碼負責人描述特定區域「什麼情況算出事」。好的規則應簡短、具體,限制次要意見、列出可跳過的路徑,並要求每項發現附上檔案與行號。把團隊口耳相傳的判斷標準寫下來,AI 審查才能接上既有的工程文化。


AI 審查之外,還有獨立的安全檢查,以及 lint、型別檢查、跨平臺測試與建置等確定性檢查。安全發現若未關閉,PR 不能合併;關閉時也要留下理由。PR 模板則要求回答兩個問題:「這項改動怎麼測?」以及「出了事怎麼撤回?」Claude 可以協助填寫,但確認責任仍在人身上。


人工批准依變更風險分級,而非依作者職位決定。 低風險變更在自動與安全檢查通過後,可不要求額外人工批准;中風險變更需要一人;高風險或涉及敏感路徑的變更需要兩人。能走較寬鬆流程的作者仍須在倉庫可信名單上,Agent 的批准不能充當人工名額;若又有新提交,先前的批准也必須重新取得。


批准後,合併佇列會將變更與最新主幹組合,重跑全部檢查。新功能先放在 feature flag 後供內部使用,公開版本先進入 latest 通道,再進入 stable。若上線後出問題,可以直接關閉 flag;團隊也安排值班輪換。事故覆盤則追問:「這件事原本應該被哪一層抓住?」團隊坦言,目前仍缺少依事故率自動收緊審批條件的熔斷機制。


這套流程也走過彎路。曾有一個 CLI PR,一夜之間經歷 50 多輪 AI 審查與修復,最後由作者手動叫停。團隊用 git blame 回查發現,審查者反覆指出的程式碼約有 59% 在第一輪時就已存在。後續多輪並非持續找到新問題,而是不斷翻出舊問題。


他們於是設下四項限制:審到第三輪仍不收斂,就停止逐項挑錯,要求作者重新考慮方案;後續複審只看新增 diff;同類問題一次修完;已提出的發現不再重複。調整後,一輪通過的 PR 由 65% 升至 78%,需要五輪以上的由 8.9% 降至 1.5%,平均通過輪數由 2.08 降至 1.32。AI 迴圈的終止條件,不能留給 AI 自己決定。


最後,團隊把過去造成事故的 PR、修復與回滾紀錄整理成回歸樣本,由熟悉程式碼的工程師確認問題及位置。審查器每天對樣本執行;提示詞、工具或模型有變動時,也先用這套樣本評估。指標不只看抓到多少 bug,還包括錯誤評論、修復輪數、人工審批時間、回滾與事故。


誤報尤其值得控制:人類作者可以略過一則無用評論,AI 作者卻可能真的照著修改,產生新提交、新一輪審查與額外成本。因此,團隊也追蹤作者收到評論後究竟是修正、指出評論錯誤,還是說明原本就有意如此。


最值得借鑑的,或許不是某一個審查 Agent,而是整套回饋機制:讓每次事故成為往後的測試題,讓檢查有明確分工,也讓人的注意力留給真正需要判斷的變更。

2026年10月19日 星期一

MFA005 使用自訂 DelegatingHandler 查看通訊內容

MFA005 使用自訂 DelegatingHandler 查看通訊內容

在上一篇關於 Microsoft Agent Framework 系列教學文章 "MFA004 使用 UseLogging 將 MAF 掛上日誌能力並且查看日誌內容" 中,我們學會了如何使用 UseLogging 中介軟體,將每一次對 LLM API 的請求與回應詳細記錄下來,方便後續的追蹤與分析。而在 MAF 內的 UseLogging 是一個中介軟體,在 MAF 內的中介軟體管線中扮演著記錄請求與回應的角色。這裡列出更多關於中介軟體的應用例如,記錄 Agent 執行時間、輸入輸出、工具呼叫及錯誤,這樣的應用可以用於追蹤 AI 客服處理過哪些問題、呼叫哪些工具以及發生了哪些錯誤。又如API 逾時、429、暫時性服務錯誤時執行重試或降級,這樣情境可以用於Azure OpenAI 暫時失敗時,依政策重試或切換備援模型。

透過 MAF 的中介軟體機制,就可以設計出各種自訂的中介軟體。在這裡可以自訂一個 DelegatingHandler,將每一次的 HTTP 請求與回應內容記錄下來,從而觀察底層的通訊細節。

在這裡的範例中將要透過 HTTP 通訊內容,觀察 MAF 是如何使用 LLM API,在實際底層運作的過程中,發送了哪些請求以及接收到哪些回應。

接下來看看如何做到這樣的效果。

建立 Console 專案

  • 開啟 Visual Studio 2026
  • 選擇「建立新專案」
  • 在 [建立新的專案] 視窗中,在右方清單內,找到並選擇「主控台應用程式」 項目
  • 然後點擊右下方「下一步」按鈕
  • 此時將會看到 [設定新的專案] 對話窗
  • 在該對話窗的 [專案名稱] 欄位中,輸入專案名稱,例如 "csHttpHandlerAgent"
  • 然後點擊右下方「下一步」按鈕
  • 接著會看到 [其他資訊] 對話窗
  • 在這個對話窗內,確認使用底下的選項
    • 架構:.NET 10.0 (或更新版本)
    • 勾選 不要使用最上層陳述式 (這是我的個人習慣)
  • 然後點擊右下方「建立」按鈕
  • 現在,已經完成了這個 Console 主控台 專案的建立

安裝需要用到的 NuGet 套件

要完成這個第一次接觸 Microsoft Agent Framework 練習專案,需要安裝以下幾個 NuGet 套件:

Microsoft.Extensions.AI

這個套件將會為生成式 AI 定義的一層「統一抽象介面 + Middleware 管線」,讓開發者可以更方便地整合不同的生成式 AI 模型,並且在應用程式中使用一致的方式進行呼叫與管理。

  • 滑鼠右擊 [csHttpHandlerAgent] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • Microsoft.Extensions.AI

Microsoft.Extensions.AI.OpenAI

這個套件是微軟官方為 .NET 整合 AI 服務所提供的擴充套件。它實現了 IChatClient 與 IEmbeddingGenerator 核心抽象介面,讓開發人員能以一致的程式碼,輕鬆串接 OpenAI 或相容的 API 端點(例如 Azure OpenAI、GitHub Models 等)。

  • 滑鼠右擊 [csHttpHandlerAgent] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • Microsoft.Extensions.AI.OpenAI

Microsoft.Agents.AI

這個套件是微軟官方推出 Microsoft Agent Framework(微軟智慧代理框架)的核心 .NET 套件。它主要用於在 .NET 環境中建構、協調與部署 AI 代理(AI Agents) 及多代理工作流(Multi-agent workflows),是用來取代舊版 AutoGen 的企業級解決方案。

  • 滑鼠右擊 [csHttpHandlerAgent] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • Microsoft.Agents.AI

OpenAI

這個套件為官方的 OpenAI .NET 客戶端類別庫,是 OpenAI NuGet 套件。此套件由 OpenAI 與 Microsoft 合作開發,提供直接存取 OpenAI 官方 REST API 的便利管道。

  • 滑鼠右擊 [csHttpHandlerAgent] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • OpenAI

宣告 API 端點與 Key 環境變數

關於這個小節的內容,將會在這個系列每篇文章中都會用到,因此,可以參考 MFA001 Microsoft Agent Framework 初次體驗開發出 AI 代理 文章中的 [宣告 API 端點與 Key 環境變數] 說明內容,若這裡提到的環境變數已經建立好了,則可以跳過這個小節內容。

修改 Program.cs 程式碼

為了要完成這個需求 : 如何用 Azure OpenAI 的 gpt-5.6-luna 聊天模型建立一個簡單 AI 代理,並產生一篇與鵝有關的詩。 因此,我們需要修改 Program.cs 程式碼,這時需要完成底下的操作。

  • 在專案內,找到並且開啟 [Program.cs] 檔案
  • 使用底下程式碼內容來取代 [Program.cs] 檔案的內容
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text;

namespace csHttpHandlerAgent;

internal class Program
{
    static async Task Main(string[] args)
    {
        Console.OutputEncoding = Encoding.UTF8;

        var apiKey = Environment.GetEnvironmentVariable("AzureOpenAI_Key");
        var endpoint = Environment.GetEnvironmentVariable("AzureOpenAI_Endpoint");
        var model = "gpt-5.6-luna";

        if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(endpoint))
        {
            Console.WriteLine("請先設定環境變數 AzureOpenAI_Key 與 AzureOpenAI_Endpoint 後再執行。");
            return;
        }

        using var httpClient = new HttpClient(new LoggingHttpHandler(new HttpClientHandler()));

        var options = new OpenAIClientOptions
        {
            Endpoint = new Uri(endpoint),
            Transport = new HttpClientPipelineTransport(httpClient)
        };

        IChatClient chatClient =
            new ChatClient(
                    model,
                    new ApiKeyCredential(apiKey),
                    options)
                .AsIChatClient();

        AIAgent agent = new ChatClientAgent(
            chatClient,                      // 聊天用戶端
            "創作引人入勝、富有創意的詩。",  // 系統提示詞 instructions
            "詩人",                          // 代理名稱   name
            null);                           // 代理描述   description

        Console.WriteLine($"{DateTime.Now} 開始呼叫 LLM API / 使用的模型: {model}");

        var response = await agent.RunAsync("寫一個關於鵝的詩。");

        Console.WriteLine();
        Console.WriteLine(response.Text);
        Console.WriteLine($"{DateTime.Now} 完成呼叫 LLM API / 使用的模型: {model}");
    }
}

internal sealed class LoggingHttpHandler : DelegatingHandler
{
    private static readonly string[] SecretHeaders =
        ["api-key", "Authorization", "OpenAI-Organization", "OpenAI-Project"];

    public LoggingHttpHandler(HttpMessageHandler innerHandler) : base(innerHandler)
    {
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Console.WriteLine();
        Console.WriteLine(new string('─', 74));
        Console.WriteLine($"→ 送出  {request.Method} {request.RequestUri}");
        DumpHeaders(request.Headers);

        if (request.Content is not null)
        {
            DumpHeaders(request.Content.Headers);
            Console.WriteLine($"  {await request.Content.ReadAsStringAsync(cancellationToken)}");
        }

        var stopwatch = Stopwatch.StartNew();
        var response = await base.SendAsync(request, cancellationToken);
        stopwatch.Stop();

        Console.WriteLine();
        Console.WriteLine($"← 收到  {(int)response.StatusCode} {response.ReasonPhrase}   耗時 {stopwatch.ElapsedMilliseconds} ms");
        DumpHeaders(response.Headers);
        DumpHeaders(response.Content.Headers);

        await response.Content.LoadIntoBufferAsync(cancellationToken);
        Console.WriteLine($"  {await response.Content.ReadAsStringAsync(cancellationToken)}");
        Console.WriteLine(new string('─', 74));

        return response;
    }

    private static void DumpHeaders(HttpHeaders headers)
    {
        foreach (var header in headers)
        {
            var value = SecretHeaders.Contains(header.Key, StringComparer.OrdinalIgnoreCase)
                ? "***"
                : string.Join(", ", header.Value);

            Console.WriteLine($"  {header.Key}: {value}");
        }
    }
}

這個程式一開始將會讀取 API Key 與服務端點的數據,接著如同前面的範例相同,先建立一個 IChatClient 物件,接者建立一個 AIAgent 物件,並使用它來與模型進行互動。不過,由於這裡希望能夠觀察底層的 HTTP 通訊內容,所以,使用這個 using var httpClient = new HttpClient(new LoggingHttpHandler(new HttpClientHandler())); 來建立 HttpClient物件,在建構函式內將其傳入 OpenAIClientOptions 的 Transport 屬性,這樣所有的 HTTP 請求都會經過 LoggingHttpHandler,從而記錄下請求與回應的內容,將會傳入一個 LoggingHttpHandler,而這個類別的建構式將會接收到一個 HttpClientHandler 作為內部處理器。

對於這個 LoggingHttpHandler 類別,它繼承自 DelegatingHandler,並且重寫了 SendAsync 方法,在每一次的 HTTP 請求發送前與回應接收後,分別記錄下請求與回應的內容,從而實現對底層通訊的監控。所謂的 SendAsync 將會在每一次 HTTP 請求被發送時被呼叫,並且會在收到回應後才完成,這使得我們能夠在請求與回應的整個過程中插入自訂的日誌邏輯。在這裡複寫了 SendAsync 方法,在這裡會使用 DumpHeaders 方法來列印請求與回應的標頭。

之後將會呼叫 await base.SendAsync(request, cancellationToken); 這行程式碼會將 HTTP 請求傳遞給內部的處理器(在這裡是 HttpClientHandler),並等待回應。當接收到 SendAsync 的回應後,才會繼續執行後續的程式碼,這使得我們能夠在請求發送前與回應接收後,分別記錄下相關的日誌資訊。在前面呼叫 DumpHeaders 方法時候,使用了 request.Headers 來記錄下請求的標頭,當 SendAsync 處理完成後,將會呼叫 DumpHeaders 方法,並且將 response.Headers 與 response.Content.Headers 傳入,以記錄回應的標頭。這樣一來,我們就能夠完整地觀察到整個 HTTP 通訊的過程,包括請求與回應的標頭與內容。

而對於 await response.Content.LoadIntoBufferAsync(cancellationToken); 這行程式碼,它會將回應的內容讀取到緩衝區中,確保後續對 response.Content.ReadAsStringAsync(cancellationToken) 的呼叫不會因為內容已經被讀取而失敗。這樣一來,我們就能夠安全地多次讀取回應的內容,並將其輸出到日誌中。

上面說明了要建立 HttpClient 物件的準備與建置工作,一旦取得 HttpClient 物件之後,接下來將會建立一個 OpenAIClientOptions 物件,並將剛剛建立的 HttpClient 傳入其 Transport 屬性,這樣所有的 HTTP 請求都會經過 LoggingHttpHandler,從而實現對底層通訊的監控。對於 OpenAIClientOptions.Transport 屬性,它接受一個 HttpPipelineTransport 物件,這個物件封裝了我們自訂的 HttpClient,確保所有的請求都會經過我們的 LoggingHttpHandler。

現在已經有了 OpenAIClientOptions 物件,接下來就可以使用它來建立 ChatClient,並將其轉換為 IChatClient 介面,從而開始與模型進行互動。在這裡會將 OpenAIClientOptions 傳入 ChatClient 的建構函式,確保所有的 HTTP 請求都會經過我們自訂的 LoggingHttpHandler。從底下的程式碼,可以看到 options 這個變數就是一個 OpenAIClientOptions 物件,並且已經包含了我們自訂的 HttpClient。

IChatClient chatClient =
    new ChatClient(
            model,
            new ApiKeyCredential(apiKey),
            options)
        .AsIChatClient();

接下來如何 MAF 框架的基本操作一般,建立一個 AIAgent 物件,並使用它來與模型進行互動。這裡會將剛剛建立的 IChatClient 物件傳入 AIAgent 的建構函式,確保所有的互動都會通過我們自訂的 LoggingHttpHandler,從而能夠觀察到底層的 HTTP 通訊內容。對於這個 AIAgent 物件,還是使用他來寫詩,當使用 agent.RunAsync 方法時,使用的提示詞為 寫一個關於鵝的詩。 這樣一來,我們就能夠在日誌中看到整個 HTTP 請求與回應的過程,從而更好地理解模型與 API 之間的通訊細節。

執行結果

現在來看看這個範例程式碼的執行結果。

  • 在 Visual Studio 2026 下,按下 F5 鍵執行程式,將會看到程式輸出的結果。
2026/10/19 上午 09:15:55 開始呼叫 LLM API / 使用的模型: gpt-5.6-luna

──────────────────────────────────────────────────────────────────────────
→ 送出  POST https://kmengineopenai1.services.ai.azure.com/openai/v1/chat/completions
  Accept: application/json
  User-Agent: OpenAI/2.13.0, (.NET 10.0.13; Microsoft Windows 10.0.26300), MEAI/10.10.0
  X-Stainless-Lang: csharp
  X-Stainless-Package-Version: 2.13.0
  X-Stainless-Runtime: dotnet
  X-Stainless-Runtime-Version: 10.0.13
  X-Stainless-OS: Windows
  X-Stainless-Arch: x64
  Authorization: ***
  Content-Type: application/json
  {"messages":[{"role":"system","content":"創作引人入勝、富有創意的詩。"},{"role":"user","content":"寫一個關於鵝的詩。"}],"model":"gpt-5.6-luna"}

← 收到  200 OK   耗時 4019 ms
  Date: Mon, 19 Oct 2026 01:15:56 GMT
  Server: istio-envoy
  Vary: Origin
  azureai-fe-is-streaming: False
  azureai-fe-offer-type: paygo
  azureml-model-session: d20261019001926-86bfbd7d
  skip-error-remapping: true
  x-accel-buffering: no
  x-ms-is-spilled-over: false
  x-ms-rai-invoked: true
  x-ms-served-model: gpt-5.6-luna-2026-07-09
  x-ratelimit-abusepenalty-active: False
  x-ratelimit-key: gpt-5.6-luna
  x-ratelimit-limit-requests: 2000
  x-ratelimit-limit-tokens: 2000000
  x-ratelimit-remaining-requests: 1999
  x-ratelimit-remaining-tokens: 1999963
  x-ratelimit-renewalperiod-requests: 60
  x-ratelimit-renewalperiod-tokens: 60
  x-ratelimit-reset-requests: 0
  x-ratelimit-reset-tokens: 0
  X-Request-ID: 6b557577-f154-4d63-bfba-3d17131cc8ad
  apim-request-id: c375c211-5950-4b71-ad35-c27279f45f4c
  x-http2-stream-id: 659
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  x-ms-client-request-id: Not-Set
  X-Content-Type-Options: nosniff
  x-ms-region: East US 2
  Content-Length: 2171
  Content-Type: application/json
  {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"detected":false,"filtered":false},"protected_material_text":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":"stop","index":0,"logprobs":null,"message":{"annotations":[],"content":"**《鵝過春水》**\n\n一隻鵝,披著午後的白,  \n從村口踱來,  \n像一朵雲忘了回天上,  \n落在小河邊洗腳。\n\n它伸長脖子,  \n向夕陽索要一枚金幣;  \n夕陽沒有回答,  \n只把滿河的碎光  \n塞進它搖晃的羽毛。\n\n「嘎——」  \n一聲,驚醒蘆葦深處的風,  \n也驚醒遠山沉睡的鐘。  \n水面於是開出一圈圈藍,  \n像有人把春天  \n輕輕推向更遠的地方。\n\n夜來時,鵝回到籬笆旁,  \n月亮跟在它身後,  \n學著它昂首、踱步,  \n學著用一聲清亮的叫喊,  \n守住人間  \n最後一盞燈。","refusal":null,"role":"assistant"}}],"created":1792372557,"id":"chatcmpl-EaWGjQ76SJGQltByY5aJwUvskmoQb","model":"gpt-5.6-luna-2026-07-09","object":"chat.completion","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}],"routing":{"serving_pipereplica":"d20261019001926-86bfbd7d-prefill-r12-dp0-prefill"},"service_tier":"default","system_fingerprint":null,"usage":{"completion_tokens":253,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":22,"rejected_prediction_tokens":0},"latency_checkpoint":{"engine_tbt_ms":7,"engine_ttft_ms":70,"engine_ttlt_ms":1876,"pre_inference_ms":134,"service_tbt_ms":7,"service_ttft_ms":367,"service_ttlt_ms":2191,"user_visible_ttft_ms":233},"prompt_tokens":37,"prompt_tokens_details":{"audio_tokens":0,"cache_write_tokens":0,"cached_tokens":0},"total_tokens":290}}

──────────────────────────────────────────────────────────────────────────

**《鵝過春水》**

一隻鵝,披著午後的白,
從村口踱來,
像一朵雲忘了回天上,
落在小河邊洗腳。

它伸長脖子,
向夕陽索要一枚金幣;
夕陽沒有回答,
只把滿河的碎光
塞進它搖晃的羽毛。

「嘎——」
一聲,驚醒蘆葦深處的風,
也驚醒遠山沉睡的鐘。
水面於是開出一圈圈藍,
像有人把春天
輕輕推向更遠的地方。

夜來時,鵝回到籬笆旁,
月亮跟在它身後,
學著它昂首、踱步,
學著用一聲清亮的叫喊,
守住人間
最後一盞燈。
2026/10/19 上午 09:15:59 完成呼叫 LLM API / 使用的模型: gpt-5.6-luna

從這裡的執行結果,可以看到 "→ 送出 POST https://kmengineopenai1.services.ai.azure.com/openai/v1/chat/completions" 的請求,在控制台中,底下將會看到完整的請求內容。在前半部,將會看到 Header 的內容,不過,由於 API Key 將會放在 Authorization Header 中,送到後端的 LLM 模型中,為了避免外洩,因此不會在這裡顯示。緊接著就是 Request Body 的內容,也就是實際送到後端的資料。

{"messages":[{"role":"system","content":"創作引人入勝、富有創意的詩。"},{"role":"user","content":"寫一個關於鵝的詩。"}],"model":"gpt-5.6-luna"}

從這裡的請求內容可以看到,這裡先使用了 system 角色,指定了系統提示詞 "創作引人入勝、富有創意的詩。",接著使用 user 角色,指定了使用者的輸入 "寫一個關於鵝的詩。"。這樣的設計可以讓模型知道系統的整體指引,並根據使用者的需求生成對應的內容。最後,指定使用了 gpt-5.6-luna 這個模型 來生成內容。

將下來將會看到 "← 收到 200 OK 耗時 4019 ms" 的回應,在控制台中,底下將會看到完整的回應內容。這裡會包含 Header 的資訊,以及實際的 Response Body,也就是模型生成的內容。

{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"detected":false,"filtered":false},"protected_material_text":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":"stop","index":0,"logprobs":null,"message":{"annotations":[],"content":"**《鵝過春水》**\n\n一隻鵝,披著午後的白,  \n從村口踱來,  \n像一朵雲忘了回天上,  \n落在小河邊洗腳。\n\n它伸長脖子,  \n向夕陽索要一枚金幣;  \n夕陽沒有回答,  \n只把滿河的碎光  \n塞進它搖晃的羽毛。\n\n「嘎——」  \n一聲,驚醒蘆葦深處的風,  \n也驚醒遠山沉睡的鐘。  \n水面於是開出一圈圈藍,  \n像有人把春天  \n輕輕推向更遠的地方。\n\n夜來時,鵝回到籬笆旁,  \n月亮跟在它身後,  \n學著它昂首、踱步,  \n學著用一聲清亮的叫喊,  \n守住人間  \n最後一盞燈。","refusal":null,"role":"assistant"}}],"created":1792372557,"id":"chatcmpl-EaWGjQ76SJGQltByY5aJwUvskmoQb","model":"gpt-5.6-luna-2026-07-09","object":"chat.completion","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"detected":false,"filtered":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}],"routing":{"serving_pipereplica":"d20261019001926-86bfbd7d-prefill-r12-dp0-prefill"},"service_tier":"default","system_fingerprint":null,"usage":{"completion_tokens":253,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":22,"rejected_prediction_tokens":0},"latency_checkpoint":{"engine_tbt_ms":7,"engine_ttft_ms":70,"engine_ttlt_ms":1876,"pre_inference_ms":134,"service_tbt_ms":7,"service_ttft_ms":367,"service_ttlt_ms":2191,"user_visible_ttft_ms":233},"prompt_tokens":37,"prompt_tokens_details":{"audio_tokens":0,"cache_write_tokens":0,"cached_tokens":0},"total_tokens":290}}

在 response 回應內容中,可以看到模型生成的詩文,包含了完整的詩句與格式。這裡的 content 欄位即為模型生成的內容,而其他欄位則包含了回應的元資料,例如 content_filter_results 用來表示內容是否符合安全規範,finish_reason 表示生成的結束原因,role 表示回應的角色等。

透過這樣的方式,我們可以完整地觀察到與 LLM 模型之間的通訊內容,無論是送出的請求還是收到的回應,都能在控制台中清楚地呈現,方便進行除錯與分析。