ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

聊天记录都在,模型为什么还是会“忘记”?

聊天记录都在,模型为什么还是会“忘记”?

从上下文投影、提示缓存到会话恢复,拆解 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 不是同一种恢复

继续原会话和从旧会话分叉,看起来都需要加载历史,但它们的所有权语义完全不同。

行为ResumeFork
会话标识沿用原 ID创建新 ID
事件日志继续追加原日志写入新的日志
活动分支接管原分支从选定节点创建新分支
外置结果与投影规则原样恢复复制必要的解释规则
目标回到原工作现场带着上下文开启新时间线

Fork 时不能只复制聊天文本。假如旧会话曾把一段大型日志外置,而新会话没有继承对应的外置映射,请求投影就可能突然恢复成全文,不仅窗口占用会变化,提示缓存前缀也会改变。需要迁移的不是全部运行状态,而是足以保证同一段历史得到同一解释的最小状态集合。

为什么“消息恢复了”,行为仍可能改变

会话连续性不只由文字决定。Agent 最近读取过哪些文件、使用过哪些工具、选择了哪种工作模式、当前工作目录在哪里、是否位于独立工作树中,这些状态都会影响下一轮行为。

如果恢复器只重建聊天消息,界面看起来可能完全正常,模型接手的工作现场却已经变化。更准确地说,Resume 是一次运行时迁移,而不只是一次文件读取。

这一点也解释了很多“恢复后突然忘记”的问题。故障可能不在摘要质量,而在其他层:投影事件没有重放,活动分支找错了,压缩边界丢失了,外置结果路径失效了,或者运行状态没有恢复。只有把问题定位到具体层,才能避免把所有异常都归咎于模型。

三条值得复用的系统原则

前缀决策必须可重复

同一份持久化记录在相同配置下,应当产生字节级稳定的请求前缀。占位文本、摘要边界和投影顺序都应确定化,否则即使语义相同,缓存也会因细微差异而失效。

结构完整性优先于极限压缩

工具调用与结果不能被拆开,消息内部的关联片段不能被任意切断。预算控制应该服从协议合法性,而不是反过来。

磁盘只追加,逻辑视图可重建

物理记录描述“发生了什么”,投影事件描述“现在应该如何看待过去”。压缩和删除尽量表现为新事件,而不是回头篡改旧数据。这样才能同时获得可恢复性、审计能力和确定性。

结语:Agent 的记忆,本质上是一次受控重建

我们常把上下文窗口想象成模型的短期记忆,把磁盘记录想象成长期记忆。但对一个能够读文件、运行命令、并行调用工具并恢复工作的 Agent 来说,这个类比还不够准确。

它真正维护的是一套多层状态系统:交互历史负责向人解释过去,请求投影负责在有限预算内组织当下,逻辑执行链负责决定未来从哪里继续,事件日志负责让这一切可以被重放。所谓“记住”,不是把所有内容永远塞进窗口;所谓“恢复”,也不是把聊天记录重新显示出来。

更准确的定义是:在资源受限的前提下,让系统能够稳定地重建下一步行动所需的现场。

下次再看到“上下文压缩”时,不妨先问它压缩了哪一层;下次再遇到“恢复后失忆”时,也不要只检查聊天记录。真正决定 Agent 能否继续工作的,往往是那套用户看不见、却一直在重建现场的投影与运行时系统。


说明:本文讨论的是成熟 Coding Agent 可采用的通用架构抽象。不同产品与版本的具体命名、阈值和实现路径可能不同。

返回列表