从上下文投影、提示缓存到会话恢复,拆解 Coding Agent 的三层记忆系统
你可能遇到过这样的场景:一个 Coding Agent 已经连续工作了几个小时,界面里的聊天记录仍然可以一路向上滚动,刚才读取过的文件、执行过的命令也都历历在目;可当你继续追问时,它却像突然失忆一样,忘了早先确认过的结论。
这通常不是模型“记忆退化”,也不一定是历史记录丢失。更可能的原因是:你在界面中看到的历史,并不等于这一轮真正发送给模型的上下文。
一旦接受这个前提,许多看似矛盾的现象就能得到解释。为什么聊天记录完整,模型却看不到其中一部分?为什么系统宁可暂时多占一些 Token,也不愿修改早先的一段文字?为什么有些压缩可以找回原文,有些压缩却会改变后续会话的起点?为什么“恢复会话”远比重新读取一个日志文件复杂?
答案藏在一套容易被忽略的系统设计中:成熟的 Agent 并不维护一份唯一的聊天历史,而是在同时维护多种彼此关联、用途不同的历史视图。
一段会话,其实有三本账
理解 Agent 上下文管理,最重要的不是先研究某个压缩算法,而是先区分三类数据。
| 视图 | 它回答的问题 | 典型内容 | 是否直接发给模型 |
|---|---|---|---|
| 交互历史 | 用户在界面上看到了什么 | 用户消息、助手回复、工具调用与结果 | 不一定 |
| 请求投影 | 模型这一轮实际看到了什么 | 筛选、折叠、外置和摘要后的消息 | 是 |
| 逻辑执行链 | 下一轮应该从哪里继续 | 活动分支、压缩边界、父子关系、运行状态 | 间接决定 |
交互历史面向人,目标是完整、可读、可追溯。请求投影面向模型,目标是在有限窗口中保留最有价值的信息。逻辑执行链面向运行时,目标是让会话能够分叉、回退、压缩、恢复,并且在下一轮继续沿正确的时间线推进。
这三者可以完全不同。界面里仍然可见的大段日志,可能早已不在请求投影中;请求投影里被替换成摘要的内容,磁盘上可能一个字都没少;磁盘中的所有事件虽然完整保存,但当前逻辑链只会选择其中一个活动分支。
因此,评估任何“上下文压缩”机制时,都应该先问三个问题:它改动的是哪一层?它在什么时候触发?原始信息还能否恢复?如果这三个问题没有说清楚,“压缩”只是一个过于宽泛的词。
真正的矛盾:窗口预算与前缀缓存
语言模型没有天然的跨请求记忆。每一轮推理都需要重新提交系统指令、对话消息、工具描述、文件内容和工具结果。假设模型窗口为 (W),实际可用于历史的预算并不是 (W) 本身,而更接近:
[
B_{history}=W-R_{output}-R_{system}-R_{tools}-R_{safety}
]
其中,输出预留、系统指令、工具定义和安全余量都必须提前扣除。对 Coding Agent 来说,最容易挤爆窗口的往往不是自然语言对话,而是搜索结果、构建日志、长文件、测试报告和批量工具返回值。
直觉上的解决方案是不断删除旧内容,但这里还有另一项成本:提示缓存。
许多推理服务会复用上一轮请求中相同的前缀。只要新请求开头与旧请求保持逐字节一致,前缀对应的中间计算就可以复用。一旦在历史前部改动了一个字符,后续缓存都有可能失效。于是系统面临一组方向相反的优化目标:减少 Token 需要改写历史,命中缓存却要求前缀尽量不变。
一个成熟实现不会简单地选择“删”或“不删”,而是根据缓存冷热、内容价值和窗口压力,在不同层上采取不同动作。
压缩不是一个动作,而是一组投影策略
将原始会话变成请求投影,可以理解为一条逐级收缩的流水线。越靠前的策略越轻量、越可逆;越靠后的策略影响越大,也越可能改变逻辑执行链。
| 阶段 | 主要对象 | 请求中的变化 | 原文是否保留 | 是否改变逻辑起点 |
|---|---|---|---|---|
| 大结果外置 | 单个超大工具结果 | 全文变为预览与位置引用 | 完整保留 | 否 |
| 细粒度回收 | 过期工具结果 | 旧结果变为占位或从投影移除 | 通常保留 | 否 |
| 区间裁剪 | 中间低价值历史 | 指定消息不再进入请求 | 保留 | 否 |
| 上下文折叠 | 一段连续历史 | 原消息变为摘要占位 | 保留 | 否 |
| 全量压缩 | 边界之前的历史 | 历史被一条工作摘要替代 | 保留 | 是 |
| 失败后压缩 | 被服务端拒绝的请求 | 压缩后重新尝试 | 保留 | 通常是 |
大结果外置:不要总结,先把正文搬出去
当工具一次返回数万行日志时,最稳妥的处理并不是立刻总结,因为总结会损失细节。更好的做法是把完整结果写入独立存储,在请求投影中只保留一段预览、结果规模、内容类型和可再次读取的位置。
这种方式本质上是一种无损间接寻址。模型先看到“这份结果是什么”,只有在确实需要细节时才重新读取对应片段。它节省的是请求空间,不牺牲磁盘上的可追溯性。
外置后的占位内容一旦进入请求前缀,后续轮次最好稳定复用同一份文本。反复调整预览措辞虽然可能再省几十个 Token,却可能破坏一大段已命中的前缀缓存,得不偿失。
细粒度回收:先清理最便宜的噪声
随着任务推进,最早的搜索输出、旧版本文件内容和已经排除的错误日志会逐渐失去价值。此时可以只回收工具结果,而不总结整段会话。
缓存已经变冷时,直接重写本地请求投影通常代价较小;缓存仍然很热时,系统更倾向于维持本地前缀不变。如果推理后端支持缓存裁剪或缓存编辑,也可以让服务端处理旧块,从而避免客户端前缀发生变化。
这类策略揭示了一个很实用的工程判断:内容是否应该保留,不只取决于语义价值,还取决于它在缓存结构中的位置。一段已经没有业务价值的文本,如果位于高复用前缀中,短时间内仍可能值得保留。
区间裁剪与折叠:改变“怎么看”,而不是修改过去
有些排查过程很长,但最终只得到一句有效结论。系统可以把这段历史折叠成简短摘要,也可以根据消息标识精确排除一段已经确认无用的支线。
关键在于,持久化记录不必真的删除旧消息。系统只需要追加一条“投影提交”,说明恢复和构建请求时应当如何解释过去的数据。下一次加载会话时,再重放这些投影规则,就能得到相同的请求视图。
这种设计类似数据库中的事件溯源:事实只追加,当前状态通过重放事件计算得到。它既保留审计能力,也让删除、折叠和撤销变得可重放、可测试。
全量压缩:摘要不仅省空间,还会建立新边界
当轻量回收已经不够,上下文仍逼近硬上限时,系统才需要进行全量压缩。它会把某个边界之前的历史总结为一份“工作状态摘要”,其中通常包含当前目标、已经确认的事实、关键决策、未完成事项、重要文件与必要约束。
下一轮请求不再携带边界之前的原始消息,而是从摘要继续。原始记录仍保存在磁盘上,但逻辑执行链已经建立了新的起点。换句话说,旧历史是“仍然存在但默认不再参与推理”,而不是被物理删除。
为了降低摘要请求的计算成本,一种常见优化是让摘要生成任务复用主会话的相同前缀和配置。这样虽然会额外生成一些输出 Token,却可能复用大段已计算前缀。它再次体现了同一原则:局部多花一点输出,可能换来更大的输入复用收益。
失败后压缩:它是保险丝,不是常规路径
客户端对 Token 数量的估计不可能永远精确。序列化差异、工具协议开销和服务端计数规则,都可能导致一个看似安全的请求被判定为超长。
因此系统还需要一道失败恢复策略:当服务端明确以“上下文过长”拒绝请求时,执行一次压缩,然后重新提交。这里必须设置严格的重试上限,通常只允许一次。否则,恢复过程自己生成的新消息可能继续推高上下文,最终形成无限压缩、无限重试的回路。
协议完整性比 Token 数更重要
上下文不能在任意位置切开。一次工具调用与对应结果构成协议上的完整单元;一条助手消息中的多个结构块也可能必须整体保留。如果压缩边界把调用和结果拆散,模型收到的消息就可能违反接口约束。
因此,边界选择不是简单地“保留最近 N 个 Token”。系统需要先找到满足预算的候选位置,再把边界向外移动到合法的结构边界。必要时宁可略微超过名义预算,也不能制造一份语义或协议上不完整的请求。
这是一条很重要的设计优先级:
协议完整性高于局部 Token 最优,确定性高于一次性的极限压缩率。
会话记录应当像事件日志,而不是可变数组
如果历史会被频繁折叠、裁剪、分叉和恢复,把它存成一个不断原地修改的 JSON 数组会很快变得脆弱。更稳健的方式是采用只追加事件日志。一个简化后的记录可能包含如下事件:
{"type":"message_appended","id":"m42","parent":"m41","role":"assistant"}{"type":"tool_result_externalized","message":"m42","artifact":"logs/run-17.txt"}{"type":"projection_committed","hidden":["m18","m19","m20"]}{"type":"compaction_boundary_created","id":"c3","parent":null,"summary":"..."}{"type":"runtime_state_checkpointed","recent_files":["src/index.ts"]}这里的重点不是字段名称,而是数据模型:消息通过父指针形成一张可分叉的图;投影规则决定哪些节点进入本轮请求;压缩边界切断默认回溯;运行时检查点保存聊天文本之外的工作状态。
在这种模型下,会话恢复不是“读取全部消息并放回内存”,而是一次确定性的重放过程:
defresume(session_id):events=load_events(session_id)tip=find_active_branch_tip(events)chain=walk_parent_links(tip)chain=stop_at_latest_compaction_boundary(chain)projected=replay_projection_events(chain,events)repaired=repair_incomplete_protocol_units(projected)runtime=restore_runtime_state(events)returnSession(messages=repaired,runtime=runtime)真正的实现还需要处理并行工具调用、回退后产生的分支、未完成的助手消息、孤立工具结果和中断写入。恢复器的职责不是把过去机械地照搬回来,而是把一个可能在任意时刻中断的执行现场,修复成可以安全继续的状态。
Resume 与 Fork 不是同一种恢复
继续原会话和从旧会话分叉,看起来都需要加载历史,但它们的所有权语义完全不同。
| 行为 | Resume | Fork |
|---|---|---|
| 会话标识 | 沿用原 ID | 创建新 ID |
| 事件日志 | 继续追加原日志 | 写入新的日志 |
| 活动分支 | 接管原分支 | 从选定节点创建新分支 |
| 外置结果与投影规则 | 原样恢复 | 复制必要的解释规则 |
| 目标 | 回到原工作现场 | 带着上下文开启新时间线 |
Fork 时不能只复制聊天文本。假如旧会话曾把一段大型日志外置,而新会话没有继承对应的外置映射,请求投影就可能突然恢复成全文,不仅窗口占用会变化,提示缓存前缀也会改变。需要迁移的不是全部运行状态,而是足以保证同一段历史得到同一解释的最小状态集合。
为什么“消息恢复了”,行为仍可能改变
会话连续性不只由文字决定。Agent 最近读取过哪些文件、使用过哪些工具、选择了哪种工作模式、当前工作目录在哪里、是否位于独立工作树中,这些状态都会影响下一轮行为。
如果恢复器只重建聊天消息,界面看起来可能完全正常,模型接手的工作现场却已经变化。更准确地说,Resume 是一次运行时迁移,而不只是一次文件读取。
这一点也解释了很多“恢复后突然忘记”的问题。故障可能不在摘要质量,而在其他层:投影事件没有重放,活动分支找错了,压缩边界丢失了,外置结果路径失效了,或者运行状态没有恢复。只有把问题定位到具体层,才能避免把所有异常都归咎于模型。
三条值得复用的系统原则
前缀决策必须可重复
同一份持久化记录在相同配置下,应当产生字节级稳定的请求前缀。占位文本、摘要边界和投影顺序都应确定化,否则即使语义相同,缓存也会因细微差异而失效。
结构完整性优先于极限压缩
工具调用与结果不能被拆开,消息内部的关联片段不能被任意切断。预算控制应该服从协议合法性,而不是反过来。
磁盘只追加,逻辑视图可重建
物理记录描述“发生了什么”,投影事件描述“现在应该如何看待过去”。压缩和删除尽量表现为新事件,而不是回头篡改旧数据。这样才能同时获得可恢复性、审计能力和确定性。
结语:Agent 的记忆,本质上是一次受控重建
我们常把上下文窗口想象成模型的短期记忆,把磁盘记录想象成长期记忆。但对一个能够读文件、运行命令、并行调用工具并恢复工作的 Agent 来说,这个类比还不够准确。
它真正维护的是一套多层状态系统:交互历史负责向人解释过去,请求投影负责在有限预算内组织当下,逻辑执行链负责决定未来从哪里继续,事件日志负责让这一切可以被重放。所谓“记住”,不是把所有内容永远塞进窗口;所谓“恢复”,也不是把聊天记录重新显示出来。
更准确的定义是:在资源受限的前提下,让系统能够稳定地重建下一步行动所需的现场。
下次再看到“上下文压缩”时,不妨先问它压缩了哪一层;下次再遇到“恢复后失忆”时,也不要只检查聊天记录。真正决定 Agent 能否继续工作的,往往是那套用户看不见、却一直在重建现场的投影与运行时系统。
说明:本文讨论的是成熟 Coding Agent 可采用的通用架构抽象。不同产品与版本的具体命名、阈值和实现路径可能不同。