上下文不是越多越好:AI Agent 的上下文压缩、选择与注入策略
系列定位:这是这个 AI Agent 系列的第 3 篇,开始进入 Agent 最容易失控的一层,也就是上下文预算、筛选和回注入机制。
很多人刚开始做 AI Agent 时,都会自然地走向一个朴素方案:只要把更多信息塞给模型,效果就会更好。
代码仓库全量丢进去,聊天历史全量拼进去,工具结果也都原样保留,再加上一堆规则和说明,表面上看似乎非常安全,因为“信息越全越不容易漏”。
但真正跑起来之后,问题通常会很快出现。模型开始忽略重点,工具结果把上下文窗口挤爆,历史消息越来越长,新任务越来越难推进。你会发现,Agent 真正缺的不是更多信息,而是更好的上下文管理。
这也是为什么上下文工程里最关键的问题,从来不是“怎么加”,而是“怎么选、怎么压、怎么在正确的时机注入”。
对 Agent 来说,上下文不是一个静态输入框,而是一套持续变化的运行时资产。系统必须决定:
-
• 哪些信息应该长期保留 -
• 哪些信息只在当前轮有效 -
• 哪些内容应该被压缩成摘要 -
• 哪些 hints 应该在进入某个目录或任务时才注入 -
• 哪些工具结果值得保留,哪些只需要留下结论
如果这些决定做不好,模型就会被无关信息淹没。上下文越多,反而越容易失焦。
为什么“把更多信息塞进去”通常不是解法
上下文窗口看起来像容量问题,实际上更像注意力问题。
模型当然能处理很长的输入,但这不意味着它会自动理解哪些内容最重要。尤其在 Agent 场景里,上下文往往混合了很多不同类型的信息:
-
• 用户目标 -
• 系统提示 -
• 工具定义 -
• 历史对话 -
• 文件内容 -
• 工具调用结果 -
• 运行时状态提示 -
• 目录级约束和团队规则
这些信息即使都“有用”,也不代表它们在同一时刻都应该被平铺给模型。
工程上真正有效的做法通常是三件事:
-
1. 选择:只拿当前任务最相关的信息 -
2. 压缩:把高成本、低增量的信息转成摘要 -
3. 注入:在合适的时机把额外上下文动态加入,而不是一开始全部堆进去
goose 在这三个方向上都有比较明确的实现,所以很适合拿来说明“上下文不是越多越好”到底是什么意思。
第一层问题:什么时候应该压缩上下文
上下文管理的第一步,不是立刻压缩,而是判断什么时候真的需要压缩。
goose 在 context_mgmt 模块里有一个很直接的判断函数:当 provider 本身不管理上下文时,系统会根据 session 里的 token 统计或者估算值,去比较当前上下文占模型窗口的比例,再决定是否触发自动压缩。
pub async fn check_if_compaction_needed(
provider: &dyn Provider,
conversation: &Conversation,
threshold_override: Option<f64>,
session: &crate::session::Session,
) -> Result<bool> {
if provider.manages_own_context() {
return Ok(false);
}
let threshold = threshold_override.unwrap_or_else(|| {
config
.get_param::<f64>("GOOSE_AUTO_COMPACT_THRESHOLD")
.unwrap_or(DEFAULT_COMPACTION_THRESHOLD)
});
let context_limit = provider.get_model_config().context_limit();
let (current_tokens, _token_source) = match session.total_tokens {
Some(tokens) => (tokens as usize, "session metadata"),
None => {
let token_counter = create_token_counter().await?;
let token_counts: Vec<_> = messages
.iter()
.filter(|m| m.is_agent_visible())
.map(|msg| token_counter.count_chat_tokens("", std::slice::from_ref(msg), &[]))
.collect();
(token_counts.iter().sum(), "estimated")
}
};
let usage_ratio = current_tokens as f64 / context_limit as f64;
Ok(usage_ratio > threshold)
}
这段逻辑背后的工程思路很值得注意。
第一,压缩不是拍脑袋触发,而是根据上下文占用比例来决定。第二,系统区分 provider 是否“自己管理上下文”,避免重复干预。第三,判断依据优先用 session 中已有的 token 统计,没有时再做估算。
也就是说,压缩在 goose 里不是应急 hack,而是运行时里有明确触发条件的一种正规操作。
第二层问题:压缩不是删历史,而是重写可见性
很多系统一提到压缩,做法就是“把前面的消息删掉”。这虽然简单,但代价很大,因为你很容易丢失任务连续性。
goose 的 compact_messages 采用的是另一种更稳妥的策略:不是粗暴删除,而是把旧消息标记为对 agent 不可见,再插入一条摘要消息和一条 continuation message,必要时还保留最近的用户文本消息。
let (summary_message, summarization_usage) =
do_compact(provider, session_id, messages_to_compact).await?;
for (idx, msg) in messages_to_compact.iter().enumerate() {
let updated_metadata = if is_most_recent
&& idx == messages_to_compact.len() - 1
&& preserved_user_message.is_some()
{
MessageMetadata::invisible()
} else {
msg.metadata.with_agent_invisible()
};
let updated_msg = msg.clone().with_metadata(updated_metadata);
final_messages.push(updated_msg);
}
let summary_msg = summary_message.with_metadata(MessageMetadata::agent_only());
let continuation_msg = Message::assistant()
.with_text(continuation_text)
.with_metadata(MessageMetadata::agent_only());
这种设计有两个明显好处。
第一,历史没有真正消失,系统仍然保留它,只是对当前 Agent 不再完全展开。第二,摘要和 continuation text 会告诉模型“前面发生过什么”以及“现在应该如何自然继续”,从而减少压缩带来的语义断裂。
这和简单截断最本质的区别在于,截断只是在节省 token,压缩则是在维持任务连续性。
第三层问题:不是所有信息都值得原样保留
在 Agent 场景里,最容易把上下文撑爆的,往往不是用户消息,而是工具调用结果。
一次搜索、一次文件列表、一次 JSON 返回,可能就有几百上千 token。如果把这些结果长期原样保留,系统很快就会被历史工具输出拖垮。
goose 在 do_compact 里做了一件很实用的事:它会优先尝试在保留上下文的情况下做摘要,但如果摘要本身也因为上下文太长而失败,就会逐步移除一部分中间的工具响应,再继续做总结。
let removal_percentages = [0, 10, 20, 50, 100];
for (attempt, &remove_percent) in removal_percentages.iter().enumerate() {
let filtered_messages = filter_tool_responses(&agent_visible_messages, remove_percent);
let messages_text = filtered_messages
.iter()
.map(|&msg| format_message_for_compacting(msg))
.collect::<Vec<_>>()
.join("\n");
match provider
.complete_fast(session_id, &system_prompt, &summarization_request, &[])
.await
{
Ok((mut response, mut provider_usage)) => {
return Ok((response, provider_usage));
}
Err(e) => {
if matches!(e, ProviderError::ContextLengthExceeded(_)) {
if attempt < removal_percentages.len() - 1 {
continue;
}
}
return Err(e.into());
}
}
}
这段代码体现了一个非常现实的工程原则:工具结果很重要,但重要的是“结论”,不是永远保留全部原文。
当上下文预算吃紧时,系统需要优先保证任务可继续推进,而不是执着于让模型重复阅读所有旧输出。
第四层问题:动态注入比静态堆积更有效
很多上下文内容不是对所有任务都重要,而是只在特定路径、特定工作目录或特定阶段才有意义。比如某个子目录下的构建规则、某类文件的修改约束、团队内部约定等等。
这类信息如果一开始就全部放进系统提示,往往只会增加噪声。更好的方法是按需注入。
goose 的 PromptManager 就在做这件事。它构造 system prompt 时,不是只拼基础模板,还会根据当前 working_dir、扩展信息、mode 和 hints 动态补充内容。
pub fn with_hints(mut self, working_dir: &Path) -> Self {
let hints_filenames = get_context_filenames();
let ignore_patterns = build_gitignore(working_dir);
let hints = load_hint_files(working_dir, &hints_filenames, &ignore_patterns);
if !hints.is_empty() {
self.hints = Some(hints);
}
self
}
if let Some(hints) = self.hints {
system_prompt_extras.insert("hints".to_string(), hints);
}
这里的重点不是“支持 hints 文件”,而是系统承认上下文应该和当前工作位置相关联。上下文不是全局常量,而是和你正在操作什么目录、什么任务、什么模式有关。
第五层问题:上下文还应该跟着工具使用路径变化
静态目录 hints 还不够,因为 Agent 在执行过程中会不断进入新的路径、接触新的文件、调用新的命令。理想情况下,系统应该根据这些动作逐渐发现“现在有哪些额外上下文值得注入”。
goose 在 SubdirectoryHintTracker 里做了一个很实用的设计。它会从工具参数和 shell 命令里解析出可能涉及的路径,把这些目录加入待处理集合,之后再按需加载这些子目录里的 hints。
pub fn record_tool_arguments(
&mut self,
arguments: &Option<serde_json::Map<String, serde_json::Value>>,
working_dir: &Path,
) {
let args = match arguments.as_ref() {
Some(a) => a,
None => return,
};
if let Some(path_str) = args.get("path").and_then(|v| v.as_str()) {
if let Some(dir) = resolve_to_parent_dir(path_str, working_dir) {
self.pending_dirs.push(dir);
}
}
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
for token in shell_words::split(cmd).unwrap_or_default() {
if token.contains(std::path::MAIN_SEPARATOR) || token.contains('.') {
if let Some(dir) = resolve_to_parent_dir(&token, working_dir) {
self.pending_dirs.push(dir);
}
}
}
}
}
这个设计非常像一个上下文感知的“路径雷达”。Agent 不需要一开始就知道仓库里所有局部规则,而是在真正触达某个子目录时,再把那个目录的上下文补进系统。
这比一开始全量塞入所有规则更合理,因为它把噪声控制到了任务路径附近。
第六层问题:运行时还会在每轮回复前重新组织上下文
上下文管理并不是只在 prompt 初始化时发生一次。真正的 Agent runtime 往往会在每一轮回复前重新处理上下文。
goose 的 reply loop 里就有一个很典型的动作:在每轮调用 provider 之前,它会先做 MOIM 注入,再根据当前对话和工具状态启动工具对摘要任务。
let conversation_with_moim = super::moim::inject_moim(
&session_config.id,
conversation.clone(),
&self.extension_manager,
&working_dir,
).await;
let mut stream = Self::stream_response_from_provider(
self.provider().await?,
&session_config.id,
&system_prompt,
conversation_with_moim.messages(),
&tools,
&toolshim_tools,
).await?;
let tool_pair_summarization_task = crate::context_mgmt::maybe_summarize_tool_pairs(
self.provider().await?,
session_config.id.clone(),
conversation.clone(),
tool_call_cut_off,
current_turn_tool_count,
);
这说明什么?说明上下文在 goose 里是运行时动态材料,不是请求开始前一次性固定的静态输入。
每一轮之前,系统都会根据当前 session、工作目录、扩展状态和历史工具调用重新决定“这次真正该喂给模型的是什么”。这才是 Agent 场景里上下文工程最本质的部分。
第七层问题:工具对也应该被摘要,而不是永远原样保留
除了整段会话压缩,goose 还单独处理了工具请求和工具响应对的摘要问题。maybe_summarize_tool_pairs 会挑出适合摘要的旧工具调用,把它们转成更短的描述性消息,避免历史工具输出不断累积。
pub fn maybe_summarize_tool_pairs(
provider: Arc<dyn Provider>,
session_id: String,
conversation: Conversation,
cutoff: usize,
protect_last_n: usize,
) -> JoinHandle<Vec<(Message, String)>> {
tokio::spawn(async move {
if !tool_pair_summarization_enabled() || provider.manages_own_context() {
return Vec::new();
}
let tool_ids = tool_ids_to_summarize(&conversation, cutoff, protect_last_n);
let mut results = Vec::new();
for tool_id in tool_ids {
match summarize_tool_call(provider.as_ref(), &session_id, &conversation, &tool_id).await {
Ok(summary) => results.push((summary, tool_id)),
Err(e) => {
warn!("Failed to summarize tool pair: {}", e);
}
}
}
results
})
}
这里有一个很成熟的取舍:系统不会去动最近几次工具调用,因为那是当前轮最相关的信息;它优先摘要更早的工具对,把“细节历史”慢慢转成“结论历史”。
这正是一个高质量 Agent 应该具备的上下文分层能力。新的、近的、对当前决策最重要的信息保留原貌;旧的、远的、已经完成闭环的信息转成摘要。
从 goose 的实现里能总结出哪些上下文原则
如果把上面的实现抽象成几条原则,其实非常清楚。
1. 上下文要有预算意识
上下文不是无限资源,所以必须以 token、窗口比例和任务阶段为依据做预算控制。
2. 上下文要有时间分层
最近发生的事情通常更重要,更适合保留原始细节;更早发生的事情则更适合被总结。
3. 上下文要有空间分层
不同目录、不同子系统、不同任务路径需要不同 hints,没必要全局平铺。
4. 上下文要和运行时联动
上下文不只是 prompt 设计问题,更是 runtime 在每轮执行前后持续维护的系统资产。
5. 上下文管理的目标不是“保留一切”
真正的目标是让模型在当前时刻看到最有决策价值的信息,而不是看到最多的信息。
结语
上下文工程最容易掉进去的误区,就是把“信息完整”误认为“信息有效”。但对 Agent 来说,真正重要的不是把一切都展示给模型,而是让模型在每一轮都能看到刚刚好的那部分信息。
goose 的实现很清楚地说明了这一点。它不是靠无限堆历史来维持能力,而是靠压缩、选择、按需注入和工具对摘要,把上下文从“越堆越长的输入”变成“可持续维护的运行时资源”。
所以说,上下文不是越多越好。真正好的上下文系统,应该像一个编辑器,而不是一个仓库。它负责把最相关、最及时、最有行动价值的信息递到模型面前,让 Agent 能继续稳定推进任务。
夜雨聆风