乐于分享
好东西不私藏

上下文不是越多越好:AI Agent 的上下文压缩、选择与注入策略

上下文不是越多越好:AI Agent 的上下文压缩、选择与注入策略

系列定位:这是这个 AI Agent 系列的第 3 篇,开始进入 Agent 最容易失控的一层,也就是上下文预算、筛选和回注入机制。

很多人刚开始做 AI Agent 时,都会自然地走向一个朴素方案:只要把更多信息塞给模型,效果就会更好。

代码仓库全量丢进去,聊天历史全量拼进去,工具结果也都原样保留,再加上一堆规则和说明,表面上看似乎非常安全,因为“信息越全越不容易漏”。

但真正跑起来之后,问题通常会很快出现。模型开始忽略重点,工具结果把上下文窗口挤爆,历史消息越来越长,新任务越来越难推进。你会发现,Agent 真正缺的不是更多信息,而是更好的上下文管理。

这也是为什么上下文工程里最关键的问题,从来不是“怎么加”,而是“怎么选、怎么压、怎么在正确的时机注入”。

对 Agent 来说,上下文不是一个静态输入框,而是一套持续变化的运行时资产。系统必须决定:

  • • 哪些信息应该长期保留
  • • 哪些信息只在当前轮有效
  • • 哪些内容应该被压缩成摘要
  • • 哪些 hints 应该在进入某个目录或任务时才注入
  • • 哪些工具结果值得保留,哪些只需要留下结论

如果这些决定做不好,模型就会被无关信息淹没。上下文越多,反而越容易失焦。

为什么“把更多信息塞进去”通常不是解法

上下文窗口看起来像容量问题,实际上更像注意力问题。

模型当然能处理很长的输入,但这不意味着它会自动理解哪些内容最重要。尤其在 Agent 场景里,上下文往往混合了很多不同类型的信息:

  • • 用户目标
  • • 系统提示
  • • 工具定义
  • • 历史对话
  • • 文件内容
  • • 工具调用结果
  • • 运行时状态提示
  • • 目录级约束和团队规则

这些信息即使都“有用”,也不代表它们在同一时刻都应该被平铺给模型。

工程上真正有效的做法通常是三件事:

  1. 1. 选择:只拿当前任务最相关的信息
  2. 2. 压缩:把高成本、低增量的信息转成摘要
  3. 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 能继续稳定推进任务。