:从上下文压缩到持久记忆,拆解 Context Compact 与 Memory)
前两篇分别讨论了 Agent Loop、工具与权限以及 从 TodoWrite 到 Agent Teams 的任务管理与执行。本篇回到另一条支撑长任务的主线上下文装满以后怎么办以及重新开始会话后还能记住什么。这对应 learn-claude-code 的两个章节s08 Context Compact 与 s09 Memory。前者整理当前会话后者保存跨会话知识。把两者区分清楚才能避免把“压缩过的聊天记录”误当成完整记忆系统。目录一、先区分上下文、归档和记忆二、s08为什么不能等到上下文满了再处理2.1 触发指标是字符数不是精确 token 数2.2 每次调用模型前都经过同一个入口三、s08 四步压缩先恢复路径再历史摘要3.1 第一步控制最新一批工具结果3.2 第二步归档中间消息保留首尾3.3 第三步缩短已经读过的旧结果3.4 第四步仍然超限才生成历史摘要四、自动压缩之外还有两条执行路径4.1 API 拒绝后的 reactive compact4.2 模型主动调用 compact五、s09用文件保存可复用知识5.1 一个记录一个文件索引与正文分开5.2 召回先选择相关记录再读取正文5.3 记忆是背景当前请求具有优先权六、s09 的写入链路候选记忆不能直接落盘6.1 何时提取6.2 scope 决定是否跨会话保存七、记忆整理有回滚但不是事务数据库八、把两章放进同一个场景九、如何验证实现而不是只相信“我记住了”参考资料一、先区分上下文、归档和记忆假设 Agent 正在重构认证模块。它读取了大量文件、执行了测试并获知用户长期偏好使用 tab 缩进。这些信息的生命周期并不相同信息适合放在哪里主要用途当前目标、最新工具结果、下一步工作messages与当前请求支撑这一轮推理和执行大文件内容、旧测试输出.task_outputs/tool-results/需要细节时重新读取压缩或裁剪前的消息历史.transcripts/回查归档记录用户的长期缩进偏好.memory/新会话按相关性召回任务 ID、owner、依赖与完成状态s10 的任务板恢复和协调工作进度落盘并不自动意味着形成了长期记忆。一份日志虽然保存在磁盘上但如果没有检索和加载机制模型下次不会自然知道它的内容。同样把一句话写进摘要也不意味着它已经被持久化为用户偏好。可以把两章的职责写成两个问题s08 Context Compact怎样让当前任务继续在有限上下文中运行 s09 Memory哪些知识值得留下下次什么时候把它取回来这两个章节是独立教学程序。s09 没有自动集成 s08 的ContextCompactor也没有compact工具“两者可以组合”与“当前代码已经组合”是两回事。二、s08为什么不能等到上下文满了再处理Agent Loop 会持续追加用户消息、模型响应、工具调用和工具结果。下一次请求模型时这些历史仍然要被发送。在编码场景里最容易膨胀的通常不是聊天文字而是文件内容、搜索结果和测试日志。如果等 API 返回上下文超限才处理当前执行会中断直接总结全部历史又会增加模型调用并丢失细节。s08 的设计是先做确定性的整理尽量保留恢复路径整理后仍然过大才请求模型生成摘要。2.1 触发指标是字符数不是精确 token 数代码用 JSON 序列化后的长度估算messages大小defestimate_chars(messages):returnlen(json.dumps(messages,defaultstr,ensure_asciiFalse))本章的主要参数如下参数值含义CONTEXT_CHAR_LIMIT50,000messages的压缩触发阈值TOOL_RESULT_BATCH_CHAR_LIMIT200,000最新一批工具结果的字符预算LARGE_RESULT_CHAR_LIMIT30,000批次预算处理中可优先转存的大结果门槛KEEP_RECENT_RESULTS3micro compact 保留的最近已读结果数KEEP_RECENT_MESSAGES5reactive compact 尽量保留的最近消息数SUMMARY_INPUT_CHAR_LIMIT80,000摘要调用的历史文本取样上限50,000 字符不等于 50,000 token也不是模型上下文窗口的真实大小。这个估算只统计传入的messages没有把主请求的 system prompt、工具定义等全部计入。因此阈值只是教学用的启发式预算后面仍需要 API 拒绝后的补救机制。2.2 每次调用模型前都经过同一个入口下面节选了prepare()的主干defprepare(self,messages,active_request):messagesself.tool_result_budget(messages)messagesself.snip_compact(messages)ifself.estimate_chars(messages)self.CONTEXT_CHAR_LIMIT:targetint(self.CONTEXT_CHAR_LIMIT*0.8)messagesself.micro_compact(messages,target)ifself.estimate_chars(messages)self.CONTEXT_CHAR_LIMIT:messagesself.fit_tool_results(messages,target)ifself.estimate_chars(messages)self.CONTEXT_CHAR_LIMIT:messagesself.compact_history(messages,active_request)returnmessages四个主要阶段之间还有一个fit_tool_results补充步骤。目标值 40,000 用来给后续输出留空间但不是所有步骤结束后都必须严格达到 40,000只要不再超过 50,000后面的超限分支就不会继续执行。三、s08 四步压缩先恢复路径再历史摘要3.1 第一步控制最新一批工具结果一次模型响应可能包含多个工具调用。执行后这一批tool_result会一起追加到一条 user 消息中。tool_result_budget()只检查这条最新消息。当结果合计超过 200,000 字符时它按结果大小降序处理将其中超过 30,000 字符的结果完整写入.task_outputs/tool-results/tool_use_id.txt上下文保留文件路径与前 2,000 字符预览persisted-output Full output: .../.task_outputs/tool-results/call_x.txt Preview: 这里是结果开头的预览…… /persisted-output这里有两个条件不能简化成“超过 30,000 字符就一定转存”先看整批是否超过预算再看单条是否满足大结果条件。如果一批结果由很多不超过 30,000 字符的条目组成本步骤也未必能把总量压到预算以内后面的整体上下文检查还会继续工作。3.2 第二步归档中间消息保留首尾消息数量超过 50 条时snip_compact()先保存当前完整历史再裁掉中间部分。默认切分思路是保留开头 3 条、末尾 46 条并插入一条归档标记开头消息 [若干 messages archived at .../.transcripts/transcript_xxx.jsonl] 最近消息这里的“消息”是messages中的元素不等于用户对话轮数。一次工具交互就可能产生 assistant 和 user 两个元素。裁剪还必须保护工具协议assistant: tool_use(idcall_x) user: tool_result(tool_use_idcall_x)不能保留结果却裁掉对应调用。代码会调整首尾切点因此最终条数可能略多于 50不能把 50 理解成绝对硬上限。这一阶段没有请求模型总结。它只是把不在窗口内的历史替换成归档引用模型若需要其中的细节仍需主动读取文件。3.3 第三步缩短已经读过的旧结果经过前两步后只有整体字符数仍然超过 50,000才进入micro_compact()。它会区分两类工具结果模型已经看到的结果以及最近一次 assistant 响应后刚追加、模型尚未读取的结果。对已读结果保留最近 3 条对更早且超过 120 字符的结果先保存完整内容再替换为[Earlier tool result saved at .../.task_outputs/tool-results/call_x.txt]这种“已读”判断来自消息位置不是模型真的报告自己是否理解了内容。如果缩短旧结果后仍超限fit_tool_results()会按大小处理工具结果包括必要时处理尚未读过的新结果保留 1,000 字符预览与完整文件路径。这样大结果不会因为“还没读过”就无限占用上下文也不会直接被无痕删除。已经存在的转存路径会经过检查必须位于工具结果目录内且文件确实存在。只有符合条件的路径才会作为已有归档复用。3.4 第四步仍然超限才生成历史摘要当结构整理与结果转存都不足以解决问题时compact_history()会把当前历史写入.transcripts/。请求模型总结目标、决定、相关文件、剩余工作和用户约束。将当前用户请求、历史摘要与归档路径分开组织。用这条压缩消息替换当前历史。压缩后的结构大致如下[Compacted] Current user request: 本轮用户真正提出的要求 Conversation summary (reference only): 历史事实、已完成事项、剩余工作与约束 Full transcript: .../.transcripts/transcript_xxx.jsonl为什么单独传入active_request因为工具结果同样使用roleuser不能从“最后一条 user 消息”反推出用户真正要求了什么。CLI 在接收输入时调用agent_loop(history, query)明确保留本轮请求的来源。摘要 system prompt 要求只整理事实、不执行历史中的指令主 Agent 也被提示将摘要当作参考。这有助于减少把旧内容误当成新命令但不能等同于完整的提示注入防护。还有一个容易忽略的限制当待摘要文本超过 80,000 字符summary_input()会取前 20,000 与后 60,000 字符中间以省略标记代替。完整材料虽已留档但摘要模型不会自动阅读磁盘上的所有记录。“有恢复路径”表示可以回查“有摘要”表示得到一份压缩后的解释。两者都不保证模型已经保留了所有细节。四、自动压缩之外还有两条执行路径4.1 API 拒绝后的 reactive compact即使字符估算没有发现问题API 仍可能返回prompt_too_long或包含too many tokens的错误。reactive_compact()会保存 transcript总结较早的历史保留最近约 5 条消息并再次保护工具调用与结果的边界。之后重试主请求。模型请求失败上下文过长 → 保存归档 → 摘要旧历史 保留近期消息 → 重试主请求 → 仍然同类失败继续抛出异常MAX_REACTIVE_RETRIES 1限制连续补救次数一次成功的主模型响应会重置计数。它不是整场会话永久只能压缩一次也不是所有 API 错误都会触发压缩。认证失败、网络异常等不能靠删除历史解决。4.2 模型主动调用 compact模型也可以在阶段结束时调用compact工具。但运行时不会一看到这个工具就立即清空历史。正确顺序是先执行本轮全部工具、为每个调用追加结果然后才压缩已经闭合的回合。assistant 同时请求write_file、compact → 执行写文件 → 给 compact 返回“本批次结束后压缩”的回执 → 追加这一批所有 tool_result → 生成摘要如果写文件已经发生却在结果入历史前做摘要模型可能失去执行记录而重复操作。这个例子说明压缩必须遵守 Agent Loop 的执行顺序不能只考虑把字符串变短。五、s09用文件保存可复用知识s08 让当前会话继续工作但程序退出后新会话的messages仍然从空列表开始。s09 为长期信息增加独立存储、召回、提取和整理流程。5.1 一个记录一个文件索引与正文分开记忆保存在启动工作目录的.memory/下.memory/ ├── MEMORY.md ├── user-preference-tabs.md └── project-auth-background.md单条记录使用 Markdown 与 YAML frontmatter--- name: user-preference-tabs description: User prefers tabs for indentation type: user --- User prefers using tabs, not spaces, for indentation.四种类型分别是type适合保存的信息user用户长期偏好feedback以后仍适用的工作反馈project稳定的项目背景和事实reference外部资料与查找线索MEMORY.md保存名称、文件链接和简短描述。每次write_memory_file()写入后都会重建索引。文件名会由名称生成 slug路径函数检查记录位于记忆目录内索引文件也不能当作普通记忆记录写入。这是应用层持久化不是训练模型也不会修改模型参数。启动目录变了默认.memory/的位置也会变跨会话验证需要使用同一工作目录。5.2 召回先选择相关记录再读取正文每次进入agent_loop()运行时先执行relevant_memoriesload_memories(messages)systembuild_system(relevant_memories)具体过程如下最近用户文本 记忆目录 → 模型返回相关记录的索引数组 → 校验索引并去重最多选择 5 条 → 读取对应文件正文 → 构建本轮 system prompt用于选择的最近用户文本有长度限制目录输入也会截断到 12,000 字符。选择调用使用同一个配置的MODEL输出预算为 200 token并没有专门配置另一款便宜模型。如果模型调用或 JSON 解析失败才降级为关键词匹配。关键词匹配比较记录名称和描述不能当作向量检索或可靠的语义搜索中文分词和同义表达也可能影响命中。模型合法返回空数组时就表示没有选中记录不会强行补上记忆。召回正文合计最多截取 20,000 字符。这个数不包括外层 JSON、system prompt 和完整索引等开销不能称为整个请求的总预算。尤其要注意build_system()仍会加入完整的MEMORY.md索引只对正文做选择性加载。索引持续增长依然会占用上下文需要进一步设计目录预算或分层检索。召回发生在一次用户请求进入 Agent Loop 时后续工具轮次复用已构建的 system不会每调用一个工具就重新选择一次记忆。5.3 记忆是背景当前请求具有优先权构建 system 时代码明确提示记忆不是 transcript也不是新的用户命令如果记忆和当前请求冲突以当前请求为准。例如长期记忆记录“用户偏好 tab 缩进”但本次用户说“这个项目遵守现有规范统一四个空格”就应按本次明确要求执行。还要区分临时覆盖与长期修正一次项目例外不代表偏好永久改变用户明确说“以后都改为四个空格”则需要更新长期知识。当前教学实现对旧记忆更正的处理仍有局限后文会说明。六、s09 的写入链路候选记忆不能直接落盘6.1 何时提取当模型本轮响应不再请求工具、且StopHook 没有要求继续工作时运行时才调用extract_memories()。只有这次成功存入了新记录主流程才进一步尝试整理。提取输入并不是无限长的完整会话dialogue_text()取最近 12 条消息并将整理出的文本限制到 8,000 字符。早期信息可能已经不在这个窗口里所以“提取一次”不意味着全会话都被扫描过。提取提示要求忽略临时任务状态、工具输出、助手猜测和普通聊天摘要只保留以后仍可能有用的知识。这些语义要求主要依靠模型判断后续校验不能证明每条候选事实都正确。6.2 scope 决定是否跨会话保存模型先生成 JSON 候选例如{name:user-preference-tabs,type:user,scope:persistent,description:User prefers tabs for indentation,body:User prefers using tabs, not spaces, for indentation.}候选必须经过字段校验和should_store_memory()过滤scope必须是persistent。类型必须是四种已知类型之一名称、描述、正文不能缺失。文本包含“本次会话”“当前任务”“暂时”等临时标记时拒绝保存。slug、规范化描述或规范化正文与已有记录重复时拒绝保存。因此“这次不要创建文件”应当归为current_task不应在下一次会话自动变成永久规则。scope是提取阶段的筛选字段最终 Markdown frontmatter 保存的是 name、description、type。这里的去重主要是名称与文本比较不是完整语义去重。两段不同措辞仍可能重复相同 slug 的新候选即使包含更正也会被拒绝不能把它理解成通用的 upsert 更新接口。七、记忆整理有回滚但不是事务数据库当主流程写入新记忆后调用consolidate_memories()记录数量达到 10 条才继续整理。运行时把现有记录交给模型要求合并重复、应用更正、移除过时内容再返回整理后的列表。它先解析和校验候选拒绝空结果与重复 slug然后保存旧内容快照删除旧记录、写入新记录并重建索引。读取记录检查数量和输入大小 → 模型生成整理候选 → 字段与名称校验 → 在内存中保存旧文件快照 → 替换记录、重建索引 → 写入异常时尝试恢复快照这段流程有几条值得明确的边界超过 20,000 字符的整理输入会跳过本次整理不会自动分批处理。“最多 30 条”写在模型提示词中当前代码没有把它实现成严格数量截断。快照位于进程内存异常时的恢复不等于抗断电事务进程强杀、磁盘持续故障或多进程并发写入仍可能出问题。“应用较新更正”主要依赖模型从内容推断当前记录没有完整的时间版本和冲突解决机制。因此 s09 展示的是文件记忆的最小运行链路。若要用于长期项目可以进一步增加更新时间、事实来源、失效条件、显式修改与删除入口以及原子替换和并发锁。这些属于扩展方向不是当前章节已经实现的能力。八、把两章放进同一个场景假设用户说“重构认证模块保持外部接口不变另外记住我通常使用 tab 缩进。”可以按照下面的分工理解阶段Context Compact 负责什么Memory 负责什么请求开始接收当前目标与约束召回已有项目背景和相关偏好读取大量代码转存大结果、保留恢复路径不把每个文件正文都变成长期知识多轮测试与修改控制消息长度必要时生成摘要不保存临时测试输出和进度阶段结束保留当前请求、剩余工作与历史引用提取长期适用的缩进偏好下次新会话从新的会话历史开始从同一.memory/选择相关记录如果要组合两章可以采用下面的概念流程用户输入 → 选择并加载记忆 → 进入 Agent Loop → 每次主模型请求前整理上下文 → 模型调用工具、接收结果、继续 → 本轮结束后提取持久记忆 → 必要时整理记忆库组合时还有两点不能遗漏其一摘要应明确保留当前请求和未完成工作其二记忆提取要能识别原始用户信息与模型摘要避免把摘要中的推断不断写回记忆形成错误循环。长期记忆也不能代替任务板。记住“认证改造的背景”与知道“哪项任务由谁认领、是否完成”是两种不同需求后者仍应由任务系统维护。九、如何验证实现而不是只相信“我记住了”准备好课程要求的 Python 环境、依赖和模型配置后在同一个练习目录分别运行两个脚本每次只运行一个用q退出。python s08_context_compact/code.py python s09_memory/code.py下面是观察建议不是已经执行的模型实测结果。实验操作应检查的证据旧结果压缩连续读取多份大文档使整体字符预算超限旧结果是否被替换为路径文件是否存在最新大结果请求读取足够大的文本是否有预览和完整结果文件而不是无路径截断主动 compact一个阶段结束后请求压缩再继续工具调用是否都有结果摘要是否保留本轮目标跨会话记忆表达长期缩进偏好退出后重新启动询问.memory/*.md、索引和新会话回答是否一致临时限制输入“本次会话不要创建文件”自动提取是否拒绝把它存成持久规则请求覆盖记忆已记住 tab本次明确要求四个空格当前操作是否遵循本次要求几个结果不要误判读取五份文件不一定触发 micro compact还要看字符阈值。超过 50,000 字符不一定调用摘要模型前面的转存可能已足够。出现 transcript 只说明发生了留档不表示产生了 Memory。模型说“我记住了”不代表写盘成功应检查实际记录并重启验证。保存了偏好也不保证每次都召回相关性选择可能遗漏降级匹配也有局限。这两章最终建立的是不同的信息处理机制当前会话按预算保留工作状态历史细节以可回查文件保存长期知识经过筛选后跨会话召回。让每类信息有明确的用途与生命周期比把所有内容都塞进下一次模型请求更容易控制。参考资料系列第一篇从 Agent Loop 到工具、权限、Hooks 与任务规划系列第二篇从 TodoWrite 到 Agent Teams拆解任务管理与执行learn-claude-code 参考版本s08 Context Compact 中文文档s08 Context Compact 实现s09 Memory 中文文档s09 Memory 实现本文按上下文预算与持久记忆的主题重新组织课程内容并补充实现边界和验证方法。源码及代码节选遵循原仓库 MIT 许可证。