大数跨境

Agent Harness 记忆压缩实战:函数结果压缩了,为什么 Token 还是一路上涨?

Agent Harness 记忆压缩实战:函数结果压缩了,为什么 Token 还是一路上涨? dotNET跨平台
2026-08-08
1
导读:在开发 Agent 应用时,我们很快会遇到一个现实问题:对话轮数越多,发送给模型的上下文越长,Token 消

在开发 Agent 应用时,我们很快会遇到一个现实问题:对话轮数越多,发送给模型的上下文越长,Token 消耗也会越来越高。

普通聊天还相对简单。如果 Agent 会调用搜索、数据库、文件读取或者业务接口,一个工具可能一次返回几千甚至几万个字符。几轮之后,会话历史就会迅速膨胀。

Microsoft Agent Framework 提供了上下文压缩能力,可以在调用模型之前,对较老的消息进行折叠、摘要或者删除。

本文通过一个小说查询示例,重点研究下面这段配置:

var compactionStrategy =
    new ToolResultCompactionStrategy(
        trigger: CompactionTriggers.TokensExceed(1000),
        minimumPreservedGroups3);

我们要回答三个问题:

  • ToolResultCompactionStrategy 到底压缩了什么?
  • 从序列化结果看,压缩是否真的生效了?
  • 为什么已经压缩,TotalTokenCount 还是不断上涨?


using AIConfig;
using Azure;
using Azure.AI.OpenAI;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using System.ClientModel;
using System.ComponentModel;
using System.Text.Json;
#pragma warning disable
var aiConfig = new AzureOpenAIConfig("gpt-5.6-luna");
var credential = new ApiKeyCredential(aiConfig.Key);
var options = new JsonSerializerOptions()
{
    WriteIndented = true,
    Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
// 分别创建主对话模型与摘要/压缩相关模型客户端。
var agentChatClient = new AzureOpenAIClient(new Uri(aiConfig.Endpoint), credential)
    .GetChatClient(aiConfig.DeploymentName)
    .AsIChatClient();
//将较旧的工具调用组折叠为压缩摘要消息,从而保留可读跟踪,而不会造成完整的消息开销。
var compactionStrategy = new ToolResultCompactionStrategy(trigger: CompactionTriggers.TokensExceed(1000), minimumPreservedGroups: 3);
// 构建带有上下文压缩能力的智能体,并注册获取小说内容的工具函数。
var agent = agentChatClient
        .AsBuilder()
        .UseAIContextProviders(new CompactionProvider(compactionStrategy))
        .BuildAIAgent(
            new ChatClientAgentOptions
            {
                Name = "Assistant",
                ChatOptions = new()
                {
                    Instructions = "你是一个友好的助手。",
                    Tools = [AIFunctionFactory.Create(GetNovel, nameof(GetNovel))]
                }
            });
// 创建会话,并连续发送多轮查询以观察压缩效果。
var session = await agent.CreateSessionAsync();
var list = new List<string>
{
    "我想找小说《雾港来信》",
    "我想找小说《纸月亮》",
    "我想找小说《第七座花园》",
    "我想找小说《雨中棋局》",
    "我想找小说《星火列车》"
};
foreach (var input in list)
{
    Console.WriteLine("您说:" + input);
    var response = await agent.RunAsync(input, session);
    Console.WriteLine("助手说:");
    Console.WriteLine(response.Text);
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"总 Token 数:{response.Usage.TotalTokenCount}\n 输入 Token 数:{response.Usage.InputTokenCount}\n 输出 Token 数:{response.Usage.OutputTokenCount}\n 缓存输入 Token 数:{response.Usage.CachedInputTokenCount}\n 推理 Token 数:{response.Usage.ReasoningTokenCount}");
    Console.ResetColor();
}
var serializedSession = await agent.SerializeSessionAsync(session);
//Console.WriteLine(JsonSerializer.Serialize(serializedSession, options: options));
PrintMessageGroups(serializedSession, options, "ToolResultCompactionStrategy");
void PrintMessageGroups(JsonElement serializedSession, JsonSerializerOptions options, string propertyName)
{
    if (serializedSession.TryGetProperty("stateBag"out var stateBag) &&
        stateBag.TryGetProperty(propertyName, out var strategy) &&
        strategy.TryGetProperty("messagegroups"out var messageGroups) &&
        messageGroups.ValueKind == JsonValueKind.Array)
    {
        foreach (var item in messageGroups.EnumerateArray())
        {
            if (item.TryGetProperty("isExcluded"out var isExcluded) && isExcluded.GetBoolean())
            {
                Console.ForegroundColor = ConsoleColor.Red;
            }
            else
            {
                Console.ForegroundColor = ConsoleColor.Green;
            }
            Console.WriteLine(JsonSerializer.Serialize(item, options));
        }
    }
    Console.ResetColor();
}
[Description("按名称获取一部小说")]
// 提供给智能体调用的工具函数:按名称返回预置小说内容。
static string GetNovel([Description("名称(带书名号)")] string name)
{
    var novels = new Dictionary<stringstring>
    {
        {
            "《雾港来信》",
            "清晨的雾把海港裹得像一封未拆的信。林澈在旧邮局值夜,发现一只没有邮戳的蓝色信封,收信人竟是三十年前失踪的母亲。信里只有一句话:别让灯塔熄灭。当晚,废弃灯塔忽然亮    起,海面传来熟悉的歌声。林澈循声而去,看见母亲年轻时的身影站在浪尖。她告诉他,港口每隔三十年会吞下一段记忆,而灯塔能照回被遗忘的人。天亮前,林澈必须选择:留 住 母  亲,还是救回整座城市失去的过去。"
        },
        {
            "《纸月亮》",
            "小镇上空每晚都会升起一轮纸做的月亮,薄得能看见背后的星光。阿眠是镇上唯一的修月师,她用银线缝补月面的裂痕。一天,她在裂缝里发现一座倒悬的城市,那里的人白天生活,  夜  晚化作影子。城市少年纪南请求她剪断月亮,因为他们被困在纸背面已百年。可若月亮坠落,小镇将永远失去夜晚。阿眠把自己的影子送进裂缝,让它替纪南托住城市。此后纸 月依旧 升 起,只是月光里多了一个女孩缝线的身影。"
        },
        {
            "《第七座花园》",
            "王宫后有六座花园,四季不败,唯独传说中的第七座无人见过。园丁少年陆安在修剪玫瑰时,听见墙内有人呼唤他的名字。他推开爬满藤蔓的小门,进入一片没有颜色的花海。守园的  盲  眼公主说,这里收藏着世人放弃的愿望,只有找回颜色,花园才会重返人间。陆安把自己的梦想一朵朵讲给花听,白花渐渐染上晨霞、湖水与火焰的色彩。最后一朵花却需要他 的未 来。陆安笑着种下种子,醒来时已成老者,手中握着一瓣金色花瓣。"
        },
        {
            "《雨中棋局》",
            "江南连下七日雨,茶馆里来了一位不带伞的老人。他摆开棋盘,说谁能赢他,便能换回一件失去之物。许多人输掉回忆,唯有卖伞女苏青坐下不走。她想换回战死兄长的最后一句话。  棋  至中盘,雨声忽变成马蹄声,棋子化作沙场上的兵甲。苏青看见兄长被困阵中,才明白老人并非赌徒,而是替亡魂寻归路的引棋人。她弃车保帅,让黑子冲出重围。棋局终了,老人消  失,伞下落下一声轻叹:好好活着,别再等雨停。"
        },
        {
            "《星火列车》",
            "午夜零点,废弃车站会停下一列没有车票的列车,车窗里盛满星光。少年迟野为寻找离家的父亲,偷偷登上最后一节车厢。列车长告诉他,这趟车只载未完成的告别,每到一站,乘客  便  能见到想念之人一分钟。迟野穿过流星隧道,看见父亲坐在煤炉旁修理怀表。父亲说自己并未离开,只是成了驱动车轮的星火,守着儿子长大。终点前,迟野把怀表还给他。列 车再次 启 程,天边多了一颗微亮的新星,而迟野终于学会回家。"
        }
    };
    return novels.TryGetValue(name, out var novel) ? novel : "没有找到这部小说。";
}

运行结果:

您说:我想找小说《雾港来信》
助手说:
《雾港来信》

清晨的雾把海港裹得像一封未拆的信。

林澈在旧邮局值夜,发现一只没有邮戳的蓝色信封,收信人竟是三十年前失踪的母亲。信里只有一句话:

别让灯塔熄灭。

当晚,废弃灯塔忽然亮起,海面传来熟悉的歌声。林澈循声而去,看见母亲年轻时的身影站在浪尖。

她告诉他,港口每隔三十年会吞下一段记忆,而灯塔能照回被遗忘的人。

天亮前,林澈必须选择:

留住母亲,还是救回整座城市失去的过去。
总 Token 数:736
 输入 Token 数:518
 输出 Token 数:218
 缓存输入 Token 数:0
 推理 Token 数:6
您说:我想找小说《纸月亮》
助手说:
《纸月亮》

小镇上空每晚都会升起一轮纸做的月亮,薄得能看见背后的星光。

阿眠是镇上唯一的修月师,她用银线缝补月面的裂痕。一天,她在裂缝里发现一座倒悬的城市,那里的人白天生活,夜晚化作影子。

城市少年纪南请求她剪断月亮,因为他们被困在纸背面已百年。可若月亮坠落,小镇将永远失去夜晚。

阿眠把自己的影子送进裂缝,让它替纪南托住城市。

此后,纸月依旧升起,只是月光里多了一个女孩缝线的身影。
总 Token 数:1530
 输入 Token 数:1325
 输出 Token 数:205
 缓存输入 Token 数:0
 推理 Token 数:5
您说:我想找小说《第七座花园》
助手说:
《第七座花园》

王宫后有六座花园,四季不败,唯独传说中的第七座无人见过。

园丁少年陆安在修剪玫瑰时,听见墙内有人呼唤他的名字。他推开爬满藤蔓的小门,进入一片没有颜色的花海。

守园的盲眼公主说,这里收藏着世人放弃的愿望,只有找回颜色,花园才会重返人间。

陆安把自己的梦想一朵朵讲给花听,白花渐渐染上晨霞、湖水与火焰的色彩。

最后一朵花却需要他的未来。

陆安笑着种下种子。醒来时,他已成为老者,手中握着一瓣金色花瓣。
总 Token 数:2355
 输入 Token 数:2130
 输出 Token 数:225
 缓存输入 Token 数:0
 推理 Token 数:5
您说:我想找小说《雨中棋局》
助手说:
《雨中棋局》

江南连下七日雨,茶馆里来了一位不带伞的老人。他摆开棋盘,说谁能赢他,便能换回一件失去之物。

许多人输掉回忆,唯有卖伞女苏青坐下不走。她想换回战死兄长的最后一句话。

棋至中盘,雨声忽变成马蹄声,棋子化作沙场上的兵甲。苏青看见兄长被困阵中,才明白老人并非赌徒,而是替亡魂寻归路的引棋人。

她弃车保帅,让黑子冲出重围。

棋局终了,老人消失,伞下落下一声轻叹:

好好活着,别再等雨停。
总 Token 数:3132
 输入 Token 数:2922
 输出 Token 数:210
 缓存输入 Token 数:0
 推理 Token 数:0
您说:我想找小说《星火列车》
助手说:
《星火列车》

午夜零点,废弃车站会停下一列没有车票的列车,车窗里盛满星光。

少年迟野为寻找离家的父亲,偷偷登上最后一节车厢。列车长告诉他,这趟车只载未完成的告别——每到一站,乘客便能见到想念之人一分钟。

迟野穿过流星隧道,看见父亲坐在煤炉旁修理怀表。

父亲说自己并未离开,只是成了驱动车轮的星火,守着儿子长大。

终点前,迟野把怀表还给他。

列车再次启程,天边多了一颗微亮的新星,而迟野终于学会回家。
总 Token 数:3886
 输入 Token 数:3674
 输出 Token 数:212
 缓存输入 Token 数:0
 推理 Token 数:6

{
"stateBag": {
"identifier""00001",
"InMemoryChatHistoryProvider": {
"messages": [{
"role""user",
"contents": [{
"$type""text",
"text""你好,我有什么证书"
}]
}, {
"role""system",
"contents": [{
"$type""text",
"text""**李明 | Backend Software Engineer**  \r\n\uD83D\uDCCD 上海,中国 | ✉️ liming@example.com | [GitHub](https://github.com/liming) | [LinkedIn](https://linkedin.com/in/liming)\r\n\r\n---\r\n\r\n### **个人简介**\r\n性别:男,1988 年出生,计算机科学与技术专业毕业。3 年后端开发经验,精通 Java 和 Python,擅长系统设计、API 开发及性能优化。具备良好的工程思维和团队协作能力,热爱编写高质量、可扩展的代码。熟悉微服务架构,致力于用技术解决实际业务问题。\r\n\r\n---\r\n\r\n### **技能清单**\r\n- **编程语言:** Java, Python, Go, SQL\r\n- **框架与技术:** Spring Boot, Django, FastAPI, gRPC, Redis, Kafka\r\n- **数据库:** MySQL, PostgreSQL, MongoDB\r\n- **其他:** 设计模式,RESTful API 设计,负载均衡,缓存优化\r\n\r\n---\r\n\r\n### **工作经历**\r\n\r\n**后端软件工程师 | 字节跳动 | 2022.05 - 现今 | 上海**\r\n- 负责短视频推送系统的后端开发,设计并优化了推荐服务接口,系统 QPS 提升 30%。\r\n- 主导基于 Spring Cloud 的微服务拆分项目,缩短了服务发布周期 20%。\r\n- 使用 Redis + Kafka 实现消息缓存和异步处理,系统稳定性提升至 99.99%。\r\n- 编写详细的技术文档并培训新人,协助团队代码评审,保证代码质量。\r\n\r\n**后端开发实习生 | 腾讯 | 2021.01 - 2021.08 | 深圳**\r\n- 参与企业微信后台服务开发,使用 Django 开发内部管理工具,减少手工操作时间约 40%。\r\n- 优化 SQL 查询,平均响应时间降低 20%,为后续大规模推广打下基础。\r\n- 与前端工程师协作,完成多个功能模块的 API 接口设计与联调。\r\n\r\n---\r\n\r\n### **项目经历**\r\n\r\n**高并发电商秒杀系统(个人项目)**\r\n- 使用 Spring Boot + Redis + RabbitMQ 设计秒杀服务,支撑 10W+ 并发用户抢购。\r\n- 实现了库存预扣减、异步下单、消息补偿机制,有效防止超卖问题。\r\n\r\n**智能客服系统(团队项目)**\r\n- 基于 FastAPI 搭建后端服务,接入外部 AI 问答引擎,平均响应时间控制在 100ms 以内。\r\n- 设计了模块化接口,方便前端快速对接,提升产品开发效率。\r\n\r\n---\r\n\r\n### **教育背景**\r\n\r\n**上海交通大学 | 计算机科学与技术 | 本科 | 2017 - 2021**\r\n\r\n- 主修课程:数据结构与算法、数据库系统、操作系统、计算机网络\r\n- 获奖情况:ACM 程序设计竞赛校级二等奖\r\n\r\n---\r\n\r\n### **证书**\r\n- 阿里云 ACP 认证:云计算工程师(2023)\r\n- Oracle 认证 Java 程序员(OCP, 2022)"
}],
"additionalProperties": {
"_attribution": {
"sourceType": {
"value""ChatHistory"
},
"sourceId""Microsoft.Agents.AI.InMemoryChatHistoryProvider"
}
}
}, {
"authorName""WorkMate",
"createdAt""2026-07-24T06:33:41+00:00",
"role""assistant",
"contents": [{
"$type""text",
"text""你有以下两项证书:\n\n1. **阿里云 ACP 认证:云计算工程师**(2023 年)\n2. **Oracle 认证 Java 程序员(OCP)**(2022 年)"
}],
"messageId""chatcmpl-E53lVGwQOlhDSUdwGczdDfLLcn7Fy"
}, {
"role""user",
"contents": [{
"$type""text",
"text""我最近考下来日语二级证书了"
}]
}, {
"authorName""WorkMate",
"createdAt""2026-07-24T06:33:55+00:00",
"role""assistant",
"contents": [{
"$type""text",
"text""恭喜你!你最近取得了**日语能力考试 N2 证书**。\n\n目前你的证书包括:\n\n1. **日语能力考试 N2 证书**(最近取得)\n2. **阿里云 ACP 认证:云计算工程师**(2023 年)\n3. **Oracle 认证 Java 程序员(OCP)**(2022 年)"
}],
"messageId""chatcmpl-E53ljo7x8f1Y7W6ZNseegQLF5vWOy"
}]
},
"session_id""dddadf86-f9d8-4e0c-a651-ec1be0457fe9"
}
}


一、先说结论:压缩确实生效了,但压得并不多

从运行结果来看,压缩功能没有失效。前四轮的工具调用组都出现了下面两个变化:

"isExcluded": true,
"excludeReason": "Collapsed by ToolResultCompactionStrategy"

与此同时,每个被排除的工具调用组旁边,都生成了一个新的 Summary 消息。也就是说,框架做了这样的替换:

原来的 FunctionCall + FunctionResult ->一条新的 Assistant Summary

但是,这个策略默认生成的并不是“内容摘要”。

它只是把原来的工具名称和工具返回值,重新包装成一条类似下面这样的文本:

[Tool Calls]
GetNovel:
  - 清晨的雾把海港裹得像一封未拆的信……

小说正文依旧完整保留。

所以,这次压缩主要省掉的是函数调用协议、调用参数、消息结构和部分元数据,并没有真正缩短小说内容。

官方文档也明确说明,ToolResultCompactionStrategy 会把旧的工具调用组折叠成一条简洁的 Assistant 消息,但默认格式仍会记录工具名称以及工具返回的结果。它不会删除用户消息,也不会删除普通的 Assistant 文本回答。

这正是本次实验中 Token 没有明显下降的核心原因。

二、这段 Agent 程序是怎么运行的?

这个示例注册了一个名为 GetNovel 的工具。

当用户输入:我想找小说《雾港来信》

Agent 大致会经过以下过程。

这里需要注意,一次:

agent.RunAsync(input, session)

并不一定只对应一次底层模型请求。

在本例中,模型通常要先判断调用哪个工具;工具执行完成后,模型还要根据工具结果生成最终回答。因此,一次 Agent 运行可能包含多条中间消息以及多次模型服务调用。AgentResponse 本身也允许包含工具调用、工具结果和其他中间过程消息。

因此,response.Usage 更适合表示“本轮 Agent 运行消耗了多少资源”,不能直接把它理解为“当前会话历史一共有多少 Token"。

三、什么是 Message Group?

压缩策略处理的基本单位不是单条消息,而是 CompactionMessageGroup。

框架会把互相关联的消息放在同一个逻辑分组里。例如,一条 Assistant 函数调用消息和对应的 Tool 函数结果消息,会被组合成一个 ToolCall 组。

这样做是为了保证工具调用的完整性。

如果只保留函数调用,却删除函数结果,或者只保留函数结果,却删除对应的调用信息,发送给模型的消息结构可能会不合法。官方文档将这种行为称为消息组的原子性:相关消息应当一起保留或者一起排除。

在本次输出中,主要出现了四种分组。

分组类型
表示的内容
压缩后的状态
User
用户输入
保留
ToolCall
函数调用和函数返回值
较旧的组被排除
Summary
压缩后生成的替代消息
保留
AssistantText
最终展示给用户的回答
保留

isExcluded: true 并不代表数据已经从序列化状态中物理删除。

它表示这组消息仍然可以留在状态里,用于存储、诊断或者调试,但在构造发送给模型的消息列表时会被跳过。CompactionMessageIndex 会分别统计全部消息和未排除消息,只有未排除的消息会进入压缩后的投影结果。

可以把它理解成:原始材料还保存在仓库里,但已经不再放进模型的行李箱。

四、压缩是在什么时候触发的?

代码中的触发条件是:

CompactionTriggers.TokensExceed(1000)

它的含义是:当当前参与上下文的消息 Token 数超过 1000 时,尝试执行压缩。

TokensExceed 只是一个触发条件,不是模型请求的硬性 Token 上限。官方定义是,当未排除消息的 Token 数超过指定值时,触发器返回成立。换句话说,1000 的意思不是:压缩完成后,输入一定不能超过 1000 Token。而是:超过 1000 Token 后,开始寻找可以压缩的旧工具调用组。假如能压缩的工具组很少,或者替换后的 Summary 依然很长,最终上下文完全可能继续超过 1000 Token。

五、minimumPreservedGroups: 3 有什么作用?

压缩策略还有一个参数:

minimumPreservedGroups3

它表示,无论 Token 压力有多大,都至少保留最近的三个非系统消息组。这是一个硬性保护线。即使压缩后仍然没有达到目标,策略也不能继续处理这三个受保护的消息组。官方文档将 MinimumPreservedGroups 定义为 hard floor,也就是压缩不能越过的下限。这也是为什么第五轮的 ToolCall 仍然显示:

"isExcluded": false

它属于最近的消息范围,还没有进入可以被折叠的历史区域。

继续执行后续对话,当新的消息组加入,会话窗口向后移动,这一组工具调用才可能变成“较旧的工具调用”,进而被压缩。

六、从运行结果计算:实际上只节省了 62 个 Token

我们可以直接使用序列化结果中的 tokenCount 做一个粗略计算。

前四个被排除的 ToolCall 组分别是:156 + 159 + 167 + 161 = 643

对应的四个 Summary 分别是:140 + 144 + 151 + 146 = 581

因此,这四次工具调用经过压缩后,大约只减少了:643 - 581 = 62 Token

相对于原来的 643 个 Token,只减少了大约 9.6%。

这说明压缩逻辑确实工作了,但压缩收益非常有限。

原因也很直接:默认 Summary 几乎完整保留了小说正文。

以《雾港来信》为例,原始工具结果是:清晨的雾把海港裹得像一封未拆的信……

压缩后变成:

[Tool Calls]
GetNovel:
  - 清晨的雾把海港裹得像一封未拆的信……

正文一个字都没有少,只是函数调用结构变简单了。

七、还有一个隐藏问题:同一篇小说实际上保存了两遍

每一轮查询中,小说正文会先作为工具结果出现:FunctionResult

模型读取工具结果后,又会把小说整理成最终回答:AssistantText

于是,同一篇小说在会话语义上出现了两次:工具结果中的完整小说 + 最终回答中的完整小说

经过 ToolResultCompactionStrategy 处理后,它会变成:Summary 中的完整小说+AssistantText 中的完整小说

工具消息虽然被折叠了,但重复内容依然存在。

而 ToolResultCompactionStrategy 不会处理普通的 Assistant 文本消息。它是相对温和的压缩策略,只针对旧工具调用组。

因此,随着查询次数增加,历史中仍然会不断累积小说正文。

这就解释了为什么每轮的输入 Token 仍然持续上涨:

  • 第 1 轮输入 Token:518
  • 第 2 轮输入 Token:1325
  • 第 3 轮输入 Token:2130
  • 第 4 轮输入 Token:2922
  • 第 5 轮输入 Token:3674


压缩减少了一些函数调用结构,但没有解决真正占空间的长文本问题。

八、为什么不能只看 TotalTokenCount 判断压缩效果?

程序打印的是:

response.Usage.TotalTokenCount
response.Usage.InputTokenCount
response.Usage.OutputTokenCount

InputTokenCount 表示生成本次响应时使用的输入 Token 数,TotalTokenCount 表示生成本次响应所使用的总 Token 数。

它们是资源消耗指标,而不是压缩器内部状态指标。

在包含工具调用的 Agent 中,一次运行可能经历:

  • 第一次模型请求:判断并生成函数调用
  • 第二次模型请求:读取函数结果并生成最终答案


两次请求都会读取一定的上下文。

所以本轮打印出来的 Input Token,可能反映了整个 Agent 运行过程中的资源使用,而不是某一次压缩完成后的单一消息列表长度。

判断压缩效果,至少需要同时观察两个维度:模型实际消耗的 Usage+ 压缩索引中的 IncludedTokenCount

前者用来分析费用,后者用来分析上下文到底缩短了多少。

九、如何打印压缩前后的有效 Token?

你现在已经能读取:

stateBag
  └─ ToolResultCompactionStrategy
       └─ messagegroups

可以在现有代码中增加一个统计函数。

static (int StoredTokens, int IncludedTokens, int ExcludedTokens)
    GetCompactionTokenStats(
        JsonElement serializedSession,
        string propertyName)
{
    var storedTokens = 0;
    var includedTokens = 0;
    if (!serializedSession.TryGetProperty("stateBag"out var stateBag) ||
        !stateBag.TryGetProperty(propertyName, out var strategy) ||
        !strategy.TryGetProperty("messagegroups"out var messageGroups) ||
        messageGroups.ValueKind != JsonValueKind.Array)
    {
        return (000);
    }
    foreach (var group in messageGroups.EnumerateArray())
    {
        var tokenCount =
            group.TryGetProperty("tokenCount"out var tokenElement)
                ? tokenElement.GetInt32()
                : 0;
        var isExcluded =
            group.TryGetProperty("isExcluded"out var excludedElement) &&
            excludedElement.GetBoolean();
        storedTokens += tokenCount;
        if (!isExcluded)
        {
            includedTokens += tokenCount;
        }
    }
    return (
        StoredTokens: storedTokens,
        IncludedTokens: includedTokens,
        ExcludedTokens: storedTokens - includedTokens);
}

调用方式如下:

var stats = GetCompactionTokenStats(
    serializedSession,
    "ToolResultCompactionStrategy");
Console.WriteLine($"压缩索引中的全部分组 Token:{stats.StoredTokens}");
Console.WriteLine($"预计继续参与上下文的 Token:{stats.IncludedTokens}");
Console.WriteLine($"已排除分组的 Token:{stats.ExcludedTokens}");

需要注意,这个结果适合用来观察压缩趋势,但不能替代服务端返回的 Usage。

一边反映“消息索引里保留了什么”,另一边反映“模型请求实际消耗了什么”,两者解决的是不同的问题。

另外,当前 API 文档仍带有预发布说明,序列化状态中的内部字段结构未来存在调整的可能。因此,不建议把 stateBag.messagegroups 直接当成长期稳定的业务数据协议。

十、怎样让函数结果真正变短?

对于大工具结果,仅使用默认的 ToolResultCompactionStrategy 往往不够。

更有效的办法是提供自定义 ToolCallFormatter。

官方 API 允许通过一个:

Func<CompactionMessageGroup, string>

把旧工具调用组转换成自定义文本。没有提供自定义格式化器时,框架才会使用默认的 YAML 风格结果。

下面是一个简单示例。

var compactionStrategy =
    new ToolResultCompactionStrategy(
        trigger: CompactionTriggers.TokensExceed(4000),
        minimumPreservedGroups: 6,
        target: CompactionTriggers.TokensBelow(2500))
    {
        ToolCallFormatter = group =>
        {
            var text =
                ToolResultCompactionStrategy
                    .DefaultToolCallFormatter(group);
            const int maxChars = 400;
            if (text.Length <= maxChars)
            {
                return text;
            }
            return text[..maxChars] +
                   "\n  - [旧工具结果已截断,仅保留前 400 个字符]";
        }
    };

这里增加了一个单独的 target:

target: CompactionTriggers.TokensBelow(2500)

它表示,当压缩已经触发后,策略尝试持续处理旧工具组,直到有效 Token 低于 2500,或者再也没有符合条件的工具组可以处理。

字符截断只适合演示。

生产环境中,更推荐根据工具类型提取真正有价值的信息。例如,旧的小说查询结果可以只保留:

调用工具:GetNovel
小说名称:《雾港来信》
调用结果:成功
内容主题:少年通过灯塔寻找失踪的母亲
完整正文:已省略

对于数据库查询,可以保留查询条件、记录数量和关键结论。

对于搜索工具,可以保留查询词、来源地址和最终结论。

对于文件读取工具,可以保留文件名、页码、段落位置以及内容摘要。

真正有效的压缩不是简单地“换一种消息格式”,而是要决定:

未来的对话究竟还需要记住哪些信息。

十一、生产环境应该采用分层压缩

ToolResultCompactionStrategy 适合解决工具调用结构过重的问题,但它不负责压缩整个对话。

一个完整的生产方案通常需要分层处理。

  • 第一层先处理工具结果,把过时的接口返回值、搜索结果和文件内容折叠掉。
  • 第二层对较老的用户消息和 Assistant 回答做语义摘要,只保留用户偏好、关键决定、业务状态和未完成任务。
  • 第三层设置滑动窗口或者截断策略,在上下文接近模型上限时删除最旧、价值最低的消息。

Microsoft Agent Framework 提供了工具结果压缩、对话摘要、滑动窗口、截断以及顺序执行多种压缩策略的 Pipeline。

整体思路可以概括为:

先压工具结果
    ↓
再总结旧对话
    ↓
最后截断低价值历史

其中,工具压缩负责减少结构性浪费,语义摘要负责保留长期记忆,截断负责保护系统不会突破模型上下文限制。

三者解决的问题并不相同。

十二、这个示例最值得记住的三件事

第一,isExcluded: true 说明压缩已经生效。

原始工具调用组仍然存在于状态中,但不会继续参与模型输入。压缩并不一定等于物理删除。

第二,默认工具压缩不是语义摘要。

默认格式仍会包含工具的完整返回结果。如果工具返回的是长文章、网页正文、大段 JSON 或文件内容,压缩收益可能非常有限。

第三,响应 Usage 不是当前会话长度。

它表示本轮生成响应所消耗的资源。函数调用型 Agent 一轮内可能发生多次模型交互,因此不能仅凭 TotalTokenCount 判断压缩是否成功。

结语

Agent 的记忆管理,本质上不是简单地保留或者删除消息。

真正需要解决的是三个问题:

  • 什么信息必须长期保留?
  • 什么信息只需要本轮使用?
  • 什么信息可以在使用后立即降级为摘要或索引?

在本次小说示例中,ToolResultCompactionStrategy 已经正确地把旧工具调用折叠成了 Summary。

但是,因为默认 Summary 仍然保存完整小说正文,而最终 Assistant 回答也保存了一份小说正文,所以整个对话的主要内容并没有真正变短。

这也是本次实验最重要的结论:压缩策略是否执行成功,和上下文是否真正变小,是两个不同的问题。

在生产系统中,不要只配置一个 Token 阈值就结束工作。

还需要根据工具返回的数据结构设计摘要规则,控制 Assistant 是否重复输出大段工具内容,并在工具压缩之外增加对话摘要和最终截断策略。

只有这样,Agent 才能在保持记忆连续性的同时,真正控制上下文长度、响应延迟和模型调用成本。

【声明】内容源于网络
0
0
dotNET跨平台
专注于.NET Core的技术传播。在这里你可以谈微软.NET,Mono的跨平台开发技术。在这里可以让你的.NET项目有新的思路,不局限于微软的技术栈,横跨Windows,
内容 2318
粉丝 0
dotNET跨平台 专注于.NET Core的技术传播。在这里你可以谈微软.NET,Mono的跨平台开发技术。在这里可以让你的.NET项目有新的思路,不局限于微软的技术栈,横跨Windows,
总阅读62.2k
粉丝0
内容2.3k