由管道结构决定的对话历史存档方式 默认针对对话历史的存档是由ChatClientAgent管道的结构决定的。在下图所示的管道结构中实现ReAct的FunctionInvokingChatClient中间件是IChatClient管道的一部分用来持久化对话历史的ChatHistoryProvider位于IChatClient管道的上游所以针对Agent的一次调用ChatHistoryProvider针对现有消息的提取和针对新消息的存储各会执行一次。但是ReAct循环的迭代次数可能会很多中间产生的消息也可能很多。如果每次迭代都等到ReAct循环结束才存档的话意味着如果最后存档失败了那么ReAct循环中产生的所有消息都会丢失。我们可以通过一个演示程序来说明这一点。为了确定CodeHistoryProvider的两个方法被调用的时机以及它们各自加载和保存的消息我们定义了如下这个ChatHistoryProviderWrapper。顾名思义ChatHistoryProviderWrapper是一个ChatHistoryProvider的包装类它重写了InvokingCoreAsync和InvokedCoreAsync方法在调用基类方法之前输出一些日志来显示当前加载和保存的消息列表。class ChatHistoryProviderWrapper(ChatHistoryProvider chatHistoryProvider) : ChatHistoryProvider { private int _index 1; protected override async ValueTaskIEnumerableChatMessage InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken default) { Console.WriteLine(${new string(-, 50)} Round {_index} {new string(-, 50)}); var messages await chatHistoryProvider.InvokingAsync(context, cancellationToken); Console.WriteLine(${messages.Count()} messages are loaded.); PrintMessages(messages); return messages; } protected override ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken default) { Console.WriteLine($Received {context.RequestMessages.Count()} request messages to save.); PrintMessages(context.RequestMessages); Console.WriteLine($Received {context.ResponseMessages?.Count()??0} response messages to save.); PrintMessages(context.ResponseMessages ?? Array.EmptyChatMessage()); return chatHistoryProvider.InvokedAsync(context, cancellationToken); } static void PrintMessages(IEnumerableChatMessage messages) { var index 1; foreach (var message in messages) { var content message.Contents.FirstOrDefault(); var contentString content switch { FunctionResultContent functionResultContent functionResultContent.Result?.ToString() ?? string.Empty, FunctionCallContent functionCallContent $FunctionCall: {functionCallContent.Name} , TextContent textContent textContent.Text, _ content?.ToString() ?? string.Empty }; Console.WriteLine($ Message {index}: Role{message.Role}, Content{contentString}); } Console.WriteLine(); } }然后我们编写了如下的演示程序。如代码片段所示我们针对OpenAIClient创建了一个IChatClient对象然后调用AsAIgent方法将其转换成一个ChatClientAgent对象。我们自定义的ChatHistoryProviderWrapper包装了一个InMemoryChatHistoryProvider对象并在创建ChatClientAgent对象的时候通过ChatClientAgentOptions将其注册了进去。此ChatClientAgentOptions的ChatOptions上还注册了LookupLocationCode和GetWeatherForecast两个工具前者根据用户提供的城市名称返回位置代码后者根据位置代码返回天气预报信息。using Azure; using dotenv.net; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; using System.ComponentModel; DotEnv.Load(); var model Environment.GetEnvironmentVariable(MODEL)!; var apiKey Environment.GetEnvironmentVariable(API_KEY)!; var endpoint Environment.GetEnvironmentVariable(OPENAI_URL)!; ChatHistoryProvider chatHistoryProvider new InMemoryChatHistoryProvider(); chatHistoryProvider new ChatHistoryProviderWrapper(chatHistoryProvider); AITool[] tools [ AIFunctionFactory.Create(LookupLocationCode,nameof(LookupLocationCode)), AIFunctionFactory.Create(GetWeatherForecast,nameof(GetWeatherForecast)) ]; var options new ChatClientAgentOptions { ChatHistoryProvider chatHistoryProvider, ChatOptions new ChatOptions { Tools tools } }; var agent new OpenAIClient( credential: new AzureKeyCredential(apiKey), options: new OpenAIClientOptions { Endpoint new Uri(endpoint) }) .GetChatClient(model:model) .AsIChatClient() .AsAIAgent(options); var session await agent.CreateSessionAsync(); var response await agent.RunAsync(根据今天苏州的天气给出穿衣建议。, session); Console.WriteLine(response); [Description(根据用户提供的城市名称返回位置代码)] static string LookupLocationCode([Description(城市名称)] string city) city switch { 苏州 SZ, 北京 BJ, _ throw new ArgumentException($Unknown city: {city}) }; [Description(根据位置代码返回天气预报信息)] static string GetWeatherForecast([Description(位置代码)] string locationCode) { // Simulate looking up weather forecast based on location code return locationCode switch { SZ 苏州今天是晴天室外温度25度西北风4级。, BJ 北京今天是多云室外温度22度东风3级。, _ throw new ArgumentException($Unknown location code: {locationCode}) }; }我们调用AIAgent的RunAsync方法来执行一个任务任务内容是根据今天苏州的天气给出穿衣建议。按照我们对ReAct的理解这次调用会经历三轮ReAct迭代除最后一次针对最终答案的输出外前两次分别是对两个工具的调用。但是针对CodeHistoryProvider的InvokingAsync和InvokedAsync方法的调用只有一次如下的输出体现了这一点。-------------------------------------------------- Round 1 -------------------------------------------------- 1 messages are loaded. Message 1: Roleuser, Content根据今天苏州的天气给出穿衣建议。 Received 1 request messages to save. Message 1: Roleuser, Content根据今天苏州的天气给出穿衣建议。 Received 5 response messages to save. Message 1: Roleassistant, ContentFunctionCall: LookupLocationCode Message 2: Roletool, ContentSZ Message 3: Roleassistant, ContentFunctionCall: GetWeatherForecast Message 4: Roletool, Content苏州今天是晴天室外温度25度西北风4级。 Message 5: Roleassistant, Content苏州今天**晴天25°C西北风4级**体感整体舒适偏暖稍有风。 ### 穿衣建议 - ✅ **上衣**短袖、薄衬衫、POLO衫都很合适 - ✅ **下装**长裤、薄款休闲裤或裙子 - ✅ **外搭**可以带一件**薄外套/防风外套**风力4级早晚或骑车时会有点风感 - ✅ **鞋子**运动鞋、休闲鞋都很合适 ### ☀️ 其他建议 - 紫外线在晴天会比较强外出可适当**防晒**防晒霜/帽子/墨镜 - 空气干燥时可多喝水 如果你是要通勤、出游或运动我也可以帮你细化穿搭建议 苏州今天**晴天25°C西北风4级**体感整体舒适偏暖稍有风。 ### 穿衣建议 - ✅ **上衣**短袖、薄衬衫、POLO衫都很合适 - ✅ **下装**长裤、薄款休闲裤或裙子 - ✅ **外搭**可以带一件**薄外套/防风外套**风力4级早晚或骑车时会有点风感 - ✅ **鞋子**运动鞋、休闲鞋都很合适 ### ☀️ 其他建议 - 紫外线在晴天会比较强外出可适当**防晒**防晒霜/帽子/墨镜 - 空气干燥时可多喝水 如果你是要通勤、出游或运动我也可以帮你细化穿搭建议 从输出可以看出本次调用会生成6条消息包含用户的原始请求并且要求ChatHistoryProvider存档一次。如果在存档的那一刻出现了异常所有的消息全部丢失。2. 利用PerServiceCallChatHistoryPersistingChatClient实现每步一存PerServiceCallChatHistoryPersistingChatClient旨在基于ReAct循环的多轮迭代中实现超高频、每步一存的聊天历史持久化与状态同步防止因为网络崩溃或单步失败导致整个复杂的工具调用链条数据丢失。这是是微软为了打造高可用、高容错、具备生产级弹性的企业级AI Agent而设计的底层机制。它通过在工具调用循环的每一次原子请求中安插持久化哨兵实现了状态的实时落库与精准路由。PerServiceCallChatHistoryPersistingChatClient无需采用常规的IChatClient中间件注册方式而是通过ChatClientAgentOptions中的RequirePerServiceCallChatHistoryPersistence选项来启用的。对于我们前面演示的实例我们只需要按照如下的方式创建ChatClientAgentOptions对象就可以将PerServiceCallChatHistoryPersistingChatClient中间件注册到ChatClientAgent管道中。var options new ChatClientAgentOptions { ChatHistoryProvider chatHistoryProvider, ChatOptions new ChatOptions { Tools tools }, RequirePerServiceCallChatHistoryPersistence true, };现在我们利用上面定义的ChatClientAgentOptions启用了PerServiceCallChatHistoryPersistingChatClient中间件。再次次运行前面的演示程序我们就会发现完全不一样的输出-------------------------------------------------- Round 1 -------------------------------------------------- 1 messages are loaded. Message 1: Roleuser, Content根据今天苏州的天气给出穿衣建议。 Received 1 request messages to save. Message 1: Roleuser, Content根据今天苏州的天气给出穿衣建议。 Received 1 response messages to save. Message 1: Roleassistant, ContentFunctionCall: LookupLocationCode -------------------------------------------------- Round 2 -------------------------------------------------- 3 messages are loaded. Message 1: Roleuser, Content根据今天苏州的天气给出穿衣建议。 Message 2: Roleassistant, ContentFunctionCall: LookupLocationCode Message 3: Roletool, ContentSZ Received 1 request messages to save. Message 1: Roletool, ContentSZ Received 1 response messages to save. Message 1: Roleassistant, ContentFunctionCall: GetWeatherForecast -------------------------------------------------- Round 3 -------------------------------------------------- 5 messages are loaded. Message 1: Roleuser, Content根据今天苏州的天气给出穿衣建议。 Message 2: Roleassistant, ContentFunctionCall: LookupLocationCode Message 3: Roletool, ContentSZ Message 4: Roleassistant, ContentFunctionCall: GetWeatherForecast Message 5: Roletool, Content苏州今天是晴天室外温度25度西北风4级。 Received 1 request messages to save. Message 1: Roletool, Content苏州今天是晴天室外温度25度西北风4级。 Received 1 response messages to save. Message 1: Roleassistant, Content苏州今天**晴天25°C西北风4级**体感总体比较舒适略有风感。给你一些穿衣建议