从零开始玩转 Microsoft Agent Framework:我的 MAF 实践之旅-第二篇

前言

书接上回,关于MAF框架的探索,上次只聊了几个基本的Agent创建,本篇将深入探讨MAF框架的三个核心进阶特性:可观测性集成、聊天记录存储与持久化,以及为智能体赋予记忆能力。

我这里的案例代码都是跟着微软的官方文档,将智能体的角色案例改造成了一个"汽车大师",包含了一些自己的理解,可能存在偏差和错误,推荐大家优先查阅官方文档。

由于MAF框架,以及其相关的生态包,目前都是Preview状态(截止到2026.1.5),所以这里只是探索,目前上生产的话还是要慎重。

可观测性

这一趴,对应的文档地址是:learn.microsoft.com/en-us/agent...

实际上,微软的文档是有一个机翻的中文版的,但机翻的效果我个人感觉有点拉,不如直接看原版,然后用翻译软件或者AI助手翻译,效果更好,当然能无障碍阅读原版是最好了。

这一节,主要是展示MAF框架如何方便的开启可观测性。核心的目标是利用OpenTelemetry标准来自动记录和导出智能体育用户之间的交互数据。

整个接入流程可以概括为

  1. 安装必要的Nuget包
  2. 启用OpenTelemtry(TracerProvider)
  3. 配置代理
  4. 查看输出结果

好了,更具体的内容大家可以参考文档,我这里直接给出我的测试案例

csharp 复制代码
using Azure.AI.OpenAI;
using Microsoft.Agents.AI;
using OpenAI;
using OpenTelemetry;
using OpenTelemetry.Trace;
using System;
using System.ClientModel;

namespace AgentFrameworkQuickStart
{
    public class ObservabilityAgent
    {
        public readonly ModelProvider modelProvider;
        public ObservabilityAgent(ModelProvider modelProvider)
        {
            this.modelProvider = modelProvider;
        }

        public async Task ObservabilityDemo()
        {
            using var tracerProvider = Sdk.CreateTracerProviderBuilder()
            .AddSource("agent-telemetry-source")
            .AddConsoleExporter()
            .Build();

            var agent = new OpenAIClient(
               new ApiKeyCredential(modelProvider.ApiKey),
               new OpenAIClientOptions { Endpoint = new Uri(modelProvider.Endpoint) })
               .GetChatClient(modelProvider.ModelId)
               .CreateAIAgent(instructions: "你是个资深汽车大师,了解很多汽车知识,包括配置,价格,驾驶体验的等等,回复内容尽可能简短高效,突出优缺点,给出综合购买建议,避免长篇大论", name: "汽车大师")
               .AsBuilder()
               .UseOpenTelemetry(sourceName: "agent-telemetry-source")
               .Build();

            await foreach (var update in agent.RunStreamingAsync("介绍一下新款宝马X3 25L这款车"))
            {
                Console.Write(update);
            }
        }
    }
}

然后可以在看一下运行效果

存储聊天记录

官方文档在可观测性之后,详细介绍了持久化对话第三方外部存储。为了聚焦核心概念,本文将直接展示如何实现一个自定义的聊天记录存储,该方法也涵盖了持久化的核心思想,大家可以参考官网查看完整的教程。

默认情况下,ChatClientAgent的聊天记录存储在AgentThread对象中,也就是内存当中,为了实现对话的持久化、跨会话恢复或大规模历史管理,开发者需要提供自定义存储实现。

本文的案例就是基于官方教程,创建了一个自定义存储类,继承抽象类 ChatMessageStore并分别实现一个存储(AddMessagesAsync)和检索(GetMessagesAsync)的关键方法。这里要注意的是,在检索方法里要考虑Token的限制,不过我们这里是技术验证阶段,这个也可以先跳过,降低一些心智负担。

代码的大体逻辑是

  • 提供一个InMemoryVectorStore示例类VectorChatMessageStore
  • 在第一次添加消息时生成一个唯一的 ThreadDbKey
  • 定义了一个内部类ChatHistoryItem来保存消息文本、时间戳和序列化后的消息体。
  • Serialize 方法只返回ThreadDbKey这样下次加载线程时,就能根据这个 Key 找回历史。

来看下代码

csharp 复制代码
using AgentFrameworkQuickStart.Models;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using OpenAI;
using Spectre.Console;
using System.ClientModel;
using System.Text.Json;

namespace AgentFrameworkQuickStart;

public class InMemoryChatHistoryAgent
{
    private readonly ModelProvider _modelProvider;
    private readonly string _threadStatePath;
    private readonly VectorStore _vectorStore = new InMemoryVectorStore();

    public InMemoryChatHistoryAgent(ModelProvider modelProvider, string threadStateFileName = "thread_state.json")
    {
        _modelProvider = modelProvider ?? throw new ArgumentNullException(nameof(modelProvider));
        _threadStatePath = Path.Combine(Directory.GetCurrentDirectory(), threadStateFileName);
    }

    public async Task RunInteractiveChatAsync()
    {
        // 创建带自定义消息存储的 Agent
        var agent = new OpenAIClient(
                new ApiKeyCredential(_modelProvider.ApiKey),
                new OpenAIClientOptions { Endpoint = new Uri(_modelProvider.Endpoint) })
            .GetChatClient(_modelProvider.ModelId)
            .CreateAIAgent(
            new ChatClientAgentOptions
            {
                Name = "记忆大师",
                Description = "你是一个有长期记忆的助手,能记住之前的对话。",
                ChatMessageStoreFactory = ctx => new VectorChatMessageStore(_vectorStore, ctx.SerializedState, ctx.JsonSerializerOptions)
            });


        // 尝试恢复线程
        AgentThread thread;
        if (File.Exists(_threadStatePath))
        {
            Console.WriteLine("检测到已保存的对话状态,正在恢复...");
            string json = await File.ReadAllTextAsync(_threadStatePath);
            var element = JsonSerializer.Deserialize<JsonElement>(json, JsonSerializerOptions.Web);
            thread = agent.DeserializeThread(element, JsonSerializerOptions.Web);
            Console.WriteLine("对话已恢复!");
        }
        else
        {
            Console.WriteLine("开始新对话(使用 InMemory 向量存储记录历史)...");
            thread = agent.GetNewThread();
        }

        while (true)
        {
            Console.Write("\n💬 你: ");
            string? input = Console.ReadLine();

            if (string.IsNullOrWhiteSpace(input)) continue;

            if (input.Equals("exit", StringComparison.OrdinalIgnoreCase))
            {
                var state = thread.Serialize(JsonSerializerOptions.Web).GetRawText();
                await File.WriteAllTextAsync(_threadStatePath, state);
                Console.WriteLine("线程状态已保存,再见!");
                break;
            }

            if (input.Equals("clear", StringComparison.OrdinalIgnoreCase))
            {
                if (File.Exists(_threadStatePath)) 
                    File.Delete(_threadStatePath);
                thread = agent.GetNewThread();
                Console.WriteLine("已开启全新对话(旧历史不可见)");
                continue;
            }

            try
            {
                var response = await agent.RunAsync(input, thread);
                Console.WriteLine($"\n助手: {response}");
            }
            catch (Exception ex)
            {
                Console.WriteLine($"错误: {ex.Message}");
                continue;
            }

            var updatedState = thread.Serialize(JsonSerializerOptions.Web).GetRawText();
            await File.WriteAllTextAsync(_threadStatePath, updatedState);
        }
    }

    //这个基本和文档的案例一致👇,我引入了AnsiConsole美化输出
    private sealed class VectorChatMessageStore : ChatMessageStore
    {
        private readonly VectorStore _vectorStore;
        public string? ThreadDbKey { get; private set; }

        public VectorChatMessageStore(
            VectorStore vectorStore,
            JsonElement serializedStoreState,
            JsonSerializerOptions? jsonSerializerOptions = null)
        {
            _vectorStore = vectorStore ?? throw new ArgumentNullException(nameof(vectorStore));
            if (serializedStoreState.ValueKind == JsonValueKind.String)
                ThreadDbKey = serializedStoreState.Deserialize<string>(jsonSerializerOptions);
        }

        public override async Task AddMessagesAsync(
            IEnumerable<ChatMessage> messages,
            CancellationToken cancellationToken = default)
        {
            ThreadDbKey ??= Guid.NewGuid().ToString("N");

            AnsiConsole.MarkupLine($"[cyan]【Add】 ThreadKey: {ThreadDbKey}, 消息数: {messages.Count()}[/]");

            var collection = _vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
            await collection.EnsureCollectionExistsAsync(cancellationToken);

            await collection.UpsertAsync(
                messages.Select(msg => new ChatHistoryItem
                {
                    Key = $"{ThreadDbKey}_{msg.MessageId}",
                    ThreadId = ThreadDbKey,
                    Timestamp = DateTimeOffset.UtcNow,
                    SerializedMessage = JsonSerializer.Serialize(msg, SourceGenerationContext.Default.ChatMessage),
                    MessageText = msg.Text ?? ""
                }),
                cancellationToken);
        }

        public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(
            CancellationToken cancellationToken = default)
        {
            if (string.IsNullOrEmpty(ThreadDbKey))
                return [];

            AnsiConsole.MarkupLine($"[yellow]【Get】 从 ThreadKey: {ThreadDbKey} 读取消息[/]");


            var collection = _vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
            await collection.EnsureCollectionExistsAsync(cancellationToken);

            // 获取该线程的所有消息(按时间倒序取最新 10 条)
            var records = collection.GetAsync(
                filter: x => x.ThreadId == ThreadDbKey,
                top: 10,
                options: new() { OrderBy = x => x.Descending(y => y.Timestamp) },
                cancellationToken);

            var messages = new List<ChatMessage>();
            await foreach (var record in records)
            {
                messages.Add(JsonSerializer.Deserialize<ChatMessage>(
                    record.SerializedMessage!,
                    SourceGenerationContext.Default.ChatMessage)!);
            }

            messages.Reverse();
            return messages;
        }

        public override JsonElement Serialize(JsonSerializerOptions? options = null)
            => JsonSerializer.SerializeToElement(ThreadDbKey, options);

        private sealed class ChatHistoryItem
        {
            [VectorStoreKey] public string? Key { get; set; }
            [VectorStoreData] public string? ThreadId { get; set; }
            [VectorStoreData] public DateTimeOffset? Timestamp { get; set; }
            [VectorStoreData] public string? SerializedMessage { get; set; }
            [VectorStoreData] public string? MessageText { get; set; }
        }
    }
}

为智能体添加记忆

本节对应文档的地址:learn.microsoft.com/en-us/agent...

这一章关于概念性的内容我这里不过多介绍了,这里简单总结一下,我觉得这一篇是MAF的基础文档介绍中最为压轴的一篇,通过AIContextProvider可以让Agent具备"学习"和"个性化"的能力,构建出复杂交互系统。如文档所示

AIContextProvider是一个抽象类,您可以从中继承,并且可以与AgentThread关联以用于ChatClientAgent。 该功能允许:

  • 在代理调用基础推理服务之前和之后运行自定义逻辑。
  • 在调用基础推理服务之前,向代理提供其他上下文。
  • 检查代理提供和生成的所有消息。

AIContextProvider本质上是一个拦截器或者说中间件,像是在Agent从"接受输入(InvokingAsync )"到"调用模型(InvokedAsync)"这个执行流程上切了2刀,这个过程可以参考以下流程图。

更多概念性内容大家还是参考文档吧,我这里就不再赘述了。咱们看案例吧

csharp 复制代码
using AgentFrameworkQuickStart.Models;
using AgentFrameworkQuickStart.Tools;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using OpenAI;
using Spectre.Console;
using System.ClientModel;
using System.Text;
using System.Text.Json;

namespace AgentFrameworkQuickStart
{
    public class CarMasterMemory : AIContextProvider
    {
        private readonly IChatClient _innerClient;
        public CarPreference Preference { get; private set; }

        public CarMasterMemory(IChatClient client, CarPreference? pref = null)
        {
            _innerClient = client;
            Preference = pref ?? new CarPreference();
        }

        public CarMasterMemory(IChatClient client, JsonElement serializedState, JsonSerializerOptions? options = null)
        {
            _innerClient = client;
            Preference = serializedState.ValueKind == JsonValueKind.Object
                ? serializedState.Deserialize<CarPreference>(options) ?? new CarPreference()
                : new CarPreference();
        }

        public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken ct = default)
        {
            var sb = new StringBuilder("\n[后台画像已加载]");
            if (Preference.BudgetMax > 0) sb.Append($" | 预算上限:{Preference.BudgetMax}万");
            if (Preference.EnergyType != "未指定") sb.Append($" | 能源偏好:{Preference.EnergyType}");
            if (Preference.MustHaves.Any()) sb.Append($" | 关键需求:{string.Join("、", Preference.MustHaves)}");

            return new ValueTask<AIContext>(new AIContext { Instructions = sb.ToString() });
        }


        public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken ct = default)
        {
            if (context.RequestMessages.Any(m => m.Role == ChatRole.User))
            {
                try
                {
                    var lastUserMessage = context.RequestMessages.LastOrDefault(m => m.Role == ChatRole.User)?.Text;
                    if (string.IsNullOrEmpty(lastUserMessage)) return;

                    var analysisOptions = new ChatOptions
                    {
                        ResponseFormat = ChatResponseFormat.Json,
                        Instructions = """
                    你是一个数据提取器。请分析用户的输入,提取购车意向。
                    返回 JSON 格式如下:
                    {
                      "BudgetMax": 数字 (如果是30万请写30, 必须是万为单位的数字),
                      "EnergyType": "字符串 (如: 纯电/燃油/混动)",
                      "MustHaves": ["需求点1", "需求点2"] (如果没有提到任何具体配置或功能需求,请返回空数组 [])
                    }
                    注意:如果是配置需求(如: 智驾、全景天窗、大空间),请放入 MustHaves。
                    """
                    };

                    var extraction = await _innerClient.GetResponseAsync<CarPreference>(
                        context.RequestMessages.TakeLast(2), // 只看最近一两轮
                        analysisOptions);

                    if (extraction.Result != null)
                    {
                        var newInfo = extraction.Result;

                        if (newInfo.BudgetMax > 5000) newInfo.BudgetMax /= 10000;
                        if (newInfo.BudgetMax > 0) this.Preference.BudgetMax = newInfo.BudgetMax;

                        if (!string.IsNullOrEmpty(newInfo.EnergyType) && newInfo.EnergyType != "未指定" && newInfo.EnergyType != "null")
                        {
                            this.Preference.EnergyType = newInfo.EnergyType;
                        }

                        if (newInfo.MustHaves != null && newInfo.MustHaves.Any())
                        {
                            var validNewItems = newInfo.MustHaves
                                .Where(s => !string.IsNullOrWhiteSpace(s) && s != "无" && s != "null");

                            var updatedList = this.Preference.MustHaves.Union(validNewItems, StringComparer.OrdinalIgnoreCase).ToList();
                            this.Preference.MustHaves = updatedList;
                        }
                    }
                }
                catch (Exception ex)
                {
                    // 调试用
                    // Console.WriteLine($"[DEBUG] 提取失败: {ex.Message}");
                }
            }
        }
        public override JsonElement Serialize(JsonSerializerOptions? options = null)
            => JsonSerializer.SerializeToElement(Preference, options);
    }

    public class CarMasterAgent : BaseAgent
    {
        private readonly VectorStore _vectorStore = new InMemoryVectorStore();

        public CarMasterAgent(ModelProvider modelProvider) : base(modelProvider) { }

        public async Task RunMasterAsync()
        {
            var client = new OpenAIClient(
                new ApiKeyCredential(modelProvider.ApiKey),
                new OpenAIClientOptions { Endpoint = new Uri(modelProvider.Endpoint) });

            var chatClient = client.GetChatClient(modelProvider.ModelId);

            var agent = chatClient.CreateAIAgent(new ChatClientAgentOptions
            {
                Name = "汽车大师",
                Description = "你是一个毒舌但专业的汽车大师。你会根据后台画像(预算、需求)给出精准建议。",
                // 1. 对话记录存入向量数据库(你写的逻辑)
                ChatMessageStoreFactory = ctx => new VectorChatMessageStore(_vectorStore, ctx.SerializedState, ctx.JsonSerializerOptions),
                // 2. 画像提炼存入上下文提供者(我优化的逻辑)
                AIContextProviderFactory = ctx => new CarMasterMemory(chatClient.AsIChatClient(), ctx.SerializedState, ctx.JsonSerializerOptions)
            });

            var thread = agent.GetNewThread();

            while (true)
            {
                var input = AnsiConsole.Ask<string>("[white]你:[/]");
                if (input == "exit") break;

                var response = await agent.RunAsync(input, thread);
                AnsiConsole.MarkupLine($"\n[cyan]大师: {response}[/]");

                var mem = thread.GetService<CarMasterMemory>()?.Preference;
                AnsiConsole.MarkupLine($"[grey]>>> 系统画像更新 | 预算: {mem?.BudgetMax}w | 能源: {mem?.EnergyType} | 需求数: {mem?.MustHaves.Count}[/]");
            }
        }

        public async Task RunMasterStreamAsync()
        {
            var client = new OpenAIClient(
                new ApiKeyCredential(modelProvider.ApiKey),
                new OpenAIClientOptions { Endpoint = new Uri(modelProvider.Endpoint) });

            var chatClient = client.GetChatClient(modelProvider.ModelId);

            var agent = chatClient.CreateAIAgent(new ChatClientAgentOptions
            {
                Name = "汽车大师",
                Description = "你是一个既毒舌又专业的汽车大师。你会根据后台画像(预算、需求)给出精准建议。重点突出优缺点,有没有排面,不要长篇大论,扯一些没用的。",
                // 对话记录存入向量数据库
                ChatMessageStoreFactory = ctx => new VectorChatMessageStore(_vectorStore, ctx.SerializedState, ctx.JsonSerializerOptions),
                // 画像提炼存入上下文提供者
                AIContextProviderFactory = ctx => new CarMasterMemory(chatClient.AsIChatClient(), ctx.SerializedState, ctx.JsonSerializerOptions)
            });

            var thread = agent.GetNewThread();

            AnsiConsole.MarkupLine("[bold green]--- 汽车大师已上线 (流式模式) ---[/]");

            while (true)
            {
                var input = AnsiConsole.Ask<string>("\n[white]你:[/]");
                if (input == "exit") break;

                AnsiConsole.Markup("[cyan]大师:[/] ");

                await foreach (var chunk in agent.RunStreamingAsync(input, thread))
                {
                    // 直接输出片段,不换行
                    Console.Write(chunk);
                }

                Console.WriteLine(); // 结束后手动换行

                await Task.Delay(500);
                var mem = thread.GetService<CarMasterMemory>()?.Preference;

                var panel = new Panel($"""
            [yellow]预算限制:[/] {mem?.BudgetMax} 万
            [yellow]能源偏好:[/] {mem?.EnergyType}
            [yellow]核心需求:[/] {(mem?.MustHaves.Any() == true ? string.Join("、", mem.MustHaves) : "尚不明确")}
            """)
                {
                    Header = new PanelHeader("🚗 [bold]当前画像记录[/]"),
                    Border = BoxBorder.Rounded
                };

                AnsiConsole.Write(panel);
            }
        }

        public async Task RunMasterWithToolsAsync()
        {
            var chatClient = new OpenAIClient(
                new ApiKeyCredential(modelProvider.ApiKey),
                new OpenAIClientOptions { Endpoint = new Uri(modelProvider.Endpoint) })
                .GetChatClient(modelProvider.ModelId);

            var agent = chatClient.CreateAIAgent(new ChatClientAgentOptions
            {
                Name = "汽车大师",
                Description = "一个从业20年的专业汽车顾问,擅长结合用户画像进行精准推荐。",

                // 将推理相关的配置放入 ChatOptions
                ChatOptions = new ChatOptions
                {
                    Instructions = "你是一个专业的汽车推荐助手。请优先参考后台画像。如果用户询问具体推荐,请调用 SearchCars 工具。",
                    Tools = [AIFunctionFactory.Create(new CarTool().SearchCars)]
                },

                AIContextProviderFactory = ctx => new CarMasterMemory(
                    chatClient.AsIChatClient(),
                    ctx.SerializedState,
                    ctx.JsonSerializerOptions),

                ChatMessageStoreFactory = ctx => new VectorChatMessageStore(
                    _vectorStore,
                    ctx.SerializedState,
                    ctx.JsonSerializerOptions)
            });

            var thread = agent.GetNewThread();

            while (true)
            {
                var input = AnsiConsole.Ask<string>("\n[white]你:[/]");
                if (input == "exit") break;

                // 使用流式输出
                AnsiConsole.Markup("[cyan]大师:[/] ");
                await foreach (var chunk in agent.RunStreamingAsync(input, thread))
                {
                    Console.Write(chunk);
                }
                Console.WriteLine();

                var mem = thread.GetService<CarMasterMemory>()?.Preference;
                AnsiConsole.Write(new Panel($"预算: {mem?.BudgetMax}w | 能源: {mem?.EnergyType}").Border(BoxBorder.Rounded));
            }
        }
    }
}

简单说明一下,这个案例,前半部分的代码是核心,重载了InvokingAsync和InvokedAsync两个方法,分别在调用模型前,和调用模型后做一些业务相关的操作,比如这个案例是根据用户输入,提炼一个用户画像,有多少预算,倾向买什么车等。

后半部分,定义了3个智能体,其中RunMasterAsync和RunMasterStreamAsync实际只有一个输出方式的区别,而RunMasterWithToolsAsync则包含了一个工具的调用,智能体会在合适的时机调用工具执行操作。

  • 不使用工具
  • 使用工具(动图后半部分执行工具)

结语

好了,受篇幅限制,就写到这里吧,下一篇再来聊聊工作流的部分。

附一条小插曲,截止到笔者发文,刚刚看到C#获得了2025年年度编程语言,多年来,C#经历了根本性的变革。从语言设计的角度来看,C#常常率先采纳主流语言中的新趋势。与此同时,它成功完成了两次重大范式转变:从仅限Windows到跨平台,以及从微软专有到开源。C#始终能在恰当的时机实现与时俱进的演进。

相关推荐
stark张宇5 小时前
Go语言核心三剑客:数组、切片与结构体使用指南
后端·go
高洁015 小时前
10分钟了解向量数据库(3
人工智能·深度学习·机器学习·transformer·知识图谱
IvorySQL5 小时前
让源码安装不再困难:IvorySQL 一键安装脚本的实现细节解析
数据库·人工智能·postgresql·开源
民乐团扒谱机5 小时前
【微实验】数模美赛备赛MATLAB实战:一文速通各种“马尔可夫”(Markov Model)
开发语言·人工智能·笔记·matlab·数据挖掘·马尔科夫链·线性系统
MistaCloud5 小时前
Pytorch深入浅出(十三)之模型微调
人工智能·pytorch·python·深度学习
雨大王5125 小时前
工业AI大模型如何重塑汽车焊接与质检流程?
人工智能·汽车
洛小豆5 小时前
她问我:数据库还在存 Timestamp?我说:大人,时代变了
数据库·后端·mysql
MARS_AI_5 小时前
当AI客服开始“察言观色”:以云蝠智能为例,大模型如何定义呼叫
人工智能
AI小怪兽5 小时前
基于YOLO11的航空安保与异常无人机检测系统(Python源码+数据集+Pyside6界面)
开发语言·人工智能·python·yolo·计算机视觉·无人机