为解决LLM调用无状态的问题,我们不得不将消息历史作为上下文的一部分发送给LLM。但随着对话的进行,消息历史会不断增长,最终可能会超出LLM的上下文窗口限制。随着LLM的上下文窗口的不断扩大,可以有朝一日上下文窗口不再成为问题,但也并不意味着将整个对话历史原封不动地发送给LLM就是最优的选择。LLM拥有上下文的丰富程度与推理的质量并不是完全正相关的,当对话历史的长度超过一定程度后,过长的上下文反而会干扰LLM对当前对话的理解和推理,导致LLM生成的响应质量下降。所以对话历史的压缩需求可能永远都不会消失。针对消息的压缩,MAF提供了不止一种解决方案,一种是使用ReducingChatClient这个IChatClient中间件,另一种就是使用本篇文章着重介绍的CompactionProvider这个AIContextProvider。
1. 利用CompactionProvider摘要对话历史
CompactionProvider支持不同的消息压缩策略,比如摘要、折叠、删除和替换等。由于CompactionProvider对压缩策略进行了抽象,原则上我们可以自行提供任一的策略来压缩对话历史。下面的例子演示了针对摘要的压缩策略,它可以在不丢失语义的前提下将多条消息压缩成一条。下面的实例演示了如何利用CompactionProvider,结合代表摘要压缩策略的SummarizationCompactionStrategy来对对话历史进行摘要压缩。
csharp
using Azure;
using dotenv.net;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
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 compactionStrategy = new SummarizationCompactionStrategy(
chatClient: summaryClient,
trigger: messageIndex => messageIndex.TotalMessageCount > 6,
minimumPreservedGroups: 1);
var compactionProvider = new CompactionProvider(compactionStrategy);
var agent = new OpenAIClient(
credential: new AzureKeyCredential(apiKey),
options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(model: "gpt-5.2-chat")
.AsIChatClient()
.AsBuilder()
.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()
.AsAIAgent(options: new ChatClientAgentOptions { AIContextProviders = [compactionProvider] });
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 agent.RunAsync(messages);
Console.WriteLine($"\n\n{response}");
如代码所示,我们创建了一个SummarizationCompactionStrategy对象来指定我们想要使用的摘要压缩策略,它的三个参数分别是:
- summaryClient:摘要需要借助LLM的语言处理能力,所以需要这个参数提供的
IChatClient来调用LLM生成摘要内容; - trigger:是一个
CompactionTrigger类型的委托用于定义触发压缩的条件。上面的例子中,我们指定的触发条件是当消息总数超过6条时就触发压缩; - minimumPreservedGroups:是一个整数值,用于指定在触发压缩时至少要保留多少消息组。消息组在基于
CompactionProvider的压缩策略中是一个重要的概念,我们将在后续部分对其进行详细介绍,这里我么姑且理解只需要保留最近的一条消息就可以了,需要对前面的消息进行压缩;
在基于这个SummarizationCompactionStrategy对象创建了一个CompactionProvider对象之后,我们将它注册到后面构建的AIAgent对象上。为了查看摘要压缩后的消息,我们调用ChatClientBuilder的Use方法注册了一个IChatClient中间件输出所有的请求消息。程序的输出如下所示:
markdown
请求消息共计2条
1. [Summary]
**对话摘要:**
- 用户询问苏州今天天气情况。
- 提供信息:
- 天气:晴天
- 气温:25°C
- 风力:西北风 4 级
无使用工具调用。对话围绕苏州当日天气展开。
2. 根据天气,给我一些着装建议。
根据目前 **25°C、晴天、西北风4级** 的天气情况,给你一些穿搭建议:
### 🌤️ 上衣
- ✅ **短袖T恤、衬衫、薄款POLO** 都很合适
- 如果怕风,可以带一件 **薄款防风外套或开衫**
### 👖 下装
- ✅ **薄款长裤、牛仔裤、休闲裤**
- ✅ 女生也可以穿 **半裙或连衣裙**
- 不建议太厚的裤子(会有点热)
### 👟 鞋子
- ✅ 运动鞋、休闲鞋、乐福鞋都合适
- 晴天路干,也可以穿透气一点的鞋
### 🌞 其他建议
- 有太阳,**可以带太阳镜或遮阳帽**
- 紫外线可能较强,建议涂防晒
- 风力4级稍有风感,头发容易被吹乱 😄
总体来说是一个**舒适偏暖、略有风的好天气**,穿得清爽一点最合适。
如果你是上班、约会还是户外活动,我也可以帮你具体搭配一下 😊
可以看到,原本的7条消息被压缩成了2条,其中第一条是压缩后的摘要消息,第二条是用户的最新消息。摘要消息中不仅保留了对话的核心信息,还对对话内容进行了适当的扩展和润色,使得它更像是人类总结出来的内容而不是简单地罗列式的摘要。虽然消息数量减少了,但是语义信息并没有丢失,所以LLM同样能够给出我们希望的响应内容。
2. 消息组
消息组对于CompactionProvider的压缩策略来说是一个非常重要的概念。我们上面多次提及的针对消息的压缩 ,其实应该改为针对消息组的压缩 。消息组是压缩系统的原子单位 ,它把一条或多条逻辑相关的ChatMessage组合成一个可被压缩策略整体处理的结构。所有压缩策略都不是直接操作ChatMessage,而是操作消息组。消息组是MAF在构建可压缩对话历史时引入的结构化分组层。它的设计目标是:
- 让压缩策略能够基于结构化的消息单元进行处理,而不是逐条操作零散的消息;
- 让工具调用、多条针对同一角色的消息,以及折叠或摘要后的内容,都能够作为一个整体被压缩、替换或保留;
- 让token/byte统计、turnIndex、排除标记等元数据绑定在消息组级别,从而实现更精确、更高效的压缩控制;
可以把它理解为:消息组 = ChatMessage的逻辑分组 + 压缩元数据 + 语义标签
上面提到的TurnIndex被称为轮次索引 。Turn表示由一次User消息开启的交互轮次,包含该User消息之后直到下一条User消息之前的所有消息。对话历史的每一条消息都具有一个确定的TurnIndex,一如下这个对话历史为例:
markdown
User 发言 → 开启新Turn
Assistant回复 → 属于当前Turn
Tool调用/ToolResult → 属于当前Turn
Assistant主动发言(无User输入)→ 仍属于当前Turn
User再次发言 → 开启新Turn
目前没有看到由官方的文档对消息组的划分规则提供明确的说明,下面所述的划分规则来源于对源代码的分析。为么方面说明,我们定义如下几种消息类型:
- Reasoning-Only:指的是那些只包含
TextReasoningContent内容的Assistant消息。这些消息通常用于表达LLM的推理过程、思考路径或者一些辅助性的解释说明,它们不包含任何工具调用的内容; - Function-Call:指的是那些包含
FunctionCallContent内容的Assistant消息; - Function-Result:指的是那些包含
FunctionResultContent内容的Tool消息;
如果一个消息组包含多条消息,有且只有如下这一种排序,我们需要这种组合来保证工具调用的完整性:包含一条Function-Call消息和紧随其后的一条Function-Result消息,以及在这前后或者其间Assistant针对工具调用和结果的推理。
- Reasoning-Only(0条或者多条)
- Function-Call(1条)
- Reasoning-Only/Function-Result(1条或者多条)
- Reasoning-Only(0条或者多条)
上述的划分规则决定了通过如下这个CompactionGroupKind枚举表示的消息组类型:
csharp
public enum CompactionGroupKind
{
System,
User,
AssistantText,
ToolCall,
Summary
}
具体类型说明如下:
- System:系统消息组,包含所有角色为System的消息;
- User:用户消息组,包含所有连续的角色为User的消息;
- AssistantText:Assistant文本消息组,包含所有连续的角色为Assistant且不包含工具调用内容的消息;
- ToolCall:工具调用消息组,按照我们前面的说法,一个包含多条消息的消息组必然属于这种类型;
- Summary:摘要消息组,包含经过摘要压缩后的消息;
2.2 CompactionMessageGroup
消息组MAF中通过如下这个CompactionMessageGroup类来表示:
csharp
public sealed class CompactionMessageGroup
{
public static readonly string SummaryPropertyKey = "_is_summary";
public CompactionGroupKind Kind { get; }
public IReadOnlyList<ChatMessage> Messages { get; }
public int MessageCount { get; }
public int ByteCount { get; }
public int TokenCount { get; }
public int? TurnIndex { get; }
public bool IsExcluded { get; set; }
public string? ExcludeReason { get; set; }
}
属性说明如下:
- Kind:表示消息组的类型;
- Messages:组成这个消息组的
ChatMessage列表; - MessageCount、ByteCount、TokenCount:这个消息组中包含的消息数量、总字节数和总token数;
- TurnIndex:这个消息组所属的Turn索引;
- IsExcluded和ExcludeReason:用于标记这个消息组是否被排除在压缩之外,以及排除的原因;
为了避免语义丢失,对于摘要生成的ChatMessage,一般不允许对它进行二次摘要,所以CompactionProvider必需能够识别什么是摘要消息。具体做法很简单,这样的ChatMessage的AdditionalProperties中会有一个键为专门的键来标记它是一个摘要消息,这个键就是CompactionMessageGroup类中的SummaryPropertyKey静态字段,值为布尔值。
3. 消息索引
作为对话历史的ChatMessage列表划分为一个个CompactionMessageGroup后,为了方便后续压缩,MAF还需要为这些消息组创建对应的消息索引。消息索引可以看作为消息组构建的、可以在上边应用相应的策略对其实施压缩的Workspace 。消息索引通过如下这个CompactionMessageIndex类型表示。
csharp
public sealed class CompactionMessageIndex
{
public IList<CompactionMessageGroup> Groups { get; }
public Tokenizer? Tokenizer { get; }
public CompactionMessageIndex(
IList<CompactionMessageGroup> groups,
Tokenizer? tokenizer = null);
public CompactionMessageGroup InsertGroup(
int index,
CompactionGroupKind kind,
IReadOnlyList<ChatMessage> messages,
int? turnIndex = null);
public CompactionMessageGroup AddGroup(
CompactionGroupKind kind,
IReadOnlyList<ChatMessage> messages,
int? turnIndex = null);
}
一个CompactionMessageIndex对象通过指定的一组CompactionMessageGroup对象来构建而成。由于需要利用分词器计算Token数量,所以在构建CompactionMessageIndex对象的时候可以选择性地传入一个Tokenizer对象。消息索引提供了InsertGroup和AddGroup两个方法来向索引中插入新的消息组或者添加新的消息组,并利用Groups和Tokenizer属性来访问当前的消息组和分词器。如果没有显式提供分词器,默认会按照每4个字节算作一个Token的方式来估算Token数量。
我们可以调用GetAllMessages方法获取整个消息列表。CompactionMessageGroup利用IsExcluded属性来标记消息组是否被排除在压缩之外,GetIncludedMessages方法返回未被排除在外的消息组提供的消息。如果知道TurnIndex,我们还可以调用GetTurnGroups方法来获取隶属于指定轮次的所有消息组。
csharp
public sealed class CompactionMessageIndex
{
public IEnumerable<ChatMessage> GetIncludedMessages();
public IEnumerable<ChatMessage> GetAllMessages();
public IEnumerable<CompactionMessageGroup> GetTurnGroups(int turnIndex);
}
CompactionMessageIndex还定义如下这些返回各种汇总统计的属性,这些统计信息对于压缩策略的决策非常重要。
csharp
public sealed class CompactionMessageIndex
{
public int TotalGroupCount { get; }
public int TotalMessageCount { get; }
public int TotalByteCount { get; }
public int TotalTokenCount { get; }
public int IncludedGroupCount { get; }
public int IncludedMessageCount { get; }
public int IncludedByteCount { get; }
public int IncludedTokenCount { get; }
public int TotalTurnCount { get; }
public int IncludedTurnCount { get; }
public int IncludedNonSystemGroupCount { get; }
public int RawMessageCount { get; }
internal static CompactionMessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null);
}
具体汇总统计包括:
- TotalGroupCount、TotalMessageCount、TotalByteCount、TotalTokenCount:整个消息索引中包含的消息组数量、消息数量、字节数和token数;
- IncludedGroupCount、IncludedMessageCount、IncludedByteCount、IncludedTokenCount:未被排除在压缩之外的消息组数量、消息数量、字节数和token数;
- TotalTurnCount、IncludedTurnCount:整个消息索引中包含的Turn数量和未被排除在压缩之外的Turn数量;
- IncludedNonSystemGroupCount:未被排除在压缩之外的、且类型不为System的消息组数量;
- RawMessageCount:原始的消息数量,也就是未经过任何分组或者压缩之前的消息数量;
4. 压缩策略
如下所示的CompactionStrategy抽象了所谓的压缩策略,它旨在从三个维度描述压缩对话历史的策略:何时触发压缩 、压缩后的目标状态 以及具体的压缩执行逻辑。
csharp
public abstract class CompactionStrategy
{
protected CompactionTrigger Trigger { get; }
protected CompactionTrigger Target { get; }
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null);
protected abstract ValueTask<bool> CompactCoreAsync(
CompactionMessageIndex index,
ILogger logger,
CancellationToken cancellationToken);
public async ValueTask<bool> CompactAsync(
CompactionMessageIndex index,
ILogger? logger = null,
CancellationToken cancellationToken = default);
}
CompactionStrategy中表示触发压缩的Trigger和表示压缩目标状态的Target都是如下这个CompactionTrigger类型的委托。这是一个输入参数为CompactionMessageIndex对象,返回值为布尔值的委托。实现具体压缩策略的子类必需重写的CompactCoreAsync方法也将压缩直接应用到作为参数的CompactionMessageIndex对象上。压缩完成之后,CompactionMessageIndex的GetIncludedMessages方法返回的消息列表就是经过压缩后的消息列表了。
csharp
public delegate bool CompactionTrigger(CompactionMessageIndex index);
5. CompactionProvider
由于压缩操作最终落在注册的CompactionStrategy对象上,所以Compaction自身的实现其实很简单。前面已经说过了,压缩针对的原子单元是CompactionMessageGroup,而不是单个的ChatMessage,所以中间涉及一个从ChatMessage列表到CompactionMessageGroup列表的构建过程。为了避免这个过程重写执行,CompactionProvider会将针对当前消息列表构建好的CompactionMessageGroup列表存储在Session状态中。对于每次Agent调用,它只需要针对新产生的消息以增量的方式构建CompactionMessageGroup列表,并将其追加到现有的列表之中。
CompactionMessageGroup列表被封装在内嵌的State类中。当ProviderSessionState初始化的时候,可以执行存储此State对象对应Session状态的键,如果没有显式指定,会默认使用CompationProvider的类型名称作为键。CompationProvider会创建一个ProviderSessionState<State>对象来管理这个状态,
csharp
public sealed class CompactionProvider : AIContextProvider
{
private readonly CompactionStrategy _compactionStrategy;
private readonly ProviderSessionState<State> _sessionState;
public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null)
{
_compactionStrategy = compactionStrategy;
stateKey ??= _compactionStrategy.GetType().Name;
StateKeys = [stateKey];
_sessionState = new ProviderSessionState<State>(
_ => new State(),
stateKey,
AgentJsonUtilities.DefaultOptions);
...
}
public override IReadOnlyList<string> StateKeys { get; }
internal sealed class State
{
public List<CompactionMessageGroup> MessageGroups { get; set; } = [];
}
}
CompactionProvider利用重写的InvokingCoreAsync方法来实现对话历史的压缩,针对指定CompactionStrategy压缩对话历史最终实现在静态方法CompactAsync中。这个方法接受一个CompactionStrategy对象和一个ChatMessage列表作为输入,首先将ChatMessage列表转换成CompactionMessageGroup列表并构建成一个CompactionMessageIndex对象,然后调用CompactionStrategy的CompactAsync方法来执行压缩,最后调用GetIncludedMessages方法返回压缩后的消息列表。
csharp
public sealed class CompactionProvider : AIContextProvider
{
public static async Task<IEnumerable<ChatMessage>> CompactAsync(
CompactionStrategy compactionStrategy,
IEnumerable<ChatMessage> messages,
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages];
CompactionMessageIndex messageIndex = CompactionMessageIndex.Create(messageList);
await compactionStrategy.CompactAsync(messageIndex, logger, cancellationToken)
.ConfigureAwait(false);
return messageIndex.GetIncludedMessages();
}
protected override async ValueTask<AIContext> InvokingCoreAsync(
InvokingContext context,
CancellationToken cancellationToken = default)
}
InvokingCoreAsync方法的具体实现逻辑如下:
- 确认Session存在,消息列表不为空,否则直接返回
InvokingContext携带的AIContext; - 将消息列表转换成
CompactionMessageGroup列表,如果Session状态中已经存在CompactionMessageGroup列表,则只针对新消息构建CompactionMessageGroup列表并追加到现有列表之中; - 利用
CompactionMessageGroup列表创建CompactionMessageIndex对象,并将其传入CompactionStrategy的CompactAsync方法来执行压缩,得到压缩后的消息列表; - 使用最新的
CompactionMessageGroup列表更新Session状态; - 提取
InvokingContext中原有的AIContext,并用压缩后的消息列表替换原有AIContext中的消息列表,构建一个新的AIContext对象返回;