上下文压缩 Compaction

本章讲解 MAF 内置的上下文压缩规则,但是要观察到压缩过程是比较麻烦的,因为正常对话聊天很难达到上下文限制,一般只有真正 Agent 工作的时候,例如读取代码、编辑文件,多轮对话之后才会累积这么多的 token,之后观察上下文压缩才会变得容易,所以本章只讲解基础。


MAF 框架的上下文压缩机制,主要包括消息类型、触发器、压缩策略、压缩器这四大方面的内容。

MAF 上下文的压缩方案是,在每次调用模型之前,先用一套策略把历史瘦身,把冗余的、过期的、可以合并的消息折叠掉,只把真正需要的那部分发给模型。

所有压缩相关的类型都在命名空间 Microsoft.Agents.AI.Compaction 下。

image-20260722141106053


压缩的时机和机制

压缩不是对单条消息操作,而是把消息组织成原子分组(Message Group),一组要么整体保留,要么整体折叠,绝不拆开。这是因为工具调用有配对约束,一条带工具调用的 assistant 消息,必须紧跟它对应的 tool 结果消息,少一条都会让模型 API 报错(OpenAI 等服务会校验 callId 配对)。


CompactionMessageIndex 负责把扁平的消息列表切成下面五种 group(CompactionGroupKind 枚举),也就是有以下五类分组的消息:

Group 类型包含的消息说明
System系统提示消息永远保留,不参与任何轮次统计
User一条用户消息每来一条就开一个新 turn
ToolCallassistant 调用消息 + 对应的若干 tool 结果消息原子单位,必须整组保留或折叠
AssistantText一条纯文本 assistant 消息(无工具调用)例如模型最终的口头回复、深度思考后给出的结论
Summary压缩策略生成的摘要消息SummarizationCompactionStrategy 产生,替代被折叠的老消息

每种 group 被压缩的方式不一样,重点看 ToolCall 和 AssistantText 这两类。

ToolCall 组,默认使用 DefaultToolCallFormatter 格式化器

以执行 shell 命令为例,原始一组是 assistant 调用 + 几 KB 的命令 stdout + assistant 口头结论三条消息打包。

ToolResultCompactionStrategy 会把整组折叠成一条 YAML 摘要,调用参数和原始输出全部丢弃,只留调用过哪个工具、返回了什么结论的骨架。


AssistantText 组(AI 的深度思考回复)

格式化折叠压不了它。对于几百上千 token 的长文回复(比如架构分析),要靠 SummarizationCompactionStrategy 把包含它的一整段老对话打包发给另一个 LLM 做摘要,用一条 Summary 替换——损失细节精度,但保留关键事实和结论。简短的口头结论则基本受保护,不被单独处理。


User / System 组

基本受保护。System 永远不动;User 只在某轮被划出滑动窗口(MinimumPreservedTurns 之外)时,才跟着整轮一起排除。


Summary 组:压缩过程的产物,一旦生成就不再被二次压缩。


分组算法(CompactionMessageIndex.AppendFromMessages)按顺序扫描消息:遇到 System 归入 System 组;遇到 User 开一个 User 组并让 turn 计数 +1;遇到带工具调用的 Assistant,就把它后面连续的 Tool 结果消息一起收进同一个 ToolCall 组。

一个 turn 就是"一条用户消息 + 它之后、下一条用户消息之前的所有响应组"。


每个 group 都预先算好了三个指标:消息数 MessageCount、字节数 ByteCount、token 数 TokenCount。token 优先用 Microsoft.ML.Tokenizers 精确分词;没传 tokenizer 时按 字节 / 4 估算。策略做决策时看的就是这些聚合值。

因为 token 的计算依赖模型自己的词表,每个模型的词表都不一样,所以客户端本身要精确计算 token 是很难的,不可能内置所有模型的词表。


关键设计:group 有一个 IsExcluded 标志位。压缩不是删除消息,而是把 group 标记为 "排除",它还留在索引里(可用于诊断、日志、持久化),只是不会被 GetIncludedMessages() 取出来发给模型。这样既保护了原始数据,又让压缩过程可逆、可观测。


压缩机制由触发条件 trigger 和目标条件 target 组成。

protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
{
    this.Trigger = Throw.IfNull(trigger);
    // 没指定 target 时,默认就是 trigger 的"反面"——压缩到 trigger 不再成立为止
    this.Target = target ?? (index => !trigger(index));
}
  • Trigger(触发条件):决定要不要开始压缩。
  • Target(目标条件):决定什么时候可以停 。策略每次排除一组消息后都重新评估 Target,一旦满足就立刻停手。

CompactionTriggers 工厂提供了一组现成的触发器:

触发器触发条件
Always无条件触发(pipeline 等场景用)
Never永不触发(相当于禁用)
TokensExceed(n)包含的 token 数 > n
TokensBelow(n)包含的 token 数 < n
MessagesExceed(n)包含的消息数 > n
TurnsExceed(n)包含的用户轮次 > n
GroupsExceed(n)包含的 group 数 > n
HasToolCalls()至少存在一个未被排除的 ToolCall 组
All(...) / Any(...)组合多个条件(AND / OR)

两个最常见的写法:TokensExceed(8000) 表示 “token 超过 8000 才开始压,压到回到 8000 以下停”;MessagesExceed(7) 表示 “消息数超过 7 条才开始压”。


CompactionProvider 压缩组件

CompactionProvider 是一个 AIContextProvider,用于执行压缩策略写好了,它挂到 agent 的 context provider 管道上,在每次调用模型之前自动执行压缩。默认不会自动注入,必须手动挂上去。


挂载方式有两种:

// 方式 A:挂在 ChatClientBuilder 上(推荐,会参与工具调用循环内的压缩)
AIAgent agent = chatClient
    .AsBuilder()
    .UseAIContextProviders(new CompactionProvider(compactionPipeline))
    .BuildAIAgent(new ChatClientAgentOptions { ... });

// 方式 B:挂在 ChatClientAgentOptions.AIContextProviders 上
//   注意:这种方式不参与工具调用循环,且生成的摘要可能被写进持久化历史
AIAgent agent = chatClient.AsBuilder().BuildAIAgent(new ChatClientAgentOptions
{
    AIContextProviders = [new CompactionProvider(compactionPipeline)]
});

注入之后,CompactionProvider.InvokingCoreAsync 在每次调用模型前执行下面这套流程(简化):

// CompactionProvider.InvokingCoreAsync 的核心流程(简化)
protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, ...)
{
    // 1. 从 session.StateBag 取出上次持久化的 group 索引(增量更新用)
    State state = this._sessionState.GetOrInitializeState(session);

    CompactionMessageIndex messageIndex;
    if (state.MessageGroups.Count > 0)
        messageIndex = new([...state.MessageGroups]);  // 复用旧索引
    else
        messageIndex = CompactionMessageIndex.Create(messageList);  // 首次:从消息建索引

    messageIndex.Update(messageList);  // 增量追加本轮新消息(保留旧的排除状态)

    // 2. 应用压缩策略
    await this._compactionStrategy.CompactAsync(messageIndex, ...);

    // 3. 把索引持久化回 StateBag(下次接着用)
    state.MessageGroups.Clear();
    state.MessageGroups.AddRange(messageIndex.Groups);

    // 4. 只返回未被排除的消息给模型
    return new AIContext { Messages = messageIndex.GetIncludedMessages(), ... };
}


有几个关键点:

  • 增量更新:Provider 把上次的 group 索引存在 session.StateBag 里,下次只追加新消息,已有的排除标记都保留,不需要每次从头重算。
  • 只过滤发往模型的消息:压缩只影响 "这次请求发给模型什么",原始历史并不被删除。被排除的 group 仍在索引里,写回 StateBag 时一并存着。
  • 摘要消息避免重复入历史:策略生成的 Summary 消息会被打上 AgentRequestMessageSourceType.ChatHistory 标记,防止它在本轮结束时又被当成新消息写回历史(那样就重复了)。

持久化的历史对话压缩

上一章提到,持久化消息有两种方案,对 Session 持久化或者使用 ChatHistoryProvider 持久化,两种方案主要矛盾点在于上下文压缩的处理,

框架内置的压缩机制 CompactionProvider 根本不走 ChatHistoryProvider,它是一个独立的 AIContextProvider,在 ChatClient 管道里运行,把压缩后的消息索引存在 session.StateBag 里,而 StateBag 会跟着 AgentSessionStore.SaveSessionAsync 自动持久化。


所以要实现存储历史对话,还是需要自己重写 ChatHistoryProvider 的,但是麻烦的地方在于,发生上下文压缩后,我怎么使用最新被压缩的历史对话替换 redis 里面持久化的对话历史呢?

第一种解决方法是我们自己在 ChatHistoryProvider 里面手动判断和压缩上下文,不在 Agent 注入 CompactionProvider ,这样我们代码就可以自行发现有没有压缩,然后触发使用压缩后的数据替换已经存储到 Redis 的历史对话。不过缺点是多次压缩后,无法回溯以前的历史记录,因为很多内容被压缩简化了。

第二种是 ChatHistoryProvider 只存未压缩前的对话,然后我们在 AgentSessionStore 实现持久化数据,这样 CompactionProvider 压缩后的数据存储到 AgentSessionStore 就行。但是这样会存储两份历史对话,开销成本大。

笔者比较建议使用第一种方案。


上一章我们用 RedisChatHistoryProvider 把历史存进了 Redis 的 List,这里就使用这个案例,改造为使用第一种方案手动压缩上下文并把压缩结果覆盖旧历史对话记录。

下面把上一章的 RedisChatHistoryProvider 扩展一下,加一个可选的 CompactionStrategy,在每次写回历史时把整条 List 压缩后覆盖写回。

public sealed class RedisChatHistoryProvider : ChatHistoryProvider
{
    private readonly IConnectionMultiplexer _redis;
    private readonly string _keyPrefix;
    private readonly int _maxMessages;
    private readonly CompactionStrategy? _compactionStrategy;   // ← 新增
    private readonly ProviderSessionState<State> _sessionState;

    public RedisChatHistoryProvider(
        IConnectionMultiplexer redis,
        CompactionStrategy? compactionStrategy = null,          // ← 新增:可选压缩策略
        string keyPrefix = "chat_history",
        int maxMessages = 1000)
    {
        _redis = redis;
        _keyPrefix = keyPrefix;
        _maxMessages = maxMessages;
        _compactionStrategy = compactionStrategy;
        _sessionState = new ProviderSessionState<State>(
            stateInitializer: _ => new State($"conv-{Guid.NewGuid():N}"),
            stateKey: this.GetType().Name);
    }

    public override IReadOnlyList<string> StateKeys => [_sessionState.StateKey];

    // 读:和上一章一样
    protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
        InvokingContext context, CancellationToken ct = default)
    {
        State state = _sessionState.GetOrInitializeState(context.Session);
        var db = _redis.GetDatabase();
        var key = $"{_keyPrefix}:{state.ConversationId}";

        RedisValue[] values = await db.ListRangeAsync(key);
        var messages = new List<ChatMessage>(values.Length);
        foreach (var value in values)
        {
            if (value.IsNullOrEmpty) continue;
            var msg = JsonSerializer.Deserialize<ChatMessage>(value.ToString());
            if (msg is not null) messages.Add(msg);
        }
        return messages;
    }

    // 写:追加新消息后,把整条历史压缩一遍再覆盖写回
    protected override async ValueTask StoreChatHistoryAsync(
        InvokedContext context, CancellationToken ct = default)
    {
        State state = _sessionState.GetOrInitializeState(context.Session);
        var db = _redis.GetDatabase();
        var key = $"{_keyPrefix}:{state.ConversationId}";

        // ① 先把本轮新消息追加进 List
        List<ChatMessage> allNew = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []).ToList();
        if (allNew.Count == 0) return;
        RedisValue[] serialized = allNew.Select(m => (RedisValue)JsonSerializer.Serialize(m)).ToArray();
        await db.ListRightPushAsync(key, serialized);

        // ② 配了压缩策略,就 LRANGE 读出全部历史、压缩、用结果覆盖整条 List
        if (_compactionStrategy is not null)
        {
            RedisValue[] all = await db.ListRangeAsync(key);
            var messages = all.Where(v => !v.IsNullOrEmpty)
                .Select(v => JsonSerializer.Deserialize<ChatMessage>(v!.ToString())!).ToList();

            // 用静态方法做一次性压缩(内部就是 建索引 → CompactAsync → 取未排除消息)
            IEnumerable<ChatMessage> compacted = await CompactionProvider.CompactAsync(
                _compactionStrategy, messages, cancellationToken: ct);

            // 覆盖写回:删旧 List,把压缩后的消息重新 RPUSH 进去
            await db.KeyDeleteAsync(key);
            RedisValue[] compactSerialized = compacted
                .Select(m => (RedisValue)JsonSerializer.Serialize(m)).ToArray();
            if (compactSerialized.Length > 0)
                await db.ListRightPushAsync(key, compactSerialized);
        }

        // ③ 兜底硬截断(不管压不压缩都保证不超过上限)
        await db.ListTrimAsync(key, -_maxMessages, -1);
    }

    public sealed class State(string conversationId)
    {
        public string ConversationId { get; } = conversationId;
    }
}

核心就是 StoreChatHistoryAsync 里那一步 ②:追加新消息后,把整条 List 读出来过一遍压缩策略,再用压缩结果覆盖回去。这样每次 RunAsync 结束,Redis 里的 List 就自动瘦身一次,不再无限膨胀。读侧 ProvideChatHistoryAsync 不用改,存进去的已经是精简过的。

挂上去用:

// 压缩策略:折叠老的工具结果 + 兜底截断
var strategy = new ContextWindowCompactionStrategy(
    maxContextWindowTokens: 12800,
    maxOutputTokens: 8000);

var redis = await ConnectionMultiplexer.ConnectAsync("192.168.50.199:6379");
var historyProvider = new RedisChatHistoryProvider(redis, compactionStrategy: strategy, maxMessages: 1000);

AIAgent agent = chatClient.AsBuilder()
    .BuildAIAgent(new ChatClientAgentOptions
    {
        ChatHistoryProvider = historyProvider,
        // 注意:这里【不要】再挂 CompactionProvider。
        // 压缩已经在 RedisChatHistoryProvider 里通过 CompactionProvider.CompactAsync 静态方法做了,
        // Redis 里存的就是压缩后的最新版本。如果再挂一层,同一批消息会被压缩两次,
        // SummarizationCompactionStrategy 还会对已经摘要过的内容重复摘要。
    });

这样压缩只有一个权威来源:RedisChatHistoryProvider 在每次 RunAsync 结束写回 Redis 时顺手压缩,Redis 里永远存的是精简过的版本,下一轮读出来直接用。


为了演示压缩,我把 maxContextWindowTokens: 3000,
maxOutputTokens: 2000,这个条件比较苛刻,所以观察到的压缩是,MAF 把之前的历史记录去掉了,只保留最后一个问题和回答。

image-20260722152944948

image-20260722152951634


CompactionStrategy 压缩原理

真正执行压缩动作的是 CompactionStrategy 抽象基类(前面 Redis 示例里用的 ContextWindowCompactionStrategy 就是它的一个子类,,这个基类用经典的模板方法模式把骨架流程和具体压法分离开:

public abstract class CompactionStrategy
{
    protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null);

    // 模板方法:固定流程,子类不能改
    public async ValueTask<bool> CompactAsync(CompactionMessageIndex index, ILogger? logger, CancellationToken ct);

    // 抽象方法:每个具体策略只实现这个,写"怎么压"
    protected abstract ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken ct);
}

CompactAsync 是写死的骨架,每次压缩都走这三步:

public async ValueTask<bool> CompactAsync(CompactionMessageIndex index, ...)
{
    // ① 门禁:不够压就直接走人(两个条件,任一成立就跳过)
    if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index))
        return false;

    // ② 记下压缩前的指标(消息数、group 数、token 数),交给子类去压
    int beforeTokens = index.IncludedTokenCount;
    // ...
    bool compacted = await this.CompactCoreAsync(index, logger, ct);

    // ③ 打日志 / OpenTelemetry 上报 before vs after
    return compacted;
}

CompactCoreAsync 是每个策略各显神通的地方。虽然实现各异,但套路一致:从最老的 group 开始,一次排除一组,每排一次就检查一遍 Target——满足就立刻停手。

子类要做的只有两件事:挑哪些 group 下手怎么处理被排除的 group。Trigger/Target、指标统计、日志遥测这些公共逻辑,基类全包了,子类不用碰。


CompactionStrategy 的子类虽然不少,但分成两类角色

CompactionStrategy(抽象基类,提供 CompactAsync 模板)
│
├─【四类真正"压法"】各自实现 CompactCoreAsync,决定怎么压
│   ├─ ToolResultCompactionStrategy   折叠老的工具调用→YAML 摘要(最温和)
│   ├─ SummarizationCompactionStrategy 把老对话整段发给 LLM 摘要(中等)
│   ├─ SlidingWindowCompactionStrategy 按轮次滑动窗口,干掉最老的整轮(较激进)
│   └─ TruncationCompactionStrategy    从最老 group 逐个排除,直到达标(兜底/最暴力)
│
└─【两个"组合封装",自己不压,只负责把上面的策略串起来/自动算阈值】
    ├─ PipelineCompactionStrategy      把多个策略按顺序串成管道
    └─ ContextWindowCompactionStrategy 给两个模型参数(窗口/输出token),内部自动构造一条 Pipeline

所以压缩机制只有四类,理解了这个模板,再看下面四类内置压法,就只是同一个流程换四种 CompactCoreAsync 实现而已。


压缩机制有多种注册方式,而你给它的那个 compactionStrategy,可以是下面任意一种。


第一种,只需要一种策略,例如限制最多 7 条记录。

CompactionStrategy strategy =
    new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7));

第二种,想要多种压法组合 → 用 Pipeline 把它们串起来。

var strategy = new PipelineCompactionStrategy(
    new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7)),
    new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(500)),
    new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
    new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(8000)))

第三种,使用 ContextWindowCompactionStrategy,它包装了 PipelineCompactionStrategy ,里面设置了一些默认约定,一般来说官方设定的值比较值得参考的。

var strategy = new ContextWindowCompactionStrategy(
   maxContextWindowTokens: 1050000, maxOutputTokens: 128000);


    public ContextWindowCompactionStrategy(
        int maxContextWindowTokens,
        int maxOutputTokens,
        double toolEvictionThreshold = DefaultToolEvictionThreshold,
        double truncationThreshold = DefaultTruncationThreshold)
        : base(CompactionTriggers.Always)
    {
            ... ...
        if (truncationThreshold < toolEvictionThreshold)
        {
            throw new ArgumentOutOfRangeException(nameof(truncationThreshold), truncationThreshold,
                $"Truncation threshold ({truncationThreshold}) must be greater than or equal to tool eviction threshold ({toolEvictionThreshold}).");
        }

        this.MaxContextWindowTokens = maxContextWindowTokens;
        this.MaxOutputTokens = maxOutputTokens;
        this.InputBudgetTokens = maxContextWindowTokens - maxOutputTokens;
        this.ToolEvictionThreshold = toolEvictionThreshold;
        this.TruncationThreshold = truncationThreshold;

        int toolEvictionTokens = (int)(this.InputBudgetTokens * toolEvictionThreshold);
        int truncationTokens = (int)(this.InputBudgetTokens * truncationThreshold);

        this._pipeline = new PipelineCompactionStrategy(
            new ToolResultCompactionStrategy(
                trigger: CompactionTriggers.TokensExceed(toolEvictionTokens),
                minimumPreservedGroups: 2),
            new TruncationCompactionStrategy(
                trigger: CompactionTriggers.TokensExceed(truncationTokens),
                minimumPreservedGroups: 2));
    }

实际项目里很少只用一种压法,而是按温和→激进的顺序串成管道。PipelineCompactionStrategy 把多个策略串起来,对同一个索引依次执行:

PipelineCompactionStrategy compactionPipeline = new(
    // 1. 温和:老的工具结果折叠成短摘要
    new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7)),
    // 2. 中等:LLM 摘要更老的对话片段
    new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
    // 3. 激进:只保留最近几轮
    new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
    // 4. 兜底:硬性 token 上限
    new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));

Pipeline 本身的 Trigger 是 Always(每次都跑),但每个子策略各自评估自己的 Trigger,前面温和的策略先减负后,后面激进的策略很可能 Trigger 就不再成立、自动跳过。比如先折叠了工具结果,token 掉到阈值以下,Truncation 就不需要再砍了。顺序很关键,必须从弱到强。


如果不想自己拼 Pipeline,ContextWindowCompactionStrategy 直接给两个模型参数就行——它内部自动造一条 折叠工具结果 + 兜底截断 的 Pipeline:

var strategy = new ContextWindowCompactionStrategy(
    maxContextWindowTokens: 1050000,   // 例如 gpt-5.4 的上下文窗口
    maxOutputTokens: 128000);          // 模型最大输出
// 内部算出 InputBudgetTokens = 1050000 - 128000 = 922000
//   ToolResult 在 0.5 × 预算 ≈ 461000 token 时触发
//   Truncation 在 0.8 × 预算 ≈ 737600 token 时触发


阈值用 toolEvictionThreshold / truncationThreshold 两个参数(范围 (0,1])覆盖。这是 HarnessAgent 默认使用的策略。

此外还有 ChatReducerCompactionStrategy适配器,把 Microsoft.Extensions.AI 里已有的 IChatReducer(比如 MessageCountingChatReducer)桥接进压缩管道,方便复用现成的 reducer。


选型建议:

  • 大多数场景用 ContextWindowCompactionStrategy 或直接 HarnessAgent + 两个 token 参数。它默认就是折叠工具结果 + 兜底截断两段,覆盖 90% 的长对话问题,且不用自己调阈值。

  • 工具结果特别大(文件读取、SQL 结果、网页抓取)的项目,重点配 ToolResultCompactionStrategyMinimumPreservedGroups 和自定义 ToolCallFormatter,让摘要里只留关键字段,砍掉冗余 payload。

  • 需要长期记忆但又不想要全文的场景,加 SummarizationCompactionStrategy务必用便宜小模型做摘要(sample 里特意注释了这点),并准备好失败容错(策略内部已处理,会自动回滚)。

  • MinimumPreservedGroups / MinimumPreservedTurns 是硬底线,宁可少压也不要压到当前对话失忆。线上出模型突然忘了刚才说的话的问题,先查这两个值是不是设太小。


内置的四种压缩策略

pipeline 顺序永远是:ToolResult → Summarization → SlidingWindow → Truncation,从最不伤信息的到最暴力的。反过来排会让激进策略先把能温和处理的消息砍光。


MAF 内置四类压法,按破坏性从弱到强排列。下面把每个策略的类型定义列出来(省略与基类重复的部分),重点看构造参数和硬底线属性。


1. ToolResultCompactionStrategy(最温和)

只盯 ToolCall 组,不动任何 user 消息、不动任何纯文本 assistant 回复。它把超出保护窗口的老 ToolCall 组整组折叠成一条 YAML 风格的摘要消息

[Tool Calls]
get_weather:
  - Sunny and 72°F
search_docs:
  - Found 3 docs


原始的 assistant 调用消息 + 一堆 tool 结果消息(可能好几条、好几 KB)被替换成这一小段文本。折叠后原组被标记 IsExcluded,并在原位置插入一个 Summary 组承载摘要。

类型定义:

public sealed class ToolResultCompactionStrategy : CompactionStrategy
{
    public const int DefaultMinimumPreserved = 16;          // 默认保护最近 16 个 group

    // 硬底线:无论如何,最近 N 个非系统 group 不会被折叠
    public int MinimumPreservedGroups { get; }

    // 自定义折叠格式化器,不传就用 DefaultToolCallFormatter(YAML 风格)
    public Func<CompactionMessageGroup, string>? ToolCallFormatter { get; init; }
}

最近被执行的 tool 往往包含当前问题还要复用的信息(比如上一条命令的输出),所以 MinimumPreservedGroups 默认设到 16,保证当前这一轮的工具交互完整可见。


2. SummarizationCompactionStrategy(中等,需要 LLM)

把较老的一段对话整段发给另一个 LLM 做摘要,用一条 summary 消息替换掉那一堆老 group。保护 system 消息和最近 MinimumPreservedGroups(默认 8)个 group,更老的才会被摘要。


类型定义:

public sealed class SummarizationCompactionStrategy : CompactionStrategy
{
    public const string DefaultSummarizationPrompt = "...";  // 内置摘要提示词
    public const int DefaultMinimumPreserved = 8;            // 默认保护最近 8 个 group

    public IChatClient ChatClient { get; }                   // 生成摘要用的 LLM(建议传便宜小模型)
    public int MinimumPreservedGroups { get; }               // 硬底线
    public string SummarizationPrompt { get; }               // 摘要提示词
}


它一次只标记一组就检查一次 Target,攒够后发一次 LLM 调用统一摘要。有个重要的容错:如果 LLM 调用失败(抛非取消异常),会把所有已排除的 group 全部恢复IsExcluded = false),保证对话不会停留在不一致状态。可以用一个更便宜的小模型做摘要以省钱:

new SummarizationCompactionStrategy(
    summarizerChatClient,                     // 建议传一个小/便宜模型的 IChatClient
    CompactionTriggers.TokensExceed(4000),
    minimumPreservedGroups: 8,
    summarizationPrompt: SummarizationCompactionStrategy.DefaultSummarizationPrompt)  // 可自定义

3. SlidingWindowCompactionStrategy(滑动窗口,较激进)

用户轮次TurnIndex)操作,直接把最老的几个完整轮次排除掉。


类型定义:

public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
{
    public const int DefaultMinimumPreserved = 1;            // 默认只保护最近 1 轮

    // 硬底线:最近 N 个用户轮次一定保留
    // 注意单位是"轮"不是"group",和前两个策略不一样
    public int MinimumPreservedTurns { get; }
}

MinimumPreservedTurns(默认 1)是硬底线——最近这几轮一定保留;turn index 为 0 或 null 的消息(系统消息、首条用户消息之前的消息)永远保留。它比按 token 截断更可预测,因为作用在逻辑轮次边界上而不是估算的 token 数上。


4. TruncationCompactionStrategy(截断,兜底/最激进)

最后的保底手段:从最老的非系统 group 开始逐个排除,直到 Target 满足。

类型定义:

public sealed class TruncationCompactionStrategy : CompactionStrategy
{
    public const int DefaultMinimumPreserved = 32;           // 默认保护最近 32 个 group(四个策略里最大)

    public int MinimumPreservedGroups { get; }               // 硬底线
}

尊重原子边界(工具调用配对不会被拆),且 MinimumPreservedGroups(默认 32)是硬底线。一般用在 pipeline 最后面兜底,确保 token 不超过硬上限。



在 HarnessAgent 中一键启用

如果用 HarnessAgent(MAF 的高层封装),连策略都不用自己拼。它内置了压缩的自动装配逻辑(HarnessAgent.BuildInnerAgent):

DisableCompaction == true              → 不启用压缩
否则若提供 CompactionStrategy          → 用你给的自定义策略
否则若同时给了两个 token 参数           → 自动构造 ContextWindowCompactionStrategy
否则                                   → 不启用压缩


最省事的开法——给两个数就行:

AIAgent agent = await agentChatClient.AsHarnessAgent(new HarnessAgentOptions
{
    Name = "ResearchAssistant",
    MaxContextWindowTokens = 1050000,   // 给了这两个就自动开压缩
    MaxOutputTokens = 128000,
});


HarnessAgentOptions 里和压缩相关的属性:

属性作用
MaxContextWindowTokens模型上下文窗口大小(token),用于算默认策略的预算
MaxOutputTokens模型单次最大输出 token
CompactionStrategy自定义策略;给了就用它,忽略上面两个 token 参数
DisableCompactiontrue 时彻底关闭压缩


HarnessAgent 不光在请求前压,还会把同一个策略通过 AsChatReducer() 桥接成 IChatReducer,挂到默认的 InMemoryChatHistoryProvider 上——这样内存里的历史本身也会被压缩,不会无限膨胀。AsChatReducer() 这个扩展方法把任意 CompactionStrategy 包成一个 IChatReducer,让压缩能复用到所有接受 IChatReducer 的地方。


手动触发压缩

关于 Redis/数据库持久化历史的压缩,已在上一节"持久化的历史对话压缩"里详细讲过,这里不重复。服务端托管(带 ConversationId)的历史归服务方管,客户端不插手。

压缩不一定非要挂在 Provider 里自动跑。三类手动用法:


1. 用静态方法 CompactionProvider.CompactAsync 做一次性压缩,适合对一批已有消息做离线瘦身,不需要 agent session:

IEnumerable<ChatMessage> compacted = await CompactionProvider.CompactAsync(
    new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(8000)),
    existingMessages);   // 你手里已有的消息列表
// compacted 就是压缩后的消息,可直接拿去发请求或落库


它内部就是 Create 索引 → CompactAsync → GetIncludedMessages 三步,不涉及 session、不持久化。


2. 把策略当 IChatReducer,喂给任何接受 reducer 的组件(比如 InMemoryChatHistoryProvider):

CompactionStrategy strategy = new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(20));
IChatReducer reducer = strategy.AsChatReducer();   // 桥接成 IChatReducer

var historyProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
    ChatReducer = reducer,   // 历史落库前先过一遍压缩
});

3. 自己手动构造索引 + 调策略(最灵活,适合调试观察中间状态):

CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
bool compacted = await strategy.CompactAsync(index, logger, cancellationToken);
// 这时可以遍历 index.Groups,看哪些被 IsExcluded、ExcludeReason 是什么
foreach (var g in index.Groups)
    Console.WriteLine($"{g.Kind} turn={g.TurnIndex} excluded={g.IsExcluded} reason={g.ExcludeReason}");
IEnumerable<ChatMessage> result = index.GetIncludedMessages();