在上一篇關於 Microsoft Agent Framework 系列教學文章 "MFA007 用 MAF 的 Workflow 把多個代理接成一張圖" 中,我們學會了如何使用多代理工作流將多個 AI 代理串接成一個完整的工作流,並且了解了如何設計代理之間的協作流程。
在這篇文章中,我們將學習如何將 AI 代理的對話狀態進行序列化,並將其持久化到檔案中,從而實現代理的記憶保存與恢復。在此將會產生一個睡前故事的示範,但是這個故事並不會一次說完,而是會分多次進行,每次都會保存當前的對話狀態,並在下次繼續時恢復。當在第二次重新這個專案程式碼的時候,代理將會從上次的對話狀態繼續故事,而不會忘記之前的內容。
在完成這篇文章的練習,我們將會學會如何讓 AI 代理記住之前的對話內容,並在下次繼續時恢復,從而實現持久化的對話記憶。
接下來看看如何做到這樣的效果。
- 開啟 Visual Studio 2026
- 選擇「建立新專案」
- 在 [建立新的專案] 視窗中,在右方清單內,找到並選擇「主控台應用程式」 項目
- 然後點擊右下方「下一步」按鈕
- 此時將會看到 [設定新的專案] 對話窗
- 在該對話窗的 [專案名稱] 欄位中,輸入專案名稱,例如 "csPersistSessionAgent"
- 然後點擊右下方「下一步」按鈕
- 接著會看到 [其他資訊] 對話窗
- 在這個對話窗內,確認使用底下的選項
- 架構:.NET 10.0 (或更新版本)
- 勾選 不要使用最上層陳述式 (這是我的個人習慣)
- 然後點擊右下方「建立」按鈕
- 現在,已經完成了這個 Console 主控台 專案的建立
要完成這個第一次接觸 Microsoft Agent Framework 練習專案,需要安裝以下幾個 NuGet 套件:
這個套件將會為生成式 AI 定義的一層「統一抽象介面 + Middleware 管線」,讓開發者可以更方便地整合不同的生成式 AI 模型,並且在應用程式中使用一致的方式進行呼叫與管理。
- 滑鼠右擊 [csPersistSessionAgent] 專案節點
- 點選彈出功能表的 [管理 NuGet 套件] 項
- 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
- Microsoft.Extensions.AI
這個套件是微軟官方為 .NET 整合 AI 服務所提供的擴充套件。它實現了 IChatClient 與 IEmbeddingGenerator 核心抽象介面,讓開發人員能以一致的程式碼,輕鬆串接 OpenAI 或相容的 API 端點(例如 Azure OpenAI、GitHub Models 等)。
- 滑鼠右擊 [csPersistSessionAgent] 專案節點
- 點選彈出功能表的 [管理 NuGet 套件] 項
- 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
- Microsoft.Extensions.AI.OpenAI
這個套件是微軟官方推出 Microsoft Agent Framework(微軟智慧代理框架)的核心 .NET 套件。它主要用於在 .NET 環境中建構、協調與部署 AI 代理(AI Agents) 及多代理工作流(Multi-agent workflows),是用來取代舊版 AutoGen 的企業級解決方案。
- 滑鼠右擊 [csPersistSessionAgent] 專案節點
- 點選彈出功能表的 [管理 NuGet 套件] 項
- 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
- Microsoft.Agents.AI
這個套件為官方的 OpenAI .NET 客戶端類別庫,是 OpenAI NuGet 套件。此套件由 OpenAI 與 Microsoft 合作開發,提供直接存取 OpenAI 官方 REST API 的便利管道。
- 滑鼠右擊 [csPersistSessionAgent] 專案節點
- 點選彈出功能表的 [管理 NuGet 套件] 項
- 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
- OpenAI
關於這個小節的內容,將會在這個系列每篇文章中都會用到,因此,可以參考 MFA001 Microsoft Agent Framework 初次體驗開發出 AI 代理 文章中的 [宣告 API 端點與 Key 環境變數] 說明內容,若這裡提到的環境變數已經建立好了,則可以跳過這個小節內容。
為了要完成這個需求 : 如何用 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;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace csPersistSessionAgent;
internal class Program
{
private const string StoryStart = "請開始說一個關於一隻叫小熊的睡前故事,只講開頭一小段(約 100 字),講到一個懸念就停下來,先不要講完。";
private const string StoryResume = "我們昨天講到哪裡了?請先用一句話提醒我,然後接著往下講一小段。";
private const int PreviewLines = 8; // JSON 節錄要印幾行
private static readonly string SessionPath = Path.Combine(AppContext.BaseDirectory, "session.json");
private static readonly JsonSerializerOptions JsonWriteOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
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, // 聊天用戶端
"你是一位溫柔的睡前故事說書人,請用繁體中文,每次只講一小段(約 100 字),語氣輕柔適合小朋友入睡。", // 系統提示詞 instructions
"說書人", // 代理名稱 name
null); // 代理描述 description
Console.WriteLine($"{DateTime.Now} 使用的模型: {model}");
if (File.Exists(SessionPath))
{
await TomorrowAsync(agent); // 明天:載入存檔,故事接著講
}
else
{
await TonightAsync(agent); // 今晚:開一個新故事,講到一半就存檔
}
}
private static async Task TonightAsync(AIAgent agent)
{
Header("今晚 / 找不到 session.json —— 開一個新故事");
var session = await agent.CreateSessionAsync();
Say("你", StoryStart);
var answer = await agent.RunAsync(StoryStart, session);
Say("說書人", answer.Text);
await SaveSessionAsync(agent, session);
Console.WriteLine();
Console.WriteLine("👉 現在關掉程式,再執行一次 dotnet run,故事會從這裡接下去。");
}
private static async Task TomorrowAsync(AIAgent agent)
{
Header("明天 / 找到 session.json —— 故事接著講");
Console.WriteLine();
Console.WriteLine("── 對照組:全新 session(沒有昨晚的記憶)──");
var freshSession = await agent.CreateSessionAsync();
Say("你", StoryResume);
var forgotten = await agent.RunAsync(StoryResume, freshSession);
Say("說書人", forgotten.Text);
Console.WriteLine();
Console.WriteLine("---------------------------------------------------");
Console.WriteLine();
Console.WriteLine("── 正解:載入 session.json 還原昨晚的 session ──");
var session = await LoadSessionAsync(agent);
Say("你", StoryResume);
var continued = await agent.RunAsync(StoryResume, session);
Say("說書人", continued.Text);
await ChatUntilBedtimeAsync(agent, session);
await SaveSessionAsync(agent, session);
Console.WriteLine();
Console.WriteLine("👉 想重新開始一個新故事,把 session.json 刪掉即可。");
}
private static async Task ChatUntilBedtimeAsync(AIAgent agent, AgentSession session)
{
Console.WriteLine();
Console.WriteLine("(想繼續聊就輸入一句話,直接按 Enter 結束並存檔)");
while (true)
{
Console.Write(" 你 ▸ ");
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
break;
}
var answer = await agent.RunAsync(input, session);
Say("說書人", answer.Text);
}
}
private static async Task SaveSessionAsync(AIAgent agent, AgentSession session)
{
JsonElement state = await agent.SerializeSessionAsync(session);
await File.WriteAllTextAsync(SessionPath, JsonSerializer.Serialize(state, JsonWriteOptions));
ShowSavedFile();
}
private static async Task<AgentSession> LoadSessionAsync(AIAgent agent)
{
var json = await File.ReadAllTextAsync(SessionPath);
using var document = JsonDocument.Parse(json);
return await agent.DeserializeSessionAsync(document.RootElement);
}
private static void ShowSavedFile()
{
var bytes = new FileInfo(SessionPath).Length;
Console.WriteLine();
Console.WriteLine($"💾 已存檔:{SessionPath}({bytes:N0} bytes)");
foreach (var line in File.ReadLines(SessionPath).Take(PreviewLines))
{
Console.WriteLine($" │ {(line.Length > 70 ? line[..70] + "…" : line)}");
}
Console.WriteLine(" │ …(略)");
}
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 物件,該代理人綁定的系統提示詞為 "你是一位溫柔的睡前故事說書人,請用繁體中文,每次只講一小段(約 100 字),語氣輕柔適合小朋友入睡。"。
接下來會先檢查是否有任何之前的對話 session 內容存在,若有的話,這表示存在著昨天還未說完的故事,此時,將會把昨天講的故事讀取進來,並恢復到代理的對話狀態中,從而可以接著昨天的故事繼續講下去。否則,將會從頭開始一個新的故事。
當要讀取昨天的故事內容,將會呼叫 TomorrowAsync 方法來載入並還原之前的對話 session。在這個方法內,不過,在這個方法內,將會使用兩種做法,使得可以看到兩者間的差異,第一個就是採用 對照組:全新 session(沒有昨晚的記憶) 方式。在這裡,將會使用 var freshSession = await agent.CreateSessionAsync(); 敘述來建立一個新的對話 session,然後再呼叫 var forgotten = await agent.RunAsync(StoryResume, freshSession); ,這裡使用的提示詞為這個變數 StoryResume 的字串: "我們昨天講到哪裡了?請先用一句話提醒我,然後接著往下講一小段。",當執行完之後,就可以從 forgotten.Text 看到語言模型產生的結果,如下所示。
── 對照組:全新 session(沒有昨晚的記憶)──
你 ▸ 我們昨天講到哪裡了?請先用一句話提醒我,然後接著往下講一小段。
說書人 ▸ 我這裡看不到昨天的故事內容,就先從小月亮來到森林邊繼續吧。
小月亮輕輕降下銀色的光,照見一隻迷路的小兔子。牠蜷在草叢旁,耳朵微微發抖。小月亮說:「別怕,我陪你找回家的路。」於是,星星們一顆接一顆亮起,像溫柔的小燈,陪著他們走過安靜的林間小徑。
從這句話可以看出 "說書人 ▸ 我這裡看不到昨天的故事內容,就先從小月亮來到森林邊繼續吧。",也就是說,語言模型並沒有記住昨天的故事內容,因此只能從新的情境開始講故事。這樣的結過並不是符合當初設計的預期,真正的目的是希望語言模型能夠記住之前的故事內容,並在新的對話中延續下去。
因此,在這個方法的後半段,將會要做到 "載入 session.json 還原昨晚的 session" 的操作,從而使語言模型能夠記住之前的故事內容,並在新的對話中延續下去。在這裡將會呼叫 LoadSessionAsync(agent) 來載入之前保存的 session,最後將其還原到 freshSession 中。
這裡首先呼叫 var json = await File.ReadAllTextAsync(SessionPath); 來讀取 session.json 的內容,接著再呼叫 JsonDocument.Parse(json) 來解析 JSON 文件,這裡將會得到型別為 JsonDocument 的物件,之後就可以從中提取出之前保存的對話歷史,並還原到 freshSession 中。最後,執行 await agent.DeserializeSessionAsync(document.RootElement) 便可以得到一個代理人物件,並且這個代理人將會記住之前的故事內容。
現在回到 TomorrowAsync 方法內,這裡將會執行 var continued = await agent.RunAsync(StoryResume, session);,這裡 StoryResume 的提示詞仍然是 "我們昨天講到哪裡了?請先用一句話提醒我,然後接著往下講一小段。",現在就來看看,這個代理人物件會得到甚麼故事內容,這裡是測試後, continued.Text 的結果。
昨天說到:小熊在草叢裡發現了一顆會呼吸、閃著藍光的星星。
小熊輕輕捧起星星,感覺它一呼一吸,掌心便暖暖地亮一下。「你是不是迷路了?」小熊小聲問。星星沒有回答,只朝森林深處飄去,留下一串銀色腳印。小熊跟著走啊走,來到一扇從沒見過的小門前,而門後竟傳來自己的聲音……
從上面的產生內容,可以看到這個代理人物件,因為有讀取之前對話的紀錄檔案內容,所以能夠記住昨天的故事,並在新的對話中延續下去。並且依照提示詞的指示,繼續往下又說了一段故事。
接著,將會呼叫 ChatUntilBedtimeAsync 方法,在這個方法內,你可以輸入一些文字內容,與這個代理人物件互動,這樣就可以依據剛剛輸入的文字來繼續產生新的故事內容出來,直到你覺得故事可以結束為止。
最後,將會呼叫 SaveSessionAsync 方法,將當前的對話 session 保存到 session.json 文件中,這樣在下次啟動程式時,就可以載入這個 session,繼續延續之前的故事。
若沒有找到之前說故事的記憶檔案,這個程式將會呼叫 TonightAsync 方法,從頭開始一個新的故事。在這個方法內,同樣的會先執行 var session = await agent.CreateSessionAsync();,然後再呼叫 var result = await agent.RunAsync(StoryStart, session); 來開始新的故事。StoryStart 的提示詞仍然是 "請開始說一個關於一隻叫小熊的睡前故事,只講開頭一小段(約 100 字),講到一個懸念就停下來,先不要講完。" 當新的故事講完之後,同樣的會呼叫 SaveSessionAsync 方法,將當前的對話 session 保存到 session.json 文件中。在這裡也是執行了 JsonElement state = await agent.SerializeSessionAsync(session) 敘述,然後再將其寫入 session.json 文件,以便下次啟動程式時可以載入這個 session。
現在來看看這個範例程式碼的執行結果。
- 在 Visual Studio 2026 下,按下 F5 鍵執行程式,將會看到程式輸出的結果。
2026/10/20 下午 01:15:56 使用的模型: gpt-5.6-luna
==========================================================================
今晚 / 找不到 session.json —— 開一個新故事
==========================================================================
你 ▸ 請開始說一個關於一隻叫小熊的睡前故事,只講開頭一小段(約 100 字),講到一個懸念就停下來,先不要講完。
說書人 ▸ 月亮升上森林時,小熊抱著心愛的星星枕頭,準備回樹洞睡覺。忽然,草叢裡傳來細細的「叮咚」聲,像有人在夜裡敲著小鈴鐺。小熊循聲走去,看見一扇從沒見過的藍色小門,而門縫裡,正透出一點金色的光……
💾 已存檔:C:\Vulcan\Github\CSharp2026\csPersistSessionAgent\csPersistSessionAgent\bin\Debug\net10.0\session.json(1,045 bytes)
│ {
│ "stateBag": {
│ "InMemoryChatHistoryProvider": {
│ "messages": [
│ {
│ "role": "user",
│ "contents": [
│ {
│ …(略)
👉 現在關掉程式,再執行一次 dotnet run,故事會從這裡接下去。
從上面的輸出內容,可以看到程式在找不到 session.json 時,會從頭開始一個新的故事。
現在停止這個專案執行,然後再次來執行這個專案
2026/10/20 下午 02:39:55 使用的模型: gpt-5.6-luna
==========================================================================
明天 / 找到 session.json —— 故事接著講
==========================================================================
── 對照組:全新 session(沒有昨晚的記憶)──
你 ▸ 我們昨天講到哪裡了?請先用一句話提醒我,然後接著往下講一小段。
說書人 ▸ 我看不到昨天的對話紀錄,就先從小兔子準備走進月光森林開始吧。
小兔子抱著星星燈,沿著銀白色的小路慢慢前進。林子裡沒有吵鬧的聲音,只有風兒輕輕唱歌。忽然,一片葉子飄到牠耳邊,悄悄說:「別害怕,今晚的月亮會陪著你。」小兔子安心地笑了,踩著柔軟的月光,朝森林深處走去。
---------------------------------------------------
── 正解:載入 session.json 還原昨晚的 session ──
你 ▸ 我們昨天講到哪裡了?請先用一句話提醒我,然後接著往下講一小段。
說書人 ▸ 昨天我們說到:小熊在古老樹洞裡,看見深處亮起了兩盞藍色的燈。
小熊屏住呼吸,抱緊蜂蜜罐,輕聲問:「你是誰呀?」藍光慢慢靠近,照出一對圓圓的眼睛,還有一頂歪歪的小帽子。那個神秘身影小聲說:「別害怕,我一直在等你,因為森林的月亮……不見了。」小熊抬頭一看,夜空果然少了一輪月亮,而樹洞裡忽然傳來咚、咚、咚的聲音……
(想繼續聊就輸入一句話,直接按 Enter 結束並存檔)
你 ▸ 我還想繼續聽一段故事
說書人 ▸ 小熊望著那頂歪歪的小帽子,鼓起勇氣問:「月亮去哪裡了呢?」小身影指向樹洞深處,說:「它被一陣淘氣的風,吹進了沉睡的雲谷。」於是,小熊牽起神秘朋友的手,沿著微弱的藍光往前走。走著走著,前方出現一扇小小的銀色門,門上刻著一行字:只有說出最溫柔的夢,才能打開它……
你 ▸
💾 已存檔:C:\Vulcan\Github\CSharp2026\csPersistSessionAgent\csPersistSessionAgent\bin\Debug\net10.0\session.json(3,136 bytes)
│ {
│ "stateBag": {
│ "InMemoryChatHistoryProvider": {
│ "messages": [
│ {
│ "role": "user",
│ "contents": [
│ {
│ …(略)
👉 想重新開始一個新故事,把 session.json 刪掉即可。
在文字 "── 正解:載入 session.json 還原昨晚的 session ──" 與 "(想繼續聊就輸入一句話,直接按 Enter 結束並存檔)" 之間,程式會嘗試讀取 session.json,並將其中的對話歷史還原到當前的 session 中,這樣就能接著昨天的故事繼續講下去。我在這裡輸入了 "我還想繼續聽一段故事",所以就看到了程式便接著又講了一段故事。這也說明了 session 的持久化功能,能夠讓對話在程式重啟後仍然保持連貫。最後,我不想在聽故事了,就可以直接按 Enter 結束,程式會將當前的 session 保存到 session.json 中,以便下次繼續使用。
