AI Agent 上下文窗口管理:对话长了,怎么自动裁剪不丢关键信息

📅 2026/7/8 18:56:33 👁️ 阅读次数 📝 编程学习
AI Agent 上下文窗口管理:对话长了,怎么自动裁剪不丢关键信息

AI Agent 上下文窗口管理:对话长了,怎么自动裁剪不丢关键信息

一、对话窗口是 Agent 的"短期记忆"天花板

AI Agent 和普通聊天机器人有一个根本的区别:Agent 需要记住之前做了什么。不能每轮对话都从零开始——它要知道上一轮调用了哪个 API、返回值是什么、推断出什么结论。这些信息构成了 Agent 的"工作记忆"——也就是上下文消息列表。

但上下文窗口是有限的。不同模型窗口大小从 8K 到 128K Token 不等,但无论多大,当 Agent 连续运行十几轮推理之后,早期消息总会把它们填满。一旦窗口满,最简单的处理就是截断——把最早的消息丢弃。但如果你在第三轮让 Agent 找到了一个关键文件路径,到了第十一轮需要引用这个路径时它却被截断了,Agent 就只能凭空猜测。

这就是上下文管理的核心矛盾:怎么在有限的窗口里保留最重要的信息,丢掉不太重要的,同时保证丢掉的不会让 Agent 后续推理出错。下面从"保留策略"、"消息重要性评分"和"摘要生成"三个角度来梳理一个可用的方案。

二、消息保留策略——不是简单裁前面

如果只是从最前面删,相当于假设"越早的消息越不重要",这在很多场景下是错的。一个有效的保留策略需要分层处理:有的消息永远不删,有的按时间衰减,有的直接替换成摘要。

flowchart TD A["新消息到达\n总 Token > 窗口限制"] --> B["A 层: 固定保留\n系统提示词、工具定义\n(约占总窗口 10%-20%)"] B --> C["B 层: 近 N 轮原始对话\n保留最近 5-8 轮完整消息\n(约占窗口 50%-60%)"] C --> D["C 层: 中间轮次摘要\n对 9-N 轮之前的消息\n生成压缩摘要存储"] D --> E["D 层: 长期记忆槽\n提取关键事实(路径/结论)\n以结构化字段存储"] E --> F{"组装最终上下文"} A --> F F --> G["A层 + C层摘要 + B层对话 + D层事实\n发送给 LLM"]

这张图表达的核心设计是"分层保留"。A 层是绝对不丢的东西——系统 Prompt 里写的 Agent 角色、可用工具列表、输出格式要求。这些信息丢了 Agent 的行为就会完全走样。B 层是最近的原始对话,保持完整形态,因为 LLM 的推理质量在原始对话上比摘要高得多。C 层是摘要区域,把很久之前的冗长对话压成一段精炼的文本,告诉 Agent"之前发生了什么"。D 层是可选的,在某些场景下,关键的离散信息(文件路径、API 返回的 ID、用户提到的配置参数)应该被显式提取出来,独立于对话流保存。

三、实现一个基础的滑动窗口 + 摘要生成器

use std::collections::VecDeque; /// 单条上下文消息 #[derive(Clone)] struct Message { role: String, // "system" | "user" | "assistant" | "tool" content: String, token_count: usize, // 近似 Token 数 (4 字符 ≈ 1 token) } /// 上下文管理器 — 滑动窗口 + 摘要 struct ContextManager { system_messages: Vec<Message>, // A 层 — 永不删除 recent_messages: VecDeque<Message>, // B 层 — 滑动窗口 max_window_tokens: usize, // 模型支持的总 Token 上限 recent_turns: usize, // B 层保留的最近轮次数 } impl ContextManager { fn new(max_window_tokens: usize, recent_turns: usize) -> Self { ContextManager { system_messages: Vec::new(), recent_messages: VecDeque::new(), max_window_tokens, recent_turns, } } /// 设置系统消息 (只初始化一次) fn set_system(&mut self, prompt: &str) { self.system_messages.push(Message { role: "system".into(), content: prompt.to_string(), token_count: prompt.len() / 4, // 粗略估算 }); } /// 添加一条新消息, 如果超窗口则自动裁旧 fn add_message(&mut self, role: &str, content: &str) { let msg = Message { role: role.to_string(), content: content.to_string(), token_count: content.len() / 4, }; self.recent_messages.push_back(msg); // 检查总 Token 是否超限, 超了就弹出最旧的消息 while self.total_tokens() > self.max_window_tokens { // 生成被移除内容的摘要, 存储到内存 let removed = self.recent_messages.pop_front(); // 如果移除的是 assistant 的推理结果, // 就用一句话总结关键结论存到 system_messages 末尾 if let Some(msg) = removed { if msg.role == "assistant" { let summary = Self::simple_summarize(&msg.content); self.system_messages.push(Message { role: "system".into(), content: format!("[历史摘要] {}", summary), token_count: summary.len() / 4, }); } } } } /// 计算当前所有消息的总 Token 数 fn total_tokens(&self) -> usize { let system_tokens: usize = self.system_messages.iter().map(|m| m.token_count).sum(); let recent_tokens: usize = self.recent_messages.iter().map(|m| m.token_count).sum(); system_tokens + recent_tokens } /// 简易摘要 — 实际场景替换为调用 LLM 生成摘要 fn simple_summarize(content: &str) -> String { // 生产环境应该调用摘要模型, 这里用截断做演示 if content.len() <= 200 { content.to_string() } else { format!("{}...(共 {} 字)", &content[..200], content.len()) } } /// 组装最终发送给 LLM 的上下文 fn build_context(&self) -> Vec<Message> { let mut ctx = self.system_messages.clone(); ctx.extend(self.recent_messages.clone().into_iter()); ctx } } fn main() { let mut ctx = ContextManager::new(8000, 6); // 8K 窗口, 保留最近 6 轮 ctx.set_system("你是一个文件管理 Agent, 可以读取和编辑文件。"); // 模拟 20 轮对话 for i in 1..=20 { ctx.add_message("user", &format!("请读取文件 report_{}.txt", i)); ctx.add_message( "assistant", &format!("文件 report_{}.txt 的内容: 这是第 {} 份报告...", i, i), ); } let context = ctx.build_context(); println!("最终上下文共 {} 条消息", context.len()); println!( "其中系统消息 {} 条, 包含历史摘要", ctx.system_messages.len() ); }

这个实现里有一个重要的设计选择:摘要信息的存储位置。我把摘要放到了system_messages列表里,以[历史摘要]前缀开头。这样做的原因是 LLM 对 system 级指令的注意力通常比对话消息更强——系统提示词中的信息更不容易被模型忽略。这是一个小但有效的策略。

四、两个需要持续优化的边界

第一个问题是摘要的质量。上面代码里的simple_summarize只是一个截断,但在实际系统中,你需要用 LLM 自己来生成摘要——发给一个轻量模型,让它在极短的延迟内输出被删除内容的总结。但这个过程中有一个"摘要精度"的悖论:摘要越精简,压缩率越高,关键信息遗漏的概率也越高。目前没有一个完美的数学公式来决定"压缩到多短",需要根据领域场景反复调整。

第二个问题是关键信息的提取。有些信息是离散的、可以被结构化存储的——比如 Agent 找到的文件路径、解析出的用户 ID、调用的命令和返回值。这类信息不应该只以对话摘要的形式存在,而应该被单独存为一个 key-value 字典(长期记忆槽),在每次构建上下文时拼接到 system prompt 末尾。这种做法比纯摘要更精确,但因为需要额外设计提取和更新的逻辑,增加了实现复杂度。

五、总结

上下文剪裁的核心不是"删掉什么",而是"留下什么以及以什么形式留下"。A 层系统和工具定义永不丢失,B 层最近几轮对话保持完整,C 层老对话用摘要替代——这套分层保留策略在实际 Agent 开发中已经被广泛验证。

对于刚开始构建 Agent 的开发者,建议先从最简单的近 N 轮截断开始,跑通了基本逻辑再来加摘要层。过早引入复杂的上下文管理,反而会让 Agent 行为更难调试。但最终,当你的 Agent 需要执行超过 20 轮的任务时,一套分层裁剪方案是绕不开的。