☰
AI编码代理上下文压缩免疫:外置记忆与状态文件实战
2026/10/15 6:37:32 网站建设 项目流程

前阵子我碰到了个特别崩溃的场景:给一个AI编码代理排了一整段连续开发任务,它读源码、跑测试、写改动,上下文一路涨到接近20万token。正当我以为一切都按计划推进时,模型侧一次上下文压缩,直接把代理打回原形——它开始反复修同一个文件、自相矛盾地改接口、甚至打算去创建已经被我删掉的模块。那一刻真像给代理做了一场“脑叶切除术”。

如果只是偶尔炸一次也就算了,问题是这种“失忆”对于长周期任务几乎是必然事件。我自己踩过几次坑之后,慢慢琢磨出一套能让编码代理对上下文压缩免疫的土办法,核心就两条:参考Unix init.d那种“进程重启但状态自恢复”的思路,再借一层《记忆碎片》里主角靠外部便签活着的思路。这套东西不依赖任何特定工具,纯粹是工作区组织和提示词设计层面的对策。无论你用的是哪种模型、哪种Agent框架,都可以直接套用。

这篇文章会从上下文压缩的成因讲起,然后给出我实际在用的工作区状态文件方案、便签协议、恢复流程,最后附上排查经验和一批“什么时候根本不需要做免疫”的判断标准。内容偏实践,适合正在用编码代理处理多文件重构、跨模块改造、或者长周期维护任务的开发者。

1. 背景:20万token的“脑叶切除术”是怎么发生的

1.1 上下文膨胀与压缩的真实触发点

先说清楚20万token是怎么堆出来的。很多人以为AI编码代理的上下文主要来自用户对话,其实不是。编码代理这类工具的上下文消耗大头通常有三块:

  • 代理为了理解项目会主动读取源文件,一个上千行的模块读进来就是好几千token。要做跨模块重构时,它可能一口气读十几个文件,瞬间就是3-5万token出去了。
  • 工具调用结果也会全部留在对话历史里。跑一次测试,stdout、stderr、exit code少则几百token,多则几千token。如果测试带日志输出,一次就能把上下文推高几千。
  • 模型自身每轮输出也是算进后续上下文的。编码代理的每个操作都要先生成计划和代码,一次完整回复可能有2000-4000token,一轮轮累积下来非常可观。

因此,一个执行了30-40轮工具的连续任务,轻松就能把上下文推到15-20万token。当模型上下文窗口逼近上限时,服务端通常会采取两类处理:一是直接把最早的对话记录截断掉,这部分信息彻底消失;二是把历史对话做摘要压缩,但摘要本身会损失大量细节,尤其是具体文件路径、具体变量名这类关键信息。

截断后的后果就是:代理明明在第三轮已经决定采用方案B并完成了初步实现,到了压扁之后它却只记得自己在“重构数据访问层”,然后按方案A的旧思路继续输出代码。轻则重复劳动,重则直接生成引用不存在的接口、覆盖已完成的改动。

1.2 为什么这对编码代理来说比想象中更致命

对普通问答来说,上下文被压缩只是回答质量下降。但编码代理是带状态的执行器,它在每一轮里做的决定都建立在前几十轮的事实之上。压缩等于突然抽走决策链条的下半部分,代理的行为就会从“连贯执行”退化成“每个动作随机排列”。

我在实践中见过最典型的情况是:代理处理一个数据库迁移任务,前20轮已经完成了新表的创建脚本,并修改了三个DAO文件。压缩发生后,它继续“完善迁移”,但不是去检查现有脚本是否完整,而是重新生成了一份同名脚本,覆盖掉原有的、经过多轮修正的版本。而且它自始至终没意识到覆盖有问题,因为在它的上下文里“曾经有这份脚本”这个事实已经不存在了。

编码任务的另一个特点是纠错成本高。问答答错了重新问一句就行,编码代理如果在一个被压缩后的状态下胡写五百行代码,你光靠肉眼review都能盯半天。所以对编码代理来说,“压缩免疫”不是一个锦上添花的优化,而是长任务能不能真正跑完的前提条件。

2. 破局思路:代理不应该“记住”,而应该“外置记忆”

2.1 Unix init.d留给我的启示:每次启动都是一次冷启动

如果你接触过传统Unix服务,应该对init.d目录不陌生。System V init把一个个服务的启停脚本放在 /etc/init.d 下,系统启动时按运行级别依次执行这些脚本,把服务拉起来。这里有个容易被忽略的细节:init.d脚本本身只负责start、stop、restart这些动作,它不会替服务“记住”业务状态。

真正让一个服务在重启后还能继续干活的是服务自己。它启动时去读自己的配置文件、数据目录、上次持久化的中间结果,然后把自己恢复到“可以继续工作”的状态。防火墙程序重启后仍然知道该放行哪些端口,因为它读了iptables的规则文件;Web服务重启后仍然知道自己的域名配置,因为它读了Nginx的conf文件。整个系统之所以能在崩溃后自愈,靠的不是某个进程的大脑,而是“外部状态文件 + 启动时主动加载”这套机制。

AI编码代理遇到的上下文压缩本质上也类似:模型侧就是一次“进程重启”,对话历史就是易失的RAM,压缩一发生,RAM里的东西就没了。代理如果还想继续干活,就得像init.d拉起的守护进程一样,主动去读磁盘上的“配置和数据文件”,把自己恢复到上一个检查点。指望上下文里残留的那点摘要是不够的,得靠工作区里真实存在的文件。

2.2 “记忆碎片”教会我的便签协议

《记忆碎片》这部电影我反复看了好几遍,主角因为无法形成新的长期记忆,只能靠便签纸、照片和纹身来维持对世界的追踪。他每过几分钟就会“失忆”,但靠着外部记录,他仍然能一步步推进自己追查的目标,甚至能完成相当复杂的多步骤行动。

其实每个AI编码代理都是电影里的主角:模型本身没有长期记忆,每一轮对话对它来说都是“新的一天”,上下文压缩只是把这个“新的一天”出现的时间点提前了而已。所以没必要把压缩当作无解灾难,只需要像主角给自己留便签一样,给代理设计一套随时可读的外部记录协议。

我刚开始理解这一层的时候很激动,但很快发现一个更关键的问题:便签不是写得越多越好。主角的便签之所以有效,是因为每张都回答了一个具体问题。对我设计的代理来说,便签必须回答这几个问题:我们现在在哪个任务上?我们已经完成了哪些关键步骤?下一步要做哪个具体动作?绝对不能碰哪些已经否决的选项?

这四件事写清楚,压缩之后代理就能靠便签精确回血。没写清楚,便签再多也只是让代理在错误方向上更笃定地狂奔。

3. 实战:给AI编码代理做上下文压缩免疫

3.1 第一层:像init.d一样设计工作区状态文件

具体落地时,我会在每个被代理管理的项目根目录下放一个隐藏目录,比如 .agent-state/,里面用固定文件名维护状态。这套结构模仿的是init.d的“服务启动时要加载的状态”:

.agent-state/ ├── TARGET.md # 最顶层目标,声明“我到底在做什么” ├── PROGRESS.md # 轻量进度,只写入已完成和下一步 ├── CONTEXT.md # 当前任务现场;代理每次被压缩后优先读这个 ├── NOTES/ # 按主题拆分的长期笔记 └── tasks/ └── 01-dao-refactor.md # 子任务级别便签

每个文件的用途必须单一。TARGET.md 不写细节,只写项目级目标,目标会被反复阅读,所以永远控制在30行以内。PROGRESS.md 是任务清单,每完成一个关键节点就更新一次,格式就三块:已完成列表、进行中的事、待办列表。CONTEXT.md 是压缩后恢复的入口,里面除了指向其他状态文件,还会抄录最重要的当前决策和禁止事项,它的理想状态是“上下文被压扁后,代理只用读这一个文件就能重新接上线”。

写入时机上,我要求代理在两种情况发生时必须更新文件:第一,完成了一个可独立验证的修改,比如“DAO层三个文件重构完成且测试通过”;第二,发现原计划需要调整时,比如“方案B不可行,改方案C”。这样做的好处是,状态文件始终反映最近一次真实进展,而不是代理想象中的进展。

3.2 第二层:Memento式便签协议

状态文件是基础,但真正让我觉得“免疫了”的是给代理设计了一套更严格的便签模板。这套模板直接作为一种固定格式写进代理的系统提示词里,要求它每执行几个动作后就把关键结论ullback成一则极简便签。

我的模板长这样:

STATUS: IN_PROGRESS(或 DONE / BLOCKED) GOAL: 完成数据访问层从JDBC到MyBatis的迁移 COMPLETED: [x] 新增 mybatis-config.xml [x] UserDao 迁移完成,单元测试通过 NEXT: 迁移 OrderDao,先读现有实现再动工 STOP: - 不要删除旧的 JDBC 实现,迁移完成并验证后再手工清理 - 不要改动 UserDao,它已经通过测试 TRUTH: - OrderDao 当前位于 src/main/java/.../OrderDao.java - 新版本接口不再支持 setConnection() REF: src/main/resources/mybatis-config.xml

注意STOP这一块。《记忆碎片》式的表达里,“不要做什么”和“下一步做什么”同样重要。我在实践中发现,上下文被压缩后,代理最危险的行为不是停滞,而是把已经否决的方案重新当成新鲜想法执行。把“停止项”写进便签,相当于给代理装了一个护栏。

这则便签不需要很长,原则是必须能被模型在一个阅读步骤里消化掉,所以整体压缩在10-20行以内。每隔几次工具调用就更新一次,更新不是重写全文,而是只替换发生变化的字段。这样代理即使在压缩发生的瞬间失去了上下文,也能在恢复后第一时间读到自己最近的状态。

3.3 压缩发生时的“第一响应动作”

即使有状态文件和便签,代理第一次遭遇压缩时仍然可能懵住,因为它刚经历了一次“失忆”,根本不知道自己失忆了。它会继续按照残留的最后一点上下文乱写,而不是主动去读状态文件。所以恢复机制必须前置到系统提示词里。

我会在给代理的全局指令里加入这样一条硬规则:

如果你发现自己对上下文中的任务进度不再清晰,或者认为某些前置步骤缺失,请不要猜测,不要继续修改代码。先执行“状态恢复动作”:读取 .agent-state/TARGET.md、PROGRESS.md、CONTEXT.md,然后输出一份“恢复确认清单”,列出现在处于哪个阶段、下一步计划做什么,并等待人类确认或直接按清单继续。

这一步非常关键。因为许多代理框架在上下文被截断后不会遇到任何显式报错,它只是安静地继续生成。如果不加这个“先读后做”规则,状态文件设计得再好也可能不被使用。

与此同时,我自己也会用脚本做一道保险。项目里放一个 load-workspace.sh,把状态文件拼成一篇bootstrap摘要,供代理在压缩后一次性读取:

#!/usr/bin/env bash # 用法:在代理被压缩后,要求它运行此脚本并阅读输出 cat .agent-state/TARGET.md echo "---" cat .agent-state/CONTEXT.md echo "---" cat .agent-state/PROGRESS.md echo "---" ls -t .agent-state/tasks/ | head -5

与其让代理自己去翻一整个目录,不如由脚本给它一份高度浓缩的“当前现场”。这就像init.d脚本里的status动作一样,让任何新接手的进程都能快速弄清状况。

4. 实操记录:一次被压缩后的快速回血

4.1 事故现场:一个重构到一半的跨平台项目

我拿一次真实踩坑经历来复盘。当时我让一个编码代理处理模拟项目X的跨平台数据访问层重构,任务是把它从一套老旧的JDBC直连方式迁移到ORM框架,涉及十几个数据访问对象,并且要求不能破坏已有接口。任务大概执行了四十多分钟,代理已经完成了UserDao和ProductDao两个模块的迁移,测试也通过了。

问题出在我一时手滑调整了模型侧的上下文策略,代理的下一次请求直接触发了压缩,20万token的上下文被截断到只剩很少一部分。恢复后的代理表现非常典型:它开始打算重新实现UserDao,因为它的上下文里“UserDao已经迁移完成”这条事实已经丢失了。我还观察到它准备新建一个已经存在的Mapper文件,并且在代码里引用了一个已经被移除的旧工具类。

我立刻中止了它的动作,没有让它继续盲目生成。

4.2 恢复流程:冷启动,而不是热修复

我的恢复动作分五步走,完全是照着init.d“重启后自检状态”的思路来的。

第一步,停止生成,隔离现场。我用Ctrl+C中断代理,不让它在失忆状态下继续产生新的改动。第二步,检查工作区脏状态。我手动跑了两个命令:

git status --short git diff --stat

这两条命令可以在几十秒内判断代理在压缩前后的实际改动范围,避免它只是在上下文中“以为自己改了”,而实际上没有落盘。

第三步,运行 load-workspace.sh,让代理重新读取状态文件。这里我不让代理自己随便翻文件,而是让它只读脚本生成的bootstrap摘要,先把目标、进度、禁止项重新装进它的上下文。

第四步,执行一致性核对。让代理把自己刚读完的状态和磁盘上真实存在的文件做一次对比,输出“已完成清单”的验证结果。这一步很重要,因为它能发现上下文被压缩前剩下的一些隐性问题,比如代理之前改了文件却忘了更新PROGRESS.md,或者PROGRESS.md写了完成但实际代码不完整。

第五步,重新接续任务。恢复后的代理被明确告知:不需要重新设计整个迁移方案,只需要继续处理还没有完成的OrderDao迁移。这相当于把一个已经启动的守护进程杀了一次,然后重新拉起,但它通过读取状态文件直接回到了原来的业务现场。

整个恢复过程大概用了三分钟。对比第一次遇到压缩时我原地修修补补折腾半小时还没把代理拉回状态,这三分钟让我彻底确认了这套方案可行。

4.3 恢复过程中的额外发现

这次事故让我注意到一个经常被忽略的点:代理在上下文被压缩前往往已经产生了一些“思维垃圾”——它在某一轮生成的备选方案、临时变量名、甚至废弃不用的导入语句,虽然没有被执行,但残留在工作区文件里。当代理恢复后,如果带着这些垃圾继续往下写,很容易把新代码和旧草稿混在一起。

所以我在恢复流程里额外加了一个动作:恢复后强制做一次代码体检,重点查未使用的导入、重复定义的工具方法、以及引用了旧接口名的残留调用。这一步不需要多复杂的工具,grep就能快速完成:

grep -rn "OldJdbcUtil" src/ || true

如果发现残留引用,就让代理先清理干净再继续下一个任务。这样恢复后的代理不仅在“任务方向”上对了,在“代码卫生”上也比压缩前更干净,有时甚至是一次变相的轻量化。

5. 常见问题与排查技巧实录

5.1 上下文压缩后的典型“失智”症状速查表

我在多轮实践中整理了一份症状与对策对照表,适合遇到压缩时快速定位问题。

常见症状深层原因恢复动作
反复修改同一个已完成模块上下文丢失了“已完成”事实重读PROGRESS.md,标记已完成项为冻结状态
突然扔掉原方案开始新方案历史摘要把决策过程压没了在STOP字段里写明已否决方案,恢复时不讨论旧方案
引用已删除的接口或工具类代码事实和上下文不一致先跑grep/编译,拿到真实错误清单再改代码
重复创建同名文件或模块不知道文件系统现状恢复后先输出目标目录的文件树,再决定是否新建
生成代码时旧逻辑与新逻辑混用上下文残留草稿被当成事实清理未提交的杂乱改动,只保留经过验证的版本

这张表格不是让你逐条对号入座,也不是说每次压缩只会触发一种症状。实际上常见的情况是一个代理同时表现出两三种症状,所以第4节里的“停止生成 -> 读状态文件 -> 一致性检查”流程无论症状如何都建议整套执行。

5.2 排查“外置记忆”是否真的在起作用

状态文件做了却不检查,等于没做。我后来养成一种习惯:每隔一段时间就做一次“空对话恢复测试”。方法是直接开一个新会话,不粘贴任何旧对话,只把一个状态文件的内容贴给模型,让它基于这些信息回答“当前任务是什么,下一步要做什么,哪些事情不能做”。

如果模型能给出准确答案,说明你的外置记忆质量合格。如果模型答得模糊,甚至把STOP里的“不要删除JDBC实现”理解成“下一步删除JDBC实现”,那么问题通常出在状态文件本身写得不够清晰,或者信息组织方式与模型的理解方式不匹配。

我调整过几次模板,发现最有效的写法是短句、明确动词、带具体路径。比如不要写“继续迁移数据访问层”,而要写“将OrderDao.java从JDBC直连改为Mapper接口实现,Mapper文件位于 src/main/resources/mapper/OrderMapper.xml”。模型对具体路径和具体文件的记忆,远比对抽象动作的记忆可靠。

另外还要定期检查状态文件的真实性。如果PROGRESS.md声称完成了某个模块,但实际代码还没编译通过,这个状态文件就会变成另一个幻觉源头,让代理在错误的“已完成”基础上继续构建。所以我在每次完成关键里程碑后都会跑一次构建,用构建结果去校正状态文件。

5.3 我踩过的几个坑

第一个坑是状态文件越写越大,最后变成一颗新炸弹。最初我让代理把所有技术细节都写进CONTEXT.md,结果文件涨到几百行,本身就被模型上下文截断,完全失去“快速回血”的意义。后来我把CONTEXT.md严格控制在30行以内,详细内容下沉到NOTES目录,只让CONTEXT.md保存指向。

第二个坑是允许多个代理并行更新同一个状态文件。有一次我开了两个代理会话分担两个模块的迁移,它们同时去写PROGRESS.md,把对方的进度覆盖了。后来我改用固定规则:每个子任务有独立的tasks/xx.md,总进度文件只允许“当前活跃代理”写入,否则就加锁标志。我的做法是在PROGRESS.md顶部留一行ACTIVE_AGENT: session-123,另一个代理看到ACTIVE不属于自己就不写总进度。

第三个坑是恢复时保留了旧对话残留。有一次我让代理在同一个会话里做恢复,虽然它读了状态文件,但旧上下文里那些已经被否决的片段仍然影响着输出。后来我坚持“空环境恢复”的原则:恢复动作必须从新会话开始,或者至少清空之前的工具输出,让代理只带着bootstrap摘要重新出发。

6. 有些时候真的不用做免疫

6.1 短任务和一次性任务

上下文压缩免疫不是银弹,也不是所有编码代理任务都需要这套基础设施。如果任务能在二十分钟内完成,或者上下文消耗低于模型窗口的50%,那大概率不会触发压缩。这种情况下给工作区加状态文件纯属增加成本和认知负担,让代理直接对话反而更高效。

我自己的判断标准是:这次任务是否需要代理在多个文件之间来回修改,并且每一轮修改都依赖于前几轮的产出。如果不需要,那这个任务就是短任务,直接让代理一口气做完即可,最多在结束时人工review一遍。比如生成一个独立脚本、修复一个单元测试、把一段代码从一种写法改成另一种写法,这些都不需要外置记忆。

6.2 与其做免疫,不如做“分阶段化疗”

比给代理设计恢复机制更省心的方案是:从源头限制每个会话的上下文膨胀。把一个大需求拆成多个独立的小批次,每个批次只负责一个子任务,上下文小、干净、用完即弃。这相当于把“长时间运行的大进程”拆成“多个短生命周期的任务”,从根本上降低压缩发生的概率。

我通常会写一个简单的调度脚本,把大任务切成三个阶段:

step1() { # 阶段1:让代理生成迁移清单,输出到 tasks/plan.md # 上下文只包含需求文档和源文件清单,完事即结束 } step2() { # 阶段2:读取tasks/plan.md,让代理逐个处理清单里的文件 # 每个文件单独启动一个代理会话,避免十几个文件的历史全部累积 } step3() { # 阶段3:汇总验证,跑编译和测试,把差异反馈给一个新的代理会话 }

这种“分阶段化疗”的思路,本质上是在压缩发生之前就把记忆切换到外部。每个阶段之间传递的就是一个小文件,下个阶段用空上下文启动也能正常工作。很多时候你会发现根本不需要“恢复”机制,因为每个代理会话都足够短,根本活不到被压缩的那一步。

不过要注意:分阶段只适合可以清晰切分的任务。如果任务要求某个代理必须全程掌握全局状态,比如“同时调整A模块并让B模块适配A的新接口”,硬拆分反而会丢失跨模块的一致性。这时候还是老老实实做状态文件和便签协议更稳。

6.3 混合策略:按失忆成本做决策

我在落地时常用“失忆成本”来判断用哪套方案:如果代理失忆后的修复时间不超过十分钟,就什么都不做;如果修复时间可能超过三十分钟,就给工作区加状态文件;如果任务跨度超过一天,或者涉及多个文件深度耦合,就默认开启完整的状态文件加便签协议,并且把大任务切成阶段,配合恢复流程一起使用。

这套混合策略看起来朴素,但在实践里非常稳。它既避免了高射炮打蚊子,也避免了大任务裸奔到最后被一次压缩全部掀翻的惨案。

我自己在项目里的习惯是:给编码代理的工作区里长期保留一套状态文件,不管这次任务用不用得到,多一个目录总比多一次灾难好。真正要权衡的只是状态文件要不要更新得那么勤——短任务就让代理少写几笔,长任务就严格按协议维护。

最后分享一个让我受益最多的细节:不要把“状态文件”当成代理的额外负担去逼它每天写作文,而是把它当成“给下一位代理的交接手册”。每一次代理因为压缩失忆,或者因为会话关闭重新启动,下一棒能不能接上,全靠这份手册写得是否真实、简洁、指向清晰。我甚至会把这句话写进代理系统提示词:“你每次更新状态文件时,都假设下一位接手的同事完全不认识你,也完全没看过当前对话。”用这种心态去写,状态文件才真正具备让代理对上下文压缩免疫的力量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询