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 轮的任务时,一套分层裁剪方案是绕不开的。



