OpenClaw Pre-compaction memory:智能体短期上下文管理的关键机制

OpenClaw Pre-compaction memory:智能体短期上下文管理的关键机制 老读者应该记得我之前拆过不少智能体框架的记忆模块但OpenClaw也就是原Clawdbot重构后的项目这套Pre-compaction memory设计确实是目前开源智能体里少有的把“短期上下文管理”做得这么细的实现。很多人在本地部署完OpenClaw第一周体验觉得“哇这AI好聪明”跑上一两周就开始遇到同一个诡异问题——聊到某个节点Agent突然开始重复做已经完成的事、忘记用户三分钟前说过的关键信息、甚至把之前的错误结论当成既定事实继续推理。这时候你去翻日志八成会看到一行提醒compaction triggered。真正决定Agent“记性”好坏的从来不是模型本身的上下文窗口有多大而是Pre-compaction memory这套机制在窗口被写满之前做了什么。这篇文章我从核心代码层面把OpenClaw的记忆压缩预处理链路拆开揉碎讲清楚触发时机怎么定、压缩前哪些上下文会被保留、哪些会被丢弃、压缩后Agent如何无缝衔接对话。适合正在研究智能体记忆系统、或者自己写Agent框架时想抄作业的开发者。1. Pre-compaction memory 的设计背景与整体架构1.1 为什么Agent需要一套专门的“压缩前记忆”机制先明确一个前提所有基于LLM的Agent无论号称多大的上下文窗口实际上都跑在一条“边写边擦”的传送带上。OpenClaw默认的Claude模型能处理几十万token但真实对话场景里一次工具调用的返回值、一段网页抓取内容、几轮用户多轮追问半小时就能把窗口吃掉大半。更麻烦的是上下文一长模型对早期信息的注意力权重会系统性衰减这就是为什么很多Agent聊久了“变笨”——不是模型不行是上下文污染太严重。OpenClaw的处理思路和其他框架最大的区别在于它把“压缩”拆成了两个阶段——Pre-compaction压缩前和Compaction压缩本身。很多框架只有后者也就是简单地把历史消息丢给LLM生成一段摘要完事。但OpenClaw在真正调用LLM做摘要之前会先执行一整套上下文收集、筛选、权重标记的流程把所有“可能有用但未必在最近对话里出现”的信息主动捞出来和当前对话历史拼装成一份结构化的记忆包。这个记忆包就是Pre-compaction memory。这个设计解决了一个核心矛盾如果只对原始对话做摘要那些早期提到过的用户偏好、已经修正过的错误假设、某个任务中间态的细节很容易在摘要里被稀释掉。而Pre-compaction机制的价值就是在大模型动手“写回忆录”之前先把散落在各个记忆模块里的关键线索找出来硬塞进压缩上下文里让LLM生成的摘要能覆盖到这些必须被长期记住的信息。1.2 Memory 子系统全景图Pre-compaction 处在哪一个环节要理解Pre-compaction memory得先看清OpenClaw整个记忆系统的分层结构。我自己梳理代码后把它分成四层记忆层级数据载体生命周期对应OpenClaw模块程序性记忆Skills定义、工具描述、指令模板长期静态procedural_memory语义记忆用户画像、事实断言、偏好记录长期动态facts/preferences情景记忆对话历史、事件流水、工具调用记录短期动态episodic_memory工作记忆Episode Buffer、当前计划、待办状态瞬时易失episode/plannerPre-compaction memory并不是第五层独立的存储而是“情景记忆 语义记忆 工作记忆”在压缩触发瞬间的一次快照聚合。代码里对应的核心函数是compactMemory被调用之前的那段getMemoryAndMaxTokens逻辑。这里有个细节值得划重点OpenClaw的getMemoryAndMaxTokens返回值里除了memory还带了一个maxTokens字段。这个字段不是模型上下文窗口的原始值而是经过max_tokens预算计算后留给后端模型实际可用的token空间。在Pre-compaction阶段系统会用这个值反向推导“当前Episode还能装多少内容”、“需要提前多久触发压缩准备”而不是傻等到窗口写满才动手。我打个比方这个机制就像跑长途之前不是等到油表亮灯才找加油站而是导航会根据剩余油量、下一段路况、加油站间距提前规划好在哪个服务区加油、加多少、要不要顺便休息。Pre-compaction memory就是这个“提前规划”的大脑。2. 核心数据结构与触发机制从 Episode 到 Compaction 的临界点2.1 Episode Buffer 的数据结构与状态标记Episode是OpenClaw工作记忆的基本单位对应一次连续的Agent运行会话。说白了就是一个消息对象数组但每个消息不是单纯的{role, content}而是带着一整套元数据的结构化对象。我从代码层面还原一下核心的数据结构定义基于TypeScript风格不同版本字段名可能略有差异interface EpisodeMessage { id: string; role: user | assistant | system | tool; content: string | ToolCallContent[]; timestamp: number; tokenCount: number; // Pre-compaction 相关标记 importanceScore?: number; // 重要性评分用于压缩时保留关键信息 isEssential?: boolean; // 是否标记为“绝对不可丢失” factContributions?: string[]; // 本条消息产出了哪些事实 status: active | pinned | archived; } interface Episode { messages: EpisodeMessage[]; totalTokens: number; startedAt: number; contextReferences: Mapstring, string; // 外部记忆引用索引 }注意totalTokens这个字段它在每次appendMessage时都会增量累加而不是在压缩时重新遍历计算。这是个看似不起眼但很关键的工程决策——预计算的代价是每次写入需要O(1)的时间去更新计数但换来的好处是触发判断零延迟不需要在对话途中突然来一次全量统计那会阻塞主流程几百毫秒。2.2 Token 预算计算maxTokens 是怎么算出来的OpenClaw在Pre-compaction阶段会执行一段非常关键的预算计算。它并不是简单地把模型的context_window减去当前已用token而是做了三层减法function getMemoryAndMaxTokens() { const contextWindow this.model.getContextWindowSize(); // 第一层预留安全边际防止单次响应超限 const safetyMargin Math.floor(contextWindow * 0.1); // 第二层系统提示词、后代知识、工具定义占用的固定开销 const systemPromptTokens estimateTokens(this.systemPrompt); const toolTokens estimateTokens(this.tools); // 第三层规划器、当前任务状态、执行上下文 const plannerTokens estimateTokens(this.planner.getState()); const usableTokens contextWindow - safetyMargin - systemPromptTokens - toolTokens - plannerTokens; return { memory: this.compactMemory(usableTokens), maxTokens: usableTokens }; }这个计算逻辑要解决的核心问题是模型每次生成回复时实际输入 系统提示词 历史对话 工具定义 当前计划 最新用户消息。如果只盯着“对话历史不超过窗口的80%”来管理那真正留给回答的token空间可能只剩10%模型就只能产出很短的回复甚至直接截断。Pre-compaction memory在这里发挥的作用是当episode.totalTokens 预估新输入 usableTokens时不是立刻粗暴地截断最老的消息而是进入“压缩预备役”——先评估哪些消息可以被吸收进摘要哪些信息需要提前转移到长期记忆然后把真正需要保留的原始消息做瘦身比如把超长的工具返回结果替换成摘要描述。2.3 触发链路的代码级还原我把触发链路简化成伪代码方便看清调用顺序async function onNewUserMessage(userMessage: Message) { // 1. 追加到当前Episode this.episode.append(userMessage); // 2. 获取压缩前的记忆快照和可用token预算 const { memory, maxTokens } this.getMemoryAndMaxTokens(); // 3. 判断是否需要触发压缩 if (this.episode.totalTokens maxTokens * 0.85) { // 提前触发防止一次工具调用返回导致直接溢出 await this.compressMemory(); } // 4. 组装最终请求 const messages [ { role: system, content: SYSTEM_PROMPT }, ...this.conversationHistory(), { role: user, content: userMessage } ]; return this.llm.complete(messages, { maxTokens }); }注意第三步的阈值用的是0.85而不是1.0。这个余量是故意留的因为工具的返回值是不可预估的——一个browser_search可能返回五千token一个file_read可能返回两万token。如果在Episode已经95%满时才触发压缩很可能会出现“压缩还没跑完新工具结果已经把窗口挤爆”的尴尬局面。我见过不少人改这个阈值但我的建议是不要低于0.8否则压缩频率过高反而浪费token在重复摘要上。3. Pre-compaction 内容的收集与组装压缩前到底要保住哪些信息3.1 会话上下文的提取与优先级排序Pre-compaction的核心工作发生在compactMemory函数内部。这个函数要回答一个问题如果只能保留有限的原始消息一份摘要哪些信息必须出现在摘要里OpenClaw的做法是给每条消息算一个importanceScore这个分数由几个因素加权得到是否包含用户明确指令、是否涉及事实性断言、是否被后续消息引用、是否来自工具的成功调用。代码里大概长这样function calculateImportance(message: EpisodeMessage): number { let score 0; if (message.role user) score 1.5; if (containsExplicitInstruction(message.content)) score 1.0; if (extractsNewFact(message.content)) score 1.2; if (this.episode.isReferencedLater(message.id)) score 1.0; if (message.role tool message.isSuccessful()) score 0.8; return score; }压缩触发时importanceScore高于阈值的消息会被标记为pinned原样保留在发送给压缩LLM的上下文中而不会被折叠。比如用户说过“以后所有周报都用英文写”这条消息的factContributions会被单独记录重要性直接拉满因为它对应了一个长期偏好。而一条失败的HTTP请求报错日志重要性就低得多可以放心丢进摘要里。3.2 长期记忆的聚合Facts、Preferences、历史视图一网打尽Pre-compaction另一个容易被忽略的环节是它会主动从长期记忆模块拉取“可能需要更新的旧信息”一并塞进压缩上下文。这一步很微妙也是我觉得OpenClaw做得聪明的地方。假设Agent在三天前记住了“用户偏好使用Python”但今天用户在对话里说“最近在学Rust”。如果不做Pre-compaction聚合压缩LLM在生成摘要时只看最近对话它可能会认为用户仍然“偏好Python”因为这个事实还留在长期记忆里没有更新。OpenClaw的解决方式是在压缩前把所有和新对话内容相关的旧facts捞出来放在摘要指令里特别强调“对比新旧信息如有冲突请修正”。这个流程对应代码里的collectAggregateMemory我这版代码里用memoryAggregate对象承载interface MemoryAggregate { facts: Fact[]; preferences: Preference[]; historicalEpisodes: EpisodeSummary[]; conversation: EpisodeMessage[]; emotionState: string; attentionState: string; }emotionState和attentionState这两个字段很有意思它们来自EmbodiedMemory是OpenClaw模拟Agent内部状态的一个体现。在压缩时带上这些状态是为了让LLM生成的摘要保持情感和注意力的一致性——比如压缩前Agent正在执行一个“需要高度专注的多步任务”摘要里就会保留“当前任务状态进行中已收集数据3/5下一步是调用分析工具”这样压缩后新一轮对话能无缝续上而不是把任务忘得一干二净。3.3 压缩Prompt的组装如何让LLM理解“你是记忆管理员”Pre-compaction memory最终要交给LLM做摘要Prompt的组装质量直接决定摘要质量。OpenClaw的prompt模板核心思路是让LLM扮演“记忆管理员”而不是“对话参与者”它要输出的是结构化摘要而非自然语言回顾。我用代码还原核心的组装片段function buildCompactionPrompt(memory: MemoryAggregate): Message[] { return [ { role: system, content: 你是一个记忆压缩器。你的任务是将以下对话和上下文压缩为一份持久记忆。 要求 1. 保留所有用户明确表达的偏好、指令、事实性陈述 2. 保留当前进行中的任务状态和下一步计划 3. 保留工具调用结果中的关键数据数值、结论、路径 4. 如果新对话与旧记忆存在矛盾以新对话为准并标注“已修正” 5. 输出格式{summary: ..., key_facts: [...], active_tasks: [...]} }, { role: user, content: 以下是需要压缩的上下文内容\n MEMORY AGGRAGATE: ${JSON.stringify(memory)} CONVERSATION HISTORY: ${memory.conversation.map(m ${m.role}: ${m.content}).join(\n)} } ]; }这里有个细节JSON格式的输出约束不是靠模型自觉而是会在parse_compaction_response里做严格的结构校验解析失败会触发重试机制。我在实际调试中遇到过几次模型输出JSON不规范导致压缩流程卡死后来在代码里加了一个max_retries3的重试兜底并在第三次失败时降级为“截断最老消息”保证对话能继续下去。4. 压缩后处理与生命周期管理摘要不会真的“替代”记忆4.1 Compacted Summary 的落库与档案迁移压缩完成后生成的摘要并不是简单地拼接回对话上下文就完事了。OpenClaw会把它写入ArchiveMemory归档记忆同时更新索引让未来任意一轮新会话都能通过语义搜索检索回这些老记忆。我在实际使用中碰到一个值得分享的坑压缩摘要如果只存文本不建索引那它几乎等于没用。因为你不可能每次对话都把历史所有摘要拼进上下文那又回到了token爆炸的原点。OpenClaw的做法是把摘要拆成两个部分——summary字段用于生成索引向量key_facts数组用于精确匹配检索。这样新对话开始时只有可能相关的历史摘要会被召回而不是一股脑全加载。4.2 状态重置与会话衔接Episode的“推倒重来”压缩完成后当前episode会被清空但清空不等于抹掉一切。有几个关键状态会被保留或者转移状态项压缩后的去处作用当前任务计划作为active_tasks存进Archive保证多步任务不因压缩而中断用户关键偏好更新进facts/preferences长期约束Agent行为执行上下文如临时变量随对话写入摘要的summary字段在下一轮检索时恢复工具调用凭证明确不保留防止敏感信息泄漏这个“选择性遗忘”的机制是Pre-compaction memory设计里最有价值的部分。它不像很多框架那样“要么全忘要么全留”。通过标记和筛选它能在压缩后保留真正影响未来行为的信息同时把噪音过滤掉。4.3 压缩后第一轮对话的行为差异压缩完成后紧接着的一轮对话在行为上会和之前有明显差异。我观察到的表现是Agent在上下文中“看”不到原始对话了但它依然能通过Archive检索召回相关的关键事实。所以你会觉得它的记忆变差了但又在关键点上能对上号。这个设计带来的一个隐性问题如果Archive检索的相似度阈值设得太高早期的重要信息可能会召回不到Agent就会“断片”。我在调试时通常会把archive_search_threshold从默认的0.7调到0.6左右召回更宽松一些覆盖更多可能相关的历史代价是多消耗一点token。权衡下来对于复杂任务场景宁可多一点上下文也不能丢关键线索。5. 常见问题与排查技巧实录5.1 token 失控压缩失效的典型症状和排查方法我自己踩过的最深的一个坑是高估了Pre-compaction的兜底能力。某次跑一个网络爬虫任务工具一次性返回了大概8万token的抓取结果这个量级比当时模型的单次工具返回上限还高。OpenClaw的压缩机制只在消息append之后检查totalTokens但工具返回本身作为一条消息本身就超过了预算。结果就是压缩还没触发消息就已经把上下文窗口挤爆了后续请求直接报错。排查方法是在日志里定位Pre-compaction相关的记录确认触发时机是不是在消息append之后。如果是那就需要在工具调用之前加一层预检预计返回超过阈值时先对工具结果做截断摘要。代码里我额外加了一段if (toolResult.tokenCount maxTokens * 0.3) { toolResult await this.truncateToolResult(toolResult, maxTokens * 0.2); }这样能保证任何单条工具消息都不会超过整体预算的30%给压缩机制留出余量。5.2 摘要丢失关键信息从三个层面修复压缩后遗漏关键信息是另一个高频问题。有一次用户明确交代“把报表发给张总”结果压缩后摘要里只剩“发送报表”收件人信息丢了Agent直接给所有联系人发了邮件——这个教训比较惨痛。修复这个问题的思路有三个层面源头标记在消息进来时如果包含类似“务必”“一定”“强调”等强指令词直接给消息打上isEssential标记压缩时原样保留不让LLM有机会改写或浓缩。压缩指令强化在Compaction Prompt里增加“用户明确指定的实体人名/时间/地点/金额必须原样保留不得简化”。事后校验压缩完成后用正则/字典检查摘要里是否包含了关键实体如果缺失则触发补充压缩。第三点我用了一个比较轻量级的方案在compressMemory返回后加一个实体校验函数def verify_summary_entities(summary, original_episode): entities extract_entities(original_episode) missing [e for e in entities if e not in summary] if missing: logger.warning(fCompaction missing entities: {missing}) # 触发补充压缩而非整个重跑 summary supplement_compaction(summary, missing) return summary5.3 压缩频率过高导致token反而增加动态阈值最后分享一个性能调优的经验。有阵子我发现某条业务线的Agent每三分钟就触发一次压缩token消耗比不做压缩还厉害。看了日志才发现是因为某个技能持续往Episode里写入大段无关的调试信息把token快速推高到阈值而每次压缩本身又要消耗一批token。解决思路是给阈值加一个动态调整统计最近N次压缩的“压缩前token量”和“压缩后摘要token量”如果发现摘要占原始对话的比例太高比如超过40%说明压缩收益太低应该把触发阈值下调提前压缩缩短单次对话的长度。反之如果摘要占比很低低于10%说明系统在过度压缩应该上调阈值减少压缩频率。const compressionRatio lastSummaryTokens / lastPreCompactionTokens; if (compressionRatio 0.4) { this.triggerThresholdRatio - 0.05; // 提前压缩 } else if (compressionRatio 0.1) { this.triggerThresholdRatio 0.05; // 延后压缩 }这个自适应方案在我的环境里跑了一周整体token消耗大约降了17%同时用户可感知的“失忆”问题反而减少了。6. 写在最后一眼看穿 Pre-compaction memory 的核心思路把Pre-compaction memory的整套代码逻辑捋完后我最深的感受是它本质上是在回答一个哲学问题——AI在忘记一件事之前应该记住关于这件事的什么。这个框架的做法不是简单地把历史丢给LLM做摘要而是在压缩前主动收集、筛选、标记、聚合所有相关信息让大模型在处理压缩时能看到一份经过预处理的“记忆底稿”。它是整个智能体记忆系统中真正的大脑而Compaction本身只是个执行器官。理解了这套机制你在调优OpenClaw时就能有的放矢如果觉得Agent记性差先去查Pre-compaction阶段的importanceScore是不是把太多噪音消息给了高分如果觉得压缩后行为分裂先去看Archive检索的召回阈值如果遇到token失控先排查看是不是有单条工具结果超过了预算保护线。我把这部分的代码逻辑、触发链路和坑点整理出来就是希望你在自己搭Agent记忆系统时不用再从头趟一遍这些坑。照着这个思路去读源码你会发现OpenClaw真正值得抄的作业不只是某个函数怎么写而是整套“在忘记之前先想好该记住什么”的设计哲学。