第 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)的做法:
- 让模型把整段历史总结成一条摘要消息(
do_compact)。 - 把原始历史全部标记为「对 agent 不可见」(
with_agent_invisible,context_mgmt/mod.rs:149)——它们还在,但不再喂给模型。 - 摘要消息标记为「只对 agent 可见」(
agent_only,context_mgmt/mod.rs:155),再加一条「你的上下文被压缩了,别声张,自然地继续」的续接说明(CONVERSATION_CONTINUATION_TEXT,context_mgmt/mod.rs:31)。 - 非手动压缩时保留最近一条用户消息(
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 可见)
这是贯穿全项目的一个基础设计:每条 Message 带 MessageMetadata,有两个独立的可见性开关——「对用户可见」和「对 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。