MAF预定义ChatClient中间件-04]ReducingChatClient——精减对话历史又不丢失基本语义

利用ReducingChatClient摘要对话内容

如下的程序演示了如何利用ReducingChatClient来部分对话内容进行摘要,保证在不丢失基本语义的前提下,腾出更多的上下文窗口。如代码片段所示,我们基于OpenAIClient创建了一个IChatClient对象,并在此基础上利用ChatClientBuilder注册了ReducingChatClient中间件,并指定了一个SummarizingChatReducer对象来提供基于摘要的队对话精减功能。我们在创建SummarizingChatReducer对象的时候,传入了一个用于对摘要进行生成的ChatClient对象,该对象依然是基于OpenAIClient创建的,并且使用了相同的模型来生成摘要。我们还为SummarizingChatReducer对象指定了targetCountthreshold两个参数,前者表示我们希望在摘要之后保留多少条消息,后者则是一个阈值,用于触发摘要操作的阈值(超过targetCount+threshold)。

using Azure; using dotenv.net; using Microsoft.Extensions.AI; using OpenAI; DotEnv.Load(); var apiKey = Environment.GetEnvironmentVariable("API_KEY")!; var endpoint = Environment.GetEnvironmentVariable("OPENAI_URL")!; var summaryClient = new OpenAIClient( credential: new AzureKeyCredential(apiKey), options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) .GetChatClient(model: "DeepSeek-V4-Pro") .AsIChatClient(); var client = new OpenAIClient( credential: new AzureKeyCredential(apiKey), options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) .GetChatClient(model: "gpt-5.2-chat") .AsIChatClient() .AsBuilder() .UseChatReducer(reducer: new SummarizingChatReducer(chatClient:summaryClient, targetCount: 3, threshold:1)) .Use((messages,options, next, cancelToken) => { Console.WriteLine( $"请求消息共计{messages.Count()}条"); var index = 1; foreach (var message in messages) { Console.WriteLine($"{index++}. {message}"); } return next(messages, options, cancelToken); }) .Build(); ChatMessage[] messages = [ new ChatMessage(ChatRole.User, "今天苏州的天气怎么样?"), new ChatMessage(ChatRole.Assistant, "苏州今天是晴天。"), new ChatMessage(ChatRole.User, "气温多少?。"), new ChatMessage(ChatRole.Assistant, "室外温度25度。"), new ChatMessage(ChatRole.User, "有风吗?"), new ChatMessage(ChatRole.Assistant, "西北风4级。"), new ChatMessage(ChatRole.User, "根据天气,给我一些着装建议。") ]; var response = await client.GetResponseAsync(messages); Console.WriteLine($"\n\n{response}");

为了查看经过ReducingChatClient精减之后的对话历史,我们在ChatClientBuilder中注册了一个简单的中间件来输出当前传入的消息列表。IChatClient管道构建成功之后,我们调用GetResponseAsync方法并指定了一组消息(共7条)来模拟一段对话的历史。由于我们在ReducingChatClient中指定了targetCount为3,并且threshold为1,必然会触发摘要操作。摘要完成后,保留了最后三条消息,只对对前4条消息进行了摘要,这一切体现在如下的输出中:

请求消息共计4条 1. 用户询问了今天苏州的天气情况,助手回答为晴天。随后用户进一步询问气温,助手回答室外温度为25度。对话围绕苏州当日的天气状况和具体气温展开,内容简洁明确。 2. 有风吗? 3. 西北风4级。 4. 根据天气,给我一些着装建议。 今天苏州**晴天,25℃,西北风4级**,体感会比较清爽,风稍微有点明显。给你一些穿搭建议: ### 👕 上衣 - **短袖T恤、薄衬衫**都可以 - 如果怕风,建议带一件**薄外套/防风夹克** ### 👖 下装 - **牛仔裤、休闲裤**都合适 - 不怕冷的话也可以穿**薄款长裙/半裙** ### 👟 鞋子 - 运动鞋、休闲鞋都很舒服 - 风有点大,尽量避免太轻薄易飘的穿搭 ### 🌞 其他建议 - 晴天紫外线可能偏强,出门可以**戴太阳镜、涂防晒** - 风力4级骑车会有点顶风,注意安全 整体来说是**舒适偏清爽型天气**,穿得轻松一点就好 👍

2. IChatReducer

ReducingChatClient的核心是IChatReducer接口,我们可以称之为精简器。它定义了一个ReduceAsync方法,用于对传入的消息列表进行精减处理。我们可以通过实现IChatReducer接口来定义自己的消息精减策略,从而满足不同场景下的需求。

public interface IChatReducer { Task<IEnumerable<ChatMessage>> ReduceAsync( IEnumerable<ChatMessage> messages, CancellationToken cancellationToken); }

2.1 SummarizingChatReducer

SummarizingChatReducerIChatReducer接口的一个实现,它通过生成摘要的方式来对消息列表进行精减。我们在创建SummarizingChatReducer对象的时候,需要传入一个用于生成摘要的IChatClient对象,以及targetCountthreshold两个参数。targetCount表示我们希望在摘要之后保留多少条消息,threshold表示触发摘要的阈值,具体来说当总消息数量>targetCount+threshold时,摘要会被触发。理想状态下,系统会尝试保留最新的targetCount条消息不被摘要,将其余的旧消息进行压缩。

public sealed class SummarizingChatReducer : IChatReducer { public string SummarizationPrompt{ get; set;} public SummarizingChatReducer(IChatClient chatClient, int targetCount, int? threshold); public async Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken); }

为了防止对话上下文被生硬切断,系统在确定从哪条消息开始保留时有两条关键的边界保护规则:

  • 保持工具调用完整性:如果切分点刚好处于工具(函数)调用或返回结果的中间,切分点会向前(更旧的消息)移动,确保函数调用(消息包含FunctionCallContext)与其响应结果(消息包含FunctionResultContent)完整保留在同一个作用域内,不被摘要拆散;
  • 避免用户问题孤立:在缓冲阈值窗口(threshold)内,系统会向前(更旧的消息)寻找角色为User的消息。一旦找到,就会在用户消息之前切断。这样可以确保用户的提问与其后续的LLM回复、工具调用保存在一起,避免问题被摘要,但答案被保留的孤立现象。

我们可以利用SummarizationPrompt属性来指定一个自定义的提示词来控制摘要的生成。默认情况下,SummarizingChatReducer会使用一个预定义的提示词来生成摘要,这个提示词会指导ChatClient如何对消息列表进行摘要处理,从而保证在不丢失基本语义的前提下,尽可能地精简消息列表。如下所示的是默认的提示词。

**Generate a clear and complete summary of the entire conversation in no more than five sentences.** The summary must always: - Reflect contributions from both the user and the assistant - Preserve context to support ongoing dialogue - Incorporate any previously provided summary - Emphasize the most relevant and meaningful points The summary must never: - Offer critique, correction, interpretation, or speculation - Highlight errors, misunderstandings, or judgments of accuracy - Comment on events or ideas not present in the conversation - Omit any details included in an earlier summary

2.2 MessageCountingChatReducer

SummarizingChatReducer不同,MessageCountingChatReducer是一个纯轻量级、零AI消耗、基于消息数量进行滑动窗口裁剪(Sliding Window)的精简器。MessageCountingChatReducer的精简策略简单粗暴,直接保留最近的N条消息,其中N由targetCount参数指定。

public sealed class MessageCountingChatReducer : IChatReducer { public MessageCountingChatReducer(int targetCount); public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken); }

两者选择保留消息的策略会不一样:

  • MessageCountingChatReducer:它会保留最近的targetCount条消息,但不包含FunctionCallContentFunctionResultContent的消息。整个消息列表包含系统消息,第一条(最旧的那条系统消息)会被保留,并置于保留消息的最前端,后续的系统消息会被直接抹除。系统消息不占用targetCount的配额,也就说最多会有targetCount+ 1条消息被保留;
  • SummarizingChatReducer:它不会丢弃工具消息。相反,它通过向前(更旧的消息)移动寻找边界,确保只要最新的上下文里触发了工具调用,整个工具调用链(调用 + 结果)就完整地保留在未摘要的消息列表中;

对于前面的实例,如果我们将ReducingChatClient中使用的精简器从SummarizingChatReducer换成MessageCountingChatReducer,那么在输出当前传入的消息列表的时候,我们会发现它直接保留了最后的三条消息,而没有对前面的消息进行任何摘要处理。

using Azure; using dotenv.net; using Microsoft.Extensions.AI; using OpenAI; DotEnv.Load(); var apiKey = Environment.GetEnvironmentVariable("API_KEY")!; var endpoint = Environment.GetEnvironmentVariable("OPENAI_URL")!; var summaryClient = new OpenAIClient( credential: new AzureKeyCredential(apiKey), options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) .GetChatClient(model: "gpt-5.2-chat") .AsIChatClient(); var client = new OpenAIClient( credential: new AzureKeyCredential(apiKey), options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) .GetChatClient(model: "gpt-5.2-chat") .AsIChatClient() .AsBuilder() .UseChatReducer(reducer: new MessageCountingChatReducer(targetCount: 3)) .Use((messages,options, next, cancelToken) => { Console.WriteLine( $"请求消息共计{messages.Count()}条"); var index = 1; foreach (var message in messages) { Console.WriteLine($"{index++}. {message}"); } return next(messages, options, cancelToken); }) .Build(); ChatMessage[] messages = [ new ChatMessage(ChatRole.User, "今天苏州的天气怎么样?"), new ChatMessage(ChatRole.Assistant, "苏州今天是晴天。"), new ChatMessage(ChatRole.User, "气温多少?。"), new ChatMessage(ChatRole.Assistant, "室外温度25度。"), new ChatMessage(ChatRole.User, "有风吗?"), new ChatMessage(ChatRole.Assistant, "西北风4级。"), new ChatMessage(ChatRole.User, "根据天气,给我一些着装建议。") ]; var response = await client.GetResponseAsync(messages); Console.WriteLine($"\n\n{response}");
请求消息共计3条 1. 有风吗? 2. 西北风4级。 3. 根据天气,给我一些着装建议。 目前是**西北风4级**,风力算是比较明显的,体感温度可能会比实际温度低一些。给你一些穿衣建议: - ✅ **外套必备**:建议穿一件防风外套、风衣或薄款夹克。 - ✅ **内搭可叠穿**:长袖T恤或薄针织衫比较合适,方便根据冷热增减。 - ✅ **下装**:长裤更舒适,避免被风吹得发凉。 - ✅ **怕冷的话**:可以加一条薄围巾,尤其是西北风通常偏干偏凉。 如果你告诉我现在的气温,我可以给你更具体的搭配建议 😊

3. ReducingChatClient

ReducingChatClient中间件的实现非常简单,它在接收到消息列表之后会调用IChatReducerReduceAsync方法来对消息列表进行精减处理,然后将精减后的消息列表传递给管道中的下一个中间件或者最终的IChatClient来生成响应。通过这种方式,ReducingChatClient能够帮助我们精简对话内容,从而腾出更多的上下文窗口来保证LLM推理的质量。

public sealed class ReducingChatClient : DelegatingChatClient { public ReducingChatClient(IChatClient innerClient, IChatReducer reducer); public override async Task<ChatResponse> GetResponseAsync( IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default); public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync( IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default); }

4. UseChatReducer扩展方法

UseChatReducer是一个ChatClientBuilder的扩展方法,它提供了一种简便的方式来注册ReducingChatClient中间件。我们只需要在构建IChatClient对象的时候调用UseChatReducer方法,并传入一个IChatReducer对象来指定我们想要使用的精简器,就可以轻松地将ReducingChatClient中间件添加到我们的IChatClient对象中了。除此之外,UseChatReducer方法还提供了一个可选的configure参数,它允许我们在注册ReducingChatClient中间件的时候对其进行一些额外的配置。

public static class ReducingChatClientBuilderExtensions { public static ChatClientBuilder UseChatReducer( this ChatClientBuilder builder, IChatReducer? reducer = null, Action<ReducingChatClient>? configure = null); }