跳到主要内容

第 4 章 · 上下文管理

这一章讲一个长跑 agent 绕不开的问题:对话越滚越长,迟早撑爆模型的上下文窗口。goose 用三招应对——整体压缩工具对摘要可见性双轨

4.1 问题:上下文是有限的「内存」

模型一次能看的 token 有上限(context limit)。agent 跑久了,历史里堆满工具调用和长输出,迟早超限。超限会直接报错 ContextLengthExceeded。goose 不能让用户每次都开新会话,所以要主动管理。

直觉类比:把上下文窗口当 RAM。RAM 快满了,要么「把旧东西压缩成摘要」(腾地方),要么「把不必给模型看的东西换出去」(不占预算)。

4.2 第一招:阈值触发的整体压缩

什么时候压? 默认在 token 用量达到 context limit 的 80%(DEFAULT_COMPACTION_THRESHOLD = 0.8,context_mgmt/mod.rs:21)时触发。check_if_compaction_needed(context_mgmt/mod.rs:188)负责判断:

// context_mgmt/mod.rs:194 — 如果 provider 自己管上下文(如某些 CLI 包装),goose 不插手
if provider.manages_own_context() { return Ok(false); }
// 优先用 session 元数据里的真实 token 数;没有就用 token_counter 估算

goose 有两条触发路径:

  • 进循环前预压:reply 里若已超阈值,先压再进循环(agents/agent.rs:1702)。
  • 循环中恢复式压缩:真撞上 ContextLengthExceeded 错误时,在错误臂里压(agents/agent.rs:2401),最多试 2 次(compaction_attempts >= 2 就放弃并提示用户,agents/agent.rs:2377)。

怎么压? compact_messages(context_mgmt/mod.rs:67)的做法:

  1. 让模型把整段历史总结成一条摘要消息(do_compact)。
  2. 把原始历史全部标记为「对 agent 不可见」(with_agent_invisible,context_mgmt/mod.rs:149)——它们还在,但不再喂给模型。
  3. 摘要消息标记为「只对 agent 可见」(agent_only,context_mgmt/mod.rs:155),再加一条「你的上下文被压缩了,别声张,自然地继续」的续接说明(CONVERSATION_CONTINUATION_TEXT,context_mgmt/mod.rs:31)。
  4. 非手动压缩时保留最近一条用户消息(context_mgmt/mod.rs:113-128)——以免把用户当前正问的问题也压没了。

压缩完会发 HistoryReplaced 事件让 UI 同步(agents/agent.rs:2415)。

4.3 第二招:工具对摘要(后台并行)

整体压缩是「大锤」。还有一招更轻的「小锉刀」:把一对对的「工具请求 + 工具结果」单独摘要掉,因为工具输出往往是上下文里最占地方的部分(一个 ls 可能吐几百行)。

这招由 maybe_summarize_tool_pairs(context_mgmt/mod.rs:559)发起,关键是它在后台异步跑:循环里 tool_pair_summarization_task 被 spawn 出去(agents/agent.rs:1989),不阻塞当前回合;一圈结束时再 await 它的结果(agents/agent.rs:2607)。

触发条件是「当前回合的工具调用数 + 历史工具数」越过一个 cutoff(tool_call_cut_off),这个 cutoff 由 compute_tool_call_cutoff(context_limit, threshold)(context_mgmt/mod.rs:450)算出来,默认也基于 80% 阈值。批大小 TOOLCALL_SUMMARIZATION_BATCH_SIZE = 10(context_mgmt/mod.rs:23)。

摘要落地时有个一致性校验:必须正好找到「请求 + 回复」两条匹配消息才替换,否则只告警不动(agents/agent.rs:2623-2633)——避免把对话搞成半残。被摘要的原始两条标 with_agent_invisible,新摘要写进 session。

4.4 第三招:可见性双轨(对用户可见 vs 对 agent 可见)

这是贯穿全项目的一个基础设计:每条 MessageMessageMetadata,有两个独立的可见性开关——「对用户可见」和「对 agent 可见」。

喂给模型时只取「对 agent 可见」的(stream_response_from_provider 的过滤,reply_parts.rs:267-271):

// agents/reply_parts.rs:267 — 只有 agent-visible 的消息才进模型上下文
let filtered_messages: Vec<Message> = messages.iter()
.filter(|m| m.is_agent_visible())
.map(|m| m.agent_visible_content())
.collect();

这套双轨让 goose 能玩很多花样:

场景可见性设置效果
压缩后的原始历史用户可见、agent 不可见用户翻得到原文,模型只看摘要
压缩摘要 / 续接说明agent 可见、用户不可见模型靠摘要续上,用户界面不被噪声打扰
goal/grind 提醒、stop-hook 拒绝理由agent 可见、用户不可见引导模型而不打扰用户(见第 1 章 §1.5)
斜杠命令的回执用户可见、agent 不可见用户看到确认,模型不被命令本身干扰

直觉:「历史里有」不等于「模型看得到」。可见性元数据把「持久化的真相」和「喂给模型的视图」解耦了——这是 goose 上下文管理的地基。

4.5 token 怎么数

判断是否超阈值要先知道当前用了多少 token。goose 两条路(context_mgmt/mod.rs:215-229):

  • 优先用 session 元数据里记录的真实用量(session.usage.total_tokens)——这是 provider 在每次调用后回报的准确值,每圈通过 update_session_metrics 累加(agents/agent.rs:2024)。
  • 没有就用本地 token_counter 估算(只数 agent-visible 的消息)。

用真实用量而非每次重数,既准又省。

4.6 小结

  • 三招:80% 阈值触发的整体摘要压缩后台并行的工具对摘要可见性双轨
  • 压缩分「进循环前预压」和「撞上超限错误后的恢复式压缩(最多 2 次)」。
  • 工具对摘要专治「工具输出占地方」,异步不阻塞,落地有一致性校验。
  • 可见性双轨是地基:历史持久化的真相 ≠ 喂给模型的视图;goal 提醒、压缩摘要、命令回执都靠它。

最后一章看巧妙之处、边界与对比 → 05-clever-and-boundaries.md