goose-context-management:为 Goose 打造的分层会话压缩与长对话续写方案
【免费下载链接】goosean open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM项目地址: https://gitcode.com/GitHub_Trending/goose3/goose
crates/goose-context-management是开源 AI 智能体项目 Goose(仓库根目录)中专门负责**会话压缩(Conversation Compaction)**的独立 crate。它的目标非常聚焦:把一长段消息历史归纳为一条摘要消息,让 Agent 的对话能够在单个模型上下文窗口被占满之后继续下去。本文以该 crate 的官方 README 为主体,结合其源码与集成代码,讲解其分层 API、结构化摘要格式、自动重试机制以及在 Goose 会话中的真实落地方式。读完本文,你将能理解 Goose 会话“越过上下文窗口续跑”的完整原理,并能在自己的 Rust 项目中直接使用summarize、compact或CompactingProvider三层接口之一。
问题背景:为什么要“压缩”而不是“丢弃”
大语言模型的每次推理都有上下文窗口上限,而一个真实的工作会话(例如让 Goose 修改代码、执行命令、读写多个文件)会产生大量消息:用户指令、Agent 的思考与回复、成批的工具调用及其结果、错误与修复过程等。这些消息会持续累积,很快逼近甚至超过窗口上限。
直接的解决办法是丢弃旧消息,但代价是丢失关键上下文(用户意图、文件改动、排错结论),后续会话无法自然延续。goose-context-management采用另一种思路:把历史对话“蒸馏”成一条承载所有关键信息的摘要消息,由 Agent 在下一轮交流中阅读这条摘要来续接会话。从 crate 源码的第一段文档注释即可看到这一设计意图:
//! Conversation compaction: summarizing a message history down to a single //! message so a conversation can continue past a model's context window.该 crate 的一个设计原则是按层划分、从小到大(layered, smallest first):需要哪一层能力就用哪一层,不必引入多余抽象。分层后的全部公共 API 都在 lib.rs 中导出:
pub use format::format_message_for_compacting; pub use model::{CompactionModel, ProviderModel, TokenEstimator}; pub use provider::CompactingProvider; pub use structured::{FileActivity, StructuredSummary}; pub use summarize::{summarize, Summary}; pub use templates::Templates;下面依次剖析这三层。
第一层:summarize—— 一次调用产出摘要
summarize是最小、最直接的 API:给定一个压缩模型和一批消息,产出一条摘要消息。README 给出了完整用法:
use goose_context_management::{summarize, Templates}; let summary = summarize(&model, None, &Templates::default(), &messages).await?; // summary.message, summary.usage返回的Summary结构体(定义在 summarize.rs)包含两个字段:
pub struct Summary { pub message: Message, // 摘要消息(角色被改写为 user) pub usage: ProviderUsage, // 本次摘要调用的 token 用量 }从源码可以确认几个值得注意的实现细节:
- 摘要请求的用户消息是固定的
"Please summarize the conversation history provided in the system prompt.",而真正的历史内容放在**系统提示(system prompt)**里,由模板引擎渲染。 - 摘要消息的 role 会被强制改为
Role::User,这样它可以作为下一条普通对话消息进入后续轮次。 - usage 统计先于摘要改写:模型原始输出(可计费 token)被记录下来之后,摘要正文才会被改写为渲染后的结构化版本,从而保证
usage反映真实的可计费 token 数(见 summarize.rs)。
超长历史的自动分级重试
summarize的调用本身也需要消耗上下文——历史太长时,连摘要模型都可能超窗。源码为此内置了一套渐进式重试策略:
const REMOVAL_PERCENTAGES: [u32; 5] = [0, 10, 20, 50, 100];流程(summarize.rs):
- 第一次尝试用完整历史(移除 0%)发起摘要请求。
- 若模型返回
ProviderError::ContextLengthExceeded,则按10% → 20% → 50% → 100%的比例逐步移除**工具响应(tool response)**后重试。 - 移除策略是“从中间向外”(middle outwards)逐条剔除工具响应消息,因为历史中部的工具响应是最不可能影响续接的信息(实现见
filter_tool_responses)。 - 如果历史中根本没有工具响应可移除,则直接快速失败并给出可操作建议:换用更大的可用上下文、禁用部分扩展以减少工具 schema 体积,或开启新会话。
- 若移除全部工具响应后仍然超窗,返回“即使移除所有工具响应仍超限”的错误。
对应测试位于同一文件的mod tests:summarize_without_tool_responses_fails_fast验证无工具响应时只发一次请求并快速失败;summarize_with_tool_responses_preserves_exhausted_removal_error验证有工具响应时会完整走完 5 次重试(summarize.rs)。
第二层:compact—— trait 化的抽象 API
compact是面向**自持会话表示(own conversation representation)**的调用方设计 trait API。调用方只需让自己的会话类型实现两个 trait,就可以把“读取历史、回写摘要”的细节交给 crate 处理。两个 trait 定义在 lib.rs:
pub trait CompactionInput { fn messages(&self) -> Vec<Message>; fn templates(&self) -> Templates { Templates::default() } } pub trait CompactionOutput { fn set_summary(&mut self, summary: Message); fn set_usage(&mut self, usage: ProviderUsage); }CompactionInput:告诉压缩器“从哪读”。messages()提供完整历史,templates()可覆盖默认提示模板(有默认实现,无需强制覆盖)。CompactionOutput:告诉压缩器“往哪写”。压缩完成后,set_summary收到摘要消息、set_usage收到用量信息。
compact的泛型实现(lib.rs)只是把输入取出、调用summarize、再把结果写回输出:
pub async fn compact<I, O>(model, estimator, input, output) -> Result<()> where I: CompactionInput + ?Sized, O: CompactionOutput + ?Sized,为方便最简单场景,crate 已经为Vec<Message>免费实现了CompactionInput:
impl CompactionInput for Vec<Message> { fn messages(&self) -> Vec<Message> { self.clone() } }因此,如果调用方恰好就是用Vec<Message>存历史,直接把它当作输入即可,无需包装类型。注意compact是基于 trait 的 API,目前仅限 Rust 使用。
其他核心导出:模型抽象、Token 估算与 Provider 包装
除两层主 API 外,crate 还导出了一组可独立使用的构件。
CompactionModel与ProviderModel
CompactionModel是压缩运行所依赖的模型抽象(model.rs),只要求实现一次对话补全:
#[async_trait] pub trait CompactionModel: Send + Sync { async fn complete( &self, system: &str, messages: &[Message], ) -> Result<(Message, ProviderUsage), ProviderError>; }实现方自己决定模型选择、fallback 与会话管道(session plumbing)——这意味着压缩可以复用 Goose 主程序里的会话记账与容错逻辑。
ProviderModel是这个 trait 的开箱即用实现,它把任何实现了goose-providers中Providertrait 的 provider 适配为CompactionModel:
pub struct ProviderModel { provider: Arc<dyn Provider>, model_config: ModelConfig, }其complete实现只是把调用转发给底层Provider::complete(model.rs)。
TokenEstimator
TokenEstimator用于可选的 token 计数(model.rs),它回答“应该把多少历史喂给摘要器”。提供两个异步方法:
pub trait TokenEstimator: Send + Sync { async fn count_chat_tokens(&self, system: &str, messages: &[Message]) -> usize; async fn count_text_tokens(&self, text: &str) -> usize; }在summarize内部,当 provider 返回的 usage 缺少输入/输出 token 时,会调用 estimator 补全(ensure_usage_tokens,见 summarize.rs)。也就是说,即使 provider 不报告 token 用量,调用方也能通过 estimator 获得完整、可计费的用量统计。
CompactingProvider—— 自动压缩的 Provider 包装器
CompactingProvider是更上层的“无人值守”方案:包装一个Provider,一旦底层补全因ContextLengthExceeded失败,就自动压缩历史并带摘要重试一次(provider.rs):
pub struct CompactingProvider { inner: Arc<dyn Provider>, templates: Templates, }其Provider实现覆盖stream与complete两条路径,核心逻辑一致(provider.rs):
match self.inner.stream(model_config, system, messages, tools).await { Err(ProviderError::ContextLengthExceeded(_)) => { let compacted = self.compacted_messages(model_config, messages).await?; self.inner.stream(model_config, system, &compacted, tools).await } other => other, }也就是说:正常调用直接透传;只有超窗错误才触发“先summarize成单条摘要,再以摘要替换原历史重试”。值得注意的一点:CompactingProvider::manages_own_context()固定返回true,向调用方声明“上下文由我自理”,从而避免上层再做额外的阈值判断。
提示模板与结构化摘要:Templates与StructuredSummary
压缩质量的好坏很大程度上取决于提示词与摘要格式的设计,这也是该 crate 最有特色的部分。
Templates:可替换的提示模板
Templates结构包含两个模板字符串(templates.rs):
pub struct Templates { pub compaction: String, // 压缩系统提示:compaction.md pub summary: String, // 摘要渲染模板:compaction_summary.md }- 内置模板通过
include_dir!在编译期打包进二进制,路径为 src/prompts/compaction.md 与 src/prompts/compaction_summary.md,因此运行时无需外部文件。 - 渲染引擎是minijinja(Jinja 语法的 Rust 实现),并注册了
code_fence过滤器,用于把代码片段包进“反引号长度自动加一”的安全围栏,防止key_code中嵌套的反引号破坏 Markdown 结构(见 templates.rs)。
compaction.md是发给摘要模型的系统提示,它把上下文压缩任务定义为“按给定 JSON schema 输出一条可续接会话的摘要”,并明确要求:
- 先在
<analysis>标签内按时间顺序梳理用户目标、方法、关键决策、文件、错误与修复(这段 scratchpad最终会被丢弃,只用于引导思考); - 在
</analysis>之后只输出一个 ```json 代码块,严格匹配给定字段 schema; - 列表按“重要度从高到低”排序,
errors_and_fixes中的报错文本、panic 内容、失败测试输出必须逐字引用而非转述; - 摘要仅供 Agent 自己阅读,因此可以远超给人看的普通摘要长度,把整个长度预算花在 JSON 字段上。
compaction_summary.md则是把结构化 JSON渲染为易读 Markdown 摘要的模板,输出包含## User Intent、## Files + Code、## Errors + Fixes、## Pending Tasks、## Current Work等分节。它在文件头部注释里说明了重要的可扩展性设计:
This template is user-overridable: place a modified copy at ~/.config/goose/prompts/compaction_summary.md to experiment with what the post-compaction context contains (e.g. user_intent[:3] to keep only the three most important goals) without rebuilding goose.即:用户可以在不重新编译 goose 的前提下,把修改版模板放到~/.config/goose/prompts/compaction_summary.md,从而控制压缩后上下文里保留什么(例如只保留最重要的三个用户目标)。
StructuredSummary:容忍模型“不听话”的宽松解析
StructuredSummary定义了结构化摘要的数据模型(structured.rs)。每个列表字段都遵循“最重要在前”的排序约定,以便消费方可以从尾部截断。字段如下:
| 字段 | 类型 | 含义 |
|---|---|---|
user_intent | Vec<String> | 每个用户目标与请求,最重要在前 |
technical_concepts | Vec<String> | 讨论到的工具、方法与概念 |
files | Vec<FileActivity> | 查看或编辑过的文件活动 |
errors_and_fixes | Vec<String> | 遇到的 bug、解决方式与用户驱动的改动 |
problem_solving | Vec<String> | 已解决/进行中的问题与关键决策 |
user_messages | Vec<String> | 所有用户消息(超长工具参数可截断) |
pending_tasks | Vec<String> | 所有未解决的用户请求,最重要在前 |
current_work | Option<String> | 摘要请求时刻进行中的工作 |
next_step | Option<String> | 直接延续用户指令的下一步(否则省略) |
FileActivity包含path、summary与可选的key_code(重要代码、签名或 diff)。
设计上对“模型不按 schema 输出”的情况非常宽容:所有字段宽松反序列化——缺省字段为空、对象或数字被转成字符串也不报错,因为模型经常在字段里塞入{"error": ..., "fix": ...}这类富化结构,不能因单个字段不合规就丢弃整个好摘要。files字段若模型输出成纯字符串,也会被当作 path-only 活动处理而非丢弃(相关测试见file_entries_parse_leniently与lenient_shapes_are_stringified_not_rejected)。
源码中还有一个极为考究的细节:模型响应里的 JSON 需要被可靠提取,但响应可能包含<analysis>scratchpad、被引用的示例 JSON、字符串值内嵌的代码围栏、甚至被摘要内容自己引用的</analysis>字样。json_candidates采用“按多个候选依次尝试 + 花括号配平”的提取策略(structured.rs),并在任何候选都不可用时回退为保留模型原始文本(无损 fallback),绝不为了追求结构化而丢弃信息。文件内大量测试(如unusable_responses_fall_back_to_raw_text、quoted_terminator_inside_summary_json_does_not_hide_it、embedded_fences_in_string_values_do_not_break_extraction)逐条验证了这些边界场景。
apply_structured_summary(summarize.rs)负责执行“解析 → 渲染”这一步:只有当结构化解析成功、且渲染结果非空时才用渲染文本覆盖原始响应;任何失败(模型未按 schema、模板被改坏、渲染出错)都只会告警并保留原始输出,保证信息零丢失。
在 Goose 主程序中的落地:阈值、可见性与续接消息
goose-context-management不只是独立的可复用 crate,它已被 Goose 主会话逻辑深度集成。集成点集中在 crates/goose/src/context_mgmt/mod.rs,几个关键事实如下。
默认压缩阈值:crate 导出DEFAULT_COMPACTION_THRESHOLD = 0.8(lib.rs),表示当会话 token 用量达到上下文窗口的 80% 时触发自动压缩。Goose 主程序将其再导出,并允许通过环境变量GOOSE_AUTO_COMPACT_THRESHOLD覆盖(check_if_compaction_needed,见 context_mgmt/mod.rs);当阈值小于等于 0 或大于等于 1 时自动压缩被禁用。若 provider 声明manages_own_context(),则跳过阈值判断直接返回不需要压缩。
压缩后会话的“可见性”分层:compact_messages(context_mgmt/mod.rs)执行完整压缩后,会重建会话消息列表,并做精细的可见性划分:
- 原始历史消息变为user 可见但 agent 不可见(
with_agent_invisible()),保留给用户回看; - 摘要消息与一条“续接引导”assistant 消息变为agent-only;
- 紧邻的用户消息被原样保留在会话中(自动压缩时),确保用户当前正在进行的请求不丢失;
- 续接消息文案随场景切换:普通对话续接、工具循环续接、用户手动压缩(manual compact)各有对应的提示文本(见
CONVERSATION_CONTINUATION_TEXT、TOOL_LOOP_CONTINUATION_TEXT、MANUAL_COMPACT_CONTINUATION_TEXT)。
usage 与 retained context:CompactionResult同时返回usage(摘要调用真实可计费 token,即使输出被改写为渲染版也不打折)与retained_context_tokens(压缩后 agent 可见上下文的估算 token,通常远小于可计费输出)。相关单元测试如test_structured_summary_is_rendered断言渲染后的摘要不再包含```json与<analysis>scratchpad 片段,同时output_tokens仍然存在。
主程序还提供了format_message_for_compacting与Templates的替换路径(compaction_templates通过crate::prompt_template::template_source加载),使 goose 运行时也能应用用户自定义模板。
消息如何被“压扁”给摘要模型:format_message_for_compacting
在把历史喂给摘要模型之前,每条消息都要被规整为便于模型读取的纯文本形式,这个职责由format_message_for_compacting承担(format.rs)。它的转换规则如下:
- 文本(
Text)→ 原文; - 图片 →
[image: {mime_type}](不传像素,只传类型标记); - 文档 →
[document: {name} ({mime_type})]; - 工具请求 →
tool_request({name}): {参数 JSON}; - 工具响应 →
tool_response: {文本内容};无文本时标记[non-text content],出错时标记[error]; - 工具确认请求、
action_required(tool_confirmation / elicitation / 对应响应)、系统通知、错误消息均有各自的紧凑文本; Thinking与RedactedThinking(推理过程)被直接丢弃,因为压缩不需要推理草稿。
每条消息最终被格式化为[{role}]: {内容},role只有user/assistant两种,多条消息以换行拼接后注入摘要系统提示的{{ messages }}占位处。这保证了摘要模型看到的输入是“扁平、文本化、信息完整”的会话记录。
跨语言访问:Python 与 Kotlin
goose-context-management本身是纯 Rust crate,但 Python 与 Kotlin 的调用方通过goose-sdk的UniFFI 绑定访问压缩能力(见 lib.rs 与 README“Cross-language access”一节)。需要特别说明的是:基于 trait 的compactAPI仅限 Rust;跨语言暴露的是更简单的函数式入口(summarize及其返回结构)。相关绑定源码位于 crates/goose-sdk/src/bindings.rs,其余依赖定义在该 crate 的 Cargo.toml(依赖goose-providers、minijinja、rmcp、serde、anyhow等)。
如何在自己的项目中使用
goose-context-management作为 workspace 成员发布,版本为0.1.0-alpha.7(见 Cargo.toml)。选用哪一层取决于你的集成深度:
- 你只需一次摘要:调用
summarize(&model, estimator, &Templates::default(), &messages),从返回的Summary中取message与usage。 - 你拥有自己的会话表示:让你的类型实现
CompactionInput/CompactionOutput,再调用compact(...),由 crate 完成“读取历史 → 生成摘要 → 回写结果”的全流程。 - 你不关心压缩时机,只要“别让我超窗”:用
CompactingProvider::new(inner)(必要时.with_templates(templates))包一层 provider,超窗自动压缩重试。 - 你想调节行为:通过
Templates { compaction, summary }自定义模板(内置模板打包在 src/prompts),或提供TokenEstimator让 usage 统计不依赖 provider 上报。
作为参考,Goose 主程序选择的是最彻底的方案:实现CompactionModel/TokenEstimator(复用会话管道与 token 计数器)、按GOOSE_AUTO_COMPACT_THRESHOLD(默认 0.8)判断是否需要压缩、压缩后精细管理消息可见性并追加续接引导消息。整个调用链贯穿 context_mgmt/mod.rs 与goose-context-management内部实现,两者共同构成了 Goose “长会话不停摆”的底层保障。
小结
goose-context-management的价值在于把“越过上下文窗口续跑会话”这一复杂工程问题,收敛为三个边界清晰、可逐层选用的抽象:
- 函数层
summarize:一次调用,产出摘要与用量; - trait 层
compact:解耦“读历史/写回”与会话表示; - provider 层
CompactingProvider:透明拦截超窗错误并自动重试。
支撑它们的是高质量的提示工程(内置 minijinja 模板、可用户覆盖)、对模型输出高度宽容的结构化解析(多候选 JSON 提取 + 无损原文回退)、以及按比例“从中间向外”剔除工具响应的分级重试策略。这些细节共同保证了压缩不只是“省 token”,更是以最小信息损失延续工作会话——这也正是 Goose 作为可长时间自主工作的 AI Agent 的关键技术底座之一。
【免费下载链接】goosean open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM项目地址: https://gitcode.com/GitHub_Trending/goose3/goose
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考