ARTICLE · 1067666
AI 对话越聊越长?用滑动窗口给上下文减减肥
尤其是带工具调用的场景。用户问一句,模型调用一次工具,工具返回一大段内容,模型再组织一次回答。几轮下来,Token 很快就上去了。
解决办法之一,就是给智能体加上滑动窗口压缩。
滑动窗口是什么
说白了,就是只让模型关注最近的对话。当对话超过设定轮数后,较早的聊天内容会从有效上下文中移出,只保留最近的交流。
它不会给旧内容写摘要,也不会提炼长期记忆。它做的事情很直接:旧内容出去,新内容进来,让上下文始终保持在一个可控范围内。
这种方式很适合小说查询、商品查询、天气查询等场景。每次提问相对独立,旧内容忘掉也不会影响后面的结果。
为什么不能直接删除几条消息
一次完整的工具调用,通常包括用户提问、模型发起调用、工具返回结果以及模型最终回答。
这些消息是一个完整的逻辑单元。如果只按照消息数量随便删除,很可能留下工具调用,却删掉工具返回结果,或者反过来。
SlidingWindowCompactionStrategy 会按照消息组处理历史记录,尽量保证工具调用链的完整性。同时,系统消息会继续保留,不会因为窗口滑动而丢失。
这段代码做了什么
示例创建了一个小说查询助手,并注册了 GetNovel 工具。
用户连续查询五部小说。每次查询时,模型都可以调用工具,根据书名取得对应的小说内容。
这里通过 TurnsExceed(4) 设置触发条件。当对话轮次超过四轮后,滑动窗口开始工作,较早的消息组会被排除出后续发送给模型的有效上下文。
最后,程序把会话状态打印出来。红色代表已经被排除的旧消息组,绿色代表当前仍然参与对话的消息组。
完整代码
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;#pragmawarning disablevar 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 SlidingWindowCompactionStrategy(trigger: CompactionTriggers.TurnsExceed(4));// 构建带有上下文压缩能力的智能体,// 并注册获取小说内容的工具函数。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{”我想找小说《雾港来信》”,”我想找小说《纸月亮》”,”我想找小说《第七座花园》”,”我想找小说《雨中棋局》”,”我想找小说《星火列车》”};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);PrintMessageGroups(serializedSession,options,”SlidingWindowCompactionStrategy”);voidPrintMessageGroups(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(”按名称获取一部小说”)]staticstringGetNovel([Description(”名称(带书名号)”)] string name){var novels = new Dictionary{{”《雾港来信》”,”清晨的雾把海港裹得像一封未拆的信。林澈在旧邮局值夜,发现一只没有邮戳的蓝色信封,收信人竟是三十年前失踪的母亲。信里只有一句话:别让灯塔熄灭。当晚,废弃灯塔忽然亮起,海面传来熟悉的歌声。林澈循声而去,看见母亲年轻时的身影站在浪尖。她告诉他,港口每隔三十年会吞下一段记忆,而灯塔能照回被遗忘的人。天亮前,林澈必须选择:留住母亲,还是救回整座城市失去的过去。”},{”《纸月亮》”,”小镇上空每晚都会升起一轮纸做的月亮,薄得能看见背后的星光。阿眠是镇上唯一的修月师,她用银线缝补月面的裂痕。一天,她在裂缝里发现一座倒悬的城市,那里的人白天生活,夜晚化作影子。城市少年纪南请求她剪断月亮,因为他们被困在纸背面已百年。可若月亮坠落,小镇将永远失去夜晚。阿眠把自己的影子送进裂缝,让它替纪南托住城市。此后纸月依旧升起,只是月光里多了一个女孩缝线的身影。”},{”《第七座花园》”,”王宫后有六座花园,四季不败,唯独传说中的第七座无人见过。园丁少年陆安在修剪玫瑰时,听见墙内有人呼唤他的名字。他推开爬满藤蔓的小门,进入一片没有颜色的花海。守园的盲眼公主说,这里收藏着世人放弃的愿望,只有找回颜色,花园才会重返人间。陆安把自己的梦想一朵朵讲给花听,白花渐渐染上晨霞、湖水与火焰的色彩。最后一朵花却需要他的未来。陆安笑着种下种子,醒来时已成老者,手中握着一瓣金色花瓣。”},{”《雨中棋局》”,”江南连下七日雨,茶馆里来了一位不带伞的老人。他摆开棋盘,说谁能赢他,便能换回一件失去之物。许多人输掉回忆,唯有卖伞女苏青坐下不走。她想换回战死兄长的最后一句话。棋至中盘,雨声忽变成马蹄声,棋子化作沙场上的兵甲。苏青看见兄长被困阵中,才明白老人并非赌徒,而是替亡魂寻归路的引棋人。她弃车保帅,让黑子冲出重围。棋局终了,老人消失,伞下落下一声轻叹:好好活着,别再等雨停。”},{”《星火列车》”,”午夜零点,废弃车站会停下一列没有车票的列车,车窗里盛满星光。少年迟野为寻找离家的父亲,偷偷登上最后一节车厢。列车长告诉他,这趟车只载未完成的告别,每到一站,乘客便能见到想念之人一分钟。迟野穿过流星隧道,看见父亲坐在煤炉旁修理怀表。父亲说自己并未离开,只是成了驱动车轮的星火,守着儿子长大。终点前,迟野把怀表还给他。列车再次启程,天边多了一颗微亮的新星,而迟野终于学会回家。”}};return novels.TryGetValue(name, out var novel)? novel: ”没有找到这部小说。”;}
滑动窗口和摘要压缩怎么选
如果旧对话没有长期价值,只需要关注最近几轮内容,使用滑动窗口最简单。如果用户前面说过的预算、偏好和关键资料,在很久以后仍然需要用到,就更适合使用摘要压缩。一个负责直接忘掉,一个负责整理后再记住。具体选择哪一种,关键不在技术有多复杂,而在旧消息对后续业务还有没有价值。
写在最后
AI 的上下文不是越长越好。无关的历史消息太多,不仅会增加 Token 消耗,还可能干扰模型判断。滑动窗口的思路很简单:只留下最近真正有用的对话,让模型轻装上阵。